diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7969672..887f54b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,7 +16,7 @@ Fixes # (issue number) Please describe the tests that you ran to verify your changes. -- [ ] Unit/Integration tests (`hatch run test`) +- [ ] Unit/Integration tests (`hatch test`) - [ ] Static type check passing (`mypy src/`) ## Checklist: @@ -25,6 +25,6 @@ Please describe the tests that you ran to verify your changes. - [ ] I have performed a self-review of my own code - [ ] My changes pass strict static type analysis (`mypy src/` generates no warnings) - [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation (under `docs/`) +- [ ] I have updated the documentation in `zcore-docs` if applicable - [ ] My changes generate no new runtime warnings - [ ] I have added tests that prove my fix is effective or that my feature works \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 7d86c99..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Deploy Documentation - -on: - push: - branches: - - master - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - cache: "pip" - - - run: python -m pip install --upgrade pip - - - run: pip install hatch - - - run: pip install -e .[docs] - - - run: mkdocs gh-deploy --force \ No newline at end of file diff --git a/.gitignore b/.gitignore index 11efdac..2b17b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ env/ # Local configuration & environment variables .env .env.local -.env.* \ No newline at end of file +.env.* + +# database +*.db \ No newline at end of file diff --git a/README.md b/README.md index f08b5a1..dcb6603 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- ZCore Logo + ZCore Logo

@@ -11,13 +11,13 @@ PyPI - - License + + License - - CI + + CI - + Documentation @@ -31,14 +31,14 @@ **ZCore is not a framework that hides FastAPI โ€” it is the chassis that stabilizes it.** -While FastAPI gives you a high-performance engine for HTTP, it provides no blueprint for structuring medium-to-large applications. ZCore fills that gap with: +While FastAPI provides a high-performance engine for HTTP, it leaves the architecture of medium-to-large applications entirely to the developer. ZCore fills that gap with: -- **๐Ÿ” Context-aware data masking** โ€” Write one schema; sensitive fields are pruned automatically per-user. -- **๐Ÿ”— Atomic Unit of Work** โ€” Group operations into all-or-nothing transactions with delayed event dispatch. -- **โšก Scoped Dependency Injection** โ€” Singleton, transient, and request-scoped dependencies resolved automatically. -- **๐Ÿ—๏ธ Modular Plugin System** โ€” Organize domains into isolated plugins with topological startup ordering. -- **๐Ÿ” Secure Dynamic Search Engine** โ€” Nested JSON filters, cursor/offset pagination, and column-level security โ€” zero boilerplate. -- **๐Ÿ“ฆ CLI Scaffolding** โ€” From `pip install` to running CRUD endpoints in under two minutes. +- **๐Ÿ” Context-Aware Data Masking** โ€” Write one schema; sensitive fields are automatically pruned per-user across validation, serialization, and OpenAPI specs. +- **๐Ÿ”— Atomic Unit of Work** โ€” Coordinate multi-repository operations into all-or-nothing transactions with deferred event dispatching. +- **โšก Scoped Dependency Injection** โ€” High-performance constructor auto-wiring for Singleton, Transient, and request-scoped dependencies. +- **๐Ÿ—๏ธ Modular Plugin Architecture** โ€” Organize business domains into decoupled plugins with topological dependency ordering. +- **๐Ÿ” Secure Dynamic Search Engine** โ€” Nested JSON filters, eager loading, cursor/offset pagination, and column-level access controls. +- **๐Ÿ“ฆ Zero-Boilerplate CLI** โ€” From `pip install` to running secure CRUD endpoints in under two minutes. --- @@ -46,13 +46,13 @@ While FastAPI gives you a high-performance engine for HTTP, it provides no bluep | Concern | Raw FastAPI | With ZCore | |---------|-------------|------------| -| **Endpoint Scaffolding** | Manually write 7+ files per domain | One `BaseRouter` class โ†’ 7 secure endpoints | -| **Data Leakage** | Multiple Pydantic models per role; manual conditionals | `Zchema` auto-prunes restricted fields per-user | -| **Database Transactions** | Scattered `commit()` / `rollback()` calls | `UnitOfWork` guarantees atomicity + deferred events | -| **Dependency Wiring** | Deeply nested `Depends()` chains | Constructor injection with `Inject()` โ€” auto-resolved | -| **Search & Pagination** | Hand-written SQL per endpoint | Declarative JSON filters + cursor/offset pagination | -| **Project Layout** | No standard; every team reinvents the wheel | `zc init` + `zc startapp` โ€” consistent domain modules | -| **Startup Orchestration** | Manual `@app.on_event` spaghetti | `Plugin` with dependency graph โ†’ topological sorting | +| **Endpoint Scaffolding** | Manually write 7+ routes and handlers per model | One `BaseRouter` class $\rightarrow$ 7 secure endpoints out-of-the-box | +| **Data Leakage** | Multiple Pydantic models per role; manual conditionals | `Zchema` auto-prunes restricted fields per active context | +| **Database Transactions** | Scattered `commit()` / `rollback()` calls | `UnitOfWork` guarantees atomicity + post-commit domain events | +| **Dependency Wiring** | Deeply nested, verbose `Depends()` parameter chains | Clean constructor auto-wiring via IoC container + `Inject[T]` | +| **Search & Pagination** | Hand-crafted SQL parsing per endpoint | Declarative JSON filters + keyset cursor and offset pagination | +| **Project Layout** | No standard; every team reinvents the wheel | `zc init` + `zc startapp` โ€” decoupled domain modularity | +| **Startup Orchestration** | Fragile `@app.on_event` chains | `Plugin` protocol with dependency DAG $\rightarrow$ topological sorting | --- @@ -73,28 +73,30 @@ zc startapp tasks --template ### 3. Define -Open `app/tasks/models.py`: +Open `tasks/models.py`: ```python +import uuid from zcore import Base from sqlalchemy.orm import Mapped, mapped_column class Task(Base): __tablename__ = "tasks" - id: Mapped[int] = mapped_column(primary_key=True) + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) title: Mapped[str] is_completed: Mapped[bool] = mapped_column(default=False) ``` -### 4. Serve +### 4. Run ```bash zc run ``` -Your API is live at **`http://127.0.0.1:8000`** with 7 CRUD + search endpoints ready. +Your API is live at **`http://127.0.0.1:8000`** with 6 CRUD endpoints and dynamic search ready. -> ๐Ÿ“– **Full walkthrough:** [10-Minute Quickstart](https://fastapi-zcore-framework.github.io/zcore/learn/quickstart/) +> ๐Ÿ“– **Full walkthrough:** [Quick Start Guide](https://baseryn.github.io/zcore-docs/docs/quick-start) --- @@ -107,32 +109,35 @@ Your API is live at **`http://127.0.0.1:8000`** with 7 CRUD + search endpoints r Write a single schema. ZCore dynamically prunes input fields (preventing mass assignment) and output fields (preventing data leakage) based on the active user's permission scopes. ```python +import uuid from zcore import Zchema -class TaskCreate(Zchema): +class TaskResponse(Zchema): __model__ = "tasks" + + id: uuid.UUID title: str - assignee_email: str | None = None # Auto-hidden from unauthorized users + salary: float # Automatically pruned if unauthorized ``` -When a user lacks `tasks:view_sensitive`, the `assignee_email` field vanishes from both validation and serialization โ€” no `if` statements. +When an authenticated user lacks view permissions for `tasks.salary`, the field vanishes from both JSON serialization and the OpenAPI schema โ€” with zero manual `if` statements.

๐Ÿ”— Atomic Transactions (Unit of Work)
-Group multiple repository operations into a single atomic unit. Events are queued and dispatched only after a successful commit. If any operation fails, the entire transaction rolls back. +Group multiple repository operations into a single atomic unit. Events are buffered and dispatched **only after** a successful database commit. If any operation fails, the entire transaction rolls back and pending events are discarded. ```python from zcore import UnitOfWork async with UnitOfWork(session, dispatcher) as uow: - await order_repo.create(order_data) + order = await order_repo.create(order_data) await inventory_repo.decrement_stock(product_id, quantity) - # Queue event for post-commit dispatch safely - uow.register_event("order.placed", {"id": order_data.id}) + # Queued safely: Dispatches ONLY after the DB commit succeeds! + uow.register_event("order.completed", {"order_id": str(order.id)}) ```
@@ -140,14 +145,15 @@ async with UnitOfWork(session, dispatcher) as uow: โšก Scoped Dependency Injection
-Inject services, repositories, and infrastructure dependencies via constructor injection. ZCore's container resolves them automatically per-request and clears request-scoped instances after each response. +Inject services, repositories, and infrastructure dependencies via standard constructor type hints. ZCore's IoC container auto-wires them dynamically and purges request-scoped instances after each response. ```python -from zcore import BaseService, Inject +from zcore import BaseService -class TaskService(BaseService): - def __init__(self, repo: TaskRepository = Inject(TaskRepository)): - super().__init__(model=Task, repository=repo) +class TaskService(BaseService[Task]): + # Pure Python constructor: Auto-wired by ZCore's DI container + def __init__(self, repository: TaskRepo): + super().__init__(model=Task, repository=repository) ``` @@ -155,38 +161,47 @@ class TaskService(BaseService): ๐Ÿ—๏ธ Modular Plugin System
-Each domain is a self-contained `Plugin` with its own models, services, routers, and lifecycle hooks. The Kernel uses topological sorting to guarantee correct startup/shutdown order. +Each domain is an isolated `Plugin` conforming to a strict lifecycle contract. The Kernel resolves dependencies topologically using a Directed Acyclic Graph (DAG) to guarantee deterministic startup and shutdown sequences. ```python +from fastapi import FastAPI from zcore import Plugin from .routers import router_instance -class TaskPlugin(Plugin): +class TasksPlugin(Plugin): name = "tasks" - dependencies = ["auth"] + version = "0.1.0" + dependencies = ["auth"] # Requires AuthPlugin to start first - def setup(self, app): + def setup(self, app: FastAPI) -> None: app.include_router(router_instance.router) - async def on_startup(self): - await cache.warm() + async def on_startup(self) -> None: + pass ```
-๐Ÿ” Secure Search Engine +๐Ÿ” Secure Dynamic Search Engine
-A dynamic query builder that translates nested JSON filters into safe SQL โ€” with depth-limit protection against DoS attacks. +A dynamic query builder that translates nested JSON filters into safe SQLAlchemy 2.0 AST queries โ€” featuring relation eager-loading, keyset cursor pagination, and depth-limit protection against DoS attacks. ```json { "filters": [ - { "field": "priority", "op": "eq", "value": "high" }, - { "field": "is_completed", "op": "eq", "value": false } + { + "op": "and", + "items": [ + { "field": "is_completed", "op": "eq", "value": false }, + { "field": "title", "op": "ilike", "value": "urgent" } + ] + } + ], + "include": ["assignee"], + "sort": [ + { "field": "created_at", "order": "desc" } ], - "sort_by": "created_at", - "sort_order": "desc", "page": 1, "size": 20 } @@ -197,15 +212,16 @@ A dynamic query builder that translates nested JSON filters into safe SQL โ€” wi ๐Ÿ“ฆ CLI Scaffolding
-Stop copying boilerplate. The `zc` CLI generates consistent, production-ready domain modules with a single command. +Stop writing boilerplate. The `zc` CLI generates consistent, production-ready domain modules and environment templates with a single command. | Command | Purpose | |---------|---------| | `zc init ` | Create a new project with settings, `.env`, and `main.py` | -| `zc startapp ` | Scaffold a domain module | -| `zc startapp --template` | Scaffold with full CRUD boilerplate | -| `zc run` | Launch Uvicorn with environment-aware config | -| `zc gensecret` | Generate a cryptographically secure `SECRET_KEY` | +| `zc startapp ` | Scaffold an empty domain module | +| `zc startapp --template` | Scaffold with full Model, Schema, Repo, Service, Router, and Plugin boilerplate | +| `zc run` | Launch Uvicorn with auto-reload and environment-aware config | +| `zc gensecret` | Generate a 64-character cryptographically secure secret key | +| `zc genenv` | Introspect registered `Settings` classes and scaffold `.env.example` |
--- @@ -214,11 +230,12 @@ Stop copying boilerplate. The `zc` CLI generates consistent, production-ready do | Resource | Description | |----------|-------------| -| [๐Ÿš€ 10-Minute Quickstart](https://fastapi-zcore-framework.github.io/zcore/learn/quickstart/) | Build a complete Task Manager API from scratch | -| [๐Ÿ“š Tutorial (Step-by-Step)](https://fastapi-zcore-framework.github.io/zcore/learn/overview/) | Deep dive into each architectural layer | -| [๐Ÿ”ง How-To Guides](https://fastapi-zcore-framework.github.io/zcore/how-to/auth-setup/) | JWT auth, transactions, field masking, storage, WebSockets | -| [๐Ÿ›๏ธ Architecture & Reference](https://fastapi-zcore-framework.github.io/zcore/features/infrastructure/di/) | Full API docs for DI, Kernel, Security, Caching, and more | -| [๐Ÿ“„ Cheat Sheet](https://fastapi-zcore-framework.github.io/zcore/cheatsheet/) | Pocket reference for quick syntax lookup | +| [๐Ÿš€ Quick Start](https://baseryn.github.io/zcore-docs/docs/quick-start) | Build a complete Task Manager API from scratch | +| [๐Ÿ“š 10-Step Quick Learn](https://baseryn.github.io/zcore-docs/docs/quick-learn/step-1) | Deep dive into each architectural layer step-by-step | +| [๐Ÿ”ง How-To Guides](https://baseryn.github.io/zcore-docs/docs/how-to) | Pagination, search, file uploads, caching, and testing | +| [๐Ÿ›๏ธ Core Concepts](https://baseryn.github.io/zcore-docs/docs/core-concepts/context) | Deep dive into DI, Kernel, Security, UoW, and Zchema | +| [๐Ÿ“œ API Reference](https://baseryn.github.io/zcore-docs/docs/api-reference/repository) | Complete class and method specifications | +| [๐Ÿ•’ Changelog](https://baseryn.github.io/zcore-docs/docs/changelog) | Release notes, breaking changes, and migrations | --- @@ -226,16 +243,16 @@ Stop copying boilerplate. The `zc` CLI generates consistent, production-ready do Contributions are welcome! Please read our guidelines before submitting a PR. -- **Issues:** Bug reports and feature requests via [GitHub Issues](https://github.com/fastapi-zcore-framework/zcore/issues) +- **Issues:** Bug reports and feature requests via [GitHub Issues](https://github.com/Baseryn/zcore/issues) - **PRs:** Open a pull request with a clear description of the change -- **Local Setup:** `pip install -e ".[all,dev]"` and run `pytest` to verify +- **Local Setup:** `pip install -e ".[all]"` and run `hatch test` to verify --- ## ๐Ÿ“„ License ZCore is licensed under the **Apache License 2.0**. -See [LICENSE](https://github.com/fastapi-zcore-framework/zcore/blob/master/LICENSE) for details. +See [LICENSE](https://github.com/Baseryn/zcore/blob/master/LICENSE) for details. --- diff --git a/docs/assets/4-service.png b/docs/assets/4-service.png deleted file mode 100644 index d47a565..0000000 Binary files a/docs/assets/4-service.png and /dev/null differ diff --git a/docs/assets/7-kernel.png b/docs/assets/7-kernel.png deleted file mode 100644 index 1a0ce39..0000000 Binary files a/docs/assets/7-kernel.png and /dev/null differ diff --git a/docs/assets/8-main.png b/docs/assets/8-main.png deleted file mode 100644 index 98b325b..0000000 Binary files a/docs/assets/8-main.png and /dev/null differ diff --git a/docs/assets/api-router.png b/docs/assets/api-router.png deleted file mode 100644 index 95f83ad..0000000 Binary files a/docs/assets/api-router.png and /dev/null differ diff --git a/docs/assets/auth-setup.png b/docs/assets/auth-setup.png deleted file mode 100644 index 26eed41..0000000 Binary files a/docs/assets/auth-setup.png and /dev/null differ diff --git a/docs/assets/banner.png b/docs/assets/banner.png deleted file mode 100644 index a78a1a7..0000000 Binary files a/docs/assets/banner.png and /dev/null differ diff --git a/docs/assets/cache.png b/docs/assets/cache.png deleted file mode 100644 index 989a69b..0000000 Binary files a/docs/assets/cache.png and /dev/null differ diff --git a/docs/assets/context.png b/docs/assets/context.png deleted file mode 100644 index 7b62511..0000000 Binary files a/docs/assets/context.png and /dev/null differ diff --git a/docs/assets/db-events.png b/docs/assets/db-events.png deleted file mode 100644 index 21d6a08..0000000 Binary files a/docs/assets/db-events.png and /dev/null differ diff --git a/docs/assets/di.png b/docs/assets/di.png deleted file mode 100644 index 6b54118..0000000 Binary files a/docs/assets/di.png and /dev/null differ diff --git a/docs/assets/events.png b/docs/assets/events.png deleted file mode 100644 index ee353f8..0000000 Binary files a/docs/assets/events.png and /dev/null differ diff --git a/docs/assets/favico.png b/docs/assets/favico.png deleted file mode 100644 index f908e2b..0000000 Binary files a/docs/assets/favico.png and /dev/null differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png deleted file mode 100644 index b64f58b..0000000 Binary files a/docs/assets/logo.png and /dev/null differ diff --git a/docs/assets/overview.png b/docs/assets/overview.png deleted file mode 100644 index 37dad1b..0000000 Binary files a/docs/assets/overview.png and /dev/null differ diff --git a/docs/assets/request-lifecycle.png b/docs/assets/request-lifecycle.png deleted file mode 100644 index 5ff9fb6..0000000 Binary files a/docs/assets/request-lifecycle.png and /dev/null differ diff --git a/docs/assets/search.png b/docs/assets/search.png deleted file mode 100644 index 3cc4ab2..0000000 Binary files a/docs/assets/search.png and /dev/null differ diff --git a/docs/assets/security.png b/docs/assets/security.png deleted file mode 100644 index 1ee0ee9..0000000 Binary files a/docs/assets/security.png and /dev/null differ diff --git a/docs/assets/storage.png b/docs/assets/storage.png deleted file mode 100644 index 0d59c74..0000000 Binary files a/docs/assets/storage.png and /dev/null differ diff --git a/docs/assets/stream.png b/docs/assets/stream.png deleted file mode 100644 index 7d153c3..0000000 Binary files a/docs/assets/stream.png and /dev/null differ diff --git a/docs/assets/zchema.png b/docs/assets/zchema.png deleted file mode 100644 index a3fbfc5..0000000 Binary files a/docs/assets/zchema.png and /dev/null differ diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md deleted file mode 100644 index 7aec530..0000000 --- a/docs/cheatsheet.md +++ /dev/null @@ -1,404 +0,0 @@ -# Pocket Reference & Cheat Sheet - -A rapid lookup guide for ZCore's core components, common patterns, and CLI commands. Each section links to the full documentation for deeper context. - ---- - -## Core Infrastructure - -### Dependency Injection - -| Concept | Usage | -|---|---| -| **IoCContainer** | `container = IoCContainer()` โ€” Central registry for all DI lifecycles | -| **Inject** | `service: MyService = Inject(MyService)` โ€” FastAPI-compatible DI wrapper | -| **Singleton** | `container.register_singleton(Interface, instance)` โ€” One instance per app lifetime | -| **Scoped** | `container.register_scoped(Interface, Impl)` โ€” One instance per HTTP request | -| **Transient** | `container.register_transient(Interface, Impl)` โ€” New instance every resolution | -| **Reflection Cache** | `_dependency_signature_cache` โ€” Caches `get_type_hints` results after first resolution | -| **Nested Dependencies** | Auto-wires recursive constructor graphs โ€” `Service โ†’ Repository โ†’ Session` | -| **Annotated Support** | Resolves `Annotated[Type, ...]` by extracting the base type | -| **SessionDep** | `db: SessionDep` โ€” `Annotated[AsyncSession, Depends(get_db)]` alias | -| **get_db** | `async def get_db()` โ€” Resolves scoped `AsyncSession` from container | - -[Full DI Documentation](features/infrastructure/di.md) - ---- - -### Framework Engine (Kernel) - -| Concept | Usage | -|---|---| -| **Plugin Protocol** | Class with `name`, `dependencies`, `setup()`, `on_startup()`, `on_shutdown()` | -| **Kernel** | `kernel = Kernel(); kernel.add_plugin(MyPlugin()); kernel.setup(app)` | -| **Lifespan** | `FastAPI(lifespan=kernel.lifespan)` โ€” Auto-orders startup/shutdown via DAG | -| **Dependency Sort** | `graphlib.TopologicalSorter` โ€” Ensures plugins init in correct order | -| **Missing Check** | Raises `RuntimeError` if a declared plugin dependency is not registered | - -[Full Kernel Documentation](features/infrastructure/kernel.md) - ---- - -### Kernel Events - -| Concept | Usage | -|---|---| -| **EventDispatcher** | `await dispatcher.dispatch("event.name", key=value)` | -| **Subscribe** | `@dispatcher.subscribe("event.name")` โ€” Decorate any sync/async handler | -| **Concurrent Execution** | `asyncio.gather(return_exceptions=True)` โ€” All async handlers run in parallel | -| **Failure Isolation** | One handler exception never blocks other handlers | - -[Full Events Documentation](features/infrastructure/events.md) - ---- - -### ASGI Middlewares - -| Concept | Usage | -|---|---| -| **RequestLogMiddleware** | `app.add_middleware(RequestLogMiddleware)` โ€” Logs method, path, duration, correlation ID | -| **ScopedDependencyMiddleware** | `app.add_middleware(ScopedDependencyMiddleware)` โ€” Initializes request-scoped DI context | -| **Correlation ID** | Auto-generated `x-request-id` header, validated against regex, returned in response | - -[Full Middleware Documentation](features/infrastructure/middleware.md) - ---- - -### Application Entry (main.py) - -| Concept | Usage | -|---|---| -| **Bootstrap Order** | `setup_logging()` โ†’ `db_manager.init_app()` โ†’ `Kernel()` โ†’ `FastAPI(lifespan=...)` โ†’ `kernel.setup(app)` โ†’ middlewares | -| **Exception Handler** | `app.add_exception_handler(AppException, app_exception_handler)` | -| **DB-Event Bridge** | `register_db_event_dispatcher(kernel.dispatcher)` | - -[Full Main Documentation](features/infrastructure/main.md) - ---- - -### Configuration Settings - -| Concept | Usage | -|---|---| -| **Settings Proxy** | `from zcore import settings` โ€” Lazy-resolved, no circular imports | -| **Pydantic V2** | `ZCoreCoreSettings` inherits `pydantic_settings.BaseSettings` | -| **Lazy Resolution** | `settings.DATABASE_URL` โ€” Resolved from IoC container on first access | -| **Env Switching** | `ENV_FILE=.env.test` โ€” Dynamic `.env` file selection | - -[Full Config Documentation](features/infrastructure/config.md) - ---- - -### CLI Scaffolding - -| Command | Description | -|---|---| -| `zc init ` | Create new project with secure `.env` and directory structure | -| `zc startapp ` | Scaffold a domain module (model, schema, repo, service, router, plugin) | -| `zc run` | Launch `uvicorn` with auto `PYTHONPATH` injection | -| `zc gensecret` | Generate a cryptographically secure 64-char secret key | - -[Full CLI Documentation](features/infrastructure/cli.md) - ---- - -### System Utilities - -| Function | Description | -|---|---| -| `json_dumps(data)` | Serialize UUID, Decimal, datetime safely | -| `json_loads(data)` | Deserialize with enhanced type support | -| `slugify(text)` | URL-safe slug: `"ZCore Framework!"` โ†’ `"zcore-framework"` | -| `validate_json_schema(data, schema)` | Validate against Draft-7, raises `ValidationError` on failure | - -[Full Utils Documentation](features/infrastructure/utils.md) - ---- - -## Data & Transactions - -### Database Manager - -| Concept | Usage | -|---|---| -| **init_app** | `db_manager.init_app(db_url="postgresql+asyncpg://...")` | -| **SQLite Bypass** | Auto-ignores `pool_size`/`max_overflow` for SQLite connections | -| **Session Context** | `async with db_manager.session() as session:` โ€” Auto rollback on exception | -| **Engine Access** | `db_manager._engine` โ€” Raw `AsyncEngine` for migrations | - -[Full DB Documentation](features/database/db.md) - ---- - -### Repository Pattern - -| Concept | Usage | -|---|---| -| **BaseRepository** | `class ProductRepo(BaseRepository[Product, CreateSchema, UpdateSchema])` | -| **Read/Write Mixins** | `get()`, `create()`, `update()`, `delete()`, `get_by_ids()` | -| **Exist Optimization** | `exists(**filters)` โ€” Uses `SELECT 1` for fast boolean check | -| **Search** | `search(SearchRequest)` โ€” Delegates to `SearchEngine` | - -[Full Repository Documentation](features/database/repository.md) - ---- - -### Business Services - -| Concept | Usage | -|---|---| -| **BaseService** | `class ProductService(BaseService[Product, CreateSchema, UpdateSchema])` | -| **Safe Commit** | `await self._safe_commit()` โ€” Context-aware, skips if UoW is active | -| **Lifecycle Hooks** | `after_create(obj)`, `after_update(obj)`, `after_delete(obj)` โ€” Override for side effects | - -[Full Service Documentation](features/database/service.md) - ---- - -### Unit of Work - -| Concept | Usage | -|---|---| -| **UoW** | `async with UnitOfWork(db_manager) as uow:` โ€” Atomic transaction boundary | -| **Post-Commit Events** | `uow.add_event("event.name", **kwargs)` โ€” Dispatched only after successful commit | -| **Isolation** | Multiple repos share the same transaction within a UoW block | - -[Full UoW Documentation](features/database/uow.md) - ---- - -### Database Events - -| Concept | Usage | -|---|---| -| **dispatch_db_event** | `dispatch_db_event("event.name", session, **kwargs)` โ€” Error-isolated propagation | -| **Registration** | `register_db_event_dispatcher(dispatcher)` โ€” Bridges DB events to app-level dispatcher | - -[Full DB Events Documentation](features/database/db-events.md) - ---- - -### Pagination Engine - -| Concept | Usage | -|---|---| -| **Cursor Pagination** | `CursorParams(cursor="base64string", size=20)` โ€” Keyset-based, stable under writes | -| **Offset Pagination** | `PageNumberParams(page=1, size=20)` โ€” Standard offset/limit | -| **Base64 Encoding** | Cursor is Base64-encoded for safe URL transport | -| **Count Optimization** | `order_by(None).count()` โ€” Strips ordering for faster count queries | - -[Full Pagination Documentation](features/database/pagination.md) - ---- - -### Dynamic Search Engine - -| Concept | Usage | -|---|---| -| **SearchRequest** | `SearchRequest(filters={...}, sorts=[...], eager_loads=[...])` | -| **JSON Query Builder** | Translates nested JSON filters to safe SQL WHERE clauses | -| **Depth Protection** | `max_depth` and `max_filters` โ€” Prevents DoS via deeply nested queries | -| **Wildcard Escaping** | Auto-escapes `%` and `_` in LIKE patterns | - -[Full Search Documentation](features/database/search.md) - ---- - -## Security & Shielding - -### Thread-Safe Context - -| Concept | Usage | -|---|---| -| **request_context** | `with request_context(user_id=..., fields={"products.cost"}):` | -| **get_current_user_id()** | Returns `uuid.UUID` from active context | -| **get_restricted_fields()** | Returns `frozenset` of restricted field paths | -| **Token Reset** | Context uses `contextvars.Token` for safe restoration | - -[Full Context Documentation](features/security/context.md) - ---- - -### Cryptographic Protocols - -| Concept | Usage | -|---|---| -| **Password Hashing** | `get_password_hash("pw")` โ€” Argon2id (64MB memory, 3 iterations, 4 threads) | -| **Token Creation** | `create_token(data={"sub": id, "scopes": [...]})` โ€” Auto-detects HS256 vs RS256 | -| **Production Guard** | Fatal `RuntimeError` if `SECRET_KEY` is default in production | -| **Asymmetric Detection** | Auto-switches to RS256 if `JWT_PRIVATE_KEY`/`JWT_PUBLIC_KEY` are set | - -[Full Security Documentation](features/security/security.md) - ---- - -### API Routing Mechanics - -| Concept | Usage | -|---|---| -| **ZCoreAPIRoute** | `APIRouter(route_class=ZCoreAPIRoute)` โ€” Custom route handler | -| **Schema Exposure** | `?schema=true` โ€” Returns context-pruned JSON-Schema | -| **Vary Header** | Auto-appends `Vary: Authorization, Cookie` on pruned responses | -| **Response Preservation** | Custom `response_class` is never silently overwritten | - -[Full API Router Documentation](features/security/api-router.md) - ---- - -### Zchema Security & Schema Pruning - -| Concept | Usage | -|---|---| -| **Zchema** | `class ProductBase(Zchema): __model__ = "products"` โ€” Single schema, context-aware | -| **Input Pruning** | `model_validator(mode="before")` โ€” Prevents mass assignment | -| **Output Pruning** | `model_serializer(mode="wrap")` โ€” Strips restricted fields from responses | -| **Schema Pruning** | `__get_pydantic_json_schema__` โ€” Hides restricted fields from `?schema=true` | - -[Full Zchema Documentation](features/security/zchema.md) - ---- - -### Centralized Exception Handler - -| Exception | HTTP Code | Usage | -|---|---|---| -| `EntityNotFound` | 404 | `raise EntityNotFound("User not found")` | -| `DuplicateEntity` | 409 | `raise DuplicateEntity("Email already exists")` | -| `AuthError` | 401 | `raise AuthError("Invalid credentials")` | -| `ForbiddenError` | 403 | `raise ForbiddenError("Insufficient permissions")` | -| `ValidationError` | 400 | `raise ValidationError("Invalid input")` | - -All exceptions return `ResponseWrapper` envelope with `error_type` and optional `payload`. - -[Full Exceptions Documentation](features/security/exceptions.md) - ---- - -## Advanced Ecosystem - -### Distributed Caching - -| Concept | Usage | -|---|---| -| **BaseCache** | `cache = BaseCache[ProductOut](prefix="products")` | -| **Get with Validation** | `await cache.get("key", target_type=ProductOut)` โ€” Auto Pydantic validation | -| **Set with TTL** | `await cache.set("key", value, ttl=300)` | -| **Fallback** | Redis โ†’ local `TTLLRUCache` (maxsize=1000) on failure | -| **Eviction Loop** | Background task runs every 60s, purges expired local entries | - -[Full Cache Documentation](features/ecosystem/cache.md) - ---- - -### File Storage & Security - -| Concept | Usage | -|---|---| -| **LocalStorageProvider** | `LocalStorageProvider(base_path="./uploads", validators=[...])` | -| **Validators** | `FileExtensionValidator`, `MaxFileSizeValidator`, `SafeMimeTypeValidator` | -| **Magic Bytes** | Reads first 2048 bytes, blocks PHP/HTML/MZ/Shebang patterns | -| **UUID Truncation** | Filenames use first 15 chars of `uuid4()` โ€” collision-resistant | -| **Path Traversal Guard** | `is_relative_to()` check prevents `../../etc/passwd` attacks | - -[Full Storage Documentation](features/ecosystem/storage.md) - ---- - -### WebSocket Streaming - -| Concept | Usage | -|---|---| -| **StreamManager** | `manager = StreamManager()` โ€” Per-user queue management | -| **Subscribe** | `async with manager.subscription(user_id) as queue:` โ€” Context manager with auto cleanup | -| **Publish** | `await manager.publish(user_id, {"type": "update", ...})` โ€” Redis PubSub + local fallback | -| **Queue Protection** | `asyncio.Queue(maxsize=100)` โ€” Overflowing queues are silently dropped | -| **Horizontal Scale** | All nodes share Redis PubSub pattern `stream:user:*` | - -[Full Stream Documentation](features/ecosystem/stream.md) - ---- - -## Quick CLI Reference - -```bash -zc init my_project # Create new project -zc startapp inventory # Scaffold domain module -zc run # Start dev server -zc gensecret # Generate secure secret key -zc --help # List all commands -``` - ---- - -## Common Patterns - -### Scaffold a Full CRUD Module - -```python -# 1. Model -class Product(Base): - __tablename__ = "products" - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - name: Mapped[str] - -# 2. Schema -class ProductBase(Zchema): - __model__ = "products" - name: str - -# 3. Repository -class ProductRepository(BaseRepository[Product, ProductBase, ProductBase]): - def __init__(self, db: AsyncSession): super().__init__(model=Product, db=db) - -# 4. Service -class ProductService(BaseService[Product, ProductBase, ProductBase]): - def __init__(self, repository: ProductRepository = Inject(ProductRepository)): - super().__init__(model=Product, repository=repository) - -# 5. Router (auto-generates 7 endpoints) -class ProductRouter(BaseRouter[ProductBase, ProductBase]): - model = Product - create_schema = ProductBase - update_schema = ProductBase - schema_out = ProductBase - service = ProductService - prefix = "/products" -``` - -### Register Exception Handler - -```python -from zcore.exceptions import AppException, app_exception_handler -app.add_exception_handler(AppException, app_exception_handler) -``` - -### Use SessionDep - -```python -from zcore.db import SessionDep - -@router.get("/items") -async def list_items(db: SessionDep): - result = await db.execute(select(Item)) - return result.scalars().all() -``` - -### Dispatch an Event - -```python -await dispatcher.dispatch("user.registered", user=user, timestamp=now) -``` - -### Use Unit of Work - -```python -async with UnitOfWork(db_manager) as uow: - user = await user_repo.create(data) - await uow.add_event("user.registered", user_id=user.id) -# Event dispatched only after successful commit -``` - ---- - -!!! tip "Full Documentation" - Each section above links to its complete documentation page. For the tutorial, see [Learn (Tutorial)](learn/overview/). For practical recipes, see [How-To Guides](how-to/auth-setup/). \ No newline at end of file diff --git a/docs/features/database/db-events.md b/docs/features/database/db-events.md deleted file mode 100644 index 71e8fc9..0000000 --- a/docs/features/database/db-events.md +++ /dev/null @@ -1,87 +0,0 @@ -# Database Event Bridge - -Decouple data persistence from application side-effects with secure, isolated event propagation. - ---- - -
-
- Type
- Middleware Bridge -
-
- Status
- Core Utility -
-
- Underlying Tech
- asyncio / structlog -
-
- -## The Challenge -Executing side-effects (like invalidating a cache or notifying a external service) directly after a database operation often leads to **Operational Fragility**. In standard implementations: -1. **Blocked Transactions:** If an event listener is slow or synchronous, it blocks the database session from closing, increasing connection wait times. -2. **Failure Leakage:** An exception in a "secondary" task (like a failing logging service) can propagate upwards and crash the primary request, even though the database write was successful. -3. **Tight Coupling:** The database layer becomes aware of high-level application concerns, making the architecture difficult to test or split into microservices later. - -## The ZCore Elegance -ZCore provides a **Global Event Bridge** specifically for database lifecycles. By using `dispatch_db_event`, you ensure that database events are transmitted to the application dispatcher within a secure `try/except` wrapper. This guarantees that no matter how many listeners fail or crash, the core database logic remains untouched and the primary execution flow continues. - -=== "ZCore Secure Dispatch" - :::python - from zcore.db import dispatch_db_event - - async def after_record_created(record): - # Dispatched via secure bridge - # Handler failures are logged but isolated - await dispatch_db_event( - "db.record_created", - payload={"id": record.id} - ) - -=== "Manual Unprotected Dispatch" - :::python - # Risks crashing the DB operation if a listener fails - async def after_record_created(record): - try: - await dispatcher.dispatch("db.record_created", record.id) - except Exception as e: - # You must manually remember to wrap every - # dispatch call to prevent leakage - logger.error(f"Listener failed: {e}") - ---- - -## Event Propagation Flow -The DB Event Bridge acts as a non-blocking safety buffer between the low-level data layer and the high-level event subscribers. - -

- Event Propagation Flow -

- ---- - -## Boundaries & Integration -The DB Event system bridges the gap between infrastructure and domain logic without forcing a lock-in. - -* **Kernel Integration:** During the `main.py` bootstrap, calling `register_db_event_dispatcher(kernel.dispatcher)` links the bridge to the primary application bus [db/events.py]. -* **Decoupled Payloads:** The `payload` argument accepts any Python object or dictionary, allowing you to pass raw database IDs or fully hydrated Pydantic models to your listeners. -* **Bypass:** If you require a "Strict Dispatch" (where the primary operation *should* fail if a listener fails), you can bypass the bridge and call `kernel.dispatcher.dispatch()` directly. - ---- - -## Under-the-Hood Spec - -### 1. The Global Dispatcher Registry -ZCore maintains a private `_global_dispatcher` reference [db/events.py]. This reference is populated once during application startup via `register_db_event_dispatcher`. This "Static Registry" pattern ensures that the database layer can access the event bus without requiring complex Dependency Injection (DI) passing through every repository. - -### 2. Async Failure Isolation -The `dispatch_db_event` function is an asynchronous wrapper [db/events.py]. It wraps the underlying dispatcher call in a comprehensive `try/except Exception` block. This is critical because it prevents "Greenlet Leakage" where an unhandled exception in an async event handler could otherwise terminate the entire coroutine stack. - -### 3. Structured Diagnostics -When a listener fails, the bridge uses `structlog` to output a detailed error record [db/events.py]. It captures the `event_name` and the specific `payload` that caused the failure, while setting `exc_info=True`. This allows architects to debug failing side-effects from logs without the users ever seeing an error response. - -!!! info "Event Handlers" - Database events are typically used for "Finalized" actions. If you need to trigger logic *within* a transaction, consider using `UnitOfWork.register_event` instead, which delays the bridge dispatch until the commit is confirmed. \ No newline at end of file diff --git a/docs/features/database/db.md b/docs/features/database/db.md deleted file mode 100644 index 514703f..0000000 --- a/docs/features/database/db.md +++ /dev/null @@ -1,93 +0,0 @@ -# Database Engine & Lifecycle - -Centralized management for asynchronous connection pools and transactional session lifecycles. - ---- - -
-
- Type
- Infrastructure Layer -
-
- Status
- Core Requirement -
-
- Underlying Tech
- SQLAlchemy 2.0 / AsyncIO -
-
- -## The Challenge -Managing database connections in an asynchronous environment requires strict discipline. Without a centralized manager, developers often face: -- **Connection Leaks:** Forgetting to close sessions or dispose of engines, leading to exhausted database pools. -- **SQLite Incompatibility:** Passing standard pooling parameters (like `pool_size` or `max_overflow`) to a SQLite driver often raises errors or causes locking issues. -- **Manual Rollbacks:** Writing repetitive `try/except/rollback/finally` blocks in every route or service to ensure data integrity during failures. -- **DI Fragmentation:** Inconsistent methods of injecting the database session into different application layers. - -## The ZCore Elegance -The `DatabaseManager` coordinates the entire SQLAlchemy lifecycle. It provides a unified `init_app` method that intelligently configures connection pools based on the database driver and exposes a thread-safe, asynchronous session factory. When combined with `SessionDep`, it ensures every request receives a cleanly managed transaction that is automatically rolled back on failure. - -=== "ZCore Orchestration" - :::python - # 1. Initialize in main.py - from zcore.db import db_manager, SessionDep - - db_manager.init_app( - db_url="postgresql+asyncpg://user:pass@host/db", - pool_size=20, - echo=False - ) - - # 2. Inject anywhere (Router, Service, or Repo) - @router.get("/data") - async def get_data(db: SessionDep): - # db is an active AsyncSession - result = await db.execute(select(MyModel)) - return result.scalars().all() - -=== "Standard SQLAlchemy Setup" - :::python - # Manual setup requires significant boilerplate - engine = create_async_engine("...", pool_size=20) - async_session = async_sessionmaker(engine, expire_on_commit=False) - - # Dependency for FastAPI - async def get_db(): - async with async_session() as session: - try: - yield session - # Manual commit might be needed here or in service - except Exception: - await session.rollback() - raise - finally: - await session.close() - - # And you must manually handle SQLite pool errors if switching envs - ---- - -## Boundaries & Integration -ZCore's database layer provides a chassis for SQLAlchemy without obscuring its standard features. - -* **SQLAlchemy 2.0 Native:** The `AsyncSession` provided by `SessionDep` is a standard SQLAlchemy object. You can use any native feature (Queries, Mappers, Extensions) without restriction. -* **IoC Resolved:** The `get_db` dependency retrieves the session from the ZCore `container`. This allows you to override the database session easily during integration tests using `app.dependency_overrides`. -* **Engine Access:** While typically managed internally, the raw `AsyncEngine` is accessible via `db_manager._engine` for low-level operations like creating tables or running migration scripts. - ---- - -## Under-the-Hood Spec - -### 1. The SQLite Pool Bypass -Inside `init_app`, ZCore performs a driver check: `if "sqlite" not in db_url`. If the connection string points to SQLite, ZCore silently ignores `pool_size`, `max_overflow`, and `pool_recycle` parameters [db/setup.py]. This prevents the common "Unexpected argument" errors thrown by the SQLite driver while allowing the same configuration code to work across Dev (SQLite) and Prod (PostgreSQL). - -### 2. Transactional Context Guard -The `db_manager.session()` context manager is built for safety [db/setup.py]. It wraps the session lifecycle in an `async with` block. If an unhandled exception occurs within that block, it explicitly calls `await session.rollback()` before propagating the error. This ensures that a failure in a service never leaves a transaction in a "hung" state. - -### 3. Pre-configured Resilience -The engine is initialized with `pool_pre_ping=True` and `expire_on_commit=False` by default [db/setup.py]. `pool_pre_ping` prevents "Server has gone away" errors by testing the connection before use, while `expire_on_commit=False` allows you to access model attributes after a commit without triggering additional, often failing, lazy-load queries. - -!!! info "Connection Recycling" - ZCore defaults `pool_recycle` to 1800 seconds (30 minutes). This prevents stale connections from being terminated by database-side timeouts, which is especially important for long-running cloud SQL instances. \ No newline at end of file diff --git a/docs/features/database/pagination.md b/docs/features/database/pagination.md deleted file mode 100644 index f3e9df3..0000000 --- a/docs/features/database/pagination.md +++ /dev/null @@ -1,91 +0,0 @@ -# Page-Number & Keyset Pagination - -High-performance dataset navigation with stable cursors, optimized count queries, and standardized metadata. - ---- - -
-
- Type
- Infrastructure Layer -
-
- Status
- Core Capability -
-
- Underlying Tech
- SQLAlchemy 2.0 / Base64 -
-
- -## The Challenge -Implementing pagination in FastAPI often results in "Leaky Implementation Details" and performance bottlenecks: -1. **Offset Drift:** In standard `LIMIT/OFFSET` pagination, if a new record is inserted while a user is on page 2, the same item may appear again on page 3. -2. **Count Inefficiency:** Running a `SELECT COUNT(*)` on a table with millions of rows every time a user navigates can slow down the entire database. -3. **Inconsistent Metadata:** Different endpoints returning different structures for `total`, `has_next`, or `page_size`, making frontend integration difficult. -4. **Complex Sorting:** Manually writing the SQL logic to sort by dynamic fields while ensuring stable ordering. - -## The ZCore Elegance -ZCore provides two standardized pagination engines: **Page-Number** (Offset-based) for small-to-medium datasets, and **Cursor** (Keyset-based) for high-performance, stable navigation in large datasets. Both engines are integrated into `BaseRepository` and use Pydantic models for strict parameter validation. - -=== "ZCore Repository Pagination" - :::python - from zcore.db import PageNumberParams, CursorParams - - # 1. Page-Number (Offset) Navigation - params = PageNumberParams(page=1, size=20, sort_by="created_at") - result = await repository.get_list(pagination=params) - # result.meta -> {total, page, total_pages, has_next, ...} - - # 2. Cursor (Keyset) High-Performance Navigation - cursor_params = CursorParams(size=50, cursor="encoded_string...") - result = await repository.get_list(pagination=cursor_params) - # result.meta -> {next_cursor, has_more, size} - -=== "FastAPI Raw Offset Implementation" - :::python - # Manual offset calculations and count queries - @app.get("/items") - async def get_items(page: int = 1, size: int = 20, db: AsyncSession = Depends(get_db)): - offset = (page - 1) * size - - # Manual count query - total = (await db.execute(select(func.count()).select_from(Item))).scalar() - - # Manual data query - query = select(Item).offset(offset).limit(size) - items = (await db.execute(query)).scalars().all() - - return { - "data": items, - "meta": {"total": total, "page": page, "size": size} - } - ---- - -## Boundaries & Integration -The pagination engine is built to handle raw SQLAlchemy queries without requiring ZCore-specific models. - -* **Select Object Support:** Both `paginate` methods accept a standard SQLAlchemy `Select` object [db/pagination.py]. You can apply complex joins and filters before passing the query to the paginator. -* **Pydantic Models:** The parameters `PageNumberParams` and `CursorParams` are standard Pydantic models. You can inherit from them to add custom filters or stricter validation. -* **Bypass:** If you require a non-standard pagination logic (e.g., specialized window functions), you can bypass the paginator and execute your own query using the repository's `db` session. - ---- - -## Under-the-Hood Spec - -### 1. The `order_by(None)` Count Optimization -When calculating the total count for Page-Number pagination, ZCore clones the original query and strips the `ORDER BY` clause using `.order_by(None)` [db/pagination.py]. This is a critical database optimization because the database engine does not need to perform expensive sorting just to count the number of rows that match the filters. - -### 2. Base64 Cursor Keyset -`CursorPagination` generates an opaque, URL-safe Base64 string for navigation [db/pagination.py]. This cursor contains a JSON payload with the sorting value (e.g., a timestamp) and the primary key (UUID) to act as a tie-breaker. This ensures that even if two items have the exact same timestamp, the pagination remains stable and unique. - -### 3. Date & Time ISO Transformation -The cursor encoder automatically detects `datetime` objects [db/pagination.py]. It ensures they are converted to UTC and serialized in ISO 8601 format before being Base64 encoded. This guarantees that cursors generated on one server instance can be safely decoded on another, regardless of local server time settings. - -### 4. Tie-Breaker Logic (desc/asc) -To prevent "skipped" items, the cursor query uses a tuple-comparison approach [db/pagination.py]. For a descending sort, it generates a query like: `WHERE (col < last_val) OR (col == last_val AND pk < last_pk)`. This ensures that every single record is visited in a deterministic order. - -!!! info "Cursor UI" - Because cursors are opaque strings, they are ideal for "Infinite Scroll" or "Load More" UI patterns. Use Page-Number pagination only when the user needs to jump to a specific numbered page (e.g., "Go to page 15"). \ No newline at end of file diff --git a/docs/features/database/repository.md b/docs/features/database/repository.md deleted file mode 100644 index b63574d..0000000 --- a/docs/features/database/repository.md +++ /dev/null @@ -1,92 +0,0 @@ -# Repository Pattern - -Decouple business logic from database execution with high-performance, type-safe, and reusable data interfaces. - ---- - -
-
- Type
- Data Access Layer -
-
- Status
- Recommended -
-
- Underlying Tech
- SQLAlchemy 2.0 / Pydantic V2 -
-
- -## The Challenge -In many FastAPI projects, database queries are scattered directly inside route handlers. This leads to: -- **Repetitive Query Logic:** Manually writing `session.execute(select(...))` and `.scalars().all()` for every endpoint. -- **Leaky Abstractions:** Changing a column name in the database forces you to find and update logic in dozens of router files. -- **Testing Hardship:** Mocking a raw `AsyncSession` with complex chain calls is significantly harder than mocking a high-level `get_by_id` method. -- **Inconsistent Pagination:** Different developers implementing offset or cursor logic differently, leading to unpredictable API behavior. - -## The ZCore Elegance -The `BaseRepository` provides a robust, pre-optimized set of CRUD and search operations. It leverages Python generics to ensure type safety across your domain and integrates seamlessly with ZCoreโ€™s specialized search and pagination engines. By inheriting from the repository, you gain standardized methods for batch operations, partial updates, and optimized existence checks out of the box. - -=== "ZCore Repository" - :::python - from zcore import BaseRepository - from .models import User - from .schemas import UserCreate, UserUpdate - - class UserRepository(BaseRepository[User, UserCreate, UserUpdate]): - def __init__(self, db: AsyncSession): - super().__init__(model=User, db=db) - - # Custom logic remains clean and isolated - async def get_active_users(self): - query = self._get_base_query().where(User.is_active == True) - result = await self.db.execute(query) - return result.scalars().all() - -=== "Manual SQLAlchemy Implementation" - :::python - # Repetitive boilerplate required for every entity - async def get_user(db: AsyncSession, user_id: uuid.UUID): - query = select(User).where(User.id == user_id) - result = await db.execute(query) - return result.scalars().first() - - async def update_user(db: AsyncSession, user_id: uuid.UUID, data: UserUpdate): - query = select(User).where(User.id == user_id) - user = (await db.execute(query)).scalars().first() - if user: - for key, value in data.model_dump().items(): - setattr(user, key, value) - await db.flush() - return user - ---- - -## Boundaries & Integration -ZCore Repositories are designed to be thin wrappers that expose the full power of SQLAlchemy. - -- **Standard AsyncSession:** The `self.db` attribute is a standard `AsyncSession`. You can execute any raw SQL or complex SQLAlchemy expression whenever the scaffolded methods are insufficient. -- **Query Extensibility:** Use `_get_base_query()` to define default filters (e.g., Soft Delete logic) and `_extend_query()` for custom joins that should apply to all read operations. -- **Eager Loading:** All read methods (`get`, `get_list`, etc.) accept an `options` parameter for standard SQLAlchemy loader options like `joinedload` or `selectinload`. -- **Selective Loading:** The `fields` parameter allows you to perform `load_only` queries, ensuring you only fetch the data you actually need from the wire. - ---- - -## Under-the-Hood Spec - -### 1. Automatic PK Inspection -Upon initialization, `BaseRepository` uses the SQLAlchemy `inspect` module to automatically identify the model's primary key [db/repository.py]. It maps this to `self.pk` and `self.pk_name`, allowing the generic CRUD methods to work regardless of whether your primary key is named `id`, `uuid`, or a custom field. - -### 2. The `get_by_ids` Fast Path -The `get_by_ids` implementation includes a high-performance "fast path" [db/repository.py]. If an empty list is passed as the `ids` parameter, the repository returns an empty sequence immediately without making a network round-trip to the database engine. - -### 3. Optimized Existence Checks -The `exist()` method is optimized for minimal database overhead [db/repository.py]. Instead of loading a full model instance, it executes a `select(1).where(...)` with a `limit(1)` clause, which is significantly faster and consumes less memory on both the application and database server. - -### 4. Flush-Oriented Mutations -All write operations (`create`, `update`, `delete`) in the repository use `await self.db.flush()` and `await self.db.refresh()` instead of `commit()` [db/repository.py]. This ensures that changes are validated by the database but remain part of an open transaction, allowing the `UnitOfWork` to manage the final atomic commit boundary. - -!!! info "Cursor Field" - By default, the repository uses the primary key as the `cursor_field` for keyset pagination. You can override this in your subclass (e.g., `self.cursor_field = "created_at"`) to support stable pagination on chronological data. \ No newline at end of file diff --git a/docs/features/database/search.md b/docs/features/database/search.md deleted file mode 100644 index e30081a..0000000 --- a/docs/features/database/search.md +++ /dev/null @@ -1,111 +0,0 @@ -# Dynamic Search Engine - -Securely translate structured JSON filters, complex sorting, and relational eager-loading into optimized SQLAlchemy queries. - ---- - -
-
- Type
- Query Utility -
-
- Status
- Recommended -
-
- Underlying Tech
- SQLAlchemy 2.0 / Pydantic -
-
- -## The Challenge -Building a truly dynamic search endpoint (e.g., `POST /products/search`) in standard FastAPI is a security and maintenance nightmare. Developers often face: -1. **Manual Filter Mapping:** Writing endless `if` statements for every possible combination of `price_gt`, `category_eq`, or `name_ilike`. -2. **Unsafe Joins:** Manually managing table joins for relational filters, often leading to duplicate rows or Cartesian products. -3. **Recursive DoS:** Deeply nested "OR" and "AND" JSON filters that can crash a database if the query complexity isn't limited. -4. **Security Leaks:** Clients accidentally filtering or sorting by restricted fields (like `margin` or `internal_id`) that should be hidden from their role. - -## The ZCore Elegance -The `SearchEngine` provides a secure, JSON-driven query builder. It parses a `SearchRequest` object containing nested filters, sorting rules, and relation-preload paths. It automatically coerces types, validates the request against active context restrictions, and limits the query depth to prevent resource exhaustion. - -=== "ZCore Dynamic Search" - :::python - from zcore.db import SearchRequest, SearchEngine - - # 1. Define a structured search payload - request = SearchRequest( - filters=[ - {"field": "price", "op": "gt", "value": 100}, - {"op": "or", "items": [ - {"field": "category.name", "op": "eq", "value": "Electronics"}, - {"field": "tags", "op": "ilike", "value": "premium"} - ]} - ], - include=["category", "tags"], - sort=[{"field": "price", "order": "desc"}] - ) - - # 2. Build and execute securely via Repository - results = await product_repository.search(request) - -=== "FastAPI Raw Manual Filtering" - :::python - # Replicating dynamic logic requires hundreds of lines: - @router.get("/search") - async def manual_search( - price_gt: float = None, - category: str = None, - db: AsyncSession = Depends(get_db) - ): - query = select(Product) - if price_gt: - query = query.where(Product.price > price_gt) - if category: - query = query.join(Category).where(Category.name == category) - - # Manually handling nested OR/AND is almost impossible - # to write safely and generically for every endpoint. - result = await db.execute(query) - return result.scalars().all() - ---- - -## Search Workflow -The `SearchEngine` validates and compiles the request through a strict security-first pipeline. - -

- Search Workflow -

- - ---- - -## Boundaries & Integration -ZCore provides a dynamic bridge without locking you out of SQLAlchemy's native power. - -* **Select Object Return:** The core engine method `build_base_query()` returns a standard SQLAlchemy `Select` object. You can still apply additional `.where()` clauses or `.limit()` before execution. -* **Custom Handlers:** If a specific field requires complex custom logic (e.g., a full-text search vector), you can register a custom handler: `engine.register_handler("full_text", my_handler_func)`. -* **Bypass:** If you only need simple primary key lookups, use the repository's `get()` or `get_by_ids()` methods instead of the search engine to save processing overhead. - ---- - -## Under-the-Hood Spec - -### 1. Recursive Depth Protection -To defend against "Recursive DoS" attacks, the `SearchEngine` enforces a `max_depth` limit (default: 3) [db/search.py]. If a client sends a JSON payload with more than 3 levels of nested `or`/`and` operators, the engine raises a `ValidationError` before any SQL is generated. - -### 2. Intelligent Loader Strategy -The engine automatically chooses the most efficient SQLAlchemy loader [db/search.py]. It inspects the model relations: -- **`joinedload`**: Used for many-to-one or one-to-one relationships to minimize queries. -- **`selectinload`**: Used for one-to-many or many-to-many collections to avoid Cartesian products and "N+1" problems. - -### 3. Safe Type Coercion -Since JSON sends everything as primitives (strings/numbers), the engine uses model inspection to coerce values [db/search.py]. It automatically detects if a target column is a `uuid.UUID`, `date`, or `datetime` and attempts to parse the incoming string using ISO 8601 standards before creating the comparison expression. - -### 4. Wildcard Sanitization -For `ilike` operations, ZCore automatically escapes SQL-sensitive wildcards (`%`, `_`, `\`) [db/search.py]. This prevents clients from performing "blind" wildcard searches that could lead to full-table scans and performance degradation. - -!!! info "Security Isolation" - The SearchEngine is strictly integrated with `zcore.context`. If a field dot-path (e.g., `category.secret_code`) is restricted in the active user context, any attempt to filter, sort, or include that field will result in a `ForbiddenError`. \ No newline at end of file diff --git a/docs/features/database/service.md b/docs/features/database/service.md deleted file mode 100644 index 5e55452..0000000 --- a/docs/features/database/service.md +++ /dev/null @@ -1,103 +0,0 @@ -# Business Service Orchestration - -Coordinate domain logic with transactional safety and granular lifecycle hooks. - ---- - -
-
- Type
- Domain Logic Layer -
-
- Status
- Highly Recommended -
-
- Underlying Tech
- Python / Unit of Work -
-
- -## The Challenge -When business logic is scattered across routers, applications become fragile and hard to test. Developers struggle with: -- **Transaction Splitting:** Sub-operations accidentally committing early, leaving the database in a partial state if a subsequent step fails. -- **Hook Spaghetti:** Manually triggering side effects (like sending an email or clearing a cache) inside every route handler. -- **Redundant Error Checks:** Repeating `if not item: raise HTTPException(404)` in every single endpoint. -- **Tight Coupling:** Business rules depending directly on HTTP request objects, making them impossible to run in background tasks or CLI scripts. - -## The ZCore Elegance -The `BaseService` acts as a pure business orchestrator. It wraps repository calls in a strict **Lifecycle Hook** pipeline and implements the **Safe Commit** protocol. This ensures that domain events are triggered correctly and transactions are only finalized if they are not part of a larger, external `UnitOfWork`. - -=== "ZCore Service Orchestration" - :::python - from zcore import BaseService, Inject - from .repositories import OrderRepository - from .models import Order - - class OrderService(BaseService[Order, OrderCreate, OrderUpdate]): - def __init__(self, repo: OrderRepository = Inject(OrderRepository)): - super().__init__(model=Order, repository=repo) - - async def post_create(self, model: Order): - # Side effect triggered automatically and safely - await notify_service.send_order_confirmation(model) - - async def pre_update(self, id, schema, partial): - # Domain validation before DB hit - if schema.status == "cancelled" and not user.is_admin: - raise ForbiddenError("Only admins can cancel orders") - -=== "FastAPI Manual Orchestration" - :::python - # Logic mixed with HTTP concerns in the router - @router.post("/") - async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)): - # 1. Manual Validation - if data.amount <= 0: raise HTTPException(400) - - # 2. Manual DB Logic - order = Order(**data.model_dump()) - db.add(order) - try: - await db.commit() # Dangerous: might break outer transactions - except Exception: - await db.rollback() - raise - - # 3. Manual Side Effect (prone to being forgotten) - await notify_service.send_confirmation(order) - return order - ---- - -## Boundaries & Integration -ZCore Services are framework-agnostic units that coordinate between the Web and Data layers. - -- **Web Independent:** Services never accept `Request` objects or return `JSONResponse`. They operate strictly on Pydantic schemas and SQLAlchemy models, allowing them to be called from Routers, Background Tasks, or CLI commands. -- **Service-to-Service DI:** Use `Inject()` in the constructor to pull in other domain services. ZCore's DI container handles the resolution and ensures correct lifecycles. -- **Hook Bypass:** If you need to perform an operation without triggering hooks (e.g., for data migrations), you can call the "raw" methods like `on_create` or `on_update` directly. -- **Transactional Unity:** Services automatically detect if they are running inside a `UnitOfWork` block and will defer commits to the outer orchestrator. - ---- - -## Under-the-Hood Spec - -### 1. The Safe Commit Protocol -Every mutation method in `WriteServiceMixin` concludes with `await self._safe_commit()` [service/base.py]. This method inspects the database session's internal `info` dictionary for the `uow_managed` flag. If a `UnitOfWork` context is active, the service **refrains from committing**, preserving the atomicity of the larger transaction. - -### 2. Execution Hook Pipeline -ZCore enforces a strict sequence for all write operations. For example, the `create` method follows this path [service/base.py]: -1. `pre_create(schema)`: Validation or data enrichment. -2. `on_create(schema)`: The actual repository write (Flush). -3. `post_create(model)`: Side effects like logging or events. -4. `_safe_commit()`: Final transactional boundary check. - -### 3. Automated Error Mapping -The `ReadServiceMixin` intercepts empty repository results and automatically raises an `EntityNotFound` exception [service/base.py]. This ensures that your API consistently returns a standardized 404 response through the global exception handler without requiring manual checks in your business code. - -### 4. Post-Read Interceptors -Read operations (`get`, `get_list`) include `post_get` and `post_get_multi` hooks [service/base.py]. These are ideal for calculating dynamic properties or performing permission-based masking on model attributes before they reach the web serialization layer. - -!!! info "Post-Search Hook" - The `SearchServiceMixin` also includes a `post_search` hook, allowing you to batch-process results from complex dynamic queries before returning them to the client. \ No newline at end of file diff --git a/docs/features/database/uow.md b/docs/features/database/uow.md deleted file mode 100644 index a65d892..0000000 --- a/docs/features/database/uow.md +++ /dev/null @@ -1,96 +0,0 @@ -# Unit of Work (Atomic Transactions) - -Coordinate multiple domain operations into a single atomic transaction with safe, post-commit event dispatching. - ---- - -
-
- Type
- Transaction Orchestrator -
-
- Status
- Highly Recommended -
-
- Underlying Tech
- SQLAlchemy 2.0 / asyncio -
-
- -## The Challenge -In complex business flows (e.g., a **Checkout** involving Inventory, Payment, and Shipping), maintaining data integrity is difficult. Standard implementations often suffer from: -1. **Premature Commits:** A sub-service (e.g., `InventoryService`) commits its changes independently. If the subsequent `PaymentService` fails, the inventory is already deducted, leaving the system in an inconsistent state. -2. **Ghost Notifications:** An event (like a "Welcome Email") is dispatched as soon as a user is created. If the database transaction later fails and rolls back, the user receives an email for an account that doesn't exist. -3. **Boilerplate Fatigue:** Wrapping every multi-step operation in `try/except/rollback` blocks leads to noisy, error-prone code. - -## The ZCore Elegance -The `UnitOfWork` (UOW) acts as the single source of truth for a transaction's lifecycle. It marks the database session as "Managed," signaling to all internal ZCore services that they must defer their commits. Furthermore, it provides an event buffer that guarantees domain events are **only dispatched after** a successful database commit. - -=== "ZCore Unit of Work" - :::python - from zcore import UnitOfWork, Inject - - async def checkout_flow( - data: CheckoutSchema, - order_service: OrderService = Inject(OrderService), - inventory_service: InventoryService = Inject(InventoryService), - dispatcher: EventDispatcher = Inject(EventDispatcher), - session: AsyncSession = SessionDep - ): - # 1. Atomic context manager - async with UnitOfWork(session, dispatcher) as uow: - # Sub-services detect UOW and skip internal commits - order = await order_service.place_order(data) - await inventory_service.reduce_stock(order.items) - - # 2. Register event for post-commit dispatch - uow.register_event("order.placed", {"id": order.id}) - - # 3. Auto-commit on exit / Auto-rollback on exception - -=== "Raw SQLAlchemy Implementation" - :::python - async def checkout_flow(data, db, dispatcher): - try: - # 1. Manual order logic - order = await create_order(db, data) - # 2. Manual inventory logic - await reduce_stock(db, order.items) - - # 3. Manual commit - await db.commit() - - # 4. Manual event dispatch (Risk: if commit failed, - # or if this fails, logic becomes messy) - await dispatcher.dispatch("order.placed", {"id": order.id}) - except Exception: - # 5. Manual rollback - await db.rollback() - raise - ---- - -## Boundaries & Integration -ZCoreโ€™s UOW is a high-level orchestrator that respects underlying SQLAlchemy protocols. - -* **Session Native:** The UOW operates on a standard SQLAlchemy `AsyncSession`. You can perform raw SQL executions within a UOW block, and they will be part of the same atomic transaction. -* **Service Interlock:** ZCore's `BaseService` is programmed to check for the `uow_managed` flag. It will only commit if it is the primary owner of the session, ensuring it doesn't break a UOW's boundary [service/base.py]. -* **Bypass:** For single-operation requests (like a simple GET or a standalone POST), you do not need the UOW. ZCore services will handle their own "Safe Commit" logic automatically. - ---- - -## Under-the-Hood Spec - -### 1. The `uow_managed` Metadata -When entering the `async with` block, the UOW sets `session.info["uow_managed"] = True` [db/uow.py]. This is a standard SQLAlchemy metadata dictionary. ZCore services inspect this flag before calling `commit()`. If `True`, they execute a `flush()` to send changes to the DB buffer but skip the `commit()`, delegating finality to the UOW. - -### 2. Transactional Event Buffering -The UOW maintains an internal `_pending_events` list [db/uow.py]. When `register_event()` is called, the event is not fired immediately. Instead, it is stored in the buffer. The UOW only iterates through this list and calls `dispatcher.dispatch()` **after** `session.commit()` has successfully returned. - -### 3. Automated Lifecycle Safety -The `__aexit__` method handles the logic of the transaction [db/uow.py]. If an exception was raised inside the block, it triggers `rollback()` and clears the event buffer. If no exception occurred, it triggers `commit()`. This ensures that even if a developer forgets to call commit manually, the transaction is handled correctly by the context manager. - -!!! success "Event Isolation" - If a database commit succeeds but an event handler subsequently fails, the database remains committed. ZCore logs the event handler failure separately, ensuring that side effects (like failing to send an email) do not retroactively break your database integrity. \ No newline at end of file diff --git a/docs/features/ecosystem/cache.md b/docs/features/ecosystem/cache.md deleted file mode 100644 index 11dc3cf..0000000 --- a/docs/features/ecosystem/cache.md +++ /dev/null @@ -1,127 +0,0 @@ -# Distributed Caching - -Accelerate response times with a transparent, dual-layer caching system that routes through Redis and falls back to a thread-safe in-memory LRU store with automatic TTL eviction. - ---- - -
-
- Type
- Performance Layer -
-
- Status
- Optional Ecosystem -
-
- Underlying Tech
- Redis / OrderedDict + threading -
-
- -## The Challenge - -Caching in an async Python application introduces several subtle failure modes: - -1. **Cache Stampede Risk:** When the distributed cache (Redis) goes down, every request hits the database simultaneously, overwhelming the connection pool. -2. **Type Corruption:** Serialized JSON blobs are returned as raw dictionaries. Consumers must manually validate or cast the data back to the expected Pydantic model, often leading to `AttributeError` at runtime. -3. **Memory Leaks:** Naive in-memory caches keep references to objects indefinitely. In long-running async workers, this causes unbounded memory growth and eventual OOM kills. -4. **Stale Data Propagation:** Expired keys linger in memory until explicitly checked, returning stale data to users. - -## The ZCore Elegance - -ZCore provides `BaseCache`, a generic interface that transparently routes reads and writes through Redis when available, falling back to a local `TTLLRUCache` with thread-safe access, automatic TTL expiry, and weak-reference tracking for global eviction sweeps. - -=== "ZCore Dual-Layer Caching" - :::python - from zcore.cache import BaseCache, init_cache, close_cache - - # 1. Initialize Redis (optional) and eviction loop - await init_cache(redis_url="redis://localhost:6379/0") - - # 2. Create a typed cache for a domain - product_cache = BaseCache[ProductOut](prefix="products") - - # 3. Cache-aside pattern with automatic Pydantic validation - async def get_product(id: uuid.UUID) -> ProductOut: - cached = await product_cache.get(str(id), target_type=ProductOut) - if cached: - return cached - product = await fetch_from_db(id) - await product_cache.set(str(id), product.model_dump(), ttl=300) - return product - - # 4. Graceful shutdown - await close_cache() - -=== "Standard Manual Caching" - :::python - import json - import redis.asyncio as aioredis - from functools import lru_cache - - # Manual Redis client with no fallback - client = aioredis.from_url("redis://localhost:6379/0") - - @lru_cache(maxsize=128) # No TTL, no thread-safety in async - async def get_product(id: uuid.UUID) -> ProductOut: - raw = await client.get(f"product:{id}") - if raw: - data = json.loads(raw) - return ProductOut(**data) # No validation, no fallback on Redis failure - product = await fetch_from_db(id) - await client.set(f"product:{id}", product.model_dump_json(), ex=300) - return product - - # Redis failure โ†’ all requests hit DB โ†’ stampede - # lru_cache is not async-safe - # No global cleanup mechanism - ---- - -## Cache Resolution Strategy - -

- Cache Resolution Strategy -

- ---- - -## Boundaries & Integration - -The caching layer is fully optional and designed to be composed with other ZCore components. - -* **Pydantic-First:** The `get` method accepts an optional `target_type` parameter. If a Pydantic model is provided, the deserialized dictionary is validated through `model_validate` before being returned. This guarantees type safety without manual casting. -* **Prefix Isolation:** Each `BaseCache` instance is created with a `prefix` string (e.g., `"products"`). All keys are automatically namespaced as `{prefix}:{key}`, preventing collision between different cache domains sharing the same Redis instance. -* **Background Eviction:** `init_cache` schedules a background `asyncio.Task` that runs every 60 seconds, calling `TTLLRUCache.evict_all_expired()` to purge stale records from all active in-memory cache instances. - ---- - -## Under-the-Hood Spec - -### 1. BaseCache / TTLLRUCache Fallback Architecture - -`BaseCache` holds a reference to a `TTLLRUCache` instance (default `maxsize=1000`) as `_local_cache` [cache/base.py]. On `set`, the method attempts Redis first. If Redis is unavailable or raises an exception (connection refused, timeout, etc.), the serialized value is written to the local LRU store. On `get`, the method tries Redis, then falls back to the local cache. - -### 2. Pydantic Type Validation on Cache Hits - -When `target_type` is provided and is a `BaseModel` subclass, the `get` method calls `target_type.model_validate(parsed_data)` [cache/base.py]. This ensures that the deserialized payload conforms exactly to the expected schema. If validation fails (e.g., due to schema drift between cache writes and reads), `None` is returned and the miss is treated as a cache miss. - -### 3. Eviction Loop Safety - -The global eviction loop `_start_eviction_loop` runs as a background `asyncio.Task` [cache/base.py]. It catches `asyncio.CancelledError` to exit cleanly and wraps the eviction call in a generic `except Exception` to prevent a single faulty cache instance from crashing the entire loop. - -### 4. WeakRef Active Cache Registry - -`TTLLRUCache` registers every new instance in a module-level `weakref.WeakSet` called `_active_caches` [cache/ttllru_cache.py]. This enables `evict_all_expired` to iterate over all existing cache instances without preventing garbage collection of discarded instances. When an instance is garbage collected, its weak reference is automatically removed from the set. - -### 5. Thread-Safe LRU with Reentrant Lock - -Each `TTLLRUCache` instance uses a `threading.Lock` to protect all read and write operations [cache/ttllru_cache.py]. The underlying storage is an `OrderedDict` mapping keys to `(expiry_timestamp, value)` tuples. On `get`, expired keys are detected inline by comparing `time.time()` against the stored expiry, and are popped before `move_to_end` slides the remaining entry to the front. - -!!! info "Redis Dependency" - The `BaseCache` Redis client (`redis.asyncio`) is imported lazily. If `redis` is not installed, `REDIS_AVAILABLE` is set to `False` and the cache operates exclusively in local fallback mode without raising import errors. - -!!! tip "Sizing the Local Cache" - The local `TTLLRUCache` defaults to a `maxsize` of 1000 entries. Adjust this in environments with high cardinality but limited memory. The LRU policy ensures that the most recently accessed items are always available. \ No newline at end of file diff --git a/docs/features/ecosystem/storage.md b/docs/features/ecosystem/storage.md deleted file mode 100644 index a88fd2f..0000000 --- a/docs/features/ecosystem/storage.md +++ /dev/null @@ -1,139 +0,0 @@ -# File Storage & Security - -Upload, stream, and delete files through a pluggable provider interface with multi-layer validationโ€”extension checks, size limits, and magic byte MIME verificationโ€”all backed by path traversal protection. - ---- - -
-
- Type
- File Management -
-
- Status
- Optional Ecosystem -
-
- Underlying Tech
- aiofiles / Magic Bytes -
-
- -## The Challenge - -File upload handling in web applications is a frequent source of security vulnerabilities and operational headaches: - -1. **Unrestricted File Size:** Large uploads can exhaust disk space, memory, or worker I/O, leading to Denial of Service (DoS). -2. **Extension Masquerading:** An attacker uploads a `.png` file that contains a PHP web shell or a Windows executable. The server trusts the extension and executes the payload. -3. **Path Traversal:** Malicious filenames like `../../etc/passwd` trick naive storage code into writing outside the designated upload directory. -4. **Filename Collisions:** Multiple users uploading `avatar.jpg` overwrite each other's files. - -## The ZCore Elegance - -ZCore provides a `LocalStorageProvider` that enforces strict path bounds, generates collision-resistant filenames using truncated UUIDs, and chains configurable validators for extension, file size, and magic byte MIME verification. - -=== "ZCore Secure File Upload" - :::python - from zcore.storage import ( - LocalStorageProvider, - FileExtensionValidator, - MaxFileSizeValidator, - SafeMimeTypeValidator, - ) - - # 1. Configure layered validators - validators = [ - FileExtensionValidator(allowed_extensions=["png", "jpg", "pdf"]), - MaxFileSizeValidator(max_size_mb=10), - SafeMimeTypeValidator(allowed_mimes=["image/png", "image/jpeg", "application/pdf"]), - ] - - # 2. Create the provider - storage = LocalStorageProvider( - base_path="./uploads", - validators=validators, - ) - - # 3. Upload (validated and sanitized) - path = await storage.upload(file=uploaded_file, related_type="avatars") - # path โ†’ "uploads/avatars/a1b2c3d4e5f6789.jpg" - -=== "Standard File Upload" - :::python - import aiofiles - import uuid - from pathlib import Path - - @app.post("/upload") - async def upload(file: UploadFile): - # Manual validationโ€”easy to skip or forget - if not file.filename.endswith((".png", ".jpg")): - raise HTTPException(400, "Invalid extension") - - # Vulnerable to path traversal - path = f"./uploads/{file.filename}" - - # No size limit enforcement - async with aiofiles.open(path, "wb") as f: - while chunk := await file.read(): - await f.write(chunk) - - # Filename collisions overwrite existing files - ---- - -## Validation Pipeline - -

- Validation Pipeline -

- - ---- - -## Boundaries & Integration - -The storage layer is designed for flexibility across deployment environments. - -* **Pluggable Providers:** The abstract `StorageProvider` base class defines the contract (`upload`, `upload_stream`, `delete`). `LocalStorageProvider` is the built-in implementation, but you can write custom providers for AWS S3, Google Cloud Storage, or any other backend by subclassing `StorageProvider`. -* **IoC Integration:** `get_storage_provider` is a FastAPI dependency that resolves the active `StorageProvider` from ZCore's DI container. This allows you to swap storage backends without changing endpoint code. -* **Validator Composability:** Validators are standalone classes implementing `BaseStorageValidator`. They are passed as a list to `LocalStorageProvider`, which iterates them in order. Any validator raising `ValidationError` aborts the upload before any disk I/O occurs. - ---- - -## Under-the-Hood Spec - -### 1. MaxFileSizeValidator Enforcement - -The `MaxFileSizeValidator` converts the configured megabyte threshold to raw bytes (`max_size_mb * 1024 * 1024`) [storage/validators.py]. It reads the file size from Starlette's native `UploadFile.size` attribute when available. For chunked multipart streams where `size` is `None`, it falls back to seeking to the end of the underlying `SpooledTemporaryFile` (`file.file.seek(0, 2)`) and reading the position, then rewinding to the start for the actual read. - -### 2. SafeMimeTypeValidator Magic Bytes Verification - -The validator reads the first 2048 bytes of the file payload [storage/validators.py]. It compares these header bytes against a hard-coded set of digital signatures (`SIGNATURES`) mapping magic byte sequences to MIME types (e.g., `b"\x89PNG\r\n\x1a\n"` maps to `"image/png"`). If no signature matches, it falls back to `mimetypes.guess_type`. The validator also explicitly blocks high-risk patterns: -- **PHP:** `b" -
- Type
- Real-Time Messaging -
-
- Status
- Optional Ecosystem -
-
- Underlying Tech
- Redis PubSub / asyncio.Queue -
- - -## The Challenge - -Building real-time user-targeted event streaming in a horizontally-scaled web application introduces several hard problems: - -1. **Multi-Node Broadcast:** In a standard setup, a WebSocket connection is pinned to a single server process. An event generated on Node A cannot reach a user connected to Node B without a shared backplane. -2. **Queue Overrun:** Naive in-process queues grow unboundedly when a slow consumer cannot keep up, eventually exhausting memory and crashing the worker. -3. **Subscription Leaks:** When a WebSocket disconnects, the associated event listener must be cleaned up. Failing to do so results in ghost subscriptions that keep processing and queuing events for connections that no longer exist. - -## The ZCore Elegance - -The `StreamManager` provides a subscription-based event streaming system. It maintains a per-user mapping of bounded `asyncio.Queue` instances, routes published events through Redis PubSub to broadcast across all application nodes, and falls back to local in-memory delivery when Redis is unavailable. - -=== "ZCore Real-Time Streaming" - :::python - from zcore.web.streams import StreamManager - from zcore.web.api_router import ZCoreAPIRoute - - stream_manager = StreamManager() - - @app.websocket("/ws/events") - async def websocket_endpoint(websocket: WebSocket): - await websocket.accept() - user_id = get_authenticated_user_id(websocket) - - # 1. Subscribe via safe context manager - async with stream_manager.subscription(user_id) as queue: - while True: - try: - event = await asyncio.wait_for(queue.get(), timeout=30) - await websocket.send_json(event) - except asyncio.TimeoutError: - await websocket.send_json({"type": "heartbeat"}) - - # Publish from any service (any node) - await stream_manager.publish(user_id, {"type": "order_update", "order_id": "123"}) - -=== "Standard WebSocket Handling" - :::python - # Single-node only, no cluster support - active_connections: dict[uuid.UUID, list[WebSocket]] = {} - - @app.websocket("/ws") - async def ws(websocket: WebSocket): - user_id = extract_user(websocket) - active_connections.setdefault(user_id, []).append(websocket) - try: - while True: - data = await websocket.receive_text() - # No Redis backplane โ€” cannot reach other nodes - except WebSocketDisconnect: - # Manual cleanupโ€”easy to forget or mis-handle - if user_id in active_connections: - active_connections[user_id].remove(websocket) - ---- - -## Streaming Architecture - -

- Streaming Architecture -

- - ---- - -## Boundaries & Integration - -The streaming layer is designed for horizontal scalability and clean lifecycle management. - -* **Redis PubSub Fallback:** The `publish` method attempts Redis first. If Redis is not configured or the call fails (timeout, connection refused), the event is routed locally via `_local_publish`. This ensures that streaming works in development without Redis and degrades gracefully in production during Redis outages. -* **Dependency Injection Friendly:** `StreamManager` is a plain Python class that can be registered as a singleton in the IoC container. Its `publish` method can be injected into any service or background task without coupling to the Web layer. -* **WebSocket Agnostic:** The `StreamManager` has no dependency on `WebSocket` or `Starlette`. It manages `asyncio.Queue` objects keyed by `uuid.UUID`. This allows it to be used with custom protocols, SSE (Server-Sent Events), or long-polling implementations. - ---- - -## Under-the-Hood Spec - -### 1. Redis PubSub Backplane with Pattern Subscription - -The `StreamManager` subscribes to the Redis pattern `stream:user:*` using `psubscribe` [web/streams.py]. This is a single subscription that matches all user-specific channels. When a message arrives, the handler parses the user ID from the channel name (`channel.split(":")[-1]`) and forwards the JSON-deserialized payload to the matching local queues. - -### 2. Bounded Queue Overflow Protection - -Each user subscription creates an `asyncio.Queue(maxsize=100)` [web/streams.py]. When `_local_publish` distributes an event, it calls `queue.put_nowait(data)`. If the queue is full, `put_nowait` raises `asyncio.QueueFull`. The handler catches this and **removes the overflowing queue** from the active list, effectively disconnecting the slowest consumer for that user. This prevents one slow WebSocket from causing memory exhaustion for the entire process. - -### 3. Subscription Context Manager with Guaranteed Cleanup - -The `subscription(user_id)` method is an `@asynccontextmanager` [web/streams.py]. It calls `subscribe` on entry (which creates the queue and starts the Redis PubSub listener if needed) and `unsubscribe` on exit (which removes the queue from the user's list and cancels the PubSub background task if no more active queues exist). This pattern guarantees cleanup even if the WebSocket handler raises an unhandled exception. - -### 4. Background Listener Lifecycle - -`start_listening` initializes the Redis PubSub connection and spawns `_listen_to_redis` as a background `asyncio.Task` [web/streams.py]. The task runs until cancelled. In the `finally` block, it always calls `punsubscribe` and `close()` on the PubSub connection, ensuring Redis resources are released. The task is lazily started on the first `subscribe` call and automatically cancelled in `unsubscribe` when the last queue is removed. - -### 5. Thread-Safe Queue State with Async Lock - -All mutations to the `users_queues` dictionary are protected by `self._lock`, an `asyncio.Lock` [web/streams.py]. This prevents race conditions where a concurrent `publish` and `unsubscribe` could modify the same user's queue list simultaneously in an async context. - -!!! info "Redis Client Isolation" - The streaming subsystem maintains its own module-level `_stream_redis_client`, separate from the caching layer's `_shared_redis_client`. Each subsystem manages its own connection lifecycle and configuration. - -!!! tip "Horizontal Scaling" - To scale WebSocket streams across multiple application instances, ensure all instances share the same Redis instance. Each node subscribes to the same `stream:user:*` pattern via its own PubSub connection, guaranteeing that an event published on any node reaches the user regardless of which node they are connected to. \ No newline at end of file diff --git a/docs/features/infrastructure/cli.md b/docs/features/infrastructure/cli.md deleted file mode 100644 index 2ebecba..0000000 --- a/docs/features/infrastructure/cli.md +++ /dev/null @@ -1,77 +0,0 @@ -# CLI Scaffolding & Orchestration - -Standardize your project structure and automate repetitive boilerplate generation with a professional command-line interface. - ---- - -
-
- Type
- Management Utility -
-
- Status
- Highly Recommended -
-
- Underlying Tech
- Pathlib / secrets / subprocess -
-
- -## The Challenge -FastAPI is intentionally minimalist, which means it lacks a built-in project generator or application "scaffolder" like Django's `startapp`. This leads to: -1. **Structural Inconsistency:** Different projects within the same team follow different folder patterns. -2. **Boilerplate Fatigue:** Developers spend hours manually creating `models.py`, `schemas.py`, `repositories.py`, and `services.py` for every new domain. -3. **Pathing Issues:** Difficulty managing `PYTHONPATH` so that modular sub-apps can import each other without complex hacks. -4. **Insecure Defaults:** Developers often use weak, hardcoded secret keys during initial setup. - -## The ZCore Elegance -The `zc` CLI tool provides a unified command set to initialize projects, scaffold modular domain-driven apps, and launch a pre-configured development server. It enforces the ZCore architectural standards while generating cryptographically secure environment files. - -=== "ZCore CLI Orchestration" - :::bash - # 1. Initialize a new project with secure secrets - zc init my_api - cd my_api - - # 2. Scaffold a domain with full Repository/Service logic - zc startapp inventory --template - - # 3. Launch the server with correct PYTHONPATH - zc run - -=== "Standard Manual Scaffolding" - :::bash - # Standard projects require manual labor for every module: - mkdir -p inventory - touch inventory/__init__.py inventory/models.py inventory/services.py - # (Open editor, manually write 100+ lines of CRUD boilerplate) - # (Manually manage .env and secret generation) - # (Manually run uvicorn with path overrides) - PYTHONPATH=. uvicorn main:app --reload - ---- - -## Boundaries & Integration -The CLI is a developer productivity tool, not a runtime dependency. - -* **Standard Python Output:** Every file generated by `zc` is a standard `.py` file based on the templates in `zcore.cli.templates`. You can delete the CLI and your project will continue to function perfectly. -* **Uvicorn Wrapper:** The `zc run` command is a wrapper around `subprocess.run` calling `uvicorn`. It reads the `HOST` and `PORT` directly from your `.env` file to ensure the CLI environment matches your application settings [cli/commands.py]. -* **Plugin Registration:** After running `startapp`, ZCore reminds you to register the generated `Plugin` in your `Kernel`. The CLI does not "magically" modify your `main.py` code to prevent accidental corruption of your project logic. - ---- - -## Under-the-Hood Spec - -### 1. Cryptographically Secure Secret Generation -The `zc init` and `zc gensecret` commands use the `secrets` module (specifically `secrets.token_hex(32)`) [cli/commands.py]. This ensures that every ZCore project starts with a unique, high-entropy 64-character secret key, mitigating common production vulnerabilities caused by weak default keys. - -### 2. Dynamic PYTHONPATH Injection -Modular applications often fail to resolve imports when running via raw `python` or `uvicorn`. The `zc run` command dynamically modifies the execution environment by setting `env["PYTHONPATH"] = "."` [cli/commands.py]. This ensures that your modular plugins can use clean, absolute imports (e.g., `from inventory.models import Stock`) regardless of the current working directory. - -### 3. Identifier & Path Validation -Before creating any files, the CLI validates that the project and app names are valid Python identifiers using `name.isidentifier()` [cli/main.py]. It also utilizes `pathlib` for strict collision checks, preventing the CLI from overwriting existing files if a developer runs a scaffolding command by mistake. - -!!! info "CLI Help" - Run `zc --help` to see all available commands, including `gensecret` for rotating keys and version checks. \ No newline at end of file diff --git a/docs/features/infrastructure/config.md b/docs/features/infrastructure/config.md deleted file mode 100644 index d1cb48b..0000000 --- a/docs/features/infrastructure/config.md +++ /dev/null @@ -1,74 +0,0 @@ -# Centralized Configuration Management - -Type-safe, environment-aware settings with lazy resolution and seamless Dependency Injection integration. - ---- - -
-
- Type
- Configuration Utility -
-
- Status
- Core Infrastructure -
-
- Underlying Tech
- Pydantic Settings V2 -
-
- -## The Challenge -Managing configuration in growing FastAPI projects often results in **Circular Import Hell**. This happens because nearly every module (Database, Security, Services) needs settings, but settings are often instantiated in `main.py`. If a module imported by `main.py` tries to import the settings instance from `main.py`, the app crashes. - -Furthermore, manually managing `.env` file paths and ensuring type-safety for environment variables across different deployment stages (dev, test, prod) adds unnecessary cognitive load. - -## The ZCore Elegance -ZCore solves this using a **Settings Proxy** combined with the global **IoC Container**. You simply import a global `settings` object. It remains "hollow" until an attribute is accessed, at which point it dynamically resolves the configuration from the container. This eliminates circular imports and allows for easy settings overriding during testing. - -=== "ZCore Lazy Configuration" - :::python - # Import and use anywhere - zero circular import risk - from zcore import settings - - def get_db_url(): - # Instance is resolved lazily on first access - return settings.DATABASE_URL - -=== "FastAPI Manual Management" - :::python - # Standard approach often leads to circularity or manual passing - from my_app.main import settings # Risk: main imports modules, modules import main - - # OR: Manual instantiation in every file (Expensive/Inconsistent) - from pydantic_settings import BaseSettings - class MySettings(BaseSettings): - DATABASE_URL: str - - settings = MySettings() # Re-reading .env every time - ---- - -## Boundaries & Integration -ZCore Configuration is a transparent wrapper around the Pydantic ecosystem. - -* **Pydantic V2 Native:** `ZCoreCoreSettings` inherits directly from `pydantic_settings.BaseSettings`. You can use all Pydantic features like `AliasPath`, `Field` validation, and complex types (SecretStr, HttpUrl) [config.py]. -* **Extensible:** You can easily extend the base settings by creating your own class. Once registered via `initialize_settings(my_inst)`, ZCore's global `settings` proxy will resolve to your custom implementation. -* **Bypass:** If you prefer not to use the global proxy, you can instantiate your own Pydantic Settings class and use it normally. ZCore's internal modules will continue to use their registered singletons without interference. - ---- - -## Under-the-Hood Spec - -### 1. The Settings Proxy -The global `settings` object is an instance of `SettingsProxy` [config.py]. It implements `__getattr__`, which intercepts attribute access and calls `get_settings()`. This function attempts to resolve the settings instance from the `IoCContainer`. If not found, it instantiates the default `ZCoreCoreSettings`, registers it as a singleton, and returns it. - -### 2. Dynamic `.env` Resolution -The `model_config` is configured to look for an environment variable named `ENV_FILE` [config.py]. If set (e.g., `ENV_FILE=.env.test`), ZCore will load that specific file. If not set, it defaults to the standard `.env` in the project root. This allows for seamless switching between environment configurations without code changes. - -### 3. DI Container Registration -The `initialize_settings` function performs a "Double Registration" [config.py]. It registers the settings instance under its specific class type *and* under the base `ZCoreCoreSettings` type. This ensures that even if you provide a custom subclass (e.g., `MyAppSettings`), ZCore's internal components can still inject the base type and receive your custom instance. - -!!! info "Production Security" - ZCore's settings default to `ENVIRONMENT="production"`. In this mode, certain security-critical defaults (like the insecure `SECRET_KEY`) will trigger fatal errors in related modules like `zcore.security.jwt` to prevent accidental insecure deployments. \ No newline at end of file diff --git a/docs/features/infrastructure/di.md b/docs/features/infrastructure/di.md deleted file mode 100644 index 04de179..0000000 --- a/docs/features/infrastructure/di.md +++ /dev/null @@ -1,119 +0,0 @@ -# Scoped Dependency Injection - -Manage object lifecycles and resolve complex dependency graphs through thread-safe, constructor-based injection. - ---- - -
-
- Type
- Inversion of Control (IoC) -
-
- Status
- Core Infrastructure -
-
- Underlying Tech
- contextvars / Reflection -
-
- -## The Challenge -In standard FastAPI, dependencies are primarily resolved at the route level via `Depends()`. While powerful, this leads to: -1. **Signature Bloat:** Passing the same 5 dependencies through every function layer (Router -> Service -> Repository). -2. **Lifecycle Confusion:** Difficulty maintaining "Request-Scoped" objects (like a database session) consistently across multiple nested services. -3. **Tight Coupling:** Classes manually instantiating their dependencies, making units hard to mock during testing. -4. **Circular References:** No built-in protection against recursive dependency loops during instantiation. - -## The ZCore Elegance -ZCore provides a central `IoCContainer` that supports **Singleton**, **Scoped**, and **Transient** lifecycles. It uses constructor-based injectionโ€”meaning your services and repositories simply declare their needs in `__init__`. ZCore analyzes the signatures, resolves the graph, and detects circular dependencies before your code even executes. - -=== "ZCore Constructor Injection" - :::python - from zcore import Inject - - class PaymentService: - # Dependencies are declared in the constructor - def __init__( - self, - repo: PaymentRepository, - notify: EmailService - ): - self.repo = repo - self.notify = notify - - # In the Router: Zero manual wiring - @router.post("/") - async def pay(service: PaymentService = Inject(PaymentService)): - return await service.process() - -=== "FastAPI Raw Implementation" - :::python - # Dependencies must be manually passed through every layer - @router.post("/") - async def pay( - db: AsyncSession = Depends(get_db), - email_client: EmailClient = Depends(get_email_client) - ): - # Manual instantiation or repetitive Depends() - repo = PaymentRepository(db) - notify = EmailService(email_client) - service = PaymentService(repo, notify) - return await service.process() - - ---- - -## Resolution Workflow -The IoC container follows a strict resolution path, checking lifecycles in order of persistence. - -

- Resolution Workflow -

- ---- - -## Boundaries & Integration -ZCore's DI system is a bridge between standard Python classes and the FastAPI ecosystem. - -* **FastAPI `Depends` Wrapper:** The `Inject()` function is a lightweight wrapper. It returns a FastAPI `Depends` object, ensuring full compatibility with FastAPI's native dependency tree and OpenAPI generation. -* **Web-Independent:** The `container.resolve()` method can be used in CLI tools, background workers, or unit tests without an active HTTP request. -* **Scoped Middleware:** Request-scoped dependencies (like `AsyncSession`) require the `ScopedDependencyMiddleware` to be active in `main.py`. This middleware initializes and clears the context-local storage for every request. - ---- - -## Under-the-Hood Spec - -### 1. High-Performance Signature Caching -ZCore avoids the runtime cost of Python reflection. The first time a class is resolved, the container uses `inspect.signature` and `typing.get_type_hints` to analyze its dependencies [kernel/di.py]. This metadata is stored in `_dependency_signature_cache`, allowing subsequent resolutions to skip the expensive introspection phase. - -### 2. Circular Dependency Safeguard -During resolution, the container maintains a recursion `_stack` [kernel/di.py]. If the container attempts to resolve a class that is already present in the current resolution stack, it raises a `CircularDependencyError` with a clear trace (e.g., `A -> B -> C -> A`), preventing a stack overflow. - -### 3. `Annotated` Metatype Resolution -The DI engine recognizes `typing.Annotated` during auto-wiring [kernel/di.py]. When a constructor parameter uses `Annotated[AsyncSession, Depends(get_db)]`, the engine strips the `Depends` wrapper by extracting the first argument of the `Annotated` origin using `get_args(annotation)[0]`. This allows ZCore to correctly resolve parameters typed with `Annotated` aliases like `SessionDep`, maintaining full compatibility with FastAPI's native dependency patterns. - -### 4. Forward Reference Resolution -The DI engine utilizes `get_type_hints` rather than raw annotations. This allows ZCore to correctly resolve string-based forward references or `from __future__ import annotations` blocks, which standard `inspect` logic often fails to parse correctly. - -### 5. Context Isolation -Scoped instances are stored using `contextvars` [kernel/di.py]. This ensures that even in highly concurrent async environments, two different requests never share the same "Scoped" object, while still allowing all services within the *same* request to share the same instance (e.g., the same Database Transaction). - -### 6. The `get_db` Dependency & `SessionDep` Alias -ZCore exposes a `get_db` async generator and a `SessionDep` type alias for seamless database session injection [db/setup.py]. `get_db` calls `container.resolve(AsyncSession)` through the IoC container, returning the request-scoped session managed by `ScopedDependencyMiddleware`. `SessionDep` is defined as `Annotated[AsyncSession, Depends(get_db)]`, providing a clean, reusable type annotation: -```python -from zcore.db import SessionDep - -@router.get("/items") -async def list_items(db: SessionDep): - result = await db.execute(select(Item)) - return result.scalars().all() -``` -This eliminates per-endpoint `Depends(get_db)` boilerplate while keeping the injection point explicit and type-checkable. - -!!! info "Lifecycle Summary" - - **Singleton:** Created once per application lifetime. - - **Scoped:** Created once per HTTP request; shared across all services in that request. - - **Transient:** A fresh instance is created every time it is requested. diff --git a/docs/features/infrastructure/events.md b/docs/features/infrastructure/events.md deleted file mode 100644 index 71bc2da..0000000 --- a/docs/features/infrastructure/events.md +++ /dev/null @@ -1,104 +0,0 @@ -# Asynchronous Event Orchestration - -Loosely coupled communication with concurrent, non-blocking execution and automated failure isolation. - ---- - -
-
- Type
- Infrastructure Engine -
-
- Status
- Core Core -
-
- Underlying Tech
- asyncio / inspect -
-
- -## The Challenge -In complex applications, triggering side effects often leads to **Tightly Coupled Spaghetti**. If a `UserService` needs to send a welcome email, notify a Slack channel, and initialize a loyalty account, it must import and call all those services directly. This results in: -1. **Testing Fragility:** To test a user registration, you must mock the email, slack, and loyalty services. -2. **Performance Bottlenecks:** Executing side effects sequentially increases the response time of the primary request. -3. **Failure Propagation:** If the email server is down and raises an exception, the entire user registration fails, even if the database write was successful. - -## The ZCore Elegance -The `EventDispatcher` enables a "Fire and Forget" architecture. Services simply dispatch an event, and the dispatcher handles the execution of all subscribers concurrently. Crucially, it isolates failuresโ€”an error in one listener will not stop other listeners or the main execution flow. - -=== "ZCore Event Dispatching" - :::python - from zcore import Inject, EventDispatcher - - class UserService: - def __init__(self, dispatcher: EventDispatcher = Inject(EventDispatcher)): - self.dispatcher = dispatcher - - async def register_user(self, schema): - # ... business logic ... - user = await self.repo.create(schema) - - # Dispatch and let the dispatcher handle the rest - await self.dispatcher.dispatch("user.registered", user=user) - return user - - # Subscribers are isolated in their own modules - @dispatcher.subscribe("user.registered") - async def send_welcome_email(user): - await email_service.send(user.email, "Welcome!") - -=== "FastAPI Manual Coupling" - :::python - # Logic is tightly bound and executed sequentially - class UserService: - async def register_user(self, schema): - user = await self.repo.create(schema) - - # 1. Sequential execution (Slow) - # 2. Tight coupling (Hard to test) - # 3. Direct failure risk (If email fails, registration fails) - try: - await self.email_service.send(user.email, "Welcome!") - await self.slack_service.notify(f"New user: {user.id}") - except Exception as e: - # Manual error handling required for every side effect - logger.error(e) - - return user - ---- - -## Dispatcher Workflow -The `EventDispatcher` orchestrates a hybrid execution path for both synchronous and asynchronous listeners. - -

- Dispatcher Workflow -

- ---- - -## Boundaries & Integration -The Event System is an optional orchestration bus that works alongside standard Python calls. - -* **Singleton Registration:** The `Kernel` automatically registers the `EventDispatcher` as a singleton in the DI container. Any component can access it via `Inject(EventDispatcher)` [kernel/engine.py]. -* **Safe for Sync/Async:** You can subscribe with both standard `def` functions and `async def` coroutines. The dispatcher uses `inspect.iscoroutinefunction` to decide how to execute them [kernel/events.py]. -* **Bypass:** If you require a strict, sequential execution where the primary operation *must* fail if a side effect fails, do not use the dispatcher. Instead, call the service methods directly or use a `UnitOfWork` for transactional integrity. - ---- - -## Under-the-Hood Spec - -### 1. Concurrent Execution Gather -When `dispatch()` is called, ZCore gathers all asynchronous subscribers and executes them concurrently using `asyncio.gather` [kernel/events.py]. This ensures that multiple side effects (like sending an email and hitting a webhook) do not increase the total response time of the request. - -### 2. Failure Isolation (`return_exceptions=True`) -The dispatcher is designed for stability. By using `return_exceptions=True` inside the gather call, ZCore ensures that if one handler raises an exception, the other handlers continue to run [kernel/events.py]. Exceptions are captured, logged via `structlog`, and the dispatcher returns `None` for the failed task instead of crashing the process. - -### 3. Dynamic Handler Inspection -The dispatcher uses the `inspect` module to verify handlers during the dispatch phase [kernel/events.py]. It wraps the execution in a `try/except` block even during the "preparation" phase, catching errors that might occur if a handler is improperly configured or has an invalid signature. - -!!! info "Transactional Events" - For database-related events, use the `UnitOfWork`. It buffers events and only calls `dispatcher.dispatch` **after** the database transaction has successfully committed [db/uow.py]. \ No newline at end of file diff --git a/docs/features/infrastructure/kernel.md b/docs/features/infrastructure/kernel.md deleted file mode 100644 index b839a01..0000000 --- a/docs/features/infrastructure/kernel.md +++ /dev/null @@ -1,97 +0,0 @@ -# Framework Engine & Orchestration - -The central nervous system coordinating plugin lifecycles, dependency resolution, and modular application transitions. - ---- - -
-
- Type
- Core Orchestrator -
-
- Status
- Core Infrastructure -
-
- Underlying Tech
- graphlib / Protocol -
-
- -## The Challenge -As FastAPI projects grow, the initialization sequence becomes a "procedural soup" inside `main.py`. Developers often encounter: -1. **Race Conditions:** A service attempting to warm up a cache before the database connection pool is initialized. -2. **Circular Import Deadlocks:** Modules importing each other's setup functions during startup. -3. **Missing Requirements:** An application starting partially even when critical infrastructure (like a message broker plugin) failed to register. -4. **Tear-down Leaks:** External connections (Redis, DB) not being closed in the correct order during shutdown, causing hanging processes. - -## The ZCore Elegance -The `Kernel` manages the application through a **Topological Plugin System**. Every architectural unit is treated as a `Plugin` with declared dependencies. The Kernel calculates the mathematically correct initialization order using a Directed Acyclic Graph (DAG), ensuring every component is ready before the next one starts. - -=== "ZCore Topological Plugins" - :::python - from zcore import Plugin, Kernel - - class PaymentPlugin(Plugin): - name = "payments" - # Explicitly wait for DB and Logging - dependencies = ["db", "logging"] - - def setup(self, app: FastAPI): - app.include_router(payment_router) - - async def on_startup(self): - await gateway.connect() - - # Kernel sorts and executes in the right order - kernel = Kernel() - kernel.add_plugin(PaymentPlugin()) - kernel.add_plugin(DatabasePlugin()) # name="db" - -=== "FastAPI Procedural Lifespan" - :::python - # Manual ordering is fragile and hard to track - @asynccontextmanager - async def lifespan(app: FastAPI): - # You must manually ensure 'db' is before 'payments' - await init_db() - await init_logging() - - # If this is moved up by mistake, the app crashes - await gateway.connect() - yield - # Manual shutdown is also tedious - await gateway.close() - await db.close() - ---- - -## Boundaries & Integration -The Kernel is a thin orchestration layer that respects the native FastAPI lifecycle. - -* **Native Lifespan:** The `kernel.lifespan` method is a standard Python `@asynccontextmanager`. It is passed directly to the `FastAPI` constructor, meaning the framework follows the standard Starlette execution path. -* **Protocol-Based:** You don't need to inherit from a specific ZCore class; as long as your object satisfies the `Plugin` protocol (having `name`, `version`, `dependencies`, and lifecycle methods), the Kernel will accept it [kernel/plugins.py]. -* **Bypass:** If a small part of your app doesn't need modularity, you can still register routes or logic directly in `main.py` using standard FastAPI methodsโ€”ZCore will ignore these and only manage the registered plugins. - ---- - -## Under-the-Hood Spec - -### 1. DAG-Based Dependency Resolution -The Kernel utilizes Python's `graphlib.TopologicalSorter` to analyze the `dependencies` list of every plugin [kernel/engine.py]. If Plugin A requires Plugin B, the sorter ensures B is processed first. If a circular dependency is detected (e.g., A -> B and B -> A), ZCore raises a `CycleError` during the `setup` phase, preventing the application from starting in an unstable state. - -### 2. Reverse-Topological Shutdown -Resource management requires a "First In, Last Out" approach. The Kernel ensures that the `on_shutdown` hooks are executed in the **reverse order** of the startup sequence [kernel/engine.py]. This ensures that shared resources (like the Database) are only closed after the domain modules that depend on them have finished their cleanup tasks. - -### 3. Missing Dependency Assertion -During the resolution phase, the Kernel cross-references every declared dependency name against the active registry [kernel/engine.py]. If a plugin declares a dependency that hasn't been registered in the `Kernel`, ZCore halts initialization with a `RuntimeError`, identifying the exact missing dependency by name. - -### 4. Triple-Wave Startup -The Kernel executes startup logic in three distinct, sequential waves across all sorted plugins to ensure total system stability [kernel/engine.py]: -1. **`before_startup`**: Verification and pre-checks. -2. **`on_startup`**: Primary resource initialization. -3. **`after_startup`**: Post-initialization signaling or background task spawns. - -!!! info "Kernel Events" - The Kernel automatically initializes a central `EventDispatcher` and registers it as a singleton in the DI container during the `setup` phase. This allows plugins to communicate through loosely coupled events without direct imports. \ No newline at end of file diff --git a/docs/features/infrastructure/main.md b/docs/features/infrastructure/main.md deleted file mode 100644 index b13af64..0000000 --- a/docs/features/infrastructure/main.md +++ /dev/null @@ -1,98 +0,0 @@ -# Application Entry Point - -The centralized orchestrator where infrastructure, security context, and domain plugins converge into a unified FastAPI application. - ---- - -
-
- Type
- Bootstrap Orchestrator -
-
- Status
- Core Requirement -
-
- Underlying Tech
- FastAPI / ASGI / ZCore Kernel -
-
- -## The Challenge -In standard FastAPI projects, the `main.py` file frequently evolves into a "God File"โ€”a massive procedural script handling everything from logging and database initialization to middleware ordering and raw router includes. This results in: -- **Procedural Fragility:** Changing the order of a single line (e.g., placing Auth middleware after Routing) causes silent, critical failures. -- **Initialization Race Conditions:** Background tasks or services attempting to run before the database connection pool is warmed up. -- **Tight Coupling:** No clean way to enable or disable features (like a Search plugin or Caching) without manually hacking the core entry file. - -## The ZCore Elegance -ZCore provides a declarative bootstrap structure via the `MAIN_PY_TEMPLATE` [cli/templates.py]. Instead of writing procedural logic, you configure the `Kernel`, register your domain `Plugins`, and attach core architectural Middlewares. The `main.py` serves strictly as an **Orchestrator**, ensuring infrastructure is ready before any domain logic begins. - -=== "ZCore Declarative main.py" - :::python - from fastapi import FastAPI - from zcore import Kernel, settings - from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware - from zcore.db import db_manager, register_db_event_dispatcher - from zcore.logging import setup_logging - - # 1. Warm up Infrastructure - setup_logging() - db_manager.init_app(db_url=settings.DATABASE_URL) - - # 2. Initialize Kernel & Event Bridge - kernel = Kernel() - register_db_event_dispatcher(kernel.dispatcher) - - # 3. Create App with Kernel Lifespan - app = FastAPI(title=settings.PROJECT_NAME, lifespan=kernel.lifespan) - - # 4. Bind Plugins & Middleware - kernel.setup(app) - app.add_middleware(RequestLogMiddleware) - app.add_middleware(ScopedDependencyMiddleware) - -=== "Standard FastAPI Procedural" - :::python - # Standard projects mix infra and business logic procedurally - app = FastAPI() - - @app.on_event("startup") - async def startup(): - # Manual, fragile ordering - await init_logging() - await init_db() - # What if this fails? Is the error caught? - await some_service_warmup() - - # Router includes are often scattered - from orders.routers import router as orders_router - app.include_router(orders_router) - - # Middleware ordering is manual and invisible - app.add_middleware(MyMiddleware) - ---- - -## Boundaries & Integration -The entry point is designed to wrap FastAPI's power without obscuring its standard API. - -- **Native App Instance:** The `app` variable is a standard `FastAPI()` instance. You can use any native FastAPI decorator (`@app.get`) or feature directly in `main.py` without restriction. -- **Middleware Freedom:** ZCore provides `RequestLogMiddleware` and `ScopedDependencyMiddleware` for architectural stability, but you can add any third-party Starlette/FastAPI middleware using the standard `app.add_middleware()` method. -- **Plugin Decoupling:** You can add or remove domain modules simply by calling `kernel.add_plugin(MyModulePlugin())`. The Kernel handles the routing registration and startup logic internally. - ---- - -## Under-the-Hood Spec - -### 1. The DB-Event Dispatcher Bridge -A unique architectural feature is `register_db_event_dispatcher(kernel.dispatcher)` [db/events.py]. This creates a secure link between low-level database lifecycle events (like transaction commits) and the high-level application `EventDispatcher`. It allows domain handlers to respond to database changes in a non-blocking, loosely coupled manner. - -### 2. Path Injection for Modular Imports -ZCore's `main.py` template includes a `sys.path.insert(0, ...)` block [cli/templates.py]. This ensures that your modular apps (generated via `zc startapp`) are discoverable as top-level modules, preventing complex relative import issues common in multi-directory FastAPI structures. - -### 3. Integrated Exception Handling -The entry point registers the `app_exception_handler` [exceptions/handlers.py]. This ensures that any `AppException` (EntityNotFound, ValidationError, etc.) raised deep within a Service or Repository is caught at the boundary, logged with structured metadata, and returned in the standardized `ResponseWrapper` JSON envelope. - -!!! info "Middleware Ordering" - In ZCore, `RequestLogMiddleware` should generally be added first to ensure the entire request duration (including DI resolution time) is captured and a correlation ID is generated before any other logic executes. \ No newline at end of file diff --git a/docs/features/infrastructure/middleware.md b/docs/features/infrastructure/middleware.md deleted file mode 100644 index 37a859b..0000000 --- a/docs/features/infrastructure/middleware.md +++ /dev/null @@ -1,80 +0,0 @@ -# ASGI Lifecycle Coordination - -Trace every transaction with correlation IDs and manage request-scoped state boundaries for safe dependency resolution. - ---- - -
-
- Type
- ASGI Middleware -
-
- Status
- Recommended Core -
-
- Underlying Tech
- Starlette / contextvars / structlog -
-
- -## The Challenge -Maintaining observability and data integrity in an asynchronous environment presents unique challenges: -1. **Disconnected Logs:** Without a Correlation ID (Request ID), tracking a single user's request journey through multiple service layers in a log aggregator is nearly impossible. -2. **Session Leakage:** Improperly managed database sessions can lead to connection leaks or, worse, different services within the same request accidentally using different transactions. -3. **Manual Context Passing:** Developers often find themselves passing a "context" dictionary or a raw database session object through every function call just to ensure a shared state. - -## The ZCore Elegance -ZCore provides a duo of high-performance ASGI middlewares that automate these concerns at the entry point. `RequestLogMiddleware` ensures every request is uniquely identifiable and timed, while `ScopedDependencyMiddleware` initializes a secure execution boundary where the database session and other scoped dependencies are automatically shared and cleaned up. - -=== "ZCore Middleware Registration" - :::python - from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware - - # Standard order in main.py - app.add_middleware(RequestLogMiddleware) - app.add_middleware(ScopedDependencyMiddleware) - -=== "FastAPI Manual Management" - :::python - # Replicating ZCore's tracing and scoping manually: - @app.middleware("http") - async def trace_and_scope(request: Request, call_next): - request_id = request.headers.get("x-request-id", str(uuid.uuid4())) - # Manually bind to logger - # Manually start a timer - # Manually open a DB session - async with session_factory() as session: - # How do we pass this session to nested services - # without passing it as an argument everywhere? - response = await call_next(request) - return response - ---- - -## Boundaries & Integration -ZCore Middlewares follow the standard Starlette/ASGI protocol, ensuring they play well with the rest of the ecosystem. - -* **Standard ASGI:** Both components are pure ASGI implementations. You can use them in any FastAPI, Starlette, or even raw ASGI application. -* **Structlog Integration:** `RequestLogMiddleware` is designed to work with ZCore's structured logging. It automatically binds the `request_id` to `structlog.contextvars`, making it visible in every log line generated during that request. -* **Bypass:** If you prefer a different logging or session management strategy, you can simply omit these middlewares. ZCore's internal components (like `Inject`) will still function, but "Scoped" lifecycles will fallback to "Transient" behavior if no active scope is detected. - ---- - -## Under-the-Hood Spec - -### 1. Hardened Correlation IDs -The `RequestLogMiddleware` uses a strict regex pattern (`REQUEST_ID_PATTERN`) to validate incoming `x-request-id` headers [web/middleware.py]. If a header is present but malformed or too long, ZCore rejects it and generates a fresh `uuid4` instead. This prevents header-injection attacks or bloated log metadata. - -### 2. The `send_wrapper` Interceptor -ZCore doesn't just log the request ID; it ensures the client receives it back. The middleware wraps the ASGI `send` callable [web/middleware.py]. When the application starts sending the response (`http.response.start`), ZCore intercepts the event and appends the `x-request-id` to the outgoing headers, facilitating easier frontend-to-backend debugging. - -### 3. Atomic Scope Cleanup -`ScopedDependencyMiddleware` generates a unique `scope_id` for every request and sets it in a `contextvar` [web/middleware.py]. Most importantly, it creates a database session using `db_manager.session()` and registers it as a scoped instance in the DI container. The `finally` block guarantees that `container.clear_scope(scope_id)` is called even if the request crashes, ensuring zero memory leaks or hanging DB connections. - -### 4. Precision Timing -Logging uses `time.perf_counter()` to calculate the `duration_ms` [web/middleware.py]. This provides sub-millisecond precision for performance monitoring, which is then logged alongside the HTTP method and path as a structured record. - -!!! info "Middleware Ordering" - It is recommended to place `RequestLogMiddleware` as the outermost layer to capture the total execution time, including the time spent in subsequent middlewares like `ScopedDependencyMiddleware`. \ No newline at end of file diff --git a/docs/features/infrastructure/utils.md b/docs/features/infrastructure/utils.md deleted file mode 100644 index a45e36f..0000000 --- a/docs/features/infrastructure/utils.md +++ /dev/null @@ -1,91 +0,0 @@ -# System Utilities - -Low-level data transformation, secure JSON serialization, and dynamic schema validation utilities. - ---- - -
-
- Type
- Utility Layer -
-
- Status
- Core Infrastructure -
-
- Underlying Tech
- json / jsonschema / re -
-
- -## The Challenge -Modern web applications frequently handle non-primitive data types (UUIDs, Decimals, Datetime objects) that the standard Python `json` library cannot serialize by default. Developers often resort to manual string casting or scattered `isoformat()` calls. - -Additionally, validating dynamic JSON payloads against schemas often requires repetitive `try-except` blocks to catch `jsonschema` errors and translate them into user-friendly API responses, leading to "Utility Spaghetti" across the codebase. - -## The ZCore Elegance -ZCore provides a robust set of helpers to standardize data handling. The `CustomJSONEncoder` automatically handles all architectural types, while `json_dumps` and `json_loads` provide a safe, high-level interface. The `validate_json_schema` utility integrates directly with ZCoreโ€™s exception system, translating structural violations into standardized `ValidationError` responses. - -=== "ZCore Utility Handling" - :::python - from zcore.utils.helpers import json_dumps, slugify - from zcore.utils.json_schema import validate_json_schema - - # 1. Automatic serialization of complex types - data = {"id": uuid.uuid4(), "price": Decimal("19.99"), "at": datetime.utcnow()} - raw_json = json_dumps(data) - - # 2. Dynamic Schema Validation with auto-error mapping - schema = {"type": "object", "required": ["name"]} - validate_json_schema({"age": 25}, schema) # Raises AppException(400) - - # 3. Standardized URL slugs - url_slug = slugify("ZCore: Modern Framework!") # "zcore-modern-framework" - -=== "Raw Python Implementation" - :::python - # Manual conversion for standard json library - import json - - data = {"id": uuid.uuid4(), "at": datetime.utcnow()} - - # Standard json.dumps(data) fails with TypeError - raw_json = json.dumps(data, default=lambda o: str(o) if isinstance(o, uuid.UUID) else o.isoformat()) - - # Manual jsonschema validation - from jsonschema import validate, ValidationError as JSValidationError - try: - validate(instance=payload, schema=schema) - except JSValidationError as e: - # Manual translation to FastAPI responses - raise HTTPException(status_code=400, detail=str(e)) - ---- - -## Boundaries & Integration -ZCore Utilities are built as thin, non-intrusive wrappers over standard libraries. - -* **Pydantic Integration:** While Pydantic handles its own serialization, ZCore's utilities are essential for low-level caching (Redis), logging, and WebSockets where raw Pydantic objects are not present [utils/helpers.py]. -* **Draft-7 Standards:** The `validate_json_schema` utility follows the JSON Schema Draft-7 meta-schema, ensuring industry-standard compatibility [utils/json_schema.py]. -* **Bypass:** You can continue to use Python's native `json` or `re` modules at any time. ZCore does not override global built-ins; it provides explicit alternatives for framework-specific needs. - ---- - -## Under-the-Hood Spec - -### 1. The Custom Serialization Pipeline -The `CustomJSONEncoder` implements a fallback mechanism [utils/helpers.py]. It prioritizes high-performance checks for `uuid.UUID`, `Decimal`, and `datetime/date/time` objects. If a type is still unrecognized, it performs a final `str()` cast to ensure the `json_dumps` operation never raises a `TypeError`, which is critical for logging reliability. - -### 2. Schema Meta-Validation -When `validate_json_schema` is called with only one argument, it performs **Meta-Validation** [utils/json_schema.py]. It uses `Draft7Validator.check_schema(data)` to verify that the provided dictionary is itself a structurally valid JSON Schema. This is used internally by the framework to validate dynamically generated schemas before they are served or processed. - -### 3. Sanitized Slugification -The `slugify` utility uses a multi-pass regex strategy [utils/helpers.py]: -1. Removes non-alphanumeric characters. -2. Normalizes spaces and underscores into single dashes. -3. Trims leading/trailing dashes. -This ensures that generated slugs are always URL-safe and conform to SEO best practices without manual character mapping. - -!!! info "Performance Note" - ZCore's `json_loads` and `json_dumps` pass through additional `**kwargs` to the underlying `json` module, allowing you to use optimizations like `separators` or `sort_keys` while still benefiting from ZCore's enhanced type support. \ No newline at end of file diff --git a/docs/features/security/api-router.md b/docs/features/security/api-router.md deleted file mode 100644 index a5d58b6..0000000 --- a/docs/features/security/api-router.md +++ /dev/null @@ -1,101 +0,0 @@ -# API Routing Mechanics - -Intercept HTTP traffic with a custom route handler that preserves response classes, injects Vary headers on context-pruned payloads, and exposes dynamic JSON-Schema introspection. - ---- - -
-
- Type
- Routing Infrastructure -
-
- Status
- Security Integration -
-
- Underlying Tech
- FastAPI APIRoute / Pydantic -
-
- -## The Challenge - -Standard FastAPI routes are stateless with respect to security context. As a result, developers often encounter: - -1. **Response Class Overwriting:** Middleware or custom response classes are silently replaced by `JSONResponse`, breaking specialized serialization logic. -2. **Cache Invisibility:** When the response payload is dynamically pruned based on user permissions, intermediate CDNs or proxies may cache a pruned response and serve it to a privileged user, leaking restricted data. -3. **Schema Blindness:** Frontend teams have no standardized way to introspect the current schemaโ€”especially which fields are restricted for the active userโ€”without manual API documentation or hard-coded logic. - -## The ZCore Elegance - -`ZCoreAPIRoute` is a drop-in replacement for FastAPI's `APIRoute`. It intercepts every request, conditionally generates context-aware JSON-Schema responses when `?schema=true` is appended, and appends `Vary: Authorization, Cookie` headers to any response that has been security-pruned. - -=== "ZCore Transparent Routing" - :::python - from zcore.web.api_router import ZCoreAPIRoute - from fastapi import APIRouter - - router = APIRouter(route_class=ZCoreAPIRoute) - - @router.post("/products", openapi_extra={"expose_schema": True}) - async def create(data: ProductCreate, service: ProductService = Inject(ProductService)): - return await service.create(data) - - # GET /products?schema=true โ†’ Returns JSON-Schema with restricted fields pruned - # POST /products โ†’ Response includes Vary: Authorization, Cookie if pruning occurred - -=== "Standard FastAPI Routing" - :::python - from fastapi import APIRouter - - router = APIRouter() - - @router.post("/products") - async def create(data: ProductCreate, service: ProductService = Depends(get_service)): - # No schema introspection - # No Vary header management - # Plain JSONResponse always used - return await service.create(data) - ---- - -## Execution Flow - -

- Execution Flow -

- ---- - -## Boundaries & Integration - -ZCoreAPIRoute works transparently with the rest of the ZCore ecosystem. - -* **BaseRouter Compatible:** The `BaseRouter` class in `web/base_router.py` uses `ZCoreAPIRoute` as its default `route_class`. Any scaffolded endpoint automatically inherits schema exposure and Vary header management. -* **Response Class Preservation:** When initializing, `ZCoreAPIRoute` inspects the current `response_class`. If it is the default `JSONResponse`, it swaps in `ZCoreJSONResponse` (which uses ZCore's unified JSON encoder). Custom response classes are left untouched. -* **Manual Endpoints:** Functional endpoints registered on a standard `APIRouter` are unaffected. Only routes using `ZCoreAPIRoute` (or a `BaseRouter` subclass) participate in the security-aware lifecycle. - ---- - -## Under-the-Hood Spec - -### 1. Response Class Preservation Logic - -Inside `ZCoreAPIRoute.__init__`, the constructor checks the inherited `response_class` [web/api_router.py]. If the class resolves to the standard FastAPI `JSONResponse`, it is replaced with `ZCoreJSONResponse`. Any custom subclass (e.g., `ORJSONResponse`) is preserved, ensuring third-party serializers are never silently overridden. - -### 2. Vary Header Append on Context-Pruned Responses - -After the route handler completes, `custom_route_handler` inspects `get_restricted_fields()` [web/api_router.py]. If the set is non-empty and the response is JSON, the method appends `Authorization` and `Cookie` to the existing `Vary` header. Duplicates are eliminated by lower-casing existing values, preventing header bloat on repeated requests. - -### 3. Schema Extraction via Dependency Introspection - -`find_input_schema` iterates over `dependant.body_params` to locate the body Pydantic model [web/api_router.py]. `find_output_schema` recursively unpacks generic origin types (e.g., `ResponseWrapper[ProductOut]`) to extract the inner model. This allows `ZCoreAPIRoute` to resolve the effective schema even when wrapped in generic containers. - -### 4. Request Body Caching - -`ZCoreRequest` overrides `body()` to cache the byte stream on first read [web/api_router.py]. This prevents downstream middlewares or validators from consuming the stream and leaving subsequent readers with an empty payload. - -!!! info "Schema Exposure Restriction" - Schema introspection (`?schema=true`) is only enabled on routes with `openapi_extra={"expose_schema": True}`. This prevents accidental leakage of internal model structures on endpoints that do not opt in. \ No newline at end of file diff --git a/docs/features/security/context.md b/docs/features/security/context.md deleted file mode 100644 index 85e5cff..0000000 --- a/docs/features/security/context.md +++ /dev/null @@ -1,85 +0,0 @@ -# Thread-Safe Execution Context - -Manage authenticated identities and data-masking policies across asynchronous boundaries with zero state pollution. - ---- - -
-
- Type
- State Management -
-
- Status
- Core Core -
-
- Underlying Tech
- contextvars -
-
- -## The Challenge -In a high-concurrency asynchronous application, tracking the "current user" or active security policies is notoriously difficult. Developers often face: -1. **Argument Drilling:** Passing a `user` object through five layers of service and repository calls just to log an ID or filter a query. -2. **State Leakage:** Using standard global variables or thread-locals, which can leak data between concurrent requests in an `async/await` environment. -3. **Inconsistent Validation:** Manually checking if a user ID is a valid UUID or if a list of restricted fields is mutable, leading to runtime errors in deep architectural layers. - -## The ZCore Elegance -ZCore utilizes Python's `contextvars` to provide a coroutine-safe execution context. The `request_context` manager allows you to bind a user identity and a set of data-masking restrictions at the start of a request. These variables are then globally accessible to any downstream function (like a Search Engine or Repository) without being explicitly passed as arguments. - -=== "ZCore Context Binding" - :::python - from zcore.context import request_context, get_current_user_id - - # 1. Bind context in a middleware or entry point - with request_context(user_id="...", fields=["products.cost"]): - # 2. Anywhere in your deep service/repository logic: - uid = get_current_user_id() - # uid is resolved automatically from the coroutine stack - -=== "FastAPI Manual Passing" - :::python - # You must pass the user through every single function - async def get_products(user_id: uuid.UUID, restricted: list[str]): - return await repo.find_all(user_id, restricted) - - async def find_all(user_id: uuid.UUID, restricted: list[str]): - # This becomes a maintenance nightmare in large apps - query = select(Product).where(Product.owner_id == user_id) - # ... - ---- - -## Boundaries & Integration -ZCore Context is a low-level utility that bridges the gap between Web and Infrastructure layers. - -* **Native ContextVars:** ZCore uses standard `contextvars.ContextVar`. This means it integrates perfectly with standard Python logging (via `structlog`) and any other library that supports the `contextvars` protocol. -* **Scoped Middleware:** The `ScopedDependencyMiddleware` automatically wraps every HTTP request in an empty context, ensuring a clean slate for every transaction. -* **Bypass:** If you prefer explicit dependency injection, you can continue to pass `user` objects manually. ZCore's context is an *additional* capability, not a restrictive requirement. - ---- - -## The Execution Flow - -

- The Execution Flow -

- - ---- - -## Under-the-Hood Spec - -### 1. Token-Based Restoration Safety -ZCore context managers (`user_context`, `request_context`) utilize the `contextvars.Token` system [context/context.py]. When a context is set, a token is generated. Upon exiting the block, the state is **explicitly reset** to its previous value using this token. This guarantees that even if a request crashes or a task is recycled, the security context never "bleeds" into the next execution. - -### 2. Immutable Restriction Sets -When you set `restricted_fields`, ZCore automatically converts the input into a `frozenset` [context/context.py]. This ensures that once fields are restricted for a request, they cannot be modified by downstream services, preventing accidental or malicious "unmasking" of sensitive data during a complex transaction. - -### 3. Immediate UUID Integrity -The `set_current_user_id` function performs immediate type coercion [context/context.py]. If a string is provided, it is validated and converted to a `uuid.UUID` instance instantly. This ensures that downstream database queries never fail due to string-to-UUID comparison mismatches, moving the validation error to the very boundary of the request. - -!!! info "Security Note" - Context variables are isolated per asynchronous task. If you spawn a background task (e.g., `asyncio.create_task`), you must manually re-bind the context if the task requires the user's identity. \ No newline at end of file diff --git a/docs/features/security/exceptions.md b/docs/features/security/exceptions.md deleted file mode 100644 index 4a1872e..0000000 --- a/docs/features/security/exceptions.md +++ /dev/null @@ -1,110 +0,0 @@ -# Centralized Exception Handler - -Map domain-level business failures into a standardized, structured API error envelope with zero boilerplate in your route handlers. - ---- - -
-
- Type
- Error Handling -
-
- Status
- Security Integration -
-
- Underlying Tech
- FastAPI Exception Handlers -
-
- -## The Challenge - -Standard FastAPI error handling often leads to inconsistent API responses: - -1. **Scattered Error Logic:** Each endpoint or service layer manually catches failures and returns ad-hoc dictionaries like `{"detail": "Not found"}` or `{"error": "Invalid data"}`, creating a fragmented API contract for frontend consumers. -2. **Missing Diagnostic Context:** Internal details (exception type, metadata payload) are lost when converting to HTTP responses, making debugging production incidents harder. -3. **Inconsistent HTTP Codes:** The same logical error (e.g., "entity not found") might return 404 in one endpoint, 500 in another, and 400 in a third, depending on where the exception was raised. - -## The ZCore Elegance - -ZCore provides a thin `AppException` hierarchy that maps directly to HTTP status codes, plus a single global handler that catches them all and formats them into the standard `ResponseWrapper` envelope. Services and repositories raise typed exceptions without any HTTP awareness. - -=== "ZCore Typed Exceptions" - :::python - from zcore.exceptions import EntityNotFound, ValidationError, ForbiddenError - - class PaymentService(BaseService): - async def process(self, payment_id: uuid.UUID): - payment = await self.repository.get(payment_id) - if not payment: - raise EntityNotFound("Payment record not found") - if not payment.is_approved: - raise ForbiddenError("Insufficient permissions to process") - if payment.amount <= 0: - raise ValidationError("Payment amount must be positive") - return payment - - # FastAPI automatically returns: - # 404 โ†’ {"success": false, "message": "Payment record not found", "meta": {"error_type": "EntityNotFound", "payload": null}} - -=== "Standard FastAPI Error Handling" - :::python - from fastapi import HTTPException - - class PaymentService: - async def process(self, payment_id: uuid.UUID): - payment = await self.repository.get(payment_id) - if not payment: - raise HTTPException(status_code=404, detail="Payment not found") - if not payment.is_approved: - raise HTTPException(status_code=403, detail="Forbidden") - if payment.amount <= 0: - raise HTTPException(status_code=400, detail="Invalid amount") - return payment - - # No standard envelope across endpoints - # No automatic error type classification in response - # Manual HTTP code management scattered through business logic - ---- - -## Boundaries & Integration - -The exception handler is a lightweight, opt-in component registered at the application level. - -* **FastAPI Standard:** The `app_exception_handler` is a standard FastAPI exception handler registered via `app.add_exception_handler(AppException, app_exception_handler)`. It works with any FastAPI application, including those not using other ZCore components. -* **Payload Customization:** Each exception can carry an optional `payload` dictionary. The handler includes this payload in the response `meta` object, enabling structured error details (e.g., field-level validation errors or conflict resource identifiers). -* **Third-Party Compatibility:** Since the handler only catches `AppException` subclasses, standard Python exceptions (e.g., `ValueError`, `KeyError`) and FastAPI's built-in `HTTPException` are unaffected and handled by their default mechanisms. - ---- - -## Under-the-Hood Spec - -### 1. Status Code Mapping via Class Attribute - -Each `AppException` subclass defines `status_code` as a class-level integer [exceptions/base.py]. `EntityNotFound` maps to `404`, `ValidationError` to `400`, `AuthError` to `401`, `ForbiddenError` to `403`, and `DuplicateEntity` to `409`. Custom exceptions inherit from `AppException` and can override `status_code` to any valid HTTP status. - -### 2. Structured Logging Capture - -The `app_exception_handler` logs every exception through `structlog` before constructing the response [exceptions/handlers.py]. The log entry includes `type`, `status_code`, `message`, `payload`, `path`, and `method`โ€”providing a complete audit trail for debugging without exposing sensitive internals to the client. - -### 3. Standard API Error Envelope - -The handler constructs a `ResponseWrapper[None]` with `success=False` [exceptions/handlers.py]. The `message` field carries the human-readable error string, while `meta.error_type` stores the exact exception class name (e.g., `"EntityNotFound"`) for programmatic consumption. The `meta.payload` field is included only if the exception was raised with contextual metadata. - -!!! info "Registration in main.py" - The exception handler must be explicitly registered during application bootstrap: - ```python - from zcore.exceptions import AppException, app_exception_handler - app.add_exception_handler(AppException, app_exception_handler) - ``` - -!!! tip "Extending the Hierarchy" - To add a custom error type, subclass `AppException` and set `status_code`: - ```python - class RateLimitExceeded(AppException): - status_code = 429 - ``` - The existing handler automatically picks up the new type at runtime. \ No newline at end of file diff --git a/docs/features/security/security.md b/docs/features/security/security.md deleted file mode 100644 index 0f107e3..0000000 --- a/docs/features/security/security.md +++ /dev/null @@ -1,97 +0,0 @@ -# Cryptographic Foundations - -Secure user credentials with Argon2id and orchestrate identity via hardened, protocol-agnostic JWT signaling. - ---- - -
-
- Type
- Security Infrastructure -
-
- Status
- Core Core -
-
- Underlying Tech
- Argon2id / PyJWT -
-
- -## The Challenge -Implementing robust security in FastAPI often suffers from "Infrastructure Drift": -1. **Stale Hashing:** Many projects still use BCrypt or PBKDF2, which are increasingly vulnerable to GPU-accelerated side-channel attacks. -2. **Secret Mismanagement:** Hardcoding secrets or failing to rotate them, often running production apps with default "example" keys. -3. **Rigid Token Logic:** Difficulty switching from Symmetric (HS256) to Asymmetric (RS256) signing as the architecture moves toward microservices or OIDC. -4. **Implicit Expiry:** Manual timestamp math for `exp` claims that lead to timezone bugs or permanent tokens. - -## The ZCore Elegance -ZCore provides a hardened security chassis. It defaults to **Argon2id** (the winner of the Password Hashing Competition) with configurable memory costs. The JWT engine is "Signature-Aware," automatically detecting whether to use Symmetric or Asymmetric keys based on your configuration, while enforcing a mandatory "Production Secret Guard" that prevents unsafe deployments. - -=== "ZCore Hardened Security" - :::python - from zcore.security import get_password_hash, create_token, verify_password - - # 1. High-Entropy Hashing (Argon2id) - hashed = get_password_hash("user_password") - is_valid = verify_password("user_password", hashed) - - # 2. Protocol-Agnostic Token Creation - # Automatically uses RSA/ECDSA if keys are configured - token = create_token(data={"sub": str(user.id), "scopes": ["admin"]}) - -=== "FastAPI Manual Security" - :::python - # Requires manual configuration of Passlib and PyJWT - from passlib.context import CryptContext - import jwt - - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - def create_token(data: dict): - to_encode = data.copy() - # Manual expiry math - expire = datetime.utcnow() + timedelta(minutes=30) - to_encode.update({"exp": expire}) - # Manual algorithm and key management - return jwt.encode(to_encode, SECRET_KEY, algorithm="HS256") - ---- - -## The Token Lifecycle - -

- The Token Lifecycle -

- - ---- - -## Boundaries & Integration -ZCore manages the cryptographic "heavy lifting" while remaining compatible with standard identity providers. - -* **OAuth2 Integration:** ZCore's `create_token` output is a standard JWT string, fully compatible with FastAPI's `OAuth2PasswordBearer` and `Security` scopes. -* **User Independence:** The security utilities do not require a Database connection. You can use them to secure machine-to-machine (M2M) communication or external microservices. -* **Bypass:** If your organization requires a different hashing algorithm (e.g., Scrypt), you can ignore the `hashing.py` utility. ZCore's higher-level components do not force a specific hash format on your `User` model. - ---- - -## Under-the-Hood Spec - -### 1. The Production Secret Guard -To prevent critical security oversights, ZCoreโ€™s JWT key resolver (`_get_signing_keys`) includes a fatal check [security/jwt.py]. If `ENVIRONMENT` is "production" and `SECRET_KEY` is set to the framework's insecure default value, the system raises a `RuntimeError` and **aborts application startup**. - -### 2. Argon2id Configuration -ZCore initializes the `PasswordHasher` with parameters optimized for modern server hardware [security/hashing.py]: -- **Memory Cost:** 64 MB (`65536`) -- **Time Cost:** 3 Iterations -- **Parallelism:** 4 Threads -These parameters ensure high resistance to specialized hardware (ASIC/GPU) cracking while maintaining sub-100ms verification times for users. - -### 3. Dynamic Asymmetric Detection -The JWT utility is designed for scale [security/jwt.py]. If `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` are detected in the settings, the engine automatically switches to Asymmetric signing (e.g., `RS256`). This allows the framework to sign tokens in the Auth service that can be verified by downstream services using only the Public Key, without sharing the master secret. - -!!! danger "Security Warning" - Never store your `SECRET_KEY` or `JWT_PRIVATE_KEY` in version control. Always use environment variables or a secure secret manager (like Vault or AWS Secrets Manager) in production. \ No newline at end of file diff --git a/docs/features/security/zchema.md b/docs/features/security/zchema.md deleted file mode 100644 index 5838850..0000000 --- a/docs/features/security/zchema.md +++ /dev/null @@ -1,115 +0,0 @@ -# Zchema Security & Schema Pruning - -Unify input validation, response serialization, and dynamic JSON-Schema generation into a single domain-aware base class that silently enforces data-masking policies. - ---- - -
-
- Type
- Schema Security Layer -
-
- Status
- Security Core -
-
- Underlying Tech
- Pydantic V2 / contextvars -
-
- -## The Challenge - -In a typical FastAPI application, security schema management suffers from three distinct failure modes: - -1. **Schema Fragmentation:** To prevent data leakage, developers define multiple Pydantic models per entity (e.g., `ProductCreate`, `ProductOutPublic`, `ProductOutAdmin`, `ProductUpdate`). A single new restricted field must be added to every variant, leading to copy-paste errors. -2. **Mass Assignment Vulnerabilities:** Input schemas that accept all model fields allow a malicious user to send unexpected fields (e.g., `is_admin=true`) that get written directly to the database. -3. **Context-Blind Serialization:** Response serialization is static. A service returns the same fields regardless of who is making the request, forcing every endpoint to manually check permissions before building the response. - -## The ZCore Elegance - -`Zchema` is a drop-in replacement for `pydantic.BaseModel` that is domain-aware and context-sensitive. You define a single schema per entity, annotate it with `__model__` to declare its database domain, and Zchema automatically prunes restricted fields during validation, serialization, and JSON-Schema generationโ€”all based on the active thread-safe context. - -=== "ZCore Unified Zchema" - :::python - from zcore.web.projection import Zchema - - class ProductBase(Zchema): - __model__ = "products" # Domain binding - name: str - price: float - secret_cost: float | None = None # Restricted when context says so - - # Inside a request where "products.secret_cost" is restricted: - schema = ProductBase(name="X", price=10, secret_cost=100) - # schema.model_dump() โ†’ {"name": "X", "price": 10} (secret_cost pruned) - - # GET /products?schema=true returns JSON-Schema WITHOUT secret_cost - -=== "Standard Pydantic Multi-Model" - :::python - from pydantic import BaseModel - - class ProductCreate(BaseModel): - name: str - price: float - secret_cost: float | None = None - - class ProductOut(BaseModel): - name: str - price: float - # secret_cost is missingโ€”but so is any admin-only field - - # Must maintain N models per entity - # Each new field requires updates across ALL variants - # No automatic context-driven pruning - ---- - -## The Pruning Pipeline - -

- The Pruning Pipeline -

- ---- - -## Boundaries & Integration - -Zchema is the backbone of ZCore's data-masking strategy and integrates seamlessly with the broader ecosystem. - -* **ResponseWrapper Inheritance:** `ResponseWrapper` inherits from `Zchema`, meaning all API responses automatically benefit from context-driven serialization pruning. -* **BaseRouter Schema Resolution:** The `BaseRouter` uses `create_schema`, `update_schema`, and `schema_out`โ€”all expected to be `Zchema` subclasses. The router's `ZCoreAPIRoute` invokes `model_json_schema()` on these classes, which triggers the customized schema generation path. -* **Context Decoupling:** Zchema never directly inspects HTTP headers or tokens. It reads restricted field paths from the thread-safe context (`get_restricted_fields()`), which is populated by upstream middleware or authentication logic. - ---- - -## Under-the-Hood Spec - -### 1. Domain Mapping via `__model__` - -Each Zchema subclass declares a `__model__` class variable matching its database table or domain name (e.g., `"products"`) [web/projection.py]. When the context holds a restricted path like `"products.secret_cost"`, `_get_relative_restricted_paths` strips the domain prefix and returns `{"secret_cost"}`. A wildcard path `"products"` maps to `{"*"}`, which clears the entire payload. - -### 2. Input Pruning via `model_validator(mode="before")` - -The `filter_restricted_inputs` validator executes before any Pydantic field validation [web/projection.py]. It makes a shallow copy of the input dictionary and calls `_prune_data` to remove any keys whose dotted paths match the restricted set. This prevents mass assignment attacks where a user injects privileged fields into a create or update payload. - -### 3. Output Pruning via `model_serializer(mode="wrap")` - -The `secure_serializer` wraps the default serialization handler [web/projection.py]. After Pydantic produces the standard dictionary, the method applies the same pruning logic to strip restricted fields from the serialized output before returning the response. - -### 4. Schema Pruning via `__get_pydantic_json_schema__` - -Zchema overrides Pydantic V2's JSON-Schema generation hook [web/projection.py]. It calls the standard handler to produce the full schema, then recursively walks the `properties` and `required` lists to remove any fields that are restricted for the current context. This ensures that frontend forms generated from `?schema=true` never include fields the user cannot see or edit. - -### 5. Recursive Nested Pruning - -The `_prune_data` helper handles nested structures [web/projection.py]. For a restriction like `"orders.items.price"`, it descends into `data["orders"]["items"]` and prunes the `price` key from each dictionary or list element. This enables field-level masking across complex, deeply nested Pydantic models. - -!!! info "Performance Note" - Zchema restricts input and output fields by value-copying the data dictionary before pruning. This ensures the original model instance is never mutated, preserving data integrity for downstream operations that may bypass the serializer. - -!!! danger "Security Note" - Because pruning happens at the Pydantic layer, it applies universallyโ€”including in background tasks, CLI commands, or any code path that uses `model_dump()` on a Zchema instance while a restricted context is active. Always reset the context when spawning isolated work. \ No newline at end of file diff --git a/docs/how-to/auth-setup.md b/docs/how-to/auth-setup.md deleted file mode 100644 index afc04da..0000000 --- a/docs/how-to/auth-setup.md +++ /dev/null @@ -1,132 +0,0 @@ -# JWT Authentication & Scopes - -Secure your API with Argon2id hashing, flexible JWT signing, and protocol-based authorization guards. - ---- - -
-
- Type
- Security / Protocol -
-
- Status
- Core Core -
-
- Underlying Tech
- Argon2id / PyJWT / contextvars -
-
- -## The Challenge -Implementing security in FastAPI often leads to "Hardcoded Identity" problems. Developers either tightly couple their routes to a specific SQLAlchemy User model or manually parse JWTs in every dependency. Furthermore, many projects still use aging hashing algorithms like BCrypt or, worse, run production environments with default, insecure secret keys found in git history. - -## The ZCore Elegance -ZCore introduces **Protocol-Based Security**. Instead of depending on a concrete model, the security layer depends on a `UserProtocol`. This allows you to use any database (SQL, NoSQL, or External Auth) as long as your user object provides the required attributes. Security is enforced through reusable `BasePermission` classes that integrate seamlessly with `BaseRouter` and the kernel's context. - -=== "ZCore Permission Guards" - :::python - from zcore import BaseRouter, RouteKey - from zcore.security import HasScopes - - class DocumentRouter(BaseRouter[DocCreate, DocUpdate]): - model = Document - # ... standard config ... - - def get_route_dependencies(self, route_key: RouteKey, action: str): - # Automated scope checks based on model actions - # e.g., "documents:delete" - if route_key == RouteKey.DELETE: - return [HasScopes("admin", action)] - - return super().get_route_dependencies(route_key, action) - -=== "FastAPI Manual Auth" - :::python - # Manual verification in every route - @router.delete("/{id}") - async def delete_doc( - id: uuid.UUID, - user: User = Depends(get_current_user) - ): - # 1. Manual active check - if not user.is_active: raise AuthError() - - # 2. Manual scope string management - if "admin" not in user.scopes and "documents:delete" not in user.scopes: - raise ForbiddenError() - - # 3. Manual service call - await service.delete(id) - ---- - -## Implementation Guide - -### 1. Define your User Model -Ensure your SQLAlchemy or Pydantic user model implements the `UserProtocol`. - -```python -class User(Base): - __tablename__ = "users" - id: Mapped[uuid.UUID] = mapped_column(primary_key=True) - is_active: Mapped[bool] = mapped_column(default=True) - is_superuser: Mapped[bool] = mapped_column(default=False) - - @property - def all_scopes(self) -> set[str]: - # Return a set of strings representing user permissions - return {r.name for r in self.roles} -``` - -### 2. Override the Identity Provider -In your `main.py`, you must tell ZCore how to resolve the current user. ZCore provides a `get_current_user_stub` that you must override. - -```python -from zcore.security import get_current_user_stub, decode_token - -async def my_auth_provider(token: str = Depends(oauth2_scheme)) -> User: - payload = decode_token(token) - user_id = payload.get("sub") - # Fetch user from DB - return await user_service.get(user_id) - -# Register the override -app.dependency_overrides[get_current_user_stub] = my_auth_provider -``` - ---- - -## The Auth Lifecycle - -

- The Auth Lifecycle -

- - ---- - -## Boundaries & Integration -ZCore provides the cryptographic and logic chassis, while you provide the data. - -* **OAuth2 Compatible:** ZCore's JWT utilities work perfectly with FastAPI's `OAuth2PasswordBearer`. -* **Hashing Freedom:** While ZCore provides `get_password_hash` using Argon2id, you can use any library for hashing; ZCore services and repositories accept raw strings. -* **Bypass Identity:** If you have public routes, simply don't add the `HasScopes` dependency. The `request_context` will remain empty (user_id = None), and ZCore's masking logic will treat the request as unauthenticated. - ---- - -## Under-the-Hood Spec - -### 1. Fatal Security Violation Guard -The `jwt.py` module contains a hard-coded check [security/jwt.py]. If the `ENVIRONMENT` is set to `production` and the `SECRET_KEY` matches the framework's default fallback string, the application will **abort startup** with a `RuntimeError`. This prevents the most common source of production JWT vulnerabilities. - -### 2. Argon2id Hardening -ZCore defaults to Argon2id (the winner of the Password Hashing Competition). The `hashing.py` module configures high-entropy parameters by default: 64MB memory cost and 3 iterations [security/hashing.py]. These parameters can be tuned via `ZCoreCoreSettings` to balance security and latency. - -### 3. Protocol-Based "Duck Typing" -The `HasScopes` permission does not perform an `isinstance()` check against a database model [security/permissions.py]. Instead, it relies on the `UserProtocol` interface. This means your "User" could be a database record, a cached Redis object, or even a Mock object during testing, as long as it has `id`, `is_active`, and `all_scopes`. - -!!! success "Security Note" - By utilizing the `user_context` within your identity provider, the authenticated user's ID is available project-wide via `get_current_user_id()` without passing the user object through every function call. \ No newline at end of file diff --git a/docs/how-to/base-router-extensibility.md b/docs/how-to/base-router-extensibility.md deleted file mode 100644 index 06bdae3..0000000 --- a/docs/how-to/base-router-extensibility.md +++ /dev/null @@ -1,84 +0,0 @@ -# Project-Wide Router Extensibility - -Scale your API architecture by extending the BaseRouter to enforce project-wide security standards and custom endpoint behaviors. - ---- - -
-
- Type
- Routing Architecture -
-
- Status
- Recommended for Scale -
-
- Underlying Tech
- FastAPI / Python OOP -
-
- -## The Challenge -In a rapidly growing project, standard CRUD routers often become a source of technical debt. You might find yourself manually adding the same "Admin Only" dependency to the `DELETE` route of 20 different modules. Or perhaps you decide that every `GET_ALL` request across the entire project must include a specific rate-limiting header. - -Using raw FastAPI `APIRouter` or rigid scaffolding tools forces you to repeat this logic in every file. If the requirement changes, you have to find and replace code in dozens of places, increasing the risk of security oversights. - -## The ZCore Elegance -ZCore's `BaseRouter` is designed for **Object-Oriented Inheritance**. Instead of using it directly, you can create a `ProjectBaseRouter` for your API. By overriding the lifecycle methodsโ€”specifically `get_route_dependencies` and `get_route_action`โ€”you can inject global security policies or modify the naming convention of permissions across all your domain modules from a single source of truth. - -=== "ZCore Architectural Extension" - :::python - # 1. Define your project-wide base router - class MyProjectBaseRouter(BaseRouter[CreateSchemaType, UpdateSchemaType]): - - def get_route_dependencies(self, route_key: RouteKey, action: str): - # Enforce project-wide policy: All DELETEs require 'superadmin' - deps = super().get_route_dependencies(route_key, action) - - if route_key == RouteKey.DELETE: - deps.append(HasScopes("superadmin")) - - return deps - - # 2. Use your custom base in domain routers - class UserRouter(MyProjectBaseRouter[UserCreate, UserUpdate]): - model = User - # DELETE /users now automatically requires 'users:delete' AND 'superadmin' - -=== "FastAPI Manual Repetition" - :::python - # You must remember to add the dependency to every router manually - router = APIRouter(prefix="/users") - - @router.delete("/{id}", dependencies=[Depends(HasScopes("users:delete")), Depends(HasScopes("superadmin"))]) - async def delete_user(id: uuid.UUID): - pass - - # ... Then repeat this exact dependency list in /products, /orders, /billing ... - # If the policy changes to "manager" instead of "superadmin", you edit 20 files. - ---- - -## Boundaries & Integration -Customized routers maintain full compatibility with the FastAPI ecosystem. - -* **Dependency Normalization:** ZCore's internal `_normalize_dependencies` helper ensures that whether you return a raw callable, a class, or a `FastAPI.Depends` object from your overrides, it is correctly wrapped and registered [web/base_router.py]. -* **Selective Overrides:** You can override a single endpoint method (e.g., `delete_endpoint`) in your project-base to implement "Soft Delete" logic project-wide, while leaving `POST` and `GET` to use the standard ZCore implementation. -* **Middleware Alternatives:** For logic that applies to *all* requests regardless of the route (like Correlation IDs), use ZCore Middlewares. Use Router Extensions for logic that varies by `RouteKey` or `Model`. - ---- - -## Under-the-Hood Spec - -### 1. The RouteKey Enum -The `RouteKey` is a `StrEnum` that maps directly to the scaffolded operations: `POST`, `GET`, `GET_ALL`, `SEARCH`, `UPDATE`, `PATCH`, and `DELETE` [web/base_router.py]. This allows your overrides to use clean, type-safe logic when determining which dependencies to inject. - -### 2. Automatic Dependency Injection (DI) -The `BaseRouter` doesn't just register functions; it registers closures that utilize `Inject(self.service)` [web/base_router.py]. This means that even in your project-wide base router, the correct domain-specific service is always resolved from the IoC container at runtime. - -### 3. Scoped Action Derivation -The `get_route_action` method bridges the router to the model [web/base_router.py]. By default, it calls `self.model.actions().VIEW` or `CREATE`. If your project uses a different naming convention for permissions (e.g., `view_products` instead of `products:view`), you can override this single method in your `ProjectBaseRouter` to re-map actions project-wide. - -!!! info "Advanced Dependency Logic" - The `get_route_dependencies` method is called during the router's `__init__` phase [web/base_router.py]. This means your dependency tree is pre-compiled at startup, maintaining FastAPI's high performance during the request-response cycle. \ No newline at end of file diff --git a/docs/how-to/custom-storage-provider.md b/docs/how-to/custom-storage-provider.md deleted file mode 100644 index 80399e1..0000000 --- a/docs/how-to/custom-storage-provider.md +++ /dev/null @@ -1,109 +0,0 @@ -# Cloud Asset Orchestration - -Implement enterprise-grade cloud storage while maintaining local security defaults and rigorous file validation. - ---- - -
-
- Type
- Storage Protocol -
-
- Status
- Optional Provider -
-
- Underlying Tech
- anyio / aiofiles / Magic Bytes -
-
- -## The Challenge -Handling file uploads in FastAPI is deceptively simple until production requirements hit. Developers usually face: -1. **Provider Lock-in:** Code written for `shutil.copyfileobj` (local disk) breaks when moving to AWS S3 or Google Cloud Storage. -2. **Insecure Extensions:** Relying on `file.content_type` is dangerous, as attackers can easily spoof MIME types by renaming an executable to `.jpg`. -3. **Path Traversal:** Unsanitized filenames allowing attackers to overwrite critical system files via `../../etc/passwd` exploits. -4. **Resource Exhaustion:** Large file uploads filling up server RAM or local disk space without pre-emptive size validation. - -## The ZCore Elegance -ZCore abstracts storage through the `StorageProvider` protocol. You can swap between `LocalStorageProvider` and a custom cloud provider (like S3) globally by changing a single DI registration. Every provider benefits from ZCore's multi-layered security validators, which verify file headers (Magic Bytes) and enforce strict path isolation. - -=== "ZCore S3 Provider Implementation" - :::python - from zcore.storage import StorageProvider - from fastapi import UploadFile - - class S3StorageProvider(StorageProvider): - def __init__(self, bucket: str, client: Any): - self.bucket = bucket - self.client = client - - async def upload(self, file: UploadFile, folder: str) -> str: - # Custom S3 logic - path = f"{folder}/{file.filename}" - await self.client.put_object(Bucket=self.bucket, Key=path, Body=await file.read()) - return path - - # In main.py: Global swap - container.register_singleton(StorageProvider, S3StorageProvider(bucket="my-assets", client=s3)) - -=== "FastAPI Manual Upload" - :::python - # Logic is hardcoded to one storage type - @app.post("/upload") - async def upload_file(file: UploadFile): - # 1. Manual Path Traversal Check - if ".." in file.filename: raise HTTPException(400) - - # 2. Manual Size Check - # (Requires reading part of the file or trusting headers) - - # 3. Hardcoded Storage Logic - with open(f"uploads/{file.filename}", "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - - # Moving to S3 requires rewriting this entire function. - ---- - -## Security Validation Layer -ZCore provides a suite of modular validators that run *before* the file reaches your storage target. - -```python -from zcore.storage import MaxFileSizeValidator, SafeMimeTypeValidator - -# Configure local storage with strict security -storage = LocalStorageProvider( - base_path="./uploads", - validators=[ - MaxFileSizeValidator(max_size_mb=5), - SafeMimeTypeValidator(allowed_mimes=["image/png", "image/jpeg", "application/pdf"]) - ] -) -``` - ---- - -## Boundaries & Integration -The storage layer is a decoupled utility that respects FastAPI's standard types. - -* **FastAPI `UploadFile`:** The `upload` method accepts the standard Starlette/FastAPI `UploadFile` object, ensuring compatibility with native multipart form handling. -* **Dependency Injection:** Use `get_storage_provider` as a FastAPI dependency to resolve the active provider in any route, regardless of whether it is local or cloud-based. -* **AnyIO Integration:** The `LocalStorageProvider` uses `anyio.Path` and `aiofiles` for non-blocking I/O, ensuring that file operations do not freeze the main event loop. - ---- - -## Under-the-Hood Spec - -### 1. Magic Byte Content Verification -The `SafeMimeTypeValidator` does not trust the file extension or the browser-supplied `content_type` [storage/validators.py]. It reads the first **2048 bytes** of the file and compares them against hardcoded "Magic Byte" signatures (e.g., `\x89PNG\r\n\x1a\n`). It also performs a specific security check to block high-risk patterns like ` -
- Type
- Context Shielding -
-
- Status
- Recommended Core -
-
- Underlying Tech
- Pydantic V2 / contextvars -
- - -## The Challenge -Modern APIs often deal with "Multifaceted Data"โ€”where the same record (e.g., a **User Profile**) has different visible fields depending on who is asking. -1. **The Public** should only see `username` and `avatar`. -2. **The Owner** should also see `email` and `billing_address`. -3. **The Admin** should see everything, including `internal_notes` and `risk_score`. - -In standard FastAPI, this usually forces developers to create a separate Pydantic model for every role (`UserPublic`, `UserPrivate`, `UserAdmin`) and write complex `if/else` logic in routers to choose the correct model. This is error-prone and causes "Schema Explosion." - -## The ZCore Elegance -ZCore's `Zchema` allows you to define **one model** for all roles. By setting the `__model__` attribute, the schema becomes "context-aware." When the request starts, your security middleware populates a list of `restricted_fields` (e.g., `["users.internal_notes"]`). ZCore then automatically strips these fields from incoming JSON payloads (Mass Assignment protection) and outgoing JSON responses (Leakage protection). - -=== "ZCore Context Masking" - :::python - from zcore import Zchema, request_context - - class UserSchema(Zchema): - __model__ = "users" # Ties to the 'users' domain - - username: str - email: str - internal_notes: str | None = None - - # Inside a middleware or dependency: - restricted = ["users.internal_notes"] - if not user.is_admin: - with request_context(user_id=user.id, fields=restricted): - # Outgoing response now automatically - # masks 'internal_notes' - return UserSchema.model_validate(user_record) - -=== "FastAPI Manual Projection" - ::python - # Multiple models required - class UserPublic(BaseModel): - username: str - - class UserPrivate(UserPublic): - email: str - internal_notes: str - - @router.get("/users/{id}") - async def get_user(id: uuid.UUID, user: User = Depends(get_user)): - record = await db.get(id) - # Manual role-based branching logic - if user.is_admin: - return UserPrivate.model_validate(record) - return UserPublic.model_validate(record) - ---- - -## Boundaries & Integration -ZCore's masking logic is a non-destructive layer built on top of Pydantic. - -* **Pydantic Native:** Pruning happens during Pydantic's internal `model_validator` and `model_serializer` phases [web/projection.py]. Your logic inside the service or repository layers remains untouchedโ€”you always work with full objects. -* **Opt-In Masking:** If a schema does not define `__model__`, ZCore ignores it. It behaves like a standard `BaseModel`. -* **Freedom to Bypass:** If you need to force-send a restricted field for a specific edge case, you can use a standard `pydantic.BaseModel` for that specific route, which will bypass ZCore's context-aware pruning. - ---- - -## Under-the-Hood Spec - -### 1. The Recursive `_prune_data` Logic -ZCore doesn't just check top-level keys. The internal `_prune_data` method recursively traverses nested dictionaries and lists [web/projection.py]. If you restrict `orders.items.cost`, ZCore will find every `cost` field inside the `items` list of an `Order` object and remove it, regardless of the nesting depth. - -### 2. Dynamic JSON-Schema Pruning -When `expose_schemas` is enabled on a router, ZCore overrides `__get_pydantic_json_schema__` [web/projection.py]. It doesn't just mask the data; it **masks the documentation**. If a user lacks permission to see `internal_notes`, that field will not appear in the JSON-Schema returned by `?schema=true`, allowing your frontend to dynamically hide form inputs. - -### 3. Cache-Safe `Vary` Headers -Because the same URL can return different JSON content based on the user's permissions, ZCore's `ZCoreAPIRoute` automatically appends `Authorization` or `Cookie` to the `Vary` HTTP header [web/api_router.py]. This ensures that downstream CDNs or browser caches do not accidentally serve a cached "Admin" response to a "Public" user. - -!!! info "Security Policy" - Restricted fields are defined as dot-paths (e.g., `model_name.field_name`). To restrict an entire model, you can use the model name itself (e.g., `users`). This will cause the entire schema to return an empty object if the user is unauthorized. \ No newline at end of file diff --git a/docs/how-to/multi-repo-transactions.md b/docs/how-to/multi-repo-transactions.md deleted file mode 100644 index 1746f74..0000000 --- a/docs/how-to/multi-repo-transactions.md +++ /dev/null @@ -1,99 +0,0 @@ -# Multi-Repository Transactions - -Ensure data integrity across multiple domain services with atomic Unit of Work orchestration and post-commit event dispatching. - ---- - -
-
- Type
- Transaction Orchestrator -
-
- Status
- Highly Recommended -
-
- Underlying Tech
- SQLAlchemy 2.0 / Unit of Work -
-
- -## The Challenge -When business logic spans multiple domains (e.g., creating an **Order**, updating **Inventory**, and deducting **User Balance**), atomicity is critical. If the database update for the balance fails, the order and inventory must not persist. - -In raw FastAPI/SQLAlchemy code, developers often struggle with: -1. **Premature Commits:** A sub-service calls `db.commit()` inside its own method, finalizing part of the transaction before the rest is ready. -2. **Ghost Events:** A domain event (like an "Order Confirmation Email") is sent to a message broker, but the database transaction subsequently fails and rolls back. The user receives an email for an order that doesn't exist. -3. **Session Pollution:** Passing a single session through five different function signatures, making the code brittle and hard to test. - -## The ZCore Elegance -ZCore utilizes the **Unit of Work (UOW)** pattern combined with a metadata-aware session [uow.py]. When you wrap operations in a `UnitOfWork` context, it marks the session as `uow_managed`. ZCore's `BaseService` detects this flag and automatically skips its internal commits, delegating the final authority to the UOW. Furthermore, events registered during the transaction are buffered and only dispatched if the commit succeeds. - -=== "ZCore Unit of Work" - :::python - from zcore import UnitOfWork, Inject - from zcore.kernel.events import EventDispatcher - - async def checkout_service( - order_data: OrderCreate, - order_service: OrderService = Inject(OrderService), - inventory_service: InventoryService = Inject(InventoryService), - session: AsyncSession = SessionDep, - dispatcher: EventDispatcher = Inject(EventDispatcher) - ): - # Atomic Boundary - async with UnitOfWork(session, dispatcher) as uow: - # Sub-services detect UOW and skip internal commits - order = await order_service.create(order_data) - await inventory_service.reduce_stock(order.items) - - # Queue event for post-commit dispatch - uow.register_event("order.placed", {"id": order.id}) - - # Commit happens automatically at the end of 'async with' - -=== "FastAPI Manual Transaction" - :::python - @app.post("/checkout") - async def checkout(data: OrderCreate, db: AsyncSession = Depends(get_db)): - try: - # 1. Manual order logic - order = await create_order(db, data) - # 2. Manual inventory logic - await reduce_stock(db, order.items) - - # 3. Manual commit - await db.commit() - - # 4. Manual event trigger - # If this fails, the user is confused, but the DB is committed. - await dispatcher.dispatch("order.placed", {"id": order.id}) - except Exception: - await db.rollback() - raise - ---- - -## Boundaries & Integration -ZCore provides a structured wrapper around SQLAlchemy's native transaction lifecycle. - -* **Session Native:** `UnitOfWork` operates on a standard `AsyncSession`. You can still call `session.execute()` directly inside a UOW block. -* **Event Decoupling:** The `EventDispatcher` is a core ZCore component. The UOW strictly manages the *timing* of the dispatch, not the handler logic itself. -* **Freedom to Bypass:** If you do not use the `UnitOfWork` context, `BaseService` defaults to its `_safe_commit` behavior, which commits the transaction immediately after the service operation concludes. - ---- - -## Under-the-Hood Spec - -### 1. The `uow_managed` Flag -Inside `UnitOfWork.__aenter__`, the framework sets `self.session.info["uow_managed"] = True` [uow.py]. The `BaseService._safe_commit` method checks this specific key in the session's internal dictionary [service/base.py]. If present, it treats the session as "borrowed" and skips the `commit()` call, ensuring that only the top-level orchestrator decides when to finalize the data. - -### 2. Transactional Event Buffering -The `UnitOfWork` maintains an internal `_pending_events` list [uow.py]. When `uow.register_event()` is called, the event is simply stored in memory. The dispatcher is only invoked in the `commit()` method *after* `await self.session.commit()` has successfully returned. If the database commit raises an exception, the event list is cleared during `rollback()`, preventing "ghost notifications." - -### 3. Automatic Exception Handling -The `__aexit__` method of the `UnitOfWork` is designed for safety [uow.py]. If an exception is raised within the `async with` block, it automatically triggers `await self.rollback()`. If the block completes successfully, it triggers `await self.commit()`. This eliminates the need for manual `try/except/rollback` blocks in your business orchestrators. - -!!! info "Concurrency Note" - Events dispatched by the Unit of Work use the `EventDispatcher.dispatch` method, which executes all registered asynchronous handlers concurrently using `asyncio.gather`. This ensures that post-transaction side effects do not serialize your application's response time. \ No newline at end of file diff --git a/docs/how-to/websocket-pubsub.md b/docs/how-to/websocket-pubsub.md deleted file mode 100644 index 28f567e..0000000 --- a/docs/how-to/websocket-pubsub.md +++ /dev/null @@ -1,97 +0,0 @@ -# WebSocket & Redis PubSub - -Multi-subscriber real-time event streaming with cluster-wide broadcasting and automated backpressure management. - ---- - -
-
- Type
- Real-time Streaming -
-
- Status
- Optional Utility -
-
- Underlying Tech
- asyncio.Queue / Redis PubSub -
-
- -## The Challenge -Scaling WebSockets in a distributed environment (multiple server instances) is complex. If **User A** is connected via WebSocket to **Server 1**, but a relevant event (like a new message) is processed by **Server 2**, Server 2 has no direct way to reach the socket on Server 1. - -Furthermore, managing these connections in raw FastAPI requires: -1. **Manual PubSub Wiring:** Subscribing to Redis channels and correctly parsing messages. -2. **Memory Leak Risks:** Forgetting to unregister listeners when a WebSocket disconnects. -3. **Slow Consumer Protection:** Preventing a slow client from filling up the server's memory with unsent messages. - -## The ZCore Elegance -The `StreamManager` provides a unified interface for local and distributed events. It uses a **Subscription Context Manager** that automatically handles registration, Redis PubSub pattern-matching, and silent cleanup upon disconnection. It implements an internal backpressure policy that automatically evicts "stuck" listeners to preserve system stability. - -=== "ZCore Stream Subscription" - :::python - from zcore.web import StreamManager - from fastapi import WebSocket - - stream_manager = StreamManager() - - @app.websocket("/ws/{user_id}") - async def websocket_endpoint(websocket: WebSocket, user_id: uuid.UUID): - await websocket.accept() - - # 1. Atomic Subscription & Auto-Cleanup - async with stream_manager.subscription(user_id) as queue: - try: - while True: - # 2. Non-blocking retrieval - data = await queue.get() - await websocket.send_json(data) - except WebSocketDisconnect: - pass - # 3. Context manager ensures unsubscription from Redis/Local memory - -=== "FastAPI Raw Implementation" - :::python - # Manual management across multiple server instances - @app.websocket("/ws/{user_id}") - async def websocket_endpoint(websocket: WebSocket, user_id: uuid.UUID): - await websocket.accept() - pubsub = redis.pubsub() - await pubsub.subscribe(f"user:{user_id}") - - try: - async for message in pubsub.listen(): - if message["type"] == "message": - await websocket.send_json(json.loads(message["data"])) - finally: - # 1. Manual cleanup is easy to forget - # 2. No protection against slow consumer backpressure - # 3. No easy way to broadcast to local queues without Redis - await pubsub.unsubscribe(f"user:{user_id}") - ---- - -## Boundaries & Integration -ZCore manages the routing logic, leaving the transport to FastAPI. - -* **Transport Agnostic:** The `StreamManager` provides a standard `asyncio.Queue`. While designed for WebSockets, you can use it for Server-Sent Events (SSE) or long-polling. -* **Redis Optional:** If `init_stream_redis` is not called, the manager operates in "Local-Only" modeโ€”perfect for development or single-node deployments. -* **Structured Serialization:** All messages routed through the manager are automatically serialized/deserialized using ZCore's `CustomJSONEncoder`, supporting UUIDs and Datetime out of the box [utils/helpers.py]. - ---- - -## Under-the-Hood Spec - -### 1. Cluster-Wide Pattern Matching -The `StreamManager` uses `psubscribe` with the pattern `stream:user:*` [web/streams.py]. Instead of creating a unique Redis subscription for every single user (which is expensive), ZCore maintains a single background task (`_listen_to_redis`) that multiplexes incoming cluster-wide messages to the correct local memory queues based on the channel name. - -### 2. Backpressure & Eviction Policy -To prevent memory exhaustion, every user queue has a `maxsize=100` [web/streams.py]. When publishing, ZCore uses `queue.put_nowait()`. If a queue is full (meaning the client is too slow to receive messages), ZCore **evicts the queue immediately** and logs a warning. This "fail-fast" approach protects the server's memory from being consumed by stale or hung connections. - -### 3. Thread-Safe State Management -All modifications to the internal `users_queues` mapping are protected by an `asyncio.Lock` [web/streams.py]. This prevents race conditions where a user might be unsubscribed while a message is simultaneously being routed to them from the Redis background task. - -!!! info "Redis Scalability" - By utilizing the `init_stream_redis` hook, you can share a single Redis connection pool between your Cache, Database (if using Redis), and Stream Manager, optimizing your infrastructure's connection count. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 2dd35ad..0000000 --- a/docs/index.md +++ /dev/null @@ -1,288 +0,0 @@ -
- ZCore Logo -

- A pragmatic and complementary architectural layer built on top of FastAPI. - Standardize your structure, protect your data, and manage atomic transactionsโ€”without losing your development freedom. -

-
- - Get Started - - - - - - GitHub Repository - -
-
- pip install fastapi-zcore-framework[all] -
-
- ---- - -## From Chaos to Structure - -FastAPI is highly performant but structureless, often leading to scattered database sessions, repetitive CRUD boilerplate, and complex dependency wiring in larger teams. - -ZCore acts as a structured chassis. See how ZCore streamlines standard web operations while keeping you fully in control: - -=== "ZCore (35 Lines โ€” 7 Secure Endpoints)" - - :::python - from zcore import BaseRouter, BaseService, BaseRepository, Base, Zchema - from sqlalchemy.orm import Mapped, mapped_column - import uuid - - # 1. Define Model & Access Scopes (Auto-generates product:create, product:view, etc.) - class Product(Base): - __tablename__ = "products" - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column() - price: Mapped[float] = mapped_column() - secret_cost: Mapped[float | None] = mapped_column() # Sensitive data - - # 2. Context-Aware Schema (Auto-prunes 'secret_cost' in real-time if user lacks permission) - class ProductBase(Zchema): - __model__ = "products" - name: str - price: float - secret_cost: float | None = None - - # 3. Standardize Layers & Scaffold Router (Generates 7 secure CRUD & Search endpoints) - class ProductRepository(BaseRepository[Product, ProductBase, ProductBase]): - def __init__(self, db: AsyncSession): - super().__init__(model=Product, db=db) - - class ProductService(BaseService[Product, ProductBase, ProductBase]): - def __init__(self, repository: ProductRepository = Inject(ProductRepository)): - super().__init__(model=Product, repository=repository) - - class ProductRouter(BaseRouter[ProductBase, ProductBase]): - model = Product - create_schema = ProductBase - update_schema = ProductBase - schema_out = ProductBase - service = ProductService - prefix = "/products" - - -=== "FastAPI (100+ Lines โ€” Only 3 Basic Endpoints)" - - :::python - # To replicate just a SUBSET of ZCore's security, transaction, and search features - # in raw FastAPI, you must manually write massive repetitive boilerplate: - - from fastapi import FastAPI, Depends, HTTPException, status - from pydantic import BaseModel - from sqlalchemy.ext.asyncio import AsyncSession - from sqlalchemy import select, func - import uuid - - # 1. Define Base Model - class Product(Base): - __tablename__ = "products" - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column() - price: Mapped[float] = mapped_column() - secret_cost: Mapped[float] = mapped_column() - - # 2. Multiple Pydantic models needed to manually prevent data leakage for different roles - class ProductCreate(BaseModel): - name: str - price: float - secret_cost: float - - class ProductOutPublic(BaseModel): - id: uuid.UUID - name: str - price: float - - class ProductOutAdmin(BaseModel): - id: uuid.UUID - name: str - price: float - secret_cost: float - - # 3. Manual Create Endpoint (With manual transaction rollback) - @app.post("/products", response_model=ProductOutAdmin) - async def create_product( - data: ProductCreate, - db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user) - ): - if "product:create" not in user.scopes: - raise HTTPException(status_code=403, detail="Access denied") - - product = Product(**data.model_dump()) - db.add(product) - try: - await db.commit() # Manual transaction commit - await db.refresh(product) - except Exception: - await db.rollback() # Manual transaction rollback on failure - raise HTTPException(status_code=400, detail="Transaction failed") - return product - - # 4. Manual Get Endpoint (With manual role-based schema projection to prevent leaks) - @app.get("/products/{id}") - async def get_product( - id: uuid.UUID, - db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user) - ): - result = await db.execute(select(Product).where(Product.id == id)) - product = result.scalar_one_or_none() - if not product: - raise HTTPException(status_code=404, detail="Product not found") - - # Manual permission-based data pruning - if "product:view_sensitive" in user.scopes: - return ProductOutAdmin.model_validate(product) - return ProductOutPublic.model_validate(product) - - # 5. Manual List Endpoint (With manual offset calculations and count queries) - @app.get("/products") - async def list_products( - page: int = 1, - size: int = 20, - db: AsyncSession = Depends(get_db) - ): - offset = (page - 1) * size - count_query = select(func.count()).select_from(Product) - total = (await db.execute(count_query)).scalar_one() - - items_query = select(Product).offset(offset).limit(size) - items = (await db.execute(items_query)).scalars().all() - - return { - "data": [ProductOutPublic.model_validate(i) for i in items], - "meta": {"total": total, "page": page, "size": size} - } - ---- - -## Instant Frontend Integration - -One of ZCore's standout features is its native schema projection. By enabling a single flag on your router, you can instantly render dynamic forms on the frontend. - -Adding `?schema=true` to any scaffolded POST, PUT, or PATCH endpoint returns the raw, validated JSON-Schema of that endpoint: - -```bash -GET /products?schema=true -``` - -```json -{ - "success": true, - "message": "Schema generated successfully", - "data": { - "title": "ProductBase", - "type": "object", - "properties": { - "name": { "type": "string" }, - "price": { "type": "number" } - }, - "required": ["name", "price"] - }, - "meta": { - "restricted_fields": [] - } -} -``` - -!!! info "Security Isolation" - If the active user does not have permission to view or edit specific fields, ZCore automatically prunes those fields from the returned JSON-Schema in real-time. - ---- - -## The Request Lifecycle - -ZCore coordinates your web, context, database, and transaction layers into a single, predictable journey. - -

- The Request Lifecycle -

- - ---- - -## Six Architectural Pillars - -ZCore packages essential enterprise-grade patterns into optional, lightweight modules. - -
- -
-
๐Ÿ›ก๏ธ
-

Context Shielding (Zchema)

-

- Write a single schema model. ZCore dynamically prunes input fields (preventing mass assignment) and output fields (preventing data leakage) based on active user context. -

-
- -
-
๐Ÿ”—
-

Atomic Transactions (UOW)

-

- Group multiple operations into a single Unit of Work. Domain events are queued and only dispatched after the database transaction commits successfully. -

-
- -
-
๐Ÿ”
-

Dynamic Search Engine

-

- A secure query builder that translates nested JSON filters, sorting, and eager-loading into safe SQL queries with depth-limit protection against DoS. -

-
- -
-
๐Ÿ—๏ธ
-

Scoped DI Container

-

- Clean constructor-based dependency injection. Registers singletons, transients, and request-scoped dependencies that are auto-cleaned per request. -

-
- -
-
๐Ÿš€
-

Progressive Routing

-

- Scaffold 7 CRUD and search endpoints in seconds, or bypass the router completely to write standard, raw FastAPI functional endpoints. -

-
- -
-
๐Ÿ“ฆ
-

Topological Plugins

-

- Structure your codebase into clean modules. The central kernel automatically sorts and loads plugins based on their declared dependencies. -

-
- -
- ---- - -## Built for Absolute Freedom - -Is ZCore another rigid framework like Django? **No.** - -We believe in **progressive disclosure**. You only use what you need, when you need it. If a specific business route requires raw, custom logic, bypass the scaffolding router entirely while still using ZCore's decoupled background layers: - -```python -# Bypass the scaffolding BaseRouter and write standard, functional FastAPI -@app.post("/custom-checkout") -async def checkout( - data: CheckoutRequest, - checkout_service: CheckoutService = Inject(CheckoutService) # Still uses Scoped DI -): - # Execute transactional business logic - return await checkout_service.process_order(data) -``` - -!!! success "Next Steps" - Ready to experience clean FastAPI architecture? Proceed to the [Installation & Scaffolding](learn/installation/) guide to create your first ZCore app in seconds. \ No newline at end of file diff --git a/docs/learn/1-db.md b/docs/learn/1-db.md deleted file mode 100644 index 8984162..0000000 --- a/docs/learn/1-db.md +++ /dev/null @@ -1,79 +0,0 @@ -# Database Models & Metadata - -Define your schema and auto-generate standardized permission scopes for every entity. - ---- - -
-
- Type
- Declarative Base -
-
- Status
- Core Requirement -
-
- Underlying Tech
- SQLAlchemy 2.0 Async -
-
- -## The Challenge -In large-scale FastAPI applications, managing authorization often becomes a "string-guessing" game. Developers manually define permission strings like `"product:view"` or `"order:delete"` in routers, while the models remain unaware of these scopes. This leads to **permission drift**, where a table is renamed or a scope is mistyped, resulting in silent authorization failures or security loopholes. - -## The ZCore Elegance -ZCore's `Base` is a thin extension of SQLAlchemy's `DeclarativeBase`. It provides a centralized `.actions()` method that dynamically generates a structured `Actions` object based on the `__tablename__`. These actions are then used by ZCore routers and security layers to enforce consistent authorization without hardcoded strings. - -=== "ZCore Model with Actions" - :::python - from zcore import Base - from sqlalchemy.orm import Mapped, mapped_column - import uuid - - class Product(Base): - __tablename__ = "products" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column() - - # ZCore automatically provides: - # Product.actions().CREATE -> "products:create" - # Product.actions().VIEW -> "products:view" - # Product.actions().DELETE -> "products:delete" - # Product.actions().LISTVIEW -> "products:listview" - -=== "Manual SQLAlchemy Approach" - :::python - # Standard SQLAlchemy models don't know about permissions. - # You are forced to manually manage strings elsewhere: - class Product(Base): - __tablename__ = "products" - id: Mapped[uuid.UUID] = mapped_column(primary_key=True) - - # In your security layer: - PRODUCT_CREATE = "products:create" # Manual string - PRODUCT_VIEW = "products:view" # Manual string - # High risk of typos and zero link to the actual model class. - ---- - -## Boundaries & Integration -ZCore does not hide SQLAlchemy; it empowers it. - -* **Standard SQLA 2.0:** Because `Base` inherits from `DeclarativeBase`, you use the modern `Mapped` and `mapped_column` syntax exactly as documented in SQLAlchemy 2.0. -* **Bypass:** If you do not wish to use ZCore's authorization system, you can still use the `Base` class for standard migrations and queries without ever calling `.actions()`. -* **Custom Engines:** While `db_manager` provides a pre-configured `AsyncEngine`, you can still create your own SQLAlchemy engine and bind ZCore models to itโ€”they are 100% compatible with standard `AsyncSession` operations. - ---- - -## Under-the-Hood Spec - -### 1. The `Actions` Dataclass -The `Actions` object is an immutable, frozen dataclass [setup.py]. When `Base.actions()` is called, ZCore iterates over the internal fields of the `Actions` class and interpolates the `__tablename__` to create unique, namespaced permission keys. This ensures that permission names are always derived directly from the source of truthโ€”the database schema. - -### 2. Intelligent Connection Pooling -The `DatabaseManager.init_app` method contains specific logic for SQLite vs. server-based databases (PostgreSQL/MySQL) [setup.py]. For SQLite, it disables `pool_size` and `max_overflow` to prevent locking issues common with file-based databases, while enabling high-performance connection pooling and `pool_pre_ping` for production-grade relational servers. - -### 3. Lifecycle Awareness -ZCore models are designed to be used with the `db_manager.session()` context manager. This manager ensures that if an exception occurs during a database operation, `session.rollback()` is invoked automatically before the error is propagated, keeping your database state clean without manual `try/except` blocks in every service. \ No newline at end of file diff --git a/docs/learn/2-schemas.md b/docs/learn/2-schemas.md deleted file mode 100644 index fb8ac78..0000000 --- a/docs/learn/2-schemas.md +++ /dev/null @@ -1,92 +0,0 @@ -# Context-Aware Data Shielding - -Define one schema; ZCore handles input pruning and output masking based on active security context. - ---- - -
-
- Type
- Schema Wrapper -
-
- Status
- Highly Recommended -
-
- Underlying Tech
- Pydantic V2 / ContextVars -
-
- -## The Challenge -Modern APIs often suffer from two major data integrity issues: -1. **Mass Assignment:** An attacker sends `{"is_admin": true}` in a registration payload, and the backend blindly persists it. -2. **Data Leakage:** Sensitive fields (like `internal_notes` or `secret_cost`) are accidentally included in a public GET response. - -To solve this in raw FastAPI, developers typically create a "Schema Explosion"โ€”multiple Pydantic models for every single operation: `UserCreate`, `UserUpdate`, `UserPublicResponse`, `UserAdminResponse`, and `UserOwnerResponse`. This leads to massive maintenance overhead and fragile code. - -## The ZCore Elegance -The `Zchema` class eliminates schema explosion. By linking a schema to its database `__model__` name, ZCore automatically cross-references the active `restricted_fields` in the current context. If a field is restricted, ZCore silently prunes it from incoming payloads and masks it in outgoing responsesโ€”all using a single model definition. - -=== "ZCore Unified Zchema" - :::python - from zcore import Zchema - - class ProductSchema(Zchema): - __model__ = "products" # Links to Product.__tablename__ - - name: str - price: float - secret_cost: float | None = None # Restricted field - - # If "products.secret_cost" is in restricted_fields: - # 1. POST input: 'secret_cost' is silently stripped. - # 2. GET response: 'secret_cost' is masked from JSON output. - # 3. ?schema=true: 'secret_cost' is removed from JSON-Schema. - -=== "FastAPI Schema Explosion" - :::python - # You are forced to maintain 3+ models for the same entity - class ProductBase(BaseModel): - name: str - price: float - - class ProductCreate(ProductBase): - pass # No secret_cost allowed here - - class ProductResponsePublic(ProductBase): - id: uuid.UUID - - class ProductResponseAdmin(ProductResponsePublic): - secret_cost: float # Only for admins - - # In your router, you must manually select the right class: - if user.is_admin: - return ProductResponseAdmin.model_validate(product) - return ProductResponsePublic.model_validate(product) - ---- - -## Boundaries & Integration -ZCore provides security shielding without breaking the Pydantic ecosystem. - -* **Pydantic V2 Native:** `Zchema` inherits from `pydantic.BaseModel`. All Pydantic features like `Field`, `computed_field`, and `model_validator` work out of the box. -* **Automatic Middleware Integration:** Pruning only works if the `ScopedDependencyMiddleware` and `request_context` are active. If used outside a ZCore request, it behaves exactly like a standard Pydantic model. -* **Bypass:** If you need a schema that never prunes data regardless of context, simply inherit from `pydantic.BaseModel` instead of `Zchema`. - ---- - -## Under-the-Hood Spec - -### 1. The `model_serializer` Wrap -ZCore uses the Pydantic V2 `model_serializer(mode="wrap")` hook [projection.py]. When a response is being generated, ZCore intercepts the dictionary representation, identifies the relative paths of restricted fields for the specific `__model__`, and recursively removes them. This happens at the last possible moment before serialization, ensuring zero data leakage. - -### 2. Silent Input Pruning -The `model_validator(mode="before")` hook [projection.py] acts as a guard against Mass Assignment. Before data is even validated against the model's types, ZCore prunes any keys that the active user is not allowed to touch. This prevents unauthorized fields from ever reaching your service or repository layers. - -### 3. Recursive Dot-Path Resolution -Restriction paths can be specific or broad. For example, if `restricted_fields` contains `products.category.internal_id`, ZCore's internal `_prune_data` method will traverse nested dictionaries or lists within the `Product` schema to find and remove that specific deeply nested key [projection.py]. - -!!! info "Security Isolation" - ZCore's pruning logic is strictly read-only regarding the original data object; it operates on a copy during serialization to ensure the internal application state remains intact while the external representation is secured. \ No newline at end of file diff --git a/docs/learn/3-repository.md b/docs/learn/3-repository.md deleted file mode 100644 index 21c0511..0000000 --- a/docs/learn/3-repository.md +++ /dev/null @@ -1,93 +0,0 @@ -# Repository Pattern & Persistence - -Decouple business logic from database execution with high-performance, type-safe, and reusable data interfaces. - ---- - -
-
- Type
- Data Access Layer -
-
- Status
- Recommended -
-
- Underlying Tech
- SQLAlchemy 2.0 / Pydantic V2 -
-
- -## The Challenge -In many FastAPI projects, database queries are scattered directly inside route handlers. This "Fat Router" anti-pattern leads to: -1. **Repetitive Boilerplate:** Manually writing `session.execute(select(...))` and `.scalars().all()` for every endpoint. -2. **Brittle Pagination:** Inconsistent implementations of offset or cursor logic across different domains. -3. **Testing Hardship:** Difficulty mocking database interactions because the business logic is tightly coupled with SQLAlchemy's execution engine. -4. **Inefficient Queries:** Forgetting to apply `load_only` or missing eager-loading configurations, leading to over-fetching or N+1 problems. - -## The ZCore Elegance -The `BaseRepository` provides a robust, pre-optimized set of CRUD and search operations. It automatically inspects your model to identify primary keys and coordinates with ZCore's pagination and search engines. By inheriting from `BaseRepository`, you gain standardized behaviors for selective field loading, batch operations, and partial updates. - -=== "ZCore Repository Interface" - :::python - from zcore import BaseRepository - from .models import Product - from .schemas import ProductCreate, ProductUpdate - - class ProductRepository(BaseRepository[Product, ProductCreate, ProductUpdate]): - def __init__(self, db: AsyncSession): - # No boilerplate required. BaseRepository handles: - # .get(), .get_list(), .search(), exist(), get_by_ids() - # .create(), .create_multi(), .update(), .update_multi() - # .delete(), .delete_multi() - super().__init__(model=Product, db=db) - - async def get_expensive_products(self, threshold: float): - # Custom logic is clean and isolated - query = self._get_base_query().where(Product.price > threshold) - result = await self.db.execute(query) - return result.scalars().all() - -=== "Manual SQLAlchemy Boilerplate" - :::python - # For every model, you must manually repeat: - async def get_product(db: AsyncSession, id: uuid.UUID): - query = select(Product).where(Product.id == id) - result = await db.execute(query) - item = result.scalar_one_or_none() - if not item: return None - return item - - async def update_product(db: AsyncSession, id: uuid.UUID, data: dict): - item = await get_product(db, id) - for key, value in data.items(): - setattr(item, key, value) - await db.flush() # Remember to flush but not commit? - return item - ---- - -## Boundaries & Integration -ZCore repositories are abstractions that expose, rather than hide, the power of SQLAlchemy. - -* **Direct DB Access:** The `self.db` attribute is a standard SQLAlchemy `AsyncSession`. You can run raw SQL or complex `expression` constructs whenever needed. -* **Query Customization:** Use `_get_base_query()` to start every query with default filters (e.g., Soft Delete logic) or `_extend_query()` to apply global modifications. -* **Eager Loading:** All read methods (`get`, `get_list`, `get_by_ids`) accept an `options` parameter where you can pass standard SQLAlchemy loader options like `joinedload` or `selectinload`. -* **Selective Fields:** The `fields` parameter allows you to pass a list of model attributes to perform a `load_only` query, optimizing network traffic between the app and the database. - ---- - -## Under-the-Hood Spec - -### 1. Automatic PK Inspection -Upon initialization, `BaseRepository` utilizes the SQLAlchemy `inspect()` module to automatically locate the model's primary key [repository.py]. It maps this to `self.pk` and `self.pk_name`, allowing generic CRUD methods to function correctly regardless of whether your primary key is named `id`, `uuid`, or `pk`. - -### 2. The `get_by_ids` Fast-Path -The `get_by_ids` method includes a silent performance optimization: if an empty list is passed as the `ids` argument, the repository returns an empty sequence immediately without making a network round-trip to the database [repository.py]. - -### 3. Flush-Oriented Mutations -All mutation methods (`create`, `update`, `delete`) in the repository utilize `await self.db.flush()` instead of `commit()` [repository.py]. This ensures that changes are sent to the database's transaction buffer but remain uncommitted, allowing the `UnitOfWork` or a higher-level service to manage the final atomic transaction boundary. - -### 4. Search Engine Integration -The `search()` method is a bridge to the `SearchEngine`. It translates a `SearchRequest` (containing JSON-based filters and sorts) into a validated SQLAlchemy query, ensuring that even dynamic queries respect the security context and depth limits [repository.py, search.py]. \ No newline at end of file diff --git a/docs/learn/4-service.md b/docs/learn/4-service.md deleted file mode 100644 index 23e4f36..0000000 --- a/docs/learn/4-service.md +++ /dev/null @@ -1,106 +0,0 @@ -# Business Services & Lifecycle - -Orchestrate domain logic with transactional safety and granular execution hooks. - ---- - -
-
- Type
- Domain Logic Layer -
-
- Status
- Highly Recommended -
-
- Underlying Tech
- Python / Unit of Work -
-
- -## The Challenge -When business logic lives in the router (the "Fat Controller" anti-pattern), applications become impossible to maintain. Developers struggle with: -1. **Leaky Abstractions:** HTTP logic (headers, status codes) mixing with business rules (tax calculations, validation). -2. **Transaction Fragility:** Manually managing `commit()` and `rollback()` in every function. If one operation fails halfway, the database is left in an inconsistent state. -3. **Hook Spaghetti:** Difficulty triggering side effects (like sending an email or clearing a cache) consistently after a database write. -4. **Redundant Validation:** Re-writing existence checks and ownership validation for every endpoint. - -## The ZCore Elegance -The `BaseService` acts as a pure-Python orchestration layer. It wraps repository calls with a rich set of **Lifecycle Hooks** (`pre_create`, `post_update`, etc.) and a smart **Safe Commit** mechanism. This ensures that business rules are executed in the correct order and that transactions are only finalized when all steps succeed. - -=== "ZCore Orchestrated Service" - :::python - from zcore import BaseService, Inject - from .repositories import OrderRepository - from .models import Order - - class OrderService(BaseService[Order, OrderCreate, OrderUpdate]): - def __init__(self, repository: OrderRepository = Inject(OrderRepository)): - super().__init__(model=Order, repository=repository) - - async def pre_create(self, schema: OrderCreate): - # 1. Validation logic before DB hit - if schema.amount <= 0: - raise ValidationError("Invalid amount") - - async def post_create(self, model: Order): - # 2. Trigger side effects after successful write - await email_service.send_receipt(model.user_id) - -=== "FastAPI Manual Logic" - :::python - # Logic scattered in the route handler - @app.post("/orders") - async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)): - # 1. Manual Validation - if data.amount <= 0: - raise HTTPException(400, "Invalid amount") - - # 2. Manual DB Logic - new_order = Order(**data.model_dump()) - db.add(new_order) - try: - await db.commit() - except Exception: - await db.rollback() - raise - - # 3. Manual Side Effect - await email_service.send_receipt(new_order.user_id) - return new_order - ---- - -## Lifecycle Execution Flow -ZCore services follow a strict execution sequence for every mutation. If any step (including a hook) raises an exception, the entire operation is aborted. - -

- Lifecycle Execution Flow -

- ---- - -## Boundaries & Integration -ZCore services are designed to be framework-agnostic business units. - -* **HTTP Independence:** Services do not accept `Request` objects or return `JSONResponse`. They deal strictly with Pydantic schemas and SQLAlchemy models, making them easily testable in CLI scripts or background workers. -* **Dependency Injection:** Use the `Inject()` helper to pull in other services (e.g., `NotificationService`) or repositories directly into the constructor. -* **Bypass:** If you don't need the lifecycle hooks, you can call the core execution methods (e.g., `on_create`, `on_update`) directly, or bypass the service layer entirely by injecting the repository into your router. - ---- - -## Under-the-Hood Spec - -### 1. The Safe Commit Protocol -The most critical feature of `WriteServiceMixin` is `_safe_commit()` [service/base.py]. Before committing, the service inspects the database session's internal metadata (`session.info`). If the key `uow_managed` is set to `True`, the service **refrains from committing**. This allows an outer `UnitOfWork` to group multiple service calls into a single atomic transaction without the services "breaking" the transaction boundary prematurely. - -### 2. Post-Read Interceptors -Read operations (`get`, `get_list`) include `post_get` and `post_get_multi` hooks [service/base.py]. These are ideal for dynamic property calculation or permission-based filtering that happens after the data leaves the repository but before it reaches the router. - -### 3. Automated Error Mapping -The service layer automatically translates missing database records into standardized `EntityNotFound` exceptions [service/base.py]. This ensures that your API consistently returns a `404 Not Found` with a structured error message without you having to write manual `if not result` checks in every method. - -### 4. Partial Patch Support -The `update` method intelligently handles the `partial` flag [service/base.py]. When `partial=True`, it instructs the repository to ignore `None` or unset values in the update schema, facilitating clean HTTP `PATCH` implementations. \ No newline at end of file diff --git a/docs/learn/5-router.md b/docs/learn/5-router.md deleted file mode 100644 index 8224a78..0000000 --- a/docs/learn/5-router.md +++ /dev/null @@ -1,91 +0,0 @@ -# Progressive API Scaffolding - -Generate 7 secure CRUD and search endpoints in seconds with zero manual boilerplate. - ---- - -
-
- Type
- Web Router Scaffolding -
-
- Status
- Optional Utility -
-
- Underlying Tech
- FastAPI / Starlette -
-
- -## The Challenge -Writing standard CRUD (Create, Read, Update, Delete) routes in FastAPI is repetitive. For every new domain, developers must: -1. Define 5-7 path functions. -2. Manually inject the correct service. -3. Apply standard status codes (`201 Created` vs `200 OK`). -4. Wrap every response in a consistent envelope. -5. Manually apply security dependencies (scopes) that often fall out of sync with the database model permissions. - -## The ZCore Elegance -The `BaseRouter` automates the scaffolding of a fully secure API. It maps the standard HTTP methods to your business service and automatically derives security scopes from your model's `.actions()`. It supports dynamic sorting, filtering (via the Search engine), and pagination out of the box. - -=== "ZCore Scaffolding" - :::python - from zcore import BaseRouter, RouteKey - from .schemas import ProductCreate, ProductUpdate, ProductResponse - from .services import ProductService - from .models import Product - - class ProductRouter(BaseRouter[ProductCreate, ProductUpdate]): - model = Product - create_schema = ProductCreate - update_schema = ProductUpdate - schema_out = ProductResponse - service = ProductService - - prefix = "/products" - tags = ["Products"] - expose_schemas = True # Enables ?schema=true - -=== "FastAPI Raw Scaffolding" - :::python - # To achieve the same result, you must write ~80 lines of code: - router = APIRouter(prefix="/products", tags=["Products"]) - - @router.post("/", status_code=201, response_model=ResponseWrapper[ProductResponse]) - async def create(data: ProductCreate, service: ProductService = Depends(get_product_service)): - if "products:create" not in user.scopes: # Manual scope check - raise ForbiddenError() - result = await service.create(data) - return ResponseWrapper(data=result) - - # ... Repeat for GET, GET ALL, SEARCH, PUT, PATCH, DELETE ... - # (And don't forget to handle pagination and 404s manually) - ---- - -## Boundaries & Integration -ZCore routers provide "Progressive Scaffolding"โ€”they grow with your complexity without locking you in. - -* **Endpoint Exclusion:** Use the `exclude` attribute to skip scaffolding specific routes. - `exclude = {RouteKey.DELETE, RouteKey.PATCH}` -* **OOP Freedom:** Every scaffolded endpoint is an overridable method (e.g., `create_endpoint`). You can override them to add custom logic while keeping the rest of the router automated. -* **Dynamic Dependencies:** Override `get_route_dependencies` to inject custom authentication, logging, or rate-limiting for specific route keys (e.g., stricter auth for `DELETE`). -* **Standard APIRouter:** The `self.router` attribute is a standard FastAPI `APIRouter`. You can add custom, raw FastAPI endpoints to it at any time using standard decorators. - ---- - -## Under-the-Hood Spec - -### 1. Unified Response Class -ZCore routers use the `ZCoreAPIRoute` class by default [web/api_router.py]. This specialized route ensures that all responses use the `ZCoreJSONResponse`, which utilizes the framework's custom JSON encoder to safely handle UUIDs, Datetime, and Decimal types without manual conversion. - -### 2. Automated Action Mapping -The router automatically coordinates with your model's `Actions` system [web/base_router.py]. It maps `RouteKey.POST` to `model.actions().CREATE` and `RouteKey.DELETE` to `model.actions().DELETE`. This ensures that your API's security requirements are always physically bound to your database schema. - -### 3. Dynamic Schema Extraction -If `expose_schemas` is enabled, the router intercepts requests with the `?schema=true` query parameter [web/api_router.py]. It identifies the target Pydantic model for the current route (whether it's an input model for POST or an output model for GET), generates its JSON-Schema, and prunes it based on the user's active context restrictions before returning it. - -### 4. Vary Header Management -To prevent caching issues when field masking is active, the `ZCoreAPIRoute` automatically appends `Authorization` or `Cookie` to the HTTP `Vary` header if restricted fields are detected in the active context [web/api_router.py]. This tells proxies and browsers that the response content may change based on the user's identity. \ No newline at end of file diff --git a/docs/learn/6-plugin.md b/docs/learn/6-plugin.md deleted file mode 100644 index a7accf6..0000000 --- a/docs/learn/6-plugin.md +++ /dev/null @@ -1,97 +0,0 @@ -# Topological Domain Packaging - -Structure your codebase into clean, self-contained modules with automated dependency resolution. - ---- - -
-
- Type
- Orchestration Protocol -
-
- Status
- Core Framework -
-
- Underlying Tech
- graphlib / typing.Protocol -
-
- -## The Challenge -As projects grow, the `main.py` file often becomes a "garbage bin" for initialization logic. Developers manually wire database connections, cache warmups, and route registrations. This leads to: -1. **Circular Import Hell:** Modules trying to import each other during startup. -2. **Race Conditions:** A service starting before the database connection is fully established. -3. **Spaghetti Entry Points:** Hard-to-read setup code that requires manual ordering (e.g., Auth must load before Billing). -4. **Tightly Coupled Logic:** Inability to easily enable or disable features (like a Search plugin) without hacking the core app setup. - -## The ZCore Elegance -ZCore uses a **Topological Plugin System**. Every domain (Orders, Users, Payments) is packaged as a `Plugin`. Plugins declare their dependencies explicitly. The ZCore Kernel then calculates the mathematically correct startup order using a Directed Acyclic Graph (DAG). This ensures your architecture is decoupled, and every module starts only when its requirements are ready. - -=== "ZCore Modular Plugin" - :::python - from zcore import Plugin - from fastapi import FastAPI - - class InventoryPlugin(Plugin): - name = "inventory" - version = "1.0.0" - dependencies = ["database", "logging"] # Declared requirements - - def setup(self, app: FastAPI): - # Wire routers to the app - from .routers import router - app.include_router(router) - - async def on_startup(self): - # Domain-specific startup logic - await cache_warmer.warm_stock_levels() - - async def on_shutdown(self): - await inventory_client.close() - -=== "FastAPI Manual Sequencing" - :::python - # Standard main.py requires manual, fragile ordering - app = FastAPI() - - @app.on_event("startup") - async def startup(): - # You must manually remember the order: - await init_logging() - await init_db() - # If you move this line up, the app might crash - await cache_warmer.warm_stock_levels() - - app.include_router(inventory_router) - # 500 lines of wiring later... - ---- - -## Boundaries & Integration -ZCore Plugins are wrappers, not cages. They act as "Initialization Anchors" for standard FastAPI components. - -* **Standard FastAPI Routes:** The `setup(app)` method receives the raw `FastAPI` instance. You use standard `app.include_router()` or `app.add_middleware()` calls exactly as you would in a normal project. -* **Decoupled Lifecycle:** You can write a Plugin that only handles background tasks without having any routes, or a Plugin that only registers Global Middlewares. -* **Manual Bypass:** If a module is too simple to warrant a Plugin, you can still register its routes directly in `main.py` using standard FastAPI methods. However, it will not benefit from ZCore's topological startup ordering. - ---- - -## Under-the-Hood Spec - -### 1. Topological Sorting (DAG) -The ZCore Kernel utilizes Python's `graphlib.TopologicalSorter` [kernel/engine.py]. When `kernel.setup(app)` is called, it builds a graph where nodes are plugin names and edges are their `dependencies`. If a cycle is detected (e.g., A depends on B, and B depends on A), ZCore raises a `RuntimeError` immediately at startup to prevent unstable execution. - -### 2. Runtime Protocol Validation -The `Plugin` interface is defined as a `runtime_checkable` Protocol [kernel/plugins.py]. This allows ZCore to verify that your classes implement the required attributes (`name`, `version`, `dependencies`) and methods without requiring strict class inheritance, though inheriting from `zcore.Plugin` is recommended for type safety. - -### 3. Sequential Lifecycle Phases -The Kernel executes startup logic in three distinct, sequential waves across all sorted plugins [kernel/engine.py]: -1. **`before_startup`**: Early-stage configuration or environment checks. -2. **`on_startup`**: Core initialization (e.g., DB connections, Cache priming). -3. **`after_startup`**: Post-init tasks (e.g., sending "System Up" notifications). -During shutdown, plugins are processed in **reverse topological order** to ensure that shared resources (like the Database) are closed only after the modules depending on them have safely shut down. - -### 4. Dependency Integrity Check -Before starting, ZCore verifies that every string in a plugin's `dependencies` list corresponds to an actually registered plugin [kernel/engine.py]. If a dependency is missing, ZCore aborts the startup and logs exactly which plugin is missing its requirement. \ No newline at end of file diff --git a/docs/learn/7-kernel.md b/docs/learn/7-kernel.md deleted file mode 100644 index 9b11dbe..0000000 --- a/docs/learn/7-kernel.md +++ /dev/null @@ -1,101 +0,0 @@ -# Framework Engine & Orchestration - -The central nervous system coordinating plugin lifecycles, dependency resolution, and concurrent application events. - ---- - -
-
- Type
- Core Orchestrator -
-
- Status
- Core Infrastructure -
-
- Underlying Tech
- graphlib / asyncio -
-
- -## The Challenge -FastAPI provides a powerful `lifespan` hook, but as the number of modules grows, managing initialization order becomes a liability. Developers often resort to: -1. **Fragile Sequencing:** Manually ordering database connections, cache priming, and background workers in a single function. A single misplaced line causes runtime failures. -2. **Coupled Event Handling:** Directly calling functions across modules (e.g., `UserService` calling `EmailService`), which creates tight coupling and makes the system harder to test or modify. -3. **Startup Bottlenecks:** Executing every initialization task sequentially, unnecessarily increasing the time it takes for the application to reach a "Healthy" state. - -## The ZCore Elegance -The `Kernel` manages the application lifecycle through **Topological Orchestration**. It builds a dependency graph of your plugins and ensures each one starts and stops in the mathematically correct order. Complementing this, the `EventDispatcher` provides a loosely coupled communication bus where handlers execute concurrently without blocking the main event loop. - -=== "ZCore Kernel & Events" - :::python - # 1. Orchestration - kernel = Kernel() - kernel.add_plugin(AuthPlugin()) - kernel.add_plugin(DatabasePlugin()) # Sorted automatically - - # 2. Decoupled Communication - @kernel.dispatcher.subscribe("user_registered") - async def send_welcome_email(payload): - # Executes concurrently with other listeners - await email_provider.send(...) - - # 3. Clean Integration - app = FastAPI(lifespan=kernel.lifespan) - kernel.setup(app) - -=== "FastAPI Raw Implementation" - :::python - # Manual, fragile ordering in a single function - @asynccontextmanager - async def lifespan(app: FastAPI): - # 1. Manual Startup Order (Risk of failure if out of sync) - await init_db() - await init_auth() - - # 2. Tight Coupling (Email must be called directly) - # No built-in way to dispatch events concurrently easily - try: - yield - finally: - # 3. Manual Shutdown Order - await close_auth() - await close_db() - - app = FastAPI(lifespan=lifespan) - ---- - -## Orchestration Workflow -The Kernel transforms your declarative plugin list into an executed lifecycle through a rigorous sorting and execution pipeline. - -

- The Unified Request Journey -

- ---- - -## Boundaries & Integration -The Kernel and Dispatcher are designed to wrap around FastAPI's native async capabilities without obscuring them. - -* **Lifespan Injection:** The `kernel.lifespan` method is a standard Python asynchronous context manager. It is passed directly to the `FastAPI` constructor, meaning the native Starlette lifecycle is what actually drives ZCore. -* **Decoupled Events:** The `EventDispatcher` is registered as a singleton in the `container`. You can inject it into any `BaseService` or `BaseRepository` using `Inject(EventDispatcher)` to trigger events from deep within your domain logic. -* **Bypass:** If you don't want to use the Plugin system, you can still use the `EventDispatcher` as a standalone library within your project to handle internal signaling. - ---- - -## Under-the-Hood Spec - -### 1. Reverse-Topological Shutdown -While startup follows a dependency path (A -> B), the Kernel ensures that shutdown occurs in **reverse order** (B -> A) [kernel/engine.py]. This ensures that shared resources, like database pools, are only closed after the domain modules using them have safely terminated. - -### 2. Concurrent Event Execution -The `EventDispatcher.dispatch` method schedules all registered async handlers using `asyncio.gather` [kernel/events.py]. Crucially, it sets `return_exceptions=True` and logs errors within the dispatcher. This prevents a failure in a single listener (e.g., a failing email server) from crashing the entire request or the application. - -### 3. Missing Dependency Assertion -During the `_resolve_dependencies` phase, the Kernel performs a cross-reference check [kernel/engine.py]. If a plugin declares a dependency string that does not exist in the active registry, the Kernel raises a descriptive `RuntimeError`, preventing the application from starting in an incomplete state. - -!!! info "Event Handlers" - The Dispatcher supports both standard synchronous functions and `asyncio` coroutines. Synchronous handlers are executed sequentially, while asynchronous ones are gathered for concurrent execution. \ No newline at end of file diff --git a/docs/learn/8-main.md b/docs/learn/8-main.md deleted file mode 100644 index 3b6f0b2..0000000 --- a/docs/learn/8-main.md +++ /dev/null @@ -1,117 +0,0 @@ -# Application Bootstrapping - -The unified entry point where infrastructure, security, and domain plugins converge into a running FastAPI instance. - ---- - -
-
- Type
- Entry Point -
-
- Status
- Mandatory Root -
-
- Underlying Tech
- FastAPI / Kernel -
-
- -## The Challenge -In many FastAPI projects, the `main.py` file eventually becomes a "God Object"โ€”a massive procedural script that handles logging, database initialization, middleware ordering, exception mapping, and router registration. This leads to: -1. **Fragile Middleware Ordering:** Placing `Authentication` after `Routing` or `Session` management after `Logging`, causing cryptic errors. -2. **Infrastructure Leaks:** Database connection strings and logging configurations mixed with business route definitions. -3. **Hard-to-Test Entry Points:** Because everything is tightly coupled in one file, spinning up the app for integration tests without the entire stack becomes difficult. - -## The ZCore Elegance -ZCore provides a declarative bootstrap template. Instead of writing procedural logic, you configure the `Kernel`, register your domain `Plugins`, and attach the core middlewares. The `main.py` serves strictly as an **Orchestrator**, delegating the heavy lifting to the infrastructure managers and domain plugins. - -=== "ZCore main.py (Orchestrated)" - :::python - # Auto-generated via 'zc init' - from fastapi import FastAPI - from zcore import Kernel, settings - from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware - from zcore.exceptions import app_exception_handler, AppException - from zcore.db import db_manager, register_db_event_dispatcher - from zcore.logging import setup_logging - - # 1. Infrastructure Setup - setup_logging() - db_manager.init_app(db_url=settings.DATABASE_URL) - - # 2. Kernel & Plugin Registration - kernel = Kernel() - kernel.add_plugin(UsersPlugin()) # Your domain logic - register_db_event_dispatcher(kernel.dispatcher) - - # 3. FastAPI Bootstrap - app = FastAPI(title=settings.PROJECT_NAME, lifespan=kernel.lifespan) - kernel.setup(app) - - # 4. Middleware & Error Handlers - app.add_middleware(RequestLogMiddleware) - app.add_middleware(ScopedDependencyMiddleware) - app.add_exception_handler(AppException, app_exception_handler) - -=== "FastAPI Raw (Procedural)" - :::python - # Typical 'main.py' in a standard project - app = FastAPI() - - @app.on_event("startup") - async def startup(): - # Manual procedural setup - logging.config.dictConfig(LOG_CONFIG) - await database.connect() - # What if this fails? Is the error caught? - await cache.connect() - - # Manual Error Handling for every custom exception - @app.exception_handler(MyCustomError) - async def handler(request, exc): - return JSONResponse(...) - - # Middleware ordering is manual and invisible - app.add_middleware(SessionMiddleware) - app.add_middleware(AuthMiddleware) - - app.include_router(users_router) - # File grows to 500+ lines... - ---- - -## Bootstrapping Lifecycle -When you launch your application (via `zc run`), ZCore executes the following phases in a strict, predictable sequence: - -

- Bootstrapping Lifecycle -

- ---- - -## Boundaries & Integration -The ZCore bootstrap process is designed to be a "wrapper" that stays out of your way. - -* **FastAPI Native:** The `app` variable is a standard `FastAPI()` instance. You can use any native FastAPI feature, such as custom lifespan context managers or third-party middlewares, alongside ZCore. -* **Infrastructure Isolation:** Database and Logging initialization happen before the app starts. This ensures that infrastructure is "warm" before any plugin logic is executed. -* **Manual Overrides:** If you need to add a router that isn't part of a plugin, you can simply call `app.include_router()` in `main.py` as you normally would. - ---- - -## Under-the-Hood Spec - -### 1. Scoped Middleware Dependency -The `ScopedDependencyMiddleware` is critical. It initializes a unique `uuid4` correlation ID for every request and sets up the request-scoped DI container [web/middleware.py]. Most importantly, it injects the active `AsyncSession` into the container, ensuring that all services and repositories within that request share the same database transaction. - -### 2. Centralized Exception Mapping -The `app_exception_handler` intercepts any exception inheriting from `AppException` [exceptions/handlers.py]. It automatically logs the error with structured metadata (request path, method, and correlation ID) and wraps the error message in the standardized `ResponseWrapper` JSON envelope, ensuring your API never returns a raw traceback. - -### 3. The DB-Event Bridge -By calling `register_db_event_dispatcher(kernel.dispatcher)`, you bridge the database layer with the application event system [db/events.py]. This allows low-level database operations (like a transaction commit) to trigger high-level domain events (like sending a notification) through a loosely coupled, non-blocking bus. - -!!! success "Project Ready" - You have now completed the core ZCore Tutorial. Your project is scaffolded, your infrastructure is secure, and your domain logic is structured. Proceed to the **How-To Guides** to implement advanced features like JWT Auth or Multi-Repository Transactions. \ No newline at end of file diff --git a/docs/learn/installation.md b/docs/learn/installation.md deleted file mode 100644 index 1ded8c2..0000000 --- a/docs/learn/installation.md +++ /dev/null @@ -1,105 +0,0 @@ -# Installation & Scaffolding - -Standardize and scaffold your architectural foundation in seconds using the ZCore CLI. - ---- - -
-
- Type
- CLI Utility -
-
- Status
- Core Tool -
-
- Underlying Tech
- Pathlib / Subprocess -
-
- -## The Challenge -Unlike frameworks like Django, FastAPI does not provide a built-in "admin" or scaffolding tool. Developers often spend the first few hours of a project manually creating directories, setting up `main.py` entry points, configuring `.env` files, and writing repetitive boilerplate for every new domain module. This lack of structure leads to inconsistent layouts across different projects within the same team. - -## The ZCore Elegance -ZCore provides the `zc` command-line interface. It handles project initialization, environment configuration, and modular "app" scaffolding with optional boilerplate templates, ensuring every project follows the same clean architectural patterns from day one. - -=== "ZCore Scaffolding" - :::bash - # 1. Install the framework - pip install fastapi-zcore-framework[all] - - # 2. Initialize a new project - zc init my_awesome_api - cd my_awesome_api - - # 3. Create a modular app with full boilerplate - zc startapp billing --template - - # 4. Launch development server - zc run - -=== "FastAPI Manual Setup" - :::bash - # Standard FastAPI requires manual setup for everything: - mkdir my_api && cd my_api - touch main.py .env requirements.txt - # (Open editor, manually copy-paste FastAPI boilerplate) - # (Manually create subdirectories for billing) - mkdir billing - touch billing/models.py billing/schemas.py billing/routers.py - # (Write repetitive CRUD logic for the 100th time...) - uvicorn main:app --reload - ---- - -## Getting Started - -### 1. Installation -Install ZCore using `pip`. It is recommended to use a virtual environment. The `[all]` extra includes SQLAlchemy, Redis, and Argon2 support. - -```bash -pip install fastapi-zcore-framework[all] -``` - -### 2. Initializing a Project -The `init` command creates the project root, a pre-configured `main.py`, a `.env` file with a generated `SECRET_KEY`, and a `requirements.txt`. - -```bash -zc init my_project -``` - -### 3. Creating a Modular App -ZCore promotes a modular domain-driven structure. Each "app" is a directory containing its own models, services, and routers, wrapped in a `Plugin` class. - -* **Clean App:** Creates empty files for a clean slate. - ```bash - zc startapp orders - ``` -* **Template App:** Populates files with ZCore's standard CRUD/Repository boilerplate. - ```bash - zc startapp orders --template - ``` - ---- - -## Boundaries & Integration -The `zc` tool is a convenience utility, not a requirement. - -* **Standard Python:** All files generated are standard `.py` files. You can modify, move, or delete them without breaking the CLI. -* **Uvicorn Integration:** The `zc run` command is a wrapper for `uvicorn main:app --reload`. It automatically reads `HOST` and `PORT` from your `.env` file and ensures your `PYTHONPATH` is set correctly. -* **Manual Control:** You are never locked into the CLI. If you prefer to write your own project structure from scratch, you can still import and use ZCore components (like `Kernel`, `BaseRouter`, or `BaseService`) manually. - ---- - -## Under-the-Hood Spec - -### 1. Cryptographically Secure Scaffolding -When you run `zc init`, the CLI uses Python's `secrets` module to generate a 64-character hex string for your `SECRET_KEY`. This ensures that even development environments start with high-entropy secrets, preventing common security oversights. - -### 2. Environment-Aware Execution -The `zc run` command performs a sanity check to ensure `main.py` exists in the current directory. It dynamically injects the current working directory into `sys.path` (via `PYTHONPATH`) before launching Uvicorn, allowing modular imports to work seamlessly without manual path hacking. - -### 3. Smart Path Evaluation -The `startapp` command uses `pathlib` to resolve absolute paths. It verifies that the requested app name is a valid Python identifier and checks for directory collisions before writing any files, preventing accidental data loss or malformed module names. \ No newline at end of file diff --git a/docs/learn/overview.md b/docs/learn/overview.md deleted file mode 100644 index 41b9f02..0000000 --- a/docs/learn/overview.md +++ /dev/null @@ -1,100 +0,0 @@ -# Overview - -Standardize your development without sacrificing the freedom of FastAPI. ZCore provides the architectural "chassis" for your application, coordinating dependencies, security context, and database transactions into a unified, predictable lifecycle. - ---- - -
-
- Type
- Architectural Layer -
-
- Status
- Recommended Core -
-
- Underlying Tech
- FastAPI / Starlette -
-
- -## The Challenge -While FastAPI is exceptional for building high-performance APIs, it provides no structural blueprint. As projects scale, developers often encounter: -- **Global Dependency Clutter:** Difficulty managing different lifecycles (Singleton vs. Scoped) across modules. -- **Transactional Inconsistency:** Scattered database commits leading to partial data writes. -- **Security Boilerplate:** Manual field masking and permission checks repeated across dozens of routes. -- **Plugin Fragmentation:** Hard-to-track initialization orders for background workers, caches, and database engines. - -## The ZCore Elegance -ZCore solves this by introducing a **Plugin-based Kernel**. Instead of manually wiring everything in a bloated `main.py`, you register domain-specific plugins. ZCore calculates the correct initialization order and manages the application lifespan automatically. - -=== "`ZCore Plugin Orchestration`" - - :::python - # Define a clean, isolated plugin for your domain - class PaymentPlugin(Plugin): - name = "payments" - dependencies = ["database", "auth"] # ZCore sorts these automatically - - def setup(self, app: FastAPI): - app.include_router(payment_router) - - async def on_startup(self): - await payment_gateway.connect() - - # In your main.py: Simple, declarative registration - kernel = Kernel() - kernel.add_plugin(PaymentPlugin()) - app = FastAPI(lifespan=kernel.lifespan) - kernel.setup(app) - -=== "`Standard FastAPI Manual Setup`" - - :::python - # Standard FastAPI requires manual, sequential ordering in main.py - app = FastAPI() - - @app.on_event("startup") - async def startup(): - # Manual ordering: If database isn't ready, payment fails. - # This becomes a "main.py" file with 500+ lines in large projects. - await init_db() - await init_auth() - await payment_gateway.connect() - - app.include_router(payment_router) - app.include_router(auth_router) - # ... and 20 other routers - ---- - -## The Unified Request Journey -ZCore coordinates the request lifecycle through specialized middlewares and context managers, ensuring that every coroutine knows the current user and their data restrictions. - -

- The Unified Request Journey -

- ---- - -## Boundaries & Integration -ZCore is designed as a **complementary chassis**. It does not hide FastAPI; it stabilizes it. - -* **Pydantic Compatibility:** `Zchema` inherits directly from Pydantic's `BaseModel`. You can use standard Pydantic features (validators, computed fields) exactly as you always have. -* **Starlette Middlewares:** ZCore middlewares are standard ASGI/Starlette implementations. You can mix them with any other third-party FastAPI middleware. -* **Bypass Strategy:** If you have a route that doesn't fit the `BaseRouter` pattern, you can write a standard FastAPI `@app.get("/")` path function. You can still use `Inject()` to retrieve services or repositories from the ZCore container within that function. - ---- - -## Under-the-Hood Spec - -### 1. Topological Dependency Resolution -The Kernel uses `graphlib.TopologicalSorter` to analyze the `dependencies` attribute of every registered plugin. This builds a Directed Acyclic Graph (DAG), ensuring that if Plugin B depends on Plugin A, Plugin A's `on_startup` always completes before Plugin B begins. - -### 2. High-Performance Reflection Cache -The Dependency Injection (DI) container utilizes a signature cache. During the first resolution of a class, ZCore uses the `inspect` module to analyze constructor type hints. This metadata is cached, allowing subsequent resolutions to skip reflection overhead entirely. - -### 3. Contextual Isolation -ZCore utilizes Python's `contextvars` to maintain thread-safe and coroutine-safe state. The `request_context` manager ensures that `current_user_id` and `restricted_fields` are never leaked between concurrent requests, even in a high-concurrency async environment. \ No newline at end of file diff --git a/docs/learn/quickstart.md b/docs/learn/quickstart.md deleted file mode 100644 index 6d623a2..0000000 --- a/docs/learn/quickstart.md +++ /dev/null @@ -1,431 +0,0 @@ -# 10-Minute Quickstart: Build a Task Manager API - -Build a production-grade Task Manager API using ZCore in under 10 minutes. This guide covers the entire journey โ€” from an empty directory to a running server with secure, scaffolded CRUD endpoints, dynamic field masking, and database persistence. - ---- - -
-
- Type
- Hands-On Tutorial -
-
- Duration
- ~10 Minutes -
-
- Difficulty
- Beginner -
-
- ---- - -## What We're Building - -A **Task Manager API** that supports: - -- โœ… Automated creation, retrieval, updates, and deletion of tasks -- โœ… Built-in keyset (cursor) and offset pagination -- โœ… Advanced relational and conditional searches out-of-the-box -- โœ… Context-aware data masking to protect sensitive fields (like assignee emails) -- โœ… Scalable execution layers with structured lifecycle hooks - -By the end, you'll have **7 fully functional endpoints** with namespaced authorization actions, running atop ZCore's Scoped Dependency Injection and Plugin lifecycle architecture. - ---- - -## Step 1: Install & Initialize - -Create a clean virtual environment and install the framework along with its database and cryptographic extras: - -```bash -# Create and activate a virtual environment -python -m venv .venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate - -# Install ZCore with all optional dependencies -pip install fastapi-zcore-framework[all] - -# Initialize a new project -zc init taskmanager -cd taskmanager -``` - -The `zc init` command generates the following clean root structure: - -``` -taskmanager/ -โ”œโ”€โ”€ main.py # Application entry point & orchestration -โ”œโ”€โ”€ .env # Project environment settings (including secure SECRET_KEY) -โ”œโ”€โ”€ requirements.txt # Dependencies list -โ”œโ”€โ”€ .gitignore # Python, IDE, and DB exclusions -โ””โ”€โ”€ zcore_dev.db # Local development SQLite database -``` - ---- - -## Step 2: Scaffold the Tasks Domain Module - -ZCore enforces a **modular, domain-driven architecture**. Each feature context lives in its own directory, grouping its data model, projection schemas, persistence unit, business logic, and routing orchestrator into a single package. - -Generate the `tasks` domain module using the built-in scaffolding tool: - -```bash -zc startapp tasks --template -``` - -This commands scaffolds a clean, layered structure inside the `tasks` directory: - -``` -tasks/ -โ”œโ”€โ”€ __init__.py -โ”œโ”€โ”€ models.py # SQLAlchemy Declarative Model -โ”œโ”€โ”€ schemas.py # Context-Aware Zchema Definitions -โ”œโ”€โ”€ repositories.py # Persistence & Query Layer -โ”œโ”€โ”€ services.py # Pure Business Logic & Lifecycle Hooks -โ”œโ”€โ”€ routers.py # Scaffolded CRUD Router -โ””โ”€โ”€ plugin.py # Kernel Lifespan Plugin Wrapper -``` - -Let's modify each component to build our task manager. - ---- - -## Step 3: Define the Database Model - -Open `tasks/models.py` and configure the database table schema. We will use modern SQLAlchemy 2.0 type mapping: - -```python -import uuid -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column -from zcore import Base - -class Task(Base): - __tablename__ = "tasks" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - title: Mapped[str] = mapped_column(String(255), index=True) - description: Mapped[str | None] = mapped_column(String(1024), default=None) - priority: Mapped[str] = mapped_column(String(50), default="medium") - is_completed: Mapped[bool] = mapped_column(Boolean, default=False) - assignee_email: Mapped[str | None] = mapped_column(String(255), default=None) -``` - -!!! info "Auto-Generated Permission Scopes" - By inheriting from `Base`, ZCore automatically exposes a descriptive `Actions` descriptor on the model based on its `__tablename__` [setup.py]:
- - `Task.actions().CREATE` → `"tasks:create"`
- - `Task.actions().VIEW` → `"tasks:view"`
- - `Task.actions().UPDATE` → `"tasks:update"`
- - `Task.actions().DELETE` → `"tasks:delete"`
- - `Task.actions().LISTVIEW` → `"tasks:listview"`
- ---- - -### Step 4: Define Context-Aware Schemas - -Open `tasks/schemas.py`. We will implement `Zchema` instead of Pydantic's raw `BaseModel` to handle data-masking at the serialization boundary: - -```python -from pydantic import ConfigDict -import uuid -from zcore import Zchema - -class TaskBase(Zchema): - __model__ = "tasks" # Binds schema to the "tasks" permission namespace - -class TaskCreate(TaskBase): - title: str - description: str | None = None - priority: str = "medium" - assignee_email: str | None = None - -class TaskUpdate(TaskBase): - title: str | None = None - description: str | None = None - priority: str | None = None - is_completed: bool | None = None - assignee_email: str | None = None - -class TaskResponse(TaskBase): - id: uuid.UUID - is_completed: bool - - model_config = ConfigDict(from_attributes=True) -``` - -!!! info "Unified Schema Projection & Mass Assignment Protection" - In traditional FastAPI, developers suffer from "Schema Explosion"โ€”creating separate classes like `TaskCreate`, `TaskUpdate`, `TaskPublicResponse`, and `TaskAdminResponse` just to handle field-level permissions. - - With ZCore's `Zchema`, you can dramatically collapse this hierarchy [projection.py]. By defining a single, unified schema with all potential attributes, ZCore's `filter_restricted_inputs` and `secure_serializer` hooks automatically intercept requests [projection.py]. If `"tasks.assignee_email"` is added to the active `restricted_fields` context: - 1. **Input Shielding:** If a malicious user tries to POST or PUT `"assignee_email"`, ZCore silently prunes it *before* validation, preventing **Mass Assignment** exploits [projection.py]. - 2. **Output Masking:** When returning data, ZCore automatically strips `"assignee_email"` from the JSON response before serialization, preventing **Data Leakage** [projection.py]. - - This allows you to manage security dynamically at runtime using a single source of truth, rather than maintaining dozens of redundant Pydantic models. ---- - -## Step 5: Configure the Repository Layer - -Open `tasks/repositories.py`. The repository abstracts raw database operations using the asynchronous SQLAlchemy engine: - -```python -from zcore import BaseRepository -from sqlalchemy.ext.asyncio import AsyncSession -from .models import Task -from .schemas import TaskCreate, TaskUpdate - -class TaskRepository(BaseRepository[Task, TaskCreate, TaskUpdate]): - def __init__(self, db: AsyncSession): - super().__init__(model=Task, db=db) -``` - -No query code is required. `BaseRepository` automatically provides high-performance implementations of `get`, `get_by_ids` (with instant fast-path validation), `get_list` (supporting offset & keyset cursors), `create`, `update`, `delete`, and `search` [repository.py]. - ---- - -## Step 6: Implement the Business Service - -Open `tasks/services.py` to write your core business logic and lifecycle hooks: - -```python -from zcore import BaseService, Inject -from .models import Task -from .schemas import TaskCreate, TaskUpdate -from .repositories import TaskRepository - -class TaskService(BaseService[Task, TaskCreate, TaskUpdate]): - def __init__(self, repository: TaskRepository = Inject(TaskRepository)): - super().__init__(model=Task, repository=repository) - - # --- Lifecycle Hooks --- - - async def pre_create(self, schema: TaskCreate) -> None: - """Executed automatically inside the write pipeline before DB insertion.""" - if schema.priority not in ["low", "medium", "high"]: - schema.priority = "medium" - - async def post_create(self, model: Task) -> None: - """Executed after successful DB insertion, prior to transaction commit.""" - # Clean boundary for publishing events, audit logs, or notifications - pass -``` - -The `Inject(TaskRepository)` annotation instructs ZCore's **Scoped DI Container** to resolve the repository automatically within the active request context [di.py]. - ---- - -## Step 7: Scaffold the Automated Router - -Open `tasks/routers.py`. We will extend `BaseRouter` to generate 7 secure CRUD and search endpoints: - -```python -from zcore import BaseRouter -from .schemas import TaskCreate, TaskUpdate, TaskResponse -from .services import TaskService -from .models import Task - -class TaskRouter(BaseRouter[TaskCreate, TaskUpdate]): - model = Task - create_schema = TaskCreate - update_schema = TaskUpdate - schema_out = TaskResponse - service = TaskService - - prefix = "/tasks" - tags = ["Tasks"] - expose_schemas = True # Activates runtime schema introspection - -# Instantiate for plugin integration -router_instance = TaskRouter() -``` - -By default, the router uses `ZCoreAPIRoute` to ensure responses are serialized using our custom high-performance encoder, automatically appending the `Vary` HTTP header to prevent caching leaks when field masking is active [api_router.py]. - ---- - -## Step 8: Package as a Kernel Plugin - -Open `tasks/plugin.py`. This class packages the `tasks` domain and defines its startup and shutdown dependencies: - -```python -from fastapi import FastAPI -from zcore import Plugin -from .routers import router_instance - -class TaskPlugin(Plugin): - name = "tasks" - version = "0.1.0" - dependencies = [] # Declare other plugin names that must boot up first - - def setup(self, app: FastAPI) -> None: - # Mount the scaffolded APIRouter instance to the FastAPI app - app.include_router(router_instance.router) - - async def on_startup(self) -> None: - """Standard startup lifecycles (e.g. warming up caches)""" - pass - - async def on_shutdown(self) -> None: - """Graceful cleanup tasks during application teardown""" - pass -``` - ---- - -## Step 9: Wire the App in `main.py` - -Open your root `main.py` file. This acts as the declarative orchestrator of the entire stack: - -```python -from fastapi import FastAPI -from zcore import Kernel, settings -from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware -from zcore.exceptions import app_exception_handler, AppException -from zcore.db import db_manager, register_db_event_dispatcher -from zcore.logging import setup_logging - -from tasks.plugin import TaskPlugin - -# 1. Initialize structured logging configuration -setup_logging() - -# 2. Configure the database connection pool (SQLite development fallback) -db_manager.init_app( - db_url=settings.DATABASE_URL, - echo=(settings.ENVIRONMENT == "development") -) - -# 3. Create the Kernel and register domain plugins -kernel = Kernel() -kernel.add_plugin(TaskPlugin()) - -# 4. Bridge database events to the central kernel dispatcher -register_db_event_dispatcher(kernel.dispatcher) - -# 5. Create FastAPI app bound to ZCore's topological lifespan -app = FastAPI( - title=settings.PROJECT_NAME, - lifespan=kernel.lifespan -) - -# 6. Finalize kernel plugin setups -kernel.setup(app) - -# 7. Add core architectural middlewares & exception handlers -app.add_middleware(RequestLogMiddleware) -app.add_middleware(ScopedDependencyMiddleware) -app.add_exception_handler(AppException, app_exception_handler) -``` - ---- - -## Step 10: Run and Verify - -Launch the development server with automated reload tracking: - -```bash -zc run -``` - -``` -๐Ÿ“ก Starting ZCore Dev Server on 127.0.0.1:8000... -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: Application startup complete. -``` - -Open a new terminal session and test the scaffolded endpoints: - -=== "1. Create a Task" - - :::bash - curl -X POST http://127.0.0.1:8000/tasks/ \ - -H "Content-Type: application/json" \ - -d '{ - "title": "Evaluate ZCore Framework", - "description": "Thoroughly test repository patterns and field masking", - "priority": "high", - "assignee_email": "architect@zcore.io" - }' - - ========= Structured Response Envelope: ========= - { - "success": true, - "message": "Success", - "data": { - "id": "e8a93cb4-5fa7-4f67-a068-d06e2ea851c2", - "title": "Evaluate ZCore Framework", - "description": "Thoroughly test repository patterns and field masking", - "priority": "high", - "assignee_email": "architect@zcore.io", - "is_completed": false - }, - "meta": null - } - -=== "2. Paginated Query (GET)" - - :::bash - curl http://127.0.0.1:8000/tasks/ - - ========= Response ========= - { - "success": true, - "message": "Success", - "data": [ - { - "id": "e8a93cb4-5fa7-4f67-a068-d06e2ea851c2", - "title": "Evaluate ZCore Framework", - "description": "Thoroughly test repository patterns and field masking", - "priority": "high", - "assignee_email": "architect@zcore.io", - "is_completed": false - } - ], - "meta": null - } - -=== "3. Secure Schema Extraction" - - To generate dynamic forms on your frontend without exposing restricted structures, fetch the OpenAPI JSON Schema directly from the endpoint via `?schema=true`: - - :::bash - curl http://127.0.0.1:8000/tasks/?schema=true - - ========= Context-Aware Schema Response: ========= - { - "success": true, - "message": "Schema generated successfully", - "data": { - "title": "TaskResponse", - "type": "object", - "properties": { - "title": { "type": "string" }, - "description": { "type": "string" }, - "priority": { "type": "string", "default": "medium" }, - "assignee_email": { "type": "string" }, - "id": { "type": "string", "format": "uuid" }, - "is_completed": { "type": "boolean" } - }, - "required": ["title", "id", "is_completed"] - }, - "meta": { - "restricted_fields": [] - } - } - ---- - -## Comparison Summary - -| Built-In Feature Layer | ZCore Code Volume | Standard FastAPI Equivalent | -| :--- | :--- | :--- | -| **Declarative Model** | ~12 lines (`models.py`) | ~12 lines | -| **Masking Schemas** | ~20 lines (`schemas.py`) | ~70 lines (Separate models per-role) | -| **Persistence Repos** | ~5 lines (`repositories.py`) | ~90 lines (Manual connection and queries) | -| **Orchestrated Service**| ~15 lines (`services.py`) | ~60 lines (Manual context and commits) | -| **CRUD Router** | ~12 lines (`routers.py`) | ~120 lines (Routing, auth, and pagination) | -| **Plugin Registry** | ~12 lines (`plugin.py`) | N/A (Main file spaghetti) | -| **Total Footprint** | **~76 lines** | **~350+ lines** | - -ZCore not only saves hundreds of lines of repetitive code, but it also natively enforces **thread-safe scoping**, **automated permission mappings**, **transactional safety**, and **real-time field masking** across your entire ecosystem. \ No newline at end of file diff --git a/docs/overrides.css b/docs/overrides.css deleted file mode 100644 index 52ed655..0000000 --- a/docs/overrides.css +++ /dev/null @@ -1,33 +0,0 @@ -[data-slot="sidebar-menu-button"] { - width: 100%; - max-width: none; - padding-top: calc(var(--spacing) * 3);; - padding-bottom: calc(var(--spacing) * 3);; -} - -[data-slot="sidebar-content"] { - width: 100%; -} - -#page-header .flex.items-center.gap-2.pt-1\.5 > button:first-child { - display: none; -} - -a[href="https://fastapi-zcore-framework.github.io/zcore/"] span.size-8 svg { - display: none; -} - -a[href="https://fastapi-zcore-framework.github.io/zcore/"] span.size-8 { - background-image: url("https://raw.githubusercontent.com/fastapi-zcore-framework/zcore/master/docs/assets/favico.png"); - background-size: contain; - background-repeat: no-repeat; - background-position: center; -} - -button[title="Toggle theme"] { - display: none !important; -} - -button[title="Toggle layout"] { - display: none !important; -} \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index a4603bf..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,94 +0,0 @@ -site_name: ZCore -site_description: Minimalist, architectural framework built on top of FastAPI. -site_author: Ali Alf Ostovar -site_url: https://fastapi-zcore-framework.github.io/zcore - -repo_name: fastapi-zcore-framework/zcore -repo_url: https://github.com/fastapi-zcore-framework/zcore - -theme: - name: shadcn - logo: assets/logo.png - favicon: assets/favico.png - palette: - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: teal - accent: purple - toggle: - icon: material/weather-night - name: Switch to light mode - features: - - navigation.tabs - - navigation.sections - - navigation.top - - content.code.copy - - content.code.select - -plugins: - - search - -markdown_extensions: - - codehilite - - pymdownx.tabbed - - admonition - -extra_css: - - overrides.css - -nav: - - Home: index.md - - - Learn: - - "10-Minute Quickstart": learn/quickstart.md - - Tutorial: - - Overview: learn/overview.md - - Installation & Scaffolding: learn/installation.md - - "Step 1: Database Models": learn/1-db.md - - "Step 2: Data Schemas": learn/2-schemas.md - - "Step 3: Repository Layer": learn/3-repository.md - - "Step 4: Business Logic": learn/4-service.md - - "Step 5: Automated Routing": learn/5-router.md - - "Step 6: Domain Packaging": learn/6-plugin.md - - "Step 7: Framework Orchestration": learn/7-kernel.md - - "Step 8: Application Bootstrapping": learn/8-main.md - - - How-To Guides: - - JWT Authentication & Scopes: how-to/auth-setup.md - - Multi-Repository Transactions: how-to/multi-repo-transactions.md - - Project-Wide Base Router & Dependencies: how-to/base-router-extensibility.md - - Dynamic Role-Based Field Masking: how-to/field-masking.md - - Custom S3 Cloud Storage: how-to/custom-storage-provider.md - - WebSockets & Redis PubSub: how-to/websocket-pubsub.md - - - Architecture & Reference: - - Core & Infrastructure: - - Dependency Injection: features/infrastructure/di.md # Covers: IoCContainer, Scoped DI context, reflection cache, nested dependencies, Annotated, get_db - - Framework Engine: features/infrastructure/kernel.md # Covers: Plugin protocol, Central lifespan, dependency sort, missing dependency check - - Kernel Events: features/infrastructure/events.md # Covers: EventDispatcher, concurrent execution, try/except isolation - - ASGI Middlewares: features/infrastructure/middleware.md # Covers: RequestLogMiddleware, ScopedDependencyMiddleware, request correlation id - - Application Entry: features/infrastructure/main.md # Covers: main.py template, setup bootstrap - - Configuration Settings: features/infrastructure/config.md # Covers: Pydantic Settings, SettingsProxy, Lazy imports - - CLI Scaffolding Tool: features/infrastructure/cli.md # Covers: zc run, gensecret, startapp templating, path evaluation - - System Utilities: features/infrastructure/utils.md # Covers: slugify, json encoding fallback, validate_json_schema - - Data & Transactions: - - Database Manager: features/database/db.md # Covers: DatabaseManager, AsyncEngine, Connection pools, SQLite fallback - - Repository Pattern: features/database/repository.md # Covers: BaseRepository, Read/Write/Search Mixins, exist optimization, get_by_ids fast path - - Business Services: features/database/service.md # Covers: BaseService, safe commit context checks, post-read/write lifecycle hooks - - Unit of Work: features/database/uow.md # Covers: UnitOfWork transaction isolation, post-commit event queues - - Database Events: features/database/db-events.md # Covers: dispatch_db_event, error-isolated event propagation - - Pagination Engine: features/database/pagination.md # Covers: Cursor/Offset pagination, Cursor Keyset sorting, Base64 encoding, order_by(None) count - - Dynamic Search Engine: features/database/search.md # Covers: SearchEngine, JSON query builder, depth and max filters security, custom handlers, escaping wildcards - - Security & Shielding: - - Thread-Safe Context: features/security/context.md # Covers: contextvars, user and restricted fields, token reset/restoration safety - - Cryptographic Protocols: features/security/security.md # Covers: Argon2 parameters, symmetric/asymmetric JWT signing, token expiry - - API Routing Mechanics: features/security/api-router.md # Covers: ZCoreAPIRoute, response class preservation, vary header append logic, schema extraction - - Zchema Security & Schema Pruning: features/security/zchema.md # Covers: Zchema model_validator/serializer, Context-aware data masking, Schema pruning - - Centralized Exception Handler: features/security/exceptions.md # Covers: AppException hierarchy, app_exception_handler, standard API error envelope - - Advanced Ecosystem: - - Distributed Caching: features/ecosystem/cache.md # Covers: BaseCache/TTLLRUCache fallback, Pydantic type validation on cache hits, eviction loop safety, weakref - - File Storage & Security: features/ecosystem/storage.md # Covers: LocalStorageProvider, MaxFileSize/SafeMimeType validators, Magic bytes validation, UUID truncation - - WebSocket Streaming: features/ecosystem/stream.md # Covers: StreamManager, Redis PubSub fallback, queue overflow protection, subscription context manager - - - Quick Views: - - Pocket Reference & Cheat Sheet: cheatsheet.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3b1bdf8..282974f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,15 +50,6 @@ crypto = [ "argon2-cffi>=23.1.0", "pyjwt>=2.8.0", ] -docs = [ - "mkdocs>=1.5.0", - "mkdocs-shadcn", - "mkdocs-material>=9.5.0", - "mkdocstrings[python]>=0.24.0", - "mkdocs-gen-files>=0.5.0", - "mkdocs-literate-nav>=0.6.0", - "mkdocs-section-index>=0.3.0" -] all = [ "sqlalchemy[asyncio]>=2.0.28", "aiosqlite>=0.20.0", @@ -69,10 +60,10 @@ all = [ ] [project.urls] -Homepage = "https://github.com/fastapi-zcore-framework/zcore" -Repository = "https://github.com/fastapi-zcore-framework/zcore" -Documentation = "https://fastapi-zcore-framework.github.io/zcore" -"Bug Tracker" = "https://github.com/fastapi-zcore-framework/zcore/issues" +Homepage = "https://github.com/Baseryn/zcore" +Repository = "https://github.com/Baseryn/zcore" +Documentation = "https://baseryn.github.io/zcore-docs/" +"Bug Tracker" = "https://github.com/Baseryn/zcore/issues" [project.scripts] zc = "zcore.cli:main" @@ -97,6 +88,24 @@ features = [ 'all' ] +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] +ignore = [ + "E501", # Line too long (managed automatically by formatter) + "B008", # Do not perform function call Depends in argument defaults (FastAPI standard) + "B024", # Base class has no abstract methods + "B039", # ContextVar mutable default + "B904", # Use raise from in except clauses + "BLE001", # Do not catch blind exception Exception + "TRY201", # Use raise without specifying exception name + "PYI036", # The argument in __aexit__ should be annotated + "SIM102", # Nested if statements +] + [tool.mypy] python_version = "3.11" strict = true diff --git a/req.txt b/req.txt deleted file mode 100644 index 9d7f059..0000000 --- a/req.txt +++ /dev/null @@ -1,49 +0,0 @@ -annotated-doc==0.0.4 -annotated-types==0.7.0 -anyio==4.14.0 -asyncpg==0.31.0 -certifi==2026.6.17 -click==8.4.1 -detect-installer==0.1.0 -dnspython==2.8.0 -email-validator==2.3.0 -fastapi==0.138.0 -fastapi-cli==0.0.27 -fastapi-cloud-cli==0.20.0 -fastar==0.11.0 -greenlet==3.5.2 -h11==0.16.0 -hiredis==3.4.0 -httpcore==1.0.9 -httptools==0.8.0 -httpx==0.28.1 -idna==3.18 -jinja2==3.1.6 -markdown-it-py==4.2.0 -markupsafe==3.0.3 -mdurl==0.1.2 -pydantic==2.13.4 -pydantic-core==2.46.4 -pydantic-extra-types==2.11.1 -pydantic-settings==2.14.2 -pygments==2.20.0 -python-dotenv==1.2.2 -python-multipart==0.0.32 -pyyaml==6.0.3 -redis==8.0.1 -rich==15.0.0 -rich-toolkit==0.20.1 -rignore==0.7.6 -sentry-sdk==2.63.0 -shellingham==1.5.4 -sqlalchemy==2.0.51 -starlette==1.3.1 -structlog==26.1.0 -typer==0.26.7 -typing-extensions==4.15.0 -typing-inspection==0.4.2 -urllib3==2.7.0 -uvicorn==0.49.0 -uvloop==0.22.1 -watchfiles==1.2.0 -websockets==16.0 diff --git a/src/zcore/__init__.py b/src/zcore/__init__.py index 40d5765..3e73391 100644 --- a/src/zcore/__init__.py +++ b/src/zcore/__init__.py @@ -1,93 +1,145 @@ -from typing import TYPE_CHECKING ,Any +from typing import TYPE_CHECKING, Any + from zcore.config import settings if TYPE_CHECKING: - from zcore.kernel.engine import Kernel - from zcore.kernel.plugins import Plugin - from zcore.kernel.di import container, Inject - from zcore.db.setup import Base, SessionDep, db_manager, get_db + from zcore.context.context import ZContext, ctx from zcore.db.repository import BaseRepository from zcore.db.search import SearchRequest + from zcore.db.setup import Base, SessionDep, db_manager, get_db from zcore.db.uow import UnitOfWork + from zcore.kernel.di import Inject, container + from zcore.kernel.engine import Kernel + from zcore.kernel.events import EventDispatcher, on_event + from zcore.kernel.plugins import Plugin + from zcore.security.auth import BaseAuth + from zcore.security.security import Security from zcore.service.base import BaseService - from zcore.web.base_router import BaseRouter, RouteKey - from zcore.web.response import ResponseWrapper from zcore.web.api_router import ZCoreAPIRoute + from zcore.web.base_router import BaseRouter, RouteKey from zcore.web.projection import Zchema + from zcore.web.response import ResponseWrapper __all__ = [ - 'Inject', - 'container', - 'Kernel', - 'settings', - 'Base', - 'SessionDep', - 'db_manager', - 'get_db', - 'BaseRepository', - 'SearchRequest', - 'UnitOfWork', - 'BaseService', - 'BaseRouter', - 'RouteKey', - 'ResponseWrapper', - 'ZCoreAPIRoute', - 'Zchema', - "Plugin" + "Base", + "BaseAuth", + "BaseRepository", + "BaseRouter", + "BaseService", + "EventDispatcher", + "Inject", + "Kernel", + "Plugin", + "ResponseWrapper", + "RouteKey", + "SearchRequest", + "Security", + "SessionDep", + "UnitOfWork", + "ZContext", + "ZCoreAPIRoute", + "Zchema", + "container", + "ctx", + "db_manager", + "get_db", + "on_event", + "settings", ] - + def __getattr__(name: str) -> Any: if name == "Kernel": from zcore.kernel.engine import Kernel + return Kernel if name == "container": from zcore.kernel.di import container + return container if name == "Inject": from zcore.kernel.di import Inject + return Inject if name == "Base": from zcore.db.setup import Base + return Base if name == "SessionDep": from zcore.db.setup import SessionDep + return SessionDep if name == "db_manager": from zcore.db.setup import db_manager + return db_manager if name == "get_db": from zcore.db.setup import get_db + return get_db if name == "BaseRepository": from zcore.db.repository import BaseRepository + return BaseRepository if name == "UnitOfWork": from zcore.db.uow import UnitOfWork + return UnitOfWork if name == "SearchRequest": from zcore.db.search import SearchRequest + return SearchRequest if name == "BaseService": from zcore.service.base import BaseService + return BaseService if name == "BaseRouter": from zcore.web.base_router import BaseRouter + return BaseRouter if name == "ResponseWrapper": from zcore.web.response import ResponseWrapper + return ResponseWrapper if name == "ZCoreAPIRoute": from zcore.web.api_router import ZCoreAPIRoute + return ZCoreAPIRoute if name == "Zchema": from zcore.web.projection import Zchema + return Zchema if name == "RouteKey": from zcore.web.base_router import RouteKey + return RouteKey if name == "Plugin": from zcore.kernel.plugins import Plugin + return Plugin - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") \ No newline at end of file + if name == "ZContext": + from zcore.context.context import ZContext + + return ZContext + if name == "ctx": + from zcore.context.context import ctx + + return ctx + if name == "Security": + from zcore.security.security import Security + + return Security + if name == "BaseAuth": + from zcore.security.auth import BaseAuth + + return BaseAuth + if name == "on_event": + from zcore.kernel.events import on_event + + return on_event + if name == "EventDispatcher": + from zcore.kernel.events import EventDispatcher + + return EventDispatcher + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/zcore/cache/__init__.py b/src/zcore/cache/__init__.py index 8213392..79bc824 100644 --- a/src/zcore/cache/__init__.py +++ b/src/zcore/cache/__init__.py @@ -1,9 +1,9 @@ -from zcore.cache.base import BaseCache, init_cache, close_cache +from zcore.cache.base import BaseCache, close_cache, init_cache from zcore.cache.ttllru_cache import TTLLRUCache __all__ = [ "BaseCache", - "init_cache", - "close_cache", "TTLLRUCache", -] \ No newline at end of file + "close_cache", + "init_cache", +] diff --git a/src/zcore/cache/base.py b/src/zcore/cache/base.py index c310a97..f0d8a36 100644 --- a/src/zcore/cache/base.py +++ b/src/zcore/cache/base.py @@ -1,14 +1,15 @@ """Caching Abstraction and Management Layer. -This module provides a unified cache manager supporting distributed caching (via Redis) -with an asynchronous, thread-safe local fallback (via `TTLLRUCache`). It coordinates -global cache lifecycles, structured serialization and deserialization, and automatically +This module provides a unified cache manager supporting distributed caching (via Redis) +with an asynchronous, thread-safe local fallback (via `TTLLRUCache`). It coordinates +global cache lifecycles, structured serialization and deserialization, and automatically spawns background memory eviction routines. """ -import structlog import asyncio -from typing import Any, Optional, TypeVar, Generic, Type, Union +from typing import Any, Generic, TypeVar + +import structlog from pydantic import BaseModel from zcore.cache.ttllru_cache import TTLLRUCache @@ -19,19 +20,20 @@ try: import redis.asyncio as aioredis + REDIS_AVAILABLE = True except ImportError: REDIS_AVAILABLE = False -_shared_redis_client: Optional[Any] = None -_eviction_task: Optional[asyncio.Task] = None +_shared_redis_client: Any | None = None +_eviction_task: asyncio.Task | None = None -def init_cache(redis_url: Optional[str] = None, **kwargs: Any) -> None: +def init_cache(redis_url: str | None = None, **kwargs: Any) -> None: """Initialize global distributed caching clients and local eviction workers. - Configures a shared Redis connection client if library support and server configurations - are available. In addition, always schedules the active background memory eviction worker + Configures a shared Redis connection client if library support and server configurations + are available. In addition, always schedules the active background memory eviction worker loop to periodically purge expired local records. Args: @@ -39,19 +41,16 @@ def init_cache(redis_url: Optional[str] = None, **kwargs: Any) -> None: **kwargs: Connection pool parameters passed directly to the Redis client initialization. """ global _shared_redis_client, _eviction_task - + if REDIS_AVAILABLE and redis_url: try: _shared_redis_client = aioredis.from_url( - redis_url, - encoding="utf-8", - decode_responses=True, - **kwargs + redis_url, encoding="utf-8", decode_responses=True, **kwargs ) logger.info("Shared Redis cache client initialized successfully.") except Exception as e: logger.error(f"Failed to initialize shared Redis client: {e}") - + if _eviction_task is None or _eviction_task.done(): _eviction_task = asyncio.create_task(_start_eviction_loop(interval=60)) @@ -60,7 +59,7 @@ async def _start_eviction_loop(interval: int = 60) -> None: """Background loop invoking garbage collection routines on expired in-memory cache keys. Args: - interval: Rest period duration in seconds between garbage collection sweeps. + interval: Rest period duration in seconds between garbage collection sweeps. Defaults to 60. """ while True: @@ -76,15 +75,15 @@ async def _start_eviction_loop(interval: int = 60) -> None: async def close_cache() -> None: """Cancel background loops and cleanly close distributed connections.""" global _shared_redis_client, _eviction_task - + if _eviction_task and not _eviction_task.done(): _eviction_task.cancel() _eviction_task = None logger.info("Cache memory eviction loop stopped.") - + if _shared_redis_client: try: - await _shared_redis_client.close() + await _shared_redis_client.aclose() _shared_redis_client = None logger.info("Shared Redis cache connection closed successfully.") except Exception as e: @@ -94,8 +93,8 @@ async def close_cache() -> None: class BaseCache(Generic[T]): """Generic base cache interface with transparent distributed and local fallbacks. - Keys set or read through this interface are auto-serialized and prefixed. - It communicates with Redis directly if initialized, falling back cleanly onto a + Keys set or read through this interface are auto-serialized and prefixed. + It communicates with Redis directly if initialized, falling back cleanly onto a local `TTLLRUCache` memory store on connection failures or missing client pools. Attributes: @@ -133,19 +132,17 @@ def _get_key(self, key: str) -> str: return f"{self.prefix}:{key}" async def get( - self, - key: str, - target_type: Optional[Type[BaseModel]] = None - ) -> Optional[Union[T, BaseModel, Any]]: + self, key: str, target_type: type[BaseModel] | None = None + ) -> T | BaseModel | Any | None: """Retrieve and deserialize a cache record by its key. - Looks up key records from the distributed Redis client first, falling back to - the local memory store if missing or unreachable. Decodes JSON values and + Looks up key records from the distributed Redis client first, falling back to + the local memory store if missing or unreachable. Decodes JSON values and validates them against optional target Pydantic schemas. Args: key: The unique identifier key query. - target_type: Optional Pydantic model class type to validate the decoded + target_type: Optional Pydantic model class type to validate the decoded payload against. Defaults to None. Returns: @@ -155,25 +152,27 @@ async def get( full_key = self._get_key(key) client = self.redis_client raw_val = None - + if client: try: raw_val = await client.get(full_key) except Exception as e: logger.error(f"Redis get failed in '{self.prefix}': {e}") - + if raw_val is None: raw_val = self._local_cache.get(full_key) - + if raw_val is None: return None - + try: - parsed_data = json_loads(raw_val) if isinstance(raw_val, (str, bytes)) else raw_val - + parsed_data = ( + json_loads(raw_val) if isinstance(raw_val, (str, bytes)) else raw_val + ) + if target_type and issubclass(target_type, BaseModel): return target_type.model_validate(parsed_data) - + return parsed_data except Exception as e: logger.error(f"Failed to deserialize cache key '{full_key}': {e}") @@ -182,7 +181,7 @@ async def get( async def set(self, key: str, value: T, ttl: int = 3600) -> None: """Serialize and persist a key-value record with a Time-To-Live (TTL). - Stores the serialized string payload in Redis if available, or caches + Stores the serialized string payload in Redis if available, or caches it locally in the thread-safe fallback memory buffer. Args: @@ -192,20 +191,16 @@ async def set(self, key: str, value: T, ttl: int = 3600) -> None: """ full_key = self._get_key(key) client = self.redis_client - + serialized_val = json_dumps(value) - + if client: try: - await client.set( - full_key, - serialized_val, - ex=ttl - ) + await client.set(full_key, serialized_val, ex=ttl) return except Exception as e: logger.error(f"Redis set failed in '{self.prefix}': {e}") - + self._local_cache.set(full_key, serialized_val, ttl=ttl) async def delete(self, key: str) -> None: @@ -222,5 +217,5 @@ async def delete(self, key: str) -> None: return except Exception as e: logger.error(f"Redis delete failed in '{self.prefix}': {e}") - - self._local_cache.delete(full_key) \ No newline at end of file + + self._local_cache.delete(full_key) diff --git a/src/zcore/cache/ttllru_cache.py b/src/zcore/cache/ttllru_cache.py index 9bcace0..6fd1a6f 100644 --- a/src/zcore/cache/ttllru_cache.py +++ b/src/zcore/cache/ttllru_cache.py @@ -1,15 +1,16 @@ """In-Memory Least-Recently-Used (LRU) Cache with Time-To-Live (TTL) Support. -This module provides a thread-safe, in-memory cache implementation. It combines -LRU eviction boundaries with explicit expiration controls (TTL), registration metrics, +This module provides a thread-safe, in-memory cache implementation. It combines +LRU eviction boundaries with explicit expiration controls (TTL), registration metrics, and global eviction utility tools. """ -import time +import contextlib import threading +import time import weakref from collections import OrderedDict -from typing import Any, Optional, List +from typing import Any # Track active cache instances via weak references to coordinate global sweeps without memory leaks _active_caches: weakref.WeakSet["TTLLRUCache"] = weakref.WeakSet() @@ -18,8 +19,8 @@ class TTLLRUCache: """Thread-safe LRU cache featuring granular record-level expiration boundaries. - Ensures that active read/write operations utilize reentrant thread locks to mitigate - concurrency race conditions. Binds instances within a weak-reference registry + Ensures that active read/write operations utilize reentrant thread locks to mitigate + concurrency race conditions. Binds instances within a weak-reference registry to facilitate background eviction sweeps. Attributes: @@ -39,7 +40,7 @@ def __init__(self, maxsize: int = 1000) -> None: self._lock = threading.Lock() _active_caches.add(self) - def get(self, key: str) -> Optional[Any]: + def get(self, key: str) -> Any | None: """Fetch an item from the cache and slide its position to the end. Bypasses and discards the element inline if it has exceeded its TTL. @@ -91,7 +92,7 @@ def delete(self, key: str) -> None: def evict_expired(self) -> None: """Sweep the local cache structure and purge any record that has exceeded its TTL.""" now = time.time() - expired_keys: List[str] = [] + expired_keys: list[str] = [] with self._lock: for k, (expiry, _) in self.cache.items(): if now > expiry: @@ -106,7 +107,5 @@ def evict_all_expired(cls) -> None: Safely handles dereferenced or collection-modified objects using weak-reference pools. """ for cache_instance in list(_active_caches): - try: + with contextlib.suppress(ReferenceError): cache_instance.evict_expired() - except ReferenceError: - pass \ No newline at end of file diff --git a/src/zcore/cli/__init__.py b/src/zcore/cli/__init__.py index cb53ae7..66e8422 100644 --- a/src/zcore/cli/__init__.py +++ b/src/zcore/cli/__init__.py @@ -1,64 +1,74 @@ import argparse -import sys import secrets +import sys + +from zcore.cli.commands import gen_env, init_project, run_server, start_app -from zcore.cli.commands import init_project, start_app, run_server def main() -> None: """ The main entry point for the ZCore CLI. This function is bound to the 'zc' shell command. """ - parser = argparse.ArgumentParser( - description="ZCore CLI Tool", - prog="zc" - ) - + parser = argparse.ArgumentParser(description="ZCore CLI Tool", prog="zc") + parser.add_argument( - "--version", - action="store_true", - help="Show the active ZCore Framework version" + "--version", action="store_true", help="Show the active ZCore Framework version" ) - + subparsers = parser.add_subparsers( - dest="command", - help="Available Framework Orchestration Commands" + dest="command", help="Available Framework Orchestration Commands" ) - + init_parser = subparsers.add_parser( - "init", - help="Initializes a new ZCore project structure" + "init", help="Initializes a new ZCore project structure" ) init_parser.add_argument( - "name", - type=str, - help="Name of your project directory (e.g., core_api)" + "name", type=str, help="Name of your project directory (e.g., core_api)" ) startapp_parser = subparsers.add_parser( - "startapp", - help="Creates a modular ZCore app (domain) inside the current project" + "startapp", + help="Creates a modular ZCore app (domain) inside the current project", ) startapp_parser.add_argument( - "name", - type=str, - help="Name of the app/module (e.g., payment_gateway)" + "name", type=str, help="Name of the app/module (e.g., payment_gateway)" ) - + startapp_parser.add_argument( - "-t", "--template", + "-t", + "--template", action="store_true", - help="Generate files populated with ZCore boilerplate templates (default generates clean/empty files)" + help="Generate files populated with ZCore boilerplate templates", ) - subparsers.add_parser( - "run", - help="Launches the local Uvicorn development server" + startapp_parser.add_argument( + "--test", action="store_true", help="Generate test files for the app" ) + subparsers.add_parser("run", help="Launches the local Uvicorn development server") + subparsers.add_parser( - "gensecret", - help="Generates a cryptographically secure 64-character hex string for production SECRET_KEY" + "gensecret", + help="Generates a cryptographically secure 64-character hex string for production SECRET_KEY", + ) + + genenv_parser = subparsers.add_parser( + "genenv", + help="Generates a template .env file based on the active Settings class fields", + ) + genenv_parser.add_argument( + "-o", + "--output", + type=str, + default=".env.example", + help="Target output filepath (default: .env.example)", + ) + genenv_parser.add_argument( + "-f", + "--force", + action="store_true", + help="Overwrite the file if it already exists", ) args = parser.parse_args() @@ -70,7 +80,9 @@ def main() -> None: if args.command == "init": project_name = args.name if not project_name.isidentifier(): - print(f"โŒ Error: '{project_name}' is not a valid directory name in Python.") + print( + f"โŒ Error: '{project_name}' is not a valid directory name in Python." + ) sys.exit(1) init_project(project_name) @@ -80,7 +92,7 @@ def main() -> None: print(f"โŒ Error: '{app_name}' is not a valid Python module name.") print("๐Ÿ‘‰ Recommendation: Use snake_case names (e.g. order_management)") sys.exit(1) - start_app(app_name, with_template=args.template) + start_app(app_name, with_template=args.template, with_test=args.test) elif args.command == "run": run_server() @@ -89,9 +101,13 @@ def main() -> None: print("๐Ÿ”‘ Generated Cryptographic Secret Key:") print(secrets.token_hex(32)) + elif args.command == "genenv": + gen_env(args.output, args.force) + else: parser.print_help() sys.exit(0) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/zcore/cli/commands.py b/src/zcore/cli/commands.py index 6c351e5..4a31dcf 100644 --- a/src/zcore/cli/commands.py +++ b/src/zcore/cli/commands.py @@ -1,22 +1,24 @@ -# src/zcore/cli/commands.py import os -import sys import secrets import subprocess +import sys from pathlib import Path + from .templates import ( - MAIN_PY_TEMPLATE, ENV_TEMPLATE, - REQUIREMENTS_TEMPLATE, GITIGNORE_TEMPLATE, + MAIN_PY_TEMPLATE, MODEL_TEMPLATE, - SCHEMA_TEMPLATE, + PLUGIN_TEMPLATE, REPOSITORY_TEMPLATE, - SERVICE_TEMPLATE, + REQUIREMENTS_TEMPLATE, ROUTER_TEMPLATE, - PLUGIN_TEMPLATE + SCHEMA_TEMPLATE, + SERVICE_TEMPLATE, + TEST_TEMPLATE, ) + def create_file(path: Path, content: str) -> None: if path.exists(): print(f"โš ๏ธ Skip: {path} already exists.") @@ -25,6 +27,7 @@ def create_file(path: Path, content: str) -> None: f.write(content) print(f"โœ… Created: {path}") + def init_project(project_name: str) -> None: project_dir = Path(project_name.lower()) if project_dir.exists(): @@ -33,74 +36,90 @@ def init_project(project_name: str) -> None: project_dir.mkdir(parents=True) generated_secret = secrets.token_hex(32) - + files_to_create = { "main.py": MAIN_PY_TEMPLATE, - ".env": ENV_TEMPLATE.format(project_name=project_name, secret_key=generated_secret), + ".env": ENV_TEMPLATE.format( + project_name=project_name, secret_key=generated_secret + ), "requirements.txt": REQUIREMENTS_TEMPLATE, - ".gitignore": GITIGNORE_TEMPLATE + ".gitignore": GITIGNORE_TEMPLATE, } print(f"\n๐Ÿงฑ Initializing ZCore Project: '{project_name}'...") for filename, content in files_to_create.items(): create_file(project_dir / filename, content) - + db_file = project_dir / "zcore_dev.db" db_file.touch() print(f"โœ… Created: {db_file}") - + print(f"\n๐ŸŽ‰ Project '{project_name}' initialized successfully!") - print(f"๐Ÿ‘‰ Run: 'cd {project_name}' and run 'python -m zcore.cli startapp ' to generate a plugin!") + print( + f"๐Ÿ‘‰ Run: 'cd {project_name}' and run 'python -m zcore.cli startapp ' to generate a plugin!" + ) + -def start_app(app_name: str, with_template: bool = False) -> None: +def start_app( + app_name: str, with_template: bool = False, with_test: bool = False +) -> None: app_dir = Path(app_name.lower()) - + if app_dir.exists(): print(f"โŒ Error: App folder '{app_dir}' already exists.") sys.exit(1) app_dir.mkdir(parents=True) - + model_name = "".join(word.capitalize() for word in app_name.split("_")) table_name = app_name.lower() - + context = { "ModelName": model_name, "table_name": table_name, "app_name": table_name, - "project_name": Path(os.getcwd()).name + "project_name": Path(os.getcwd()).name, } files_to_create = { "__init__.py": "", "models.py": MODEL_TEMPLATE.format(**context) if with_template else "", "schemas.py": SCHEMA_TEMPLATE.format(**context) if with_template else "", - "repositories.py": REPOSITORY_TEMPLATE.format(**context) if with_template else "", + "repositories.py": REPOSITORY_TEMPLATE.format(**context) + if with_template + else "", "services.py": SERVICE_TEMPLATE.format(**context) if with_template else "", "routers.py": ROUTER_TEMPLATE.format(**context) if with_template else "", - "plugin.py": PLUGIN_TEMPLATE.format(**context) + "plugin.py": PLUGIN_TEMPLATE.format(**context), } - print(f"\n๐Ÿš€ Scaffolding ZCore Domain App (with Built-In Plugin): {model_name}..." - if with_template else f"\n๐Ÿ’ซ Scaffolding Clean ZCore App (WITHOUT Boilerplate): {model_name}...") + if with_test: + files_to_create["tests.py"] = ( + TEST_TEMPLATE.format(**context) if with_template else "" + ) + + print(f"\n๐Ÿš€ Scaffolding ZCore Domain App: {model_name}...") for filename, content in files_to_create.items(): create_file(app_dir / filename, content) - - print(f"\n๐ŸŽ‰ Modular App '{app_name}' created successfully with its core Plugin wrapper!") + + print(f"\n๐ŸŽ‰ Modular App '{app_name}' created successfully!") print("๐Ÿ‘‰ REGISTER this plugin in your main.py:") print(f" from {app_name}.plugin import {model_name}Plugin") print(f" kernel.add_plugin({model_name}Plugin())") + def run_server() -> None: if not os.path.exists("main.py"): - print("โŒ Error: 'main.py' not found in current directory. Are you in a ZCore project root?") + print( + "โŒ Error: 'main.py' not found in current directory. Are you in a ZCore project root?" + ) sys.exit(1) host = "127.0.0.1" port = "8000" if os.path.exists(".env"): - with open(".env", "r") as env_file: + with open(".env") as env_file: for line in env_file: if line.strip() and not line.startswith("#"): parts = line.strip().split("=", 1) @@ -108,22 +127,103 @@ def run_server() -> None: key, val = parts[0].strip(), parts[1].strip() if key == "HOST": host = val - elif key == "PORT": + if key == "PORT": port = val print(f"๐Ÿ“ก Starting ZCore Dev Server on {host}:{port}...") - + env = os.environ.copy() current_pythonpath = env.get("PYTHONPATH", "") - env["PYTHONPATH"] = f".{os.pathsep}{current_pythonpath}" if current_pythonpath else "." + env["PYTHONPATH"] = ( + f".{os.pathsep}{current_pythonpath}" if current_pythonpath else "." + ) try: subprocess.run( - [sys.executable, "-m", "uvicorn", "main:app", f"--host={host}", f"--port={port}", "--reload"], + [ + sys.executable, + "-m", + "uvicorn", + "main:app", + f"--host={host}", + f"--port={port}", + "--reload", + ], env=env, - check=True + check=True, ) except KeyboardInterrupt: print("\n๐Ÿ‘‹ Server shutdown cleanly.") except Exception as e: - print(f"โŒ Failed to run Uvicorn dev server: {e}") \ No newline at end of file + print(f"โŒ Failed to run Uvicorn dev server: {e}") + + +def gen_env(output_file: str = ".env.example", force: bool = False) -> None: + """Generates a template .env file based on the registered Settings class. + + This function discovers the active Settings class by trying to import the user's main + module (supporting relative imports by using package context), resolving the settings + instance via IoC container or subclass tracking, and formatting the field default values. + """ + import importlib + + cwd = Path.cwd() + parent_dir = cwd.parent + module_name = cwd.name + + imported = False + if module_name.isidentifier(): + if str(parent_dir) not in sys.path: + sys.path.insert(0, str(parent_dir)) + try: + importlib.import_module(f"{module_name}.main") + imported = True + except Exception: + pass + + if not imported: + if str(cwd) not in sys.path: + sys.path.insert(0, str(cwd)) + + from pydantic_core import PydanticUndefined + + from zcore.config import Settings, get_settings + + subclasses = Settings.__subclasses__() + if subclasses: + settings_class = max(subclasses, key=lambda c: len(c.model_fields)) + else: + try: + settings_class = get_settings().__class__ + except Exception: + settings_class = Settings + + out_path = Path(output_file) + if out_path.exists() and not force: + print( + f"โŒ Error: Output file '{out_path}' already exists. Use --force to overwrite." + ) + sys.exit(1) + + env_lines = [] + for field_name, field_info in settings_class.model_fields.items(): + default_val = field_info.default + if default_val is PydanticUndefined or default_val is None: + env_lines.append(f"{field_name}=") + else: + if isinstance(default_val, bool): + env_lines.append(f"{field_name}={default_val!s}") + elif isinstance(default_val, (int, float)): + env_lines.append(f"{field_name}={default_val}") + else: + env_lines.append(f"{field_name}={default_val!s}") + + try: + with open(out_path, "w", encoding="utf-8") as f: + f.write("\n".join(env_lines) + "\n") + print( + f"โœ… Created: {out_path} based on '{settings_class.__name__}' configuration fields." + ) + except Exception as e: + print(f"โŒ Failed to generate env file: {e}") + sys.exit(1) diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py index c7c418e..09d2a8d 100644 --- a/src/zcore/cli/templates.py +++ b/src/zcore/cli/templates.py @@ -17,7 +17,7 @@ db_url=settings.DATABASE_URL, pool_size=settings.POOL_SIZE, max_overflow=settings.MAX_OVERFLOW, - echo=(settings.ENVIRONMENT == "development") + echo=(settings.DEBUG) ) # Initialize ZCore Kernel & Plugins @@ -45,12 +45,12 @@ async def root(): return { "status": "healthy", "framework": "ZCore", - "environment": settings.ENVIRONMENT + "debug": settings.DEBUG } """ ENV_TEMPLATE = """PYTHONPATH=. -ENVIRONMENT=development +DEBUG=True PROJECT_NAME="{project_name}" DATABASE_URL=sqlite+aiosqlite:///zcore_dev.db SECRET_KEY="{secret_key}" @@ -156,13 +156,13 @@ def __init__(self, db: AsyncSession): super().__init__(model={ModelName}, db=db) """ -SERVICE_TEMPLATE = """from zcore import BaseService, Inject +SERVICE_TEMPLATE = """from zcore import BaseService from .models import {ModelName} from .schemas import {ModelName}Create, {ModelName}Update from .repositories import {ModelName}Repository class {ModelName}Service(BaseService[{ModelName}, {ModelName}Create, {ModelName}Update]): - def __init__(self, repository: {ModelName}Repository = Inject({ModelName}Repository)): + def __init__(self, repository: {ModelName}Repository): super().__init__(model={ModelName}, repository=repository) """ @@ -231,4 +231,17 @@ async def after_startup(self) -> None: async def on_shutdown(self) -> None: # Cleanup tasks for this specific module pass -""" \ No newline at end of file +""" + +TEST_TEMPLATE = """import pytest +import uuid +from zcore.testing import ZTestClient +from main import app + +@pytest.mark.asyncio +async def test_get_{app_name}_list(): + uid = uuid.uuid4() + async with ZTestClient(app, user_id=uid, scopes=["{app_name}:listview"]) as client: + response = await client.get("/{app_name}/") + assert response.status_code == 200 +""" diff --git a/src/zcore/config.py b/src/zcore/config.py index e5258e7..04da4e7 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -1,25 +1,27 @@ """ZCore Core Configuration Module. This module provides the core settings and configuration loading infrastructure for the -ZCore framework. It leverages Pydantic Settings (v2) for validation and environment +ZCore framework. It leverages Pydantic Settings (v2) for validation and environment variable parsing, and registers itself within the dependency injection (DI) container to support singleton management. A dynamic proxy is also provided to support lazy resolution across the application lifecycle. """ import os -from typing import Any, Type, TypeVar, cast +from typing import Any, TypeVar, cast + from pydantic_settings import BaseSettings, SettingsConfigDict + from zcore.kernel.di import container -T = TypeVar("T", bound="ZCoreCoreSettings") +T = TypeVar("T", bound="Settings") -class ZCoreCoreSettings(BaseSettings): +class Settings(BaseSettings): """Core settings and environment variables configuration for the ZCore framework. - This class parses configuration variables from both environment variables and - optional file-based sources (such as a `.env` file). It manages configuration for + This class parses configuration variables from both environment variables and + optional file-based sources (such as a `.env` file). It manages configuration for the database engine, authentication parameters, file storage paths, and other core services. Attributes: @@ -34,57 +36,55 @@ class ZCoreCoreSettings(BaseSettings): REFRESH_TOKEN_EXPIRE_DAYS: Expiry duration for refresh tokens in days. STORAGE_PATH: Local filesystem base path reserved for target storage uploads. REDIS_URL: Redis connection URI, or None if Redis is not used. - ENVIRONMENT: Deployment environment context (e.g., 'production', 'development', 'test'). + DEBUG: Boolean flag indicating whether the application is in debug mode. """ model_config = SettingsConfigDict( - env_file=os.getenv("ENV_FILE", ".env"), - extra="ignore", - case_sensitive=True + env_file=os.getenv("ENV_FILE", ".env"), extra="ignore", case_sensitive=True ) DATABASE_URL: str = "sqlite+aiosqlite:///zcore.db" MAX_OVERFLOW: int = 10 POOL_SIZE: int = 5 DATABASE_TEST_URL: str = "sqlite+aiosqlite:///zcore_test.db" - + SECRET_KEY: str = "zcore-insecure-fallback-secret-key-must-be-changed" PROJECT_NAME: str = "ZCore Application" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - + STORAGE_PATH: str = "./storage" REDIS_URL: str | None = None - ENVIRONMENT: str = "production" + DEBUG: bool = True -def initialize_settings(settings_inst: ZCoreCoreSettings) -> None: +def initialize_settings(settings_inst: Settings) -> None: """Register the settings instance in the IoC dependency injection container. This function binds the instantiated settings class to the DI container. If the - provided instance is a subclass of ZCoreCoreSettings, it registers both the specific - subclass and the base ZCoreCoreSettings type, allowing downstream components to + provided instance is a subclass of Settings, it registers both the specific + subclass and the base Settings type, allowing downstream components to inject the base class or the custom subclass seamlessly. Args: - settings_inst: An instance of `ZCoreCoreSettings` (or its subclasses) + settings_inst: An instance of `Settings` (or its subclasses) to register into the global container. """ container.register_singleton(settings_inst.__class__, settings_inst) - if settings_inst.__class__ is not ZCoreCoreSettings: - container.register_singleton(ZCoreCoreSettings, settings_inst) + if settings_inst.__class__ is not Settings: + container.register_singleton(Settings, settings_inst) -def get_settings(settings_class: Type[T] = ZCoreCoreSettings) -> T: +def get_settings(settings_class: type[T] = Settings) -> T: """Retrieve the settings instance from the dependency injection container. If the specified settings class has not yet been registered in the DI container, this function instantiates it, registers it as a singleton, and then returns it. Args: - settings_class: The class type of the settings to resolve. - Defaults to ZCoreCoreSettings. + settings_class: The class type of the settings to resolve. + Defaults to Settings. Returns: The resolved settings instance of type `T`. @@ -100,9 +100,9 @@ def get_settings(settings_class: Type[T] = ZCoreCoreSettings) -> T: class SettingsProxy: """Proxy object providing lazy attribute access to the active settings instance. - This proxy allows developers to import a global `settings` object without triggering + This proxy allows developers to import a global `settings` object without triggering premature initialization of the dependency injection container or settings configuration - lookup during import time. Configuration lookups are dynamically resolved against the + lookup during import time. Configuration lookups are dynamically resolved against the active registered settings instance on demand. """ @@ -116,10 +116,10 @@ def __getattr__(self, name: str) -> Any: The value associated with the specified attribute name. Raises: - AttributeError: If the resolved settings instance does not contain + AttributeError: If the resolved settings instance does not contain the requested attribute. """ return getattr(get_settings(), name) -settings = SettingsProxy() \ No newline at end of file +settings = SettingsProxy() diff --git a/src/zcore/context/__init__.py b/src/zcore/context/__init__.py index a735909..e7b8f85 100644 --- a/src/zcore/context/__init__.py +++ b/src/zcore/context/__init__.py @@ -1,19 +1,6 @@ -from zcore.context.context import ( - get_current_user_id, - set_current_user_id, - get_restricted_fields, - set_restricted_fields, - user_context, - restricted_fields_context, - request_context, -) +from zcore.context.context import ZContext, ctx __all__ = [ - "get_current_user_id", - "set_current_user_id", - "get_restricted_fields", - "set_restricted_fields", - "user_context", - "restricted_fields_context", - "request_context", -] \ No newline at end of file + "ZContext", + "ctx", +] diff --git a/src/zcore/context/context.py b/src/zcore/context/context.py index f89b455..d573956 100644 --- a/src/zcore/context/context.py +++ b/src/zcore/context/context.py @@ -1,157 +1,177 @@ -"""ZCore Thread and Coroutine Context Management. +""" +ZCore Context Management Module. -This module provides thread-local and coroutine-safe state storage using Python's -`contextvars` library. It tracks the current authenticated user identifier and any -restricted data fields configured on the active execution context. It manages these -parameters across asynchronous task boundaries to prevent concurrent state pollution. +This module provides a robust, thread-safe, and coroutine-aware context storage +system using Python's `contextvars` library. It is designed to manage +request-scoped state, such as authentication identifiers and security filters, +ensuring state isolation across asynchronous task boundaries and preventing +leaks between concurrent executions. """ import uuid -from contextvars import ContextVar, Token -from typing import Generator, Union +from collections.abc import Iterable from contextlib import contextmanager +from contextvars import ContextVar, Token +from typing import Any -# Safe context variables scoped to the current asynchronous execution context -_current_user_id: ContextVar[Union[uuid.UUID, None]] = ContextVar("user_id", default=None) -_restricted_fields: ContextVar[Union[frozenset[str], None]] = ContextVar("restricted_fields", default=None) - - -def set_current_user_id(user_id: Union[uuid.UUID, str, None]) -> Token[Union[uuid.UUID, None]]: - """Set the current authenticated user's identifier in the execution context. - - Converts string-based UUID parameters into formal `uuid.UUID` class instances - immediately to ensure downstream type integrity and avoid resolution errors. - - Args: - user_id: The identifier to set in the context. Can be a pre-constructed - `uuid.UUID` instance, a valid UUID string, or None to clear the active context. - - Returns: - A context token representing the previous state, used to restore context - boundaries later. - - Raises: - ValueError: If a string-based parameter is not a valid UUID string representation. - TypeError: If the parameter is not a `uuid.UUID` object, a string, or None. - """ - if user_id is None: - return _current_user_id.set(None) - - if isinstance(user_id, str): - try: - validated_id = uuid.UUID(user_id) - except ValueError as e: - raise ValueError(f"Invalid UUID string provided to context: '{user_id}'") from e - elif isinstance(user_id, uuid.UUID): - validated_id = user_id - else: - raise TypeError("user_id must be an instance of uuid.UUID, a valid UUID string, or None") - - return _current_user_id.set(validated_id) - - -def get_current_user_id() -> Union[uuid.UUID, None]: - """Retrieve the current user identifier from the active execution context. - - Returns: - The active user's UUID, or None if the current context is unauthenticated. - """ - return _current_user_id.get() - - -def set_restricted_fields( - fields: Union[set[str], list[str], frozenset[str], None] -) -> Token[Union[frozenset[str], None]]: - """Configure the restricted data fields for the active execution context. - - Converts raw sequence parameters (lists or standard sets) into immutable `frozenset` - structures to enforce mutation-safe isolation and prevent accidental leaks. - - Args: - fields: A collection of blocked field dot-paths, or None to clear active restrictions. - - Returns: - A context token representing the previous state, used to restore context - boundaries later. - """ - if fields is None: - return _restricted_fields.set(None) - - # Store as frozenset to guarantee thread-safe and mutation-safe isolation - frozen_fields = frozenset(fields) - return _restricted_fields.set(frozen_fields) - - -def get_restricted_fields() -> frozenset[str]: - """Retrieve the set of restricted fields configured on the active execution context. - - Returns: - An immutable frozenset containing field dot-paths blocked from being loaded - by query builders or exposed by response projection utilities. - """ - val = _restricted_fields.get() - if val is None: - return frozenset() - return val - - -@contextmanager -def user_context(user_id: Union[uuid.UUID, str, None]) -> Generator[None, None, None]: - """Context manager to safely bind and restore a user identifier. +_request_context_store: ContextVar[dict[str, Any]] = ContextVar( + "request_context_store", default={} +) - Args: - user_id: The target identifier to set. Accepts UUID instances, string - representations, or None. - Yields: - None. +class ZContext: """ - token = set_current_user_id(user_id) - try: - yield - finally: - _current_user_id.reset(token) - - -@contextmanager -def restricted_fields_context( - fields: Union[set[str], list[str], frozenset[str], None] -) -> Generator[None, None, None]: - """Context manager to safely bind and restore restricted fields. - - Args: - fields: A collection of blocked field paths to bind to the active block. + Unified interface for managing asynchronous execution contexts. - Yields: - None. + Encapsulates the logic for storing and retrieving scoped data. This class + supports dynamic key-value storage while providing strongly-typed properties + for common framework-level attributes like user identity and data restrictions. """ - token = set_restricted_fields(fields) - try: - yield - finally: - _restricted_fields.reset(token) + @classmethod + def set(cls, key: str, value: Any) -> None: + """ + Stores a value in the current execution context. + + Args: + key: The unique identifier for the context entry. + value: The data to persist in the current scope. + """ + current_store = _request_context_store.get() + new_store = dict(current_store) + new_store[key] = value + _request_context_store.set(new_store) + + @classmethod + def get(cls, key: str, default: Any = None) -> Any: + """ + Retrieves a value from the current execution context. + + Args: + key: The identifier for the requested context entry. + default: The fallback value if the key is not found. + + Returns: + The associated value or the specified default. + """ + return _request_context_store.get().get(key, default) + + @classmethod + def remove(cls, key: str) -> None: + """ + Deletes a specific entry from the current execution context. + + Args: + key: The identifier to be removed from the context store. + """ + current_store = _request_context_store.get() + if key in current_store: + new_store = dict(current_store) + new_store.pop(key, None) + _request_context_store.set(new_store) + + @classmethod + def initialize(cls) -> Token[dict[str, Any]]: + """ + Resets the context store to an empty state for the current scope. + + Returns: + A contextvars Token used for restoring the previous state. + """ + return _request_context_store.set({}) + + @classmethod + def reset(cls, token: Token[dict[str, Any]]) -> None: + """ + Restores the context store to a state corresponding to the provided token. + + Args: + token: A valid token returned by a previous context operation. + """ + _request_context_store.reset(token) + + @property + def user_id(self) -> uuid.UUID | None: + """ + Retrieves the authenticated user identifier from the current context. + + Returns: + The user's UUID if authenticated, otherwise None. + """ + return self.get("user_id") + + @user_id.setter + def user_id(self, value: uuid.UUID | str | None) -> None: + """ + Sets and validates the user identifier for the current context. + + Args: + value: A UUID instance, a valid UUID string, or None to clear. + + Raises: + ValueError: If a string input is not a valid UUID format. + TypeError: If the input type is unsupported. + """ + if value is None: + self.set("user_id", None) + return + + if isinstance(value, str): + try: + validated_id = uuid.UUID(value) + except ValueError as e: + raise ValueError(f"Invalid UUID string: '{value}'") from e + elif isinstance(value, uuid.UUID): + validated_id = value + else: + raise TypeError("user_id must be a uuid.UUID, a valid UUID string, or None") + + self.set("user_id", validated_id) + + @property + def restricted_fields(self) -> frozenset[str]: + """ + Accesses the collection of data fields restricted in the current context. + + Returns: + An immutable frozenset of restricted field paths. + """ + return self.get("restricted_fields", frozenset()) + + @restricted_fields.setter + def restricted_fields(self, value: Iterable[str] | None) -> None: + """ + Updates the restricted fields, ensuring immutability through frozenset. + + Args: + value: An iterable of field paths or None to clear restrictions. + """ + if value is None: + self.set("restricted_fields", frozenset()) + else: + self.set("restricted_fields", frozenset(value)) + + @contextmanager + def scope(self, **kwargs: Any) -> Any: + """ + Context manager for localized state overrides within a block. + + Args: + **kwargs: Attributes to set temporarily in the context. + + Yields: + None + """ + token = _request_context_store.set(dict(_request_context_store.get())) + try: + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + else: + self.set(key, value) + yield + finally: + _request_context_store.reset(token) -@contextmanager -def request_context( - user_id: Union[uuid.UUID, str, None], - fields: Union[set[str], list[str], frozenset[str], None] -) -> Generator[None, None, None]: - """Compound context manager to cleanly coordinate both user and restricted field states. - - Designed to handle context setups and teardowns atomically during HTTP request lifecycles. - - Args: - user_id: The target identifier to bind. - fields: The restricted field path collection to bind. - Yields: - None. - """ - user_token = set_current_user_id(user_id) - fields_token = set_restricted_fields(fields) - try: - yield - finally: - _current_user_id.reset(user_token) - _restricted_fields.reset(fields_token) \ No newline at end of file +ctx = ZContext() diff --git a/src/zcore/db/__init__.py b/src/zcore/db/__init__.py index 58a58dd..ebb0b51 100644 --- a/src/zcore/db/__init__.py +++ b/src/zcore/db/__init__.py @@ -1,34 +1,34 @@ -from zcore.db.setup import Base, db_manager, get_db, SessionDep, Actions -from zcore.db.repository import BaseRepository, AbstractRepository -from zcore.db.uow import UnitOfWork -from zcore.db.search import SearchRequest, SearchEngine -from zcore.db.events import register_db_event_dispatcher, dispatch_db_event +from zcore.db.events import dispatch_db_event, register_db_event_dispatcher from zcore.db.pagination import ( - PaginatedResult, BasePagination, - PageNumberPagination, CursorPagination, - PageNumberParams, CursorParams, + PageNumberPagination, + PageNumberParams, + PaginatedResult, ) +from zcore.db.repository import AbstractRepository, BaseRepository +from zcore.db.search import SearchEngine, SearchRequest +from zcore.db.setup import Actions, Base, SessionDep, db_manager, get_db +from zcore.db.uow import UnitOfWork __all__ = [ - "Base", - "db_manager", - "get_db", - "SessionDep", - "BaseRepository", "AbstractRepository", - "UnitOfWork", - "PaginatedResult", + "Actions", + "Base", "BasePagination", - "PageNumberPagination", + "BaseRepository", "CursorPagination", - "PageNumberParams", "CursorParams", - "SearchRequest", + "PageNumberPagination", + "PageNumberParams", + "PaginatedResult", "SearchEngine", - "register_db_event_dispatcher", + "SearchRequest", + "SessionDep", + "UnitOfWork", + "db_manager", "dispatch_db_event", - 'Actions', -] \ No newline at end of file + "get_db", + "register_db_event_dispatcher", +] diff --git a/src/zcore/db/events.py b/src/zcore/db/events.py index cd8551f..21d97ba 100644 --- a/src/zcore/db/events.py +++ b/src/zcore/db/events.py @@ -1,12 +1,14 @@ """Database Lifecycle Event Dispatcher. This module provides a secure bridge between database operations and the core application -event dispatcher. It ensures that transactional and lifecycle events can be dispatched +event dispatcher. It ensures that transactional and lifecycle events can be dispatched safely without blocking synchronous execution paths or greenlets. """ -import structlog from typing import Any + +import structlog + from zcore.kernel.events import EventDispatcher logger = structlog.get_logger() @@ -17,7 +19,7 @@ def register_db_event_dispatcher(dispatcher: EventDispatcher) -> None: """Register the global event dispatcher instance for database lifecycle events. - This function configures the central event dispatcher used by database modules to + This function configures the central event dispatcher used by database modules to notify downstream systems of transactional changes or lifecycle transitions. Args: @@ -30,7 +32,7 @@ def register_db_event_dispatcher(dispatcher: EventDispatcher) -> None: async def dispatch_db_event(event_name: str, payload: Any) -> None: """Safely dispatch database lifecycle events within the active async event loop. - This function dispatches database events asynchronously, catching and logging any + This function dispatches database events asynchronously, catching and logging any exceptions encountered during execution to safeguard calling databases from handler failures. Args: @@ -42,4 +44,6 @@ async def dispatch_db_event(event_name: str, payload: Any) -> None: try: await _global_dispatcher.dispatch(event_name, payload) except Exception as e: - logger.error(f"Failed to dispatch database event '{event_name}': {e}", exc_info=True) \ No newline at end of file + logger.error( + f"Failed to dispatch database event '{event_name}': {e}", exc_info=True + ) diff --git a/src/zcore/db/pagination.py b/src/zcore/db/pagination.py index 75d36cd..86206dc 100644 --- a/src/zcore/db/pagination.py +++ b/src/zcore/db/pagination.py @@ -2,24 +2,26 @@ This module provides pagination strategies for relational query outputs. It supports both Page-Number (Offset-based) pagination and high-performance Cursor (Keyset-based) -pagination with base64 encoded metadata, integrating with Pydantic V2 schemas for request +pagination with base64 encoded metadata, integrating with Pydantic V2 schemas for request parameter validation. """ -import uuid -import math import base64 -from typing import Any, Sequence, TypeVar, Generic, Optional, Type, Literal -from datetime import datetime, timezone -from pydantic import BaseModel, Field +import contextlib +import math +import uuid +from collections.abc import Sequence +from datetime import UTC, datetime +from typing import Any, Generic, Literal, TypeVar -from sqlalchemy.sql.sqltypes import DateTime -from sqlalchemy.sql import Select +from pydantic import BaseModel, Field +from sqlalchemy import and_, func, inspect, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, or_, inspect +from sqlalchemy.sql import Select +from sqlalchemy.sql.sqltypes import DateTime -from zcore.utils.helpers import json_dumps, json_loads from zcore.exceptions.base import ValidationError +from zcore.utils.helpers import json_dumps, json_loads T = TypeVar("T") @@ -53,13 +55,13 @@ class PageNumberParams(BaseModel): sort_by: The model attribute name to apply sorting on. Defaults to None. sort_order: Sorting direction, either ascending ('asc') or descending ('desc'). Defaults to "asc". - include_count: If True, performs an additional database execution to count the + include_count: If True, performs an additional database execution to count the total matching resources. Defaults to True. """ page: int = Field(default=1, ge=1) size: int = Field(default=20, ge=1, le=100) - sort_by: Optional[str] = None + sort_by: str | None = None sort_order: Literal["asc", "desc"] = "asc" include_count: bool = True @@ -68,12 +70,12 @@ class CursorParams(BaseModel): """Pydantic parameters representing cursor keyset pagination configurations. Attributes: - cursor: The base64-encoded keyset cursor string specifying position context. + cursor: The base64-encoded keyset cursor string specifying position context. If None, fetches from the beginning. Defaults to None. size: The maximum quantity of records to yield in the dataset. Defaults to 20. """ - cursor: Optional[str] = None + cursor: str | None = None size: int = Field(default=20, ge=1, le=100) @@ -84,14 +86,10 @@ class BasePagination(Generic[T]): params_class: The Pydantic model type representing valid pagination inputs. """ - params_class: Type[BaseModel] + params_class: type[BaseModel] async def paginate( - self, - session: AsyncSession, - query: Select, - params: Any, - model: Any + self, session: AsyncSession, query: Select, params: Any, model: Any ) -> PaginatedResult[T]: """Apply pagination constraints onto an active SQLAlchemy Query. @@ -120,11 +118,7 @@ class PageNumberPagination(BasePagination[T]): params_class = PageNumberParams async def paginate( - self, - session: AsyncSession, - query: Select, - params: PageNumberParams, - model: Any + self, session: AsyncSession, query: Select, params: PageNumberParams, model: Any ) -> PaginatedResult[T]: """Paginate results using page numbers and offsets. @@ -147,8 +141,10 @@ async def paginate( if params.sort_by: valid_columns = {col.key for col in inspect(model).columns} if params.sort_by not in valid_columns: - raise ValidationError(message=f"Invalid sort field: '{params.sort_by}' on {model.__name__}") - + raise ValidationError( + message=f"Invalid sort field: '{params.sort_by}' on {model.__name__}" + ) + col = getattr(model, params.sort_by) if params.sort_order == "desc": query = query.order_by(col.desc()) @@ -163,12 +159,12 @@ async def paginate( if params.include_count: cleaned_query = query.order_by(None) count_query = select(func.count()).select_from(cleaned_query.subquery()) - + count_result = await session.execute(count_query) total = count_result.scalar_one() total_pages = math.ceil(total / size) if size > 0 else 0 has_next = page < total_pages - + paginated_query = query.offset(offset).limit(size) items_result = await session.execute(paginated_query) items = list(items_result.scalars().all()) @@ -176,7 +172,7 @@ async def paginate( paginated_query = query.offset(offset).limit(size + 1) items_result = await session.execute(paginated_query) items = list(items_result.scalars().all()) - + if len(items) > size: items = items[:size] has_next = True @@ -187,7 +183,7 @@ async def paginate( "size": size, "total_pages": total_pages, "has_next": has_next, - "has_prev": has_prev + "has_prev": has_prev, } return PaginatedResult(data=items, meta=meta) @@ -196,7 +192,7 @@ class CursorPagination(BasePagination[T]): """Keyset-based cursor pagination strategy. Provides stable paging execution across highly dynamic or high-throughput datasets. - Utilizes base64 encoded keyset strings to reference chronological positions without + Utilizes base64 encoded keyset strings to reference chronological positions without offset drift. """ @@ -224,17 +220,11 @@ def _encode_cursor(self, last_item: Any) -> str: """ value = getattr(last_item, self.cursor_field, None) if isinstance(value, datetime): - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - else: - value = value.astimezone(timezone.utc) + value = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) value = value.isoformat() - - payload = { - "value": value, - "id": str(getattr(last_item, "id", "")) - } - + + payload = {"value": value, "id": str(getattr(last_item, "id", ""))} + json_str = json_dumps(payload) encoded = base64.urlsafe_b64encode(json_str.encode()).decode() return encoded.rstrip("=") @@ -258,7 +248,11 @@ def _decode_cursor(self, cursor_str: str) -> dict[str, Any] | None: cursor_str += "=" * padding_needed decoded = base64.urlsafe_b64decode(cursor_str.encode()).decode() decoded_dict = json_loads(decoded) - if not isinstance(decoded_dict, dict) or "value" not in decoded_dict or "id" not in decoded_dict: + if ( + not isinstance(decoded_dict, dict) + or "value" not in decoded_dict + or "id" not in decoded_dict + ): raise ValidationError(message="Malformed cursor parameter provided.") return decoded_dict except ValidationError: @@ -267,11 +261,7 @@ def _decode_cursor(self, cursor_str: str) -> dict[str, Any] | None: raise ValidationError(message="Malformed cursor parameter provided.") from e async def paginate( - self, - session: AsyncSession, - query: Select, - params: CursorParams, - model: Any + self, session: AsyncSession, query: Select, params: CursorParams, model: Any ) -> PaginatedResult[T]: """Paginate results using encoded keyset cursors. @@ -285,7 +275,7 @@ async def paginate( A keyset-based PaginatedResult container. Raises: - ValidationError: If the specified cursor_field is invalid or not located + ValidationError: If the specified cursor_field is invalid or not located on the target database model. """ size = params.size @@ -293,41 +283,29 @@ async def paginate( valid_columns = {col.key for col in inspect(model).columns} if self.cursor_field not in valid_columns: - raise ValidationError(message=f"Invalid cursor field: '{self.cursor_field}' on {model.__name__}") + raise ValidationError( + message=f"Invalid cursor field: '{self.cursor_field}' on {model.__name__}" + ) col = getattr(model, self.cursor_field) - pk_col = getattr(model, "id") + pk_col = model.id if cursor_data: val = cursor_data["value"] last_id = cursor_data["id"] - + if isinstance(last_id, str): - try: + with contextlib.suppress(ValueError): last_id = uuid.UUID(last_id) - except ValueError: - pass if isinstance(col.type, DateTime) and isinstance(val, str): - try: + with contextlib.suppress(ValueError): val = datetime.fromisoformat(val) - except ValueError: - pass if self.order == "desc": - query = query.where( - or_( - col < val, - and_(col == val, pk_col < last_id) - ) - ) + query = query.where(or_(col < val, and_(col == val, pk_col < last_id))) else: - query = query.where( - or_( - col > val, - and_(col == val, pk_col > last_id) - ) - ) + query = query.where(or_(col > val, and_(col == val, pk_col > last_id))) if self.order == "desc": query = query.order_by(col.desc(), pk_col.desc()) @@ -345,9 +323,5 @@ async def paginate( items = items[:size] next_cursor = self._encode_cursor(items[-1]) - meta = { - "next_cursor": next_cursor, - "has_more": has_more, - "size": size - } - return PaginatedResult(data=items, meta=meta) \ No newline at end of file + meta = {"next_cursor": next_cursor, "has_more": has_more, "size": size} + return PaginatedResult(data=items, meta=meta) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index ccd06ff..e1c0a42 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -1,31 +1,27 @@ """ZCore Database Repository Pattern Layer. -This module provides a Repository pattern implementation for SQLAlchemy -2.0 and Pydantic V2. It decouples business logic from database interactions by exposing -highly specialized, reusable interfaces divided into read, write, and search capabilities. +This module provides a Repository pattern implementation for SQLAlchemy +2.0 and Pydantic V2. It decouples business logic from database interactions by exposing +highly specialized, reusable interfaces divided into read, write, and search capabilities. It supports dynamic pagination, eager load optimization, and field pruning. """ +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Generic, TypeVar + from pydantic import BaseModel -from typing import TYPE_CHECKING, Generic, TypeVar, Type, Any, Sequence, Optional, List -from sqlalchemy import select, inspect, Select +from sqlalchemy import Select, func, inspect, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm.interfaces import ExecutableOption from sqlalchemy.orm import load_only +from sqlalchemy.orm.interfaces import ExecutableOption +from zcore.db.pagination import CursorPagination, CursorParams, PageNumberPagination from zcore.db.setup import Base -from zcore.db.pagination import ( - PageNumberPagination, - CursorPagination, - CursorParams -) if TYPE_CHECKING: from zcore.db.search import SearchRequest ModelType = TypeVar("ModelType", bound=Base) -CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) -UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) class AbstractRepository(Generic[ModelType]): @@ -43,7 +39,7 @@ class AbstractRepository(Generic[ModelType]): """ db: AsyncSession - model: Type[ModelType] + model: type[ModelType] pk: Any pk_name: str cursor_field: str @@ -51,67 +47,34 @@ class AbstractRepository(Generic[ModelType]): def _get_base_query(self) -> Select: """Construct the initial select statement for query operations. - Returns: - The base Select statement. - """ - return select(self.model) - - def _extend_query(self, query: Select) -> Select: - """Apply additional clauses or modifications to the active query. - - Args: - query: The active SQLAlchemy Select query statement. + Enforces context security by dynamically executing 'scope_query' + if defined on the model layer. Returns: - The modified Select statement. + The base Select statement. """ + query = select(self.model) + scoper = getattr(self.model, "scope_query", None) + if scoper: + return scoper(query) return query - async def get( - self, - id: Any, - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None - ) -> Optional[ModelType]: - """Fetch a single record by its primary key identifier. - - Args: - id: The primary key value of the targeted record. - fields: Specific entity fields to selectively load. If None, all attributes - are loaded. Defaults to None. - options: Additional SQLAlchemy executable options (e.g., join-loads). - Defaults to None. - - Returns: - The retrieved model instance, or None if no matching record is found. - - Raises: - NotImplementedError: Raised if the subclass does not implement this method. - """ - raise NotImplementedError - - async def get_by_ids( - self, - ids: List[Any], - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None - ) -> Sequence[ModelType]: - """Fetch a sequence of records matching a list of primary keys. + def _apply_filters(self, query: Select, *criterion: Any, **filters: Any) -> Select: + """Apply dynamic criteria and keyword filters onto the Select query. Args: - ids: A list of primary key values to look up. - fields: Specific entity fields to selectively load. If None, all attributes - are loaded. Defaults to None. - options: Additional SQLAlchemy executable options (e.g., join-loads). - Defaults to None. + query: The active SQLAlchemy Select query. + *criterion: Positional binary SQLAlchemy filter expressions. + **filters: Keyword key-value arguments for standard equality filters. Returns: - A sequence of retrieved database model instances. - - Raises: - NotImplementedError: Raised if the subclass does not implement this method. + The configured Select query. """ - raise NotImplementedError + if criterion: + query = query.where(*criterion) + if filters: + query = query.filter_by(**filters) + return query class ReadRepositoryMixin(AbstractRepository[ModelType]): @@ -121,133 +84,167 @@ class ReadRepositoryMixin(AbstractRepository[ModelType]): multi-key batch retrievals, and paginated listings. """ - async def exist(self, id: Any) -> bool: - """Check if a record with the specified primary key exists in the database. + async def exist(self, *criterion: Any, **filters: Any) -> bool: + """Check if records matching the filters exist in the database. Args: - id: The primary key of the target record. + *criterion: Positional binary SQLAlchemy filter expressions. + **filters: Keyword key-value arguments for standard equality filters. Returns: - True if the record is found, False otherwise. + True if matching records are found, False otherwise. """ - query = select(self.model.id).where(self.pk == id).limit(1) + query = select(self.pk) + query = self._apply_filters(query, *criterion, **filters).limit(1) result = await self.db.execute(query) return result.first() is not None async def get( - self, - id: Any, - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None - ) -> Optional[ModelType]: - """Fetch a single record by its primary key. + self, + *criterion: Any, + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, + **filters: Any, + ) -> ModelType | None: + """Fetch a single record by dynamic filters. Args: - id: The primary key of the target record. + *criterion: Positional binary SQLAlchemy filter expressions. fields: Specific entity fields to selectively load. Defaults to None. - options: Additional SQLAlchemy execution options. Defaults to None. + options: Additional SQLAlchemy executable options. Defaults to None. + **filters: Keyword key-value arguments for standard equality filters. Returns: - The retrieved model instance, or None if not found. + The retrieved model instance, or None if no matching record is found. """ - query = self._get_base_query().where(self.pk == id) - query = self._extend_query(query) + query = self._get_base_query() + query = self._apply_filters(query, *criterion, **filters) if fields: query = query.options(load_only(*fields)) if options: - query = query.options(*options) if isinstance(options, list) else query.options(options) + query = ( + query.options(*options) + if isinstance(options, list) + else query.options(options) + ) result = await self.db.execute(query) return result.scalars().first() async def get_by_ids( - self, - ids: List[Any], - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + ids: list[Any], + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, ) -> Sequence[ModelType]: - """Fetch a sequence of records matching the provided primary key list. - - This method includes a fast path that returns an empty sequence directly - if the requested `ids` parameter is empty, avoiding unnecessary database hits. + """Fetch a sequence of records matching a list of primary keys. Args: - ids: A list of target primary key values. + ids: A list of primary key values to look up. fields: Specific entity fields to selectively load. Defaults to None. - options: Additional SQLAlchemy execution options. Defaults to None. + options: Additional SQLAlchemy executable options. Defaults to None. Returns: A sequence of retrieved database model instances. """ - # Fast path: Skip DB hit if empty list passed if not ids: return [] - + query = self._get_base_query().where(self.pk.in_(ids)) - query = self._extend_query(query) if fields: query = query.options(load_only(*fields)) if options: - query = query.options(*options) if isinstance(options, list) else query.options(options) + query = ( + query.options(*options) + if isinstance(options, list) + else query.options(options) + ) result = await self.db.execute(query) return result.scalars().all() async def get_list( - self, - pagination: Any = None, - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + pagination: Any = None, + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, + *criterion: Any, + **filters: Any, ) -> Any: - """Fetch a paginated or complete list of records. + """Fetch a paginated or complete list of records matching filters. Args: - pagination: Pagination parameters. Supports CursorParams or standard page parameters. - If None, fetches the entire unfiltered result set. Defaults to None. + pagination: Pagination parameters. Defaults to None. fields: Specific entity fields to selectively load. Defaults to None. options: Additional SQLAlchemy execution options. Defaults to None. + *criterion: Positional binary SQLAlchemy filter expressions. + **filters: Keyword key-value arguments for standard equality filters. Returns: - A list of matching records, or a paginated response container containing - items and metadata. + A list of matching records, or a paginated response container. """ query = self._get_base_query() - query = self._extend_query(query) + query = self._apply_filters(query, *criterion, **filters) if fields: query = query.options(load_only(*fields)) if options: - query = query.options(*options) if isinstance(options, list) else query.options(options) - + query = ( + query.options(*options) + if isinstance(options, list) + else query.options(options) + ) + if pagination is None: result = await self.db.execute(query) return result.scalars().all() - - paginator = CursorPagination(self.cursor_field) if isinstance(pagination, CursorParams) else PageNumberPagination() + + paginator = ( + CursorPagination(self.cursor_field) + if isinstance(pagination, CursorParams) + else PageNumberPagination() + ) return await paginator.paginate(self.db, query, pagination, self.model) + async def count(self, *criterion: Any, **filters: Any) -> int: + """Count records matching dynamic criteria. -class WriteRepositoryMixin(Generic[ModelType, CreateSchemaType, UpdateSchemaType], AbstractRepository[ModelType]): - """Mixin implementing data modification and persistence operations. + Args: + *criterion: Positional binary SQLAlchemy filter expressions. + **filters: Keyword key-value arguments for standard equality filters. + + Returns: + The total volume of matching records. + """ + base_query = self._get_base_query() + query = self._apply_filters(base_query, *criterion, **filters) + query = query.order_by(None) + count_query = select(func.count()).select_from(query.subquery()) + result = await self.db.execute(count_query) + return result.scalar_one() - Provides database writes, including single and multi-record insertions, dynamic - updates (with partial patch support), and single or multi-record deletions. Matches operations - with Unit of Work patterns by utilizing `flush` instead of direct `commit`. - """ - async def create(self, schema: CreateSchemaType) -> ModelType: - """Create a new database record from a validated creation schema. +class WriteRepositoryMixin(Generic[ModelType], AbstractRepository[ModelType]): + """Mixin implementing data modification and persistence operations.""" + + async def create(self, schema: BaseModel, **extra_data: Any) -> ModelType: + """Create a new database record from a validated creation schema and dynamic fields. Args: schema: The validated Pydantic model containing properties for the new record. + **extra_data: Dynamic fields to merge with the primary payload. Returns: The created and refreshed database model instance. """ - record = self.model(**schema.model_dump()) + data = schema.model_dump() + data.update(extra_data) + record = self.model(**data) self.db.add(record) await self.db.flush() await self.db.refresh(record) return record - async def create_multi(self, schemas: List[CreateSchemaType], refresh: bool = False) -> Sequence[ModelType]: + async def create_multi( + self, schemas: list[BaseModel], refresh: bool = False + ) -> Sequence[ModelType]: """Create multiple database records from a list of validation schemas. Args: @@ -260,7 +257,7 @@ async def create_multi(self, schemas: List[CreateSchemaType], refresh: bool = Fa """ if not schemas: return [] - + records = [self.model(**schema.model_dump()) for schema in schemas] self.db.add_all(records) await self.db.flush() @@ -269,22 +266,26 @@ async def create_multi(self, schemas: List[CreateSchemaType], refresh: bool = Fa await self.db.refresh(record) return records - async def update(self, id: Any, schema: UpdateSchemaType, partial: bool = False) -> Optional[ModelType]: - """Update an existing database record. + async def update( + self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any + ) -> ModelType | None: + """Update an existing database record with dynamic fields. Args: id: The primary key identifier of the record to update. schema: The Pydantic update schema containing modified parameters. partial: If True, applies modifications as a partial patch (ignoring unset fields). If False, updates the record using all fields. Defaults to False. + **extra_data: Dynamic fields to merge with the primary payload. Returns: The updated and refreshed database model instance, or None if the record was not found. """ - record = await self.get(id) + record = await self.get(**{self.pk_name: id}) if not record: return None update_data = schema.model_dump(exclude_unset=partial) + update_data.update(extra_data) for field, value in update_data.items(): setattr(record, field, value) await self.db.flush() @@ -292,10 +293,7 @@ async def update(self, id: Any, schema: UpdateSchemaType, partial: bool = False) return record async def update_multi( - self, - data: dict[Any, UpdateSchemaType], - partial: bool = False, - refresh: bool = False + self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False ) -> Sequence[ModelType]: """Batch update multiple database records mapped by their primary keys. @@ -310,7 +308,7 @@ async def update_multi( """ if not data: return [] - + records = await self.get_by_ids(ids=list(data.keys())) record_map = {getattr(r, self.pk_name): r for r in records} updated_records = [] @@ -327,7 +325,7 @@ async def update_multi( await self.db.refresh(record) return updated_records - async def delete(self, id: Any) -> Optional[ModelType]: + async def delete(self, id: Any) -> ModelType | None: """Delete a single record by its primary key identifier. Args: @@ -336,14 +334,14 @@ async def delete(self, id: Any) -> Optional[ModelType]: Returns: The deleted database model instance, or None if the record was not found. """ - record = await self.get(id) + record = await self.get(**{self.pk_name: id}) if not record: return None await self.db.delete(record) await self.db.flush() return record - async def delete_multi(self, ids: List[Any]) -> Sequence[ModelType]: + async def delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: """Delete multiple records matching the provided list of primary keys. Args: @@ -354,7 +352,7 @@ async def delete_multi(self, ids: List[Any]) -> Sequence[ModelType]: """ if not ids: return [] - + records = await self.get_by_ids(ids=ids) for record in records: await self.db.delete(record) @@ -363,58 +361,48 @@ async def delete_multi(self, ids: List[Any]) -> Sequence[ModelType]: class SearchRepositoryMixin(AbstractRepository[ModelType]): - """Mixin coordinating structured application search operations. - - Integrates with the framework search engine to generate queries with complex - filter, sorting, and relation preloading patterns. - """ + """Mixin coordinating structured application search operations.""" async def search(self, search_in: "SearchRequest", pagination: Any = None) -> Any: """Search and filter database models dynamically. - Utilizes the system `SearchEngine` to parse filters, apply relation joins, - and handle sorting rules, yielding either a plain sequence or paginated lists. - Args: search_in: A SearchRequest parameter configuration representing constraints. - pagination: Pagination settings (CursorParams or standard page parameter mapping). - Defaults to None. + pagination: Pagination settings. Defaults to None. Returns: - A paginated response object containing matches, or a complete list of + A paginated response object containing matches, or a complete list of unpaginated models. """ from zcore.db.search import SearchEngine + engine = SearchEngine(self.model) base_query = self._get_base_query() query = engine.build_base_query(search_in, base_query=base_query) - query = self._extend_query(query) + query = self._apply_filters(query) if pagination is None: result = await self.db.execute(query) return result.scalars().all() - - paginator = CursorPagination(self.cursor_field) if isinstance(pagination, CursorParams) else PageNumberPagination() + + paginator = ( + CursorPagination(self.cursor_field) + if isinstance(pagination, CursorParams) + else PageNumberPagination() + ) return await paginator.paginate(self.db, query, pagination, self.model) class BaseRepository( - Generic[ModelType, CreateSchemaType, UpdateSchemaType], + Generic[ModelType], ReadRepositoryMixin[ModelType], - WriteRepositoryMixin[ModelType, CreateSchemaType, UpdateSchemaType], - SearchRepositoryMixin[ModelType] + WriteRepositoryMixin[ModelType], + SearchRepositoryMixin[ModelType], ): - """The default implementation combining Read, Write, and Search capabilities. + """The default implementation combining Read, Write, and Search capabilities.""" - Integrates standard read, write, and dynamic query routines with default primary key - inspection logic. - """ - - def __init__(self, model: Type[ModelType], db: AsyncSession): + def __init__(self, model: type[ModelType], db: AsyncSession): """Initialize the BaseRepository. - Dynamically inspects the model schema definition to locate and map - the primary key column and attributes automatically. - Args: model: The SQLAlchemy declarative model class linked to this repository. db: The active asynchronous SQLAlchemy session. @@ -423,4 +411,4 @@ def __init__(self, model: Type[ModelType], db: AsyncSession): self.db = db self.pk = inspect(self.model).primary_key[0] self.pk_name = self.pk.name - self.cursor_field = self.pk_name \ No newline at end of file + self.cursor_field = self.pk_name diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index f514b45..fbae574 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -1,24 +1,26 @@ """Dynamic Database Search and Filtering Engine. This module provides a dynamic query-builder that parses client-supplied filters, -sorting criteria, and relation-preload requests. Crucially, the system coordinates -with security context restrictions, intercepting and blocking queries that target +sorting criteria, and relation-preload requests. Crucially, the system coordinates +with security context restrictions, intercepting and blocking queries that target unauthorized fields or database relations. """ from __future__ import annotations + import uuid -from datetime import datetime, date -from typing import Any, List, Optional, Literal, Type, TypeVar, Dict, Callable, Set -from pydantic import BaseModel, Field +from collections.abc import Callable +from datetime import date, datetime +from typing import Any, Literal, TypeVar -from sqlalchemy import select, asc, desc, inspect, or_, and_ +from pydantic import BaseModel, Field +from sqlalchemy import and_, asc, desc, inspect, or_, select from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.sql import Select +from zcore.context.context import ctx from zcore.db.setup import Base -from zcore.context.context import get_restricted_fields -from zcore.exceptions.base import ValidationError, ForbiddenError +from zcore.exceptions.base import ForbiddenError, ValidationError ModelType = TypeVar("ModelType", bound=Base) @@ -38,10 +40,12 @@ class FilterItem(BaseModel): Defaults to None. """ - field: Optional[str] = None - op: Literal["eq", "ne", "gt", "lt", "ge", "le", "ilike", "in", "is_null", "or", "and"] - value: Optional[Any] = None - items: Optional[List[FilterItem]] = None + field: str | None = None + op: Literal[ + "eq", "ne", "gt", "lt", "ge", "le", "ilike", "in", "is_null", "or", "and" + ] + value: Any | None = None + items: list[FilterItem] | None = None class SortItem(BaseModel): @@ -62,7 +66,7 @@ class SearchRequest(BaseModel): Attributes: filters: A recursive collection of filter constraints. Defaults to an empty list. - include: Relationship attributes or dot-paths indicating database relationships + include: Relationship attributes or dot-paths indicating database relationships to eager load. Defaults to an empty list. sort: Explicit ordering instructions. Defaults to an empty list. size: The limit on retrieved records. Defaults to 20. @@ -70,12 +74,12 @@ class SearchRequest(BaseModel): cursor: Keyset pagination indicator. Defaults to None. """ - filters: Optional[List[FilterItem]] = [] - include: Optional[List[str]] = [] - sort: Optional[List[SortItem]] = [] + filters: list[FilterItem] | None = [] + include: list[str] | None = [] + sort: list[SortItem] | None = [] size: int = Field(default=20, le=100) page: int = 1 - cursor: Optional[str] = None + cursor: str | None = None class SearchEngine: @@ -91,7 +95,7 @@ class SearchEngine: custom_handlers: Custom translation callbacks registered for specific fields. """ - def __init__(self, model: Type[ModelType]): + def __init__(self, model: type[ModelType]): """Initialize the SearchEngine. Args: @@ -99,9 +103,11 @@ def __init__(self, model: Type[ModelType]): """ self.model = model self.mapper = inspect(model) - self.custom_handlers: Dict[str, Callable[[Any], Any]] = {} + self.custom_handlers: dict[str, Callable[[Any], Any]] = {} - def register_handler(self, field_name: str, handler: Callable[[Any], Any]) -> SearchEngine: + def register_handler( + self, field_name: str, handler: Callable[[Any], Any] + ) -> SearchEngine: """Bind a custom callback handler to parse a specific field's values dynamically. Args: @@ -114,7 +120,7 @@ def register_handler(self, field_name: str, handler: Callable[[Any], Any]) -> Se self.custom_handlers[field_name] = handler return self - def _is_path_restricted(self, path: str, restricted_set: Set[str]) -> bool: + def _is_path_restricted(self, path: str, restricted_set: set[str]) -> bool: """Assess if a specific database dot-path is restricted by security policies. Args: @@ -126,16 +132,16 @@ def _is_path_restricted(self, path: str, restricted_set: Set[str]) -> bool: """ if not restricted_set: return False - + normalized_path = path.lower() for restricted in restricted_set: normalized_restricted = restricted.lower() - + if normalized_path == normalized_restricted: return True if normalized_path.startswith(normalized_restricted + "."): return True - + return False def _validate_request(self, search_in: SearchRequest, max_depth: int = 3) -> None: @@ -147,50 +153,70 @@ def _validate_request(self, search_in: SearchRequest, max_depth: int = 3) -> Non Raises: ForbiddenError: If access to a requested column or relation is restricted. - ValidationError: If inclusion depth exceeds limits or if a requested path + ValidationError: If inclusion depth exceeds limits or if a requested path does not exist on the target schemas. """ - restricted = set(get_restricted_fields()) + restricted = set(ctx.restricted_fields) valid_columns = {col.key for col in self.mapper.columns} - + MAX_INCLUDE_DEPTH = 3 if search_in.include: for path in search_in.include: if self._is_path_restricted(path, restricted): - raise ForbiddenError(message=f"Access to relation path '{path}' is restricted due to security policies.") - + raise ForbiddenError( + message=f"Access to relation path '{path}' is restricted due to security policies." + ) + parts = path.split(".") if len(parts) > MAX_INCLUDE_DEPTH + 1: - raise ValidationError(message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {MAX_INCLUDE_DEPTH}.") - + raise ValidationError( + message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {MAX_INCLUDE_DEPTH}." + ) + accumulated_path: list[str] = [] for part in parts: accumulated_path.append(part) current_path = ".".join(accumulated_path) if self._is_path_restricted(current_path, restricted): - raise ForbiddenError(message=f"Access to relation path '{current_path}' is restricted.") - + raise ForbiddenError( + message=f"Access to relation path '{current_path}' is restricted." + ) + current_model = self.model for part in parts: rel = inspect(current_model).relationships.get(part) if not rel: - raise ValidationError(message=f"Invalid include relation path: '{path}'") + raise ValidationError( + message=f"Invalid include relation path: '{path}'" + ) current_model = rel.mapper.class_ if len(parts) > MAX_INCLUDE_DEPTH: - raise ValidationError(message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {MAX_INCLUDE_DEPTH}.") + raise ValidationError( + message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {MAX_INCLUDE_DEPTH}." + ) if search_in.sort: for s in search_in.sort: if self._is_path_restricted(s.field, restricted): - raise ForbiddenError(message=f"Sorting by restricted field '{s.field}' is forbidden.") + raise ForbiddenError( + message=f"Sorting by restricted field '{s.field}' is forbidden." + ) if s.field not in valid_columns: - raise ValidationError(message=f"Invalid sort field: '{s.field}' on {self.model.__name__}") + raise ValidationError( + message=f"Invalid sort field: '{s.field}' on {self.model.__name__}" + ) if search_in.filters: - self._validate_filters_recursive(search_in.filters, valid_columns, restricted, current_depth=1, max_depth=max_depth) - - def _validate_filter_field(self, field_path: str, restricted: Set[str]) -> None: + self._validate_filters_recursive( + search_in.filters, + valid_columns, + restricted, + current_depth=1, + max_depth=max_depth, + ) + + def _validate_filter_field(self, field_path: str, restricted: set[str]) -> None: """Ensure a single filter field path exists and does not violate access policies. Args: @@ -203,32 +229,38 @@ def _validate_filter_field(self, field_path: str, restricted: Set[str]) -> None: """ parts = field_path.split(".") current_model = self.model - + accumulated_path: list[str] = [] for i, part in enumerate(parts): accumulated_path.append(part) current_path = ".".join(accumulated_path) - + if self._is_path_restricted(current_path, restricted): - raise ForbiddenError(message=f"Filtering by restricted path '{current_path}' is forbidden.") - + raise ForbiddenError( + message=f"Filtering by restricted path '{current_path}' is forbidden." + ) + if i == len(parts) - 1: valid_columns = {col.key for col in inspect(current_model).columns} if part not in valid_columns: - raise ValidationError(message=f"Invalid filter field: '{part}' on {current_model.__name__}") + raise ValidationError( + message=f"Invalid filter field: '{part}' on {current_model.__name__}" + ) else: rel = inspect(current_model).relationships.get(part) if not rel: - raise ValidationError(message=f"Invalid filter relation: '{part}' on {current_model.__name__}") + raise ValidationError( + message=f"Invalid filter relation: '{part}' on {current_model.__name__}" + ) current_model = rel.mapper.class_ def _validate_filters_recursive( - self, - filters: List[FilterItem], - valid_columns: Set[str], - restricted: Set[str], - current_depth: int, - max_depth: int + self, + filters: list[FilterItem], + valid_columns: set[str], + restricted: set[str], + current_depth: int, + max_depth: int, ) -> None: """Recursively validate a list of search filters. @@ -243,12 +275,16 @@ def _validate_filters_recursive( ValidationError: If the query filter structure exceeds nesting thresholds. """ if current_depth > max_depth: - raise ValidationError(message="Search query filter structure is too complex.") + raise ValidationError( + message="Search query filter structure is too complex." + ) for f in filters: if f.op in ["or", "and"]: if f.items: - self._validate_filters_recursive(f.items, valid_columns, restricted, current_depth + 1, max_depth) + self._validate_filters_recursive( + f.items, valid_columns, restricted, current_depth + 1, max_depth + ) else: if f.field: self._validate_filter_field(f.field, restricted) @@ -289,7 +325,9 @@ def _get_operator_expression(self, f: FilterItem) -> Any: return self._build_expression_for_field(self.model, f.field, f.op, f.value) - def _build_expression_for_field(self, current_model: Any, field_path: str, op: str, value: Any) -> Any: + def _build_expression_for_field( + self, current_model: Any, field_path: str, op: str, value: Any + ) -> Any: """Recursively resolve comparison expressions for single or dot-path relations. Args: @@ -318,7 +356,9 @@ def _build_expression_for_field(self, current_model: Any, field_path: str, op: s target_model = rel.mapper.class_ remaining_path = ".".join(parts[1:]) - sub_expr = self._build_expression_for_field(target_model, remaining_path, op, value) + sub_expr = self._build_expression_for_field( + target_model, remaining_path, op, value + ) if sub_expr is None: return None @@ -340,25 +380,25 @@ def _coerce_value(self, col: Any, value: Any) -> Any: """ if value is None: return value - + if isinstance(value, (list, tuple)): return [self._coerce_value(col, v) for v in value] try: python_type = col.type.python_type - + if python_type is date and isinstance(value, str): return date.fromisoformat(value.split("T")[0]) - + if python_type is datetime and isinstance(value, str): return datetime.fromisoformat(value) - + if python_type is uuid.UUID and isinstance(value, str): return uuid.UUID(value) - + if python_type is bool and isinstance(value, str): return value.lower() in ("true", "1", "yes", "t", "y") - + except (NotImplementedError, AttributeError, ValueError, TypeError): pass @@ -375,13 +415,13 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: Returns: The generated comparison clause, or None if unsupported. """ - if op == "is_null": + if op == "is_null": return col.is_(None) if value else col.isnot(None) - if op == "ilike": + if op == "ilike": escaped_value = self._escape_like_wildcards(value) return col.ilike(f"%{escaped_value}%", escape="\\") - + coerced_value = self._coerce_value(col, value) if op == "eq": @@ -396,13 +436,17 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: return col >= coerced_value if op == "le": return col <= coerced_value - - if op == "in": - return col.in_(coerced_value if isinstance(coerced_value, (list, tuple)) else [coerced_value]) - + + if op == "in": + return col.in_( + coerced_value + if isinstance(coerced_value, (list, tuple)) + else [coerced_value] + ) + return None - def _apply_includes(self, query: Select, include_paths: List[str]) -> Select: + def _apply_includes(self, query: Select, include_paths: list[str]) -> Select: """Configure relation loader strategies on the Select query. Args: @@ -413,31 +457,35 @@ def _apply_includes(self, query: Select, include_paths: List[str]) -> Select: The query configured with appropriate relation loader options. """ unique_paths = sorted(list(set(include_paths)), key=len) - + for path in unique_paths: parts = path.split(".") loader = None current_model = self.model - + for i, part in enumerate(parts): rel = inspect(current_model).relationships.get(part) - if not rel: + if not rel: break - + load_method_name = selectinload if rel.uselist else joinedload if i == 0: load_func = selectinload if rel.uselist else joinedload loader = load_func(getattr(current_model, part)) else: - loader = getattr(loader, load_method_name.__name__)(getattr(current_model, part)) - + loader = getattr(loader, load_method_name.__name__)( + getattr(current_model, part) + ) + current_model = rel.mapper.class_ - + if loader: query = query.options(loader) return query - def build_base_query(self, search_in: SearchRequest, base_query: Optional[Select] = None) -> Select: + def build_base_query( + self, search_in: SearchRequest, base_query: Select | None = None + ) -> Select: """Parse search inputs, validate policies, and construct the base Select statement. Args: @@ -445,13 +493,13 @@ def build_base_query(self, search_in: SearchRequest, base_query: Optional[Select base_query: Optional base Select query statement to extend. Defaults to None. Returns: - A secure, compiled SQLAlchemy SELECT statement complete with filtering, + A secure, compiled SQLAlchemy SELECT statement complete with filtering, eager loading joins, and sorting options. """ self._validate_request(search_in) - + query = base_query if base_query is not None else select(self.model) - + if search_in.include: query = self._apply_includes(query, search_in.include) @@ -480,4 +528,4 @@ def build_query(self, search_in: SearchRequest) -> Select: """ query = self.build_base_query(search_in) offset = (search_in.page - 1) * search_in.size - return query.offset(offset).limit(search_in.size) \ No newline at end of file + return query.offset(offset).limit(search_in.size) diff --git a/src/zcore/db/setup.py b/src/zcore/db/setup.py index 9c07e98..52a2892 100644 --- a/src/zcore/db/setup.py +++ b/src/zcore/db/setup.py @@ -1,28 +1,36 @@ """Database Engine and Declarative Base Infrastructure. -This module initializes the core SQLAlchemy engine and session factories for -asynchronous communication. It also provides the fundamental declarative base class +This module initializes the core SQLAlchemy engine and session factories for +asynchronous communication. It also provides the fundamental declarative base class enriched with class-level metadata helper methods to manage object security permissions. """ -import structlog -from fastapi import Depends +import time +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import AsyncGenerator, Any, Optional, Annotated -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession, AsyncEngine +from typing import Annotated, Any + +import structlog +from fastapi import Depends +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.orm import DeclarativeBase from zcore.kernel.di import container -logger = structlog.get_logger() - @dataclass(frozen=True) class Actions: """Action permission mappings tied to a specific database model. - This immutable container maps standard CRUD/view operational concepts to unique + This immutable container maps standard CRUD/view operational concepts to unique permission keys for use in security policy evaluation. Attributes: @@ -50,7 +58,7 @@ def actions(cls, t_name: str) -> "Actions": An instance of Actions containing computed permission keys. """ actions = {} - for action in cls.__dataclass_fields__.keys(): + for action in cls.__dataclass_fields__: actions[action] = f"{t_name}:{action.lower()}" return cls(**actions) @@ -58,7 +66,7 @@ def actions(cls, t_name: str) -> "Actions": class Base(DeclarativeBase): """Declarative Base class for SQLAlchemy ORM models in ZCore. - Provides a foundational configuration structure, including methods to expose standard + Provides a foundational configuration structure, including methods to expose standard authorization action keys mapped directly to database tables. """ @@ -74,14 +82,16 @@ def actions(cls) -> Actions: """ t_name = getattr(cls, "__tablename__", None) if not t_name: - raise AttributeError(f"Model {cls.__name__} does not have a __tablename__ defined.") + raise AttributeError( + f"Model {cls.__name__} does not have a __tablename__ defined." + ) return Actions.actions(t_name) class DatabaseManager: """Coordinator for the asynchronous database connection pool and engine lifecycles. - Manages the creation and disposal of the primary asynchronous database engine and + Manages the creation and disposal of the primary asynchronous database engine and exposes a session factory utilized across the application. Attributes: @@ -92,17 +102,56 @@ class DatabaseManager: def __init__(self) -> None: """Initialize the DatabaseManager with empty internal engines and factories.""" - self._engine: Optional[AsyncEngine] = None - self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None + self._engine: AsyncEngine | None = None + self._session_factory: async_sessionmaker[AsyncSession] | None = None + + def _register_query_logger(self, sync_engine: Engine) -> None: + """Register events on connection cursors to intercept, sanitize, and log query statistics.""" + dialect_name = sync_engine.dialect.name + db_logger = structlog.get_logger(f"zcore.db.{dialect_name}") + + @event.listens_for(sync_engine, "before_cursor_execute") + def before_cursor_execute( + conn, cursor, statement, parameters, context, exec_many + ): + context._query_start_time = time.perf_counter() + + @event.listens_for(sync_engine, "after_cursor_execute") + def after_cursor_execute( + conn, cursor, statement, parameters, context, exec_many + ): + start_time = getattr(context, "_query_start_time", None) + duration_ms = 0.0 + if start_time: + duration_ms = (time.perf_counter() - start_time) * 1000 + + compact_statement = " ".join(statement.split()) + + # Bypass low-level database metadata or catalog lookups to avoid noise + ignored_keywords = [ + "pg_catalog", + "schema", + "standard_conforming_strings", + "transaction", + ] + if any(kw in compact_statement.lower() for kw in ignored_keywords): + return + + db_logger.info( + "sql_query", + sql=compact_statement, + params=parameters, + duration_ms=round(duration_ms, 2), + ) def init_app( - self, - db_url: str, - pool_size: int = 5, - max_overflow: int = 10, - pool_recycle: int = 1800, + self, + db_url: str, + pool_size: int = 5, + max_overflow: int = 10, + pool_recycle: int = 1800, echo: bool = False, - **engine_kwargs: Any + **engine_kwargs: Any, ) -> None: """Configure the connection pool, engine, and session factories. @@ -114,7 +163,7 @@ def init_app( pool_size: The connection pool size for non-SQLite databases. Defaults to 5. max_overflow: The max overflowing connections beyond pool size. Defaults to 10. pool_recycle: Connection recycle time in seconds. Defaults to 1800. - echo: Verbose SQL logging flag. Defaults to False. + echo: Verbose SQL logging flag. Unused directly, replaced by interceptor. **engine_kwargs: Additional keyword arguments forwarded to `create_async_engine`. """ kwargs: dict[str, Any] = {} @@ -125,23 +174,33 @@ def init_app( self._engine = create_async_engine( db_url, - echo=echo, + echo=False, # Core SQLAlchemy raw echo is disabled to prevent logger double-cluttering pool_pre_ping=True, **kwargs, - **engine_kwargs + **engine_kwargs, ) self._session_factory = async_sessionmaker( - bind=self._engine, - class_=AsyncSession, - expire_on_commit=False + bind=self._engine, class_=AsyncSession, expire_on_commit=False + ) + + # Attach the query-timing event hook directly to the underlying sync engine pool + self._register_query_logger(self._engine.sync_engine) + + # Resolve dialect-specific structural logging namespace + dialect_logger = structlog.get_logger(f"zcore.db.{self._engine.dialect.name}") + dialect_logger.info( + "DatabaseManager successfully initialized with dialect statement logger." ) - logger.info("DatabaseManager successfully initialized.") async def close(self) -> None: """Dispose of the database connection pool and terminate engine lifecycles.""" if self._engine: await self._engine.dispose() - logger.info("DatabaseManager engine connections closed.") + + dialect_logger = structlog.get_logger( + f"zcore.db.{self._engine.dialect.name}" + ) + dialect_logger.info("DatabaseManager engine connections closed.") @asynccontextmanager async def session(self) -> AsyncGenerator[AsyncSession, None]: @@ -156,8 +215,10 @@ async def session(self) -> AsyncGenerator[AsyncSession, None]: an automatic rollback before propagating. """ if not self._session_factory: - raise RuntimeError("DatabaseManager has not been initialized. Call init_app() first.") - + raise RuntimeError( + "DatabaseManager has not been initialized. Call init_app() first." + ) + async with self._session_factory() as session: try: yield session @@ -179,4 +240,4 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: yield session -SessionDep = Annotated[AsyncSession, Depends(get_db)] \ No newline at end of file +SessionDep = Annotated[AsyncSession, Depends(get_db)] diff --git a/src/zcore/db/uow.py b/src/zcore/db/uow.py index 9bd16d5..379d491 100644 --- a/src/zcore/db/uow.py +++ b/src/zcore/db/uow.py @@ -1,13 +1,15 @@ """Unit of Work Pattern Implementation. -This module coordinates transactional business boundaries, ensuring that change sets +This module coordinates transactional business boundaries, ensuring that change sets are executed or discarded atomically and that domain events are decoupled from commit operations by delaying dispatch until database transactions succeed. """ +from typing import Any + import structlog -from typing import Any, List, Tuple from sqlalchemy.ext.asyncio import AsyncSession + from zcore.kernel.events import EventDispatcher logger = structlog.get_logger() @@ -16,14 +18,14 @@ class UnitOfWork: """Coordinates database commits and buffers associated application domain events. - Ensures that domain events are only dispatched after their associated database - modifications have successfully committed. Implements the asynchronous context manager + Ensures that domain events are only dispatched after their associated database + modifications have successfully committed. Implements the asynchronous context manager protocol to automate transactional commit and rollback behaviors. Attributes: session: The underlying asynchronous database connection. dispatcher: The central system dispatcher used for publishing events. - _pending_events: A list of buffered tuple events (event_name, payload) awaiting + _pending_events: A list of buffered tuple events (event_name, payload) awaiting successful commit. """ @@ -36,7 +38,7 @@ def __init__(self, session: AsyncSession, dispatcher: EventDispatcher) -> None: """ self.session = session self.dispatcher = dispatcher - self._pending_events: List[Tuple[str, Any]] = [] + self._pending_events: list[tuple[str, Any]] = [] def register_event(self, event_name: str, payload: Any) -> None: """Queue a domain event for post-commit dispatch. @@ -51,7 +53,7 @@ async def commit(self) -> None: """Commit the database session and dispatch all pending events. This method attempts to commit the database session. If the commit succeeds, - buffered domain events are popped and dispatched. If the commit fails, the + buffered domain events are popped and dispatched. If the commit fails, the session is rolled back and the original database exception is raised. Raises: @@ -71,8 +73,8 @@ async def commit(self) -> None: await self.dispatcher.dispatch(event_name, payload) except Exception as ex: logger.error( - f"UnitOfWork event handler failed for event '{event_name}': {ex}", - exc_info=True + f"UnitOfWork event handler failed for event '{event_name}': {ex}", + exc_info=True, ) async def rollback(self) -> None: @@ -83,7 +85,7 @@ async def rollback(self) -> None: async def __aenter__(self) -> "UnitOfWork": """Enter the asynchronous context manager block. - Configures the session metadata to signify it is now managed within a + Configures the session metadata to signify it is now managed within a Unit of Work lifecycle boundary. Returns: @@ -95,7 +97,7 @@ async def __aenter__(self) -> "UnitOfWork": async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """Exit the asynchronous context manager block. - Automatically performs a rollback if an unhandled exception is encountered, + Automatically performs a rollback if an unhandled exception is encountered, or commits the transaction otherwise. Args: @@ -107,4 +109,4 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: if exc_type: await self.rollback() else: - await self.commit() \ No newline at end of file + await self.commit() diff --git a/src/zcore/exceptions/__init__.py b/src/zcore/exceptions/__init__.py index 8e69491..792659e 100644 --- a/src/zcore/exceptions/__init__.py +++ b/src/zcore/exceptions/__init__.py @@ -1,8 +1,8 @@ from zcore.exceptions.base import ( AppException, - EntityNotFound, - DuplicateEntity, AuthError, + DuplicateEntity, + EntityNotFound, ForbiddenError, ValidationError, ) @@ -10,10 +10,10 @@ __all__ = [ "AppException", - "EntityNotFound", - "DuplicateEntity", "AuthError", + "DuplicateEntity", + "EntityNotFound", "ForbiddenError", "ValidationError", "app_exception_handler", -] \ No newline at end of file +] diff --git a/src/zcore/exceptions/base.py b/src/zcore/exceptions/base.py index 0753515..b2fe5f6 100644 --- a/src/zcore/exceptions/base.py +++ b/src/zcore/exceptions/base.py @@ -1,17 +1,15 @@ """ZCore Exception Definitions. -This module defines the primary domain-specific exception hierarchy used throughout -the framework. These exceptions are mapped to standard HTTP status codes, allowing +This module defines the primary domain-specific exception hierarchy used throughout +the framework. These exceptions are mapped to standard HTTP status codes, allowing consistent translation from interior business failures into client-facing HTTP responses. """ -from typing import Optional - class AppException(Exception): """Base exception class for all custom runtime errors within the ZCore application. - Acts as the parent class for errors that need to be captured by global HTTP handlers + Acts as the parent class for errors that need to be captured by global HTTP handlers and returned as standardized API error responses. Attributes: @@ -21,8 +19,8 @@ class AppException(Exception): """ status_code: int = 500 - - def __init__(self, message: str, payload: Optional[dict] = None) -> None: + + def __init__(self, message: str, payload: dict | None = None) -> None: """Initialize the AppException. Args: @@ -82,4 +80,4 @@ class ValidationError(AppException): status_code: Map status code to 400 (Bad Request). """ - status_code = 400 \ No newline at end of file + status_code = 400 diff --git a/src/zcore/exceptions/handlers.py b/src/zcore/exceptions/handlers.py index 26ad404..d978ba1 100644 --- a/src/zcore/exceptions/handlers.py +++ b/src/zcore/exceptions/handlers.py @@ -1,25 +1,33 @@ """Structured Exception Handlers. This module maps custom application exceptions (`AppException`) to standardized API JSON responses. -It captures diagnostics, warning contexts, and metadata, formatting them using the system +It captures diagnostics, warning contexts, and metadata, formatting them using the system response wrapper envelope prior to transmission. """ +from typing import Any + import structlog from fastapi import Request from fastapi.responses import JSONResponse from zcore.exceptions.base import AppException +from zcore.utils.helpers import json_dumps from zcore.web.response import ResponseWrapper log = structlog.get_logger() +class ZCoreJSONResponse(JSONResponse): + def render(self, content: Any) -> bytes: + return json_dumps(content).encode("utf-8") + + async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: """Asynchronously intercept application exceptions and build unified JSON responses. - Captures context parameter diagnostics, outputs metrics to structured log targets, - and packs the diagnostic message and metadata payload inside a structured + Captures context parameter diagnostics, outputs metrics to structured log targets, + and packs the diagnostic message and metadata payload inside a structured `ResponseWrapper` response envelope. Args: @@ -36,20 +44,16 @@ async def app_exception_handler(request: Request, exc: AppException) -> JSONResp message=exc.message, payload=exc.payload, path=request.url.path, - method=request.method + method=request.method, ) - + response_payload = ResponseWrapper[None]( success=False, message=exc.message, data=None, - meta={ - "error_type": exc.__class__.__name__, - "payload": exc.payload - } + meta={"error_type": exc.__class__.__name__, "payload": exc.payload}, + ) + + return ZCoreJSONResponse( + status_code=exc.status_code, content=response_payload.model_dump() ) - - return JSONResponse( - status_code=exc.status_code, - content=response_payload.model_dump() - ) \ No newline at end of file diff --git a/src/zcore/kernel/__init__.py b/src/zcore/kernel/__init__.py index 312552f..0c8fbd3 100644 --- a/src/zcore/kernel/__init__.py +++ b/src/zcore/kernel/__init__.py @@ -1,14 +1,15 @@ -from zcore.kernel.di import container, Inject, IoCContainer, Injector +from zcore.kernel.di import Inject, Injector, IoCContainer, container from zcore.kernel.engine import Kernel -from zcore.kernel.events import EventDispatcher +from zcore.kernel.events import EventDispatcher, on_event from zcore.kernel.plugins import Plugin __all__ = [ - "container", + "EventDispatcher", "Inject", - "IoCContainer", "Injector", + "IoCContainer", "Kernel", - "EventDispatcher", "Plugin", -] \ No newline at end of file + "container", + "on_event", +] diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py index 58f0d1f..734ba2e 100644 --- a/src/zcore/kernel/di.py +++ b/src/zcore/kernel/di.py @@ -1,30 +1,43 @@ """Inversion of Control (IoC) and Dependency Injection (DI) Container. This module implements custom Singleton, Scoped, and Transient injection strategies. -It resolves types dynamically using constructor reflection, utilizing signature caching -to mitigate reflection runtime performance overhead, and implements protective mechanisms +It resolves types dynamically using constructor reflection, utilizing signature caching +to mitigate reflection runtime performance overhead, and implements protective mechanisms against cyclic dependencies. """ import inspect +from collections.abc import Callable from contextvars import ContextVar -from typing import Any, Callable, Dict, Type, TypeVar, Optional, Set, List, get_type_hints, get_origin, get_args, Annotated +from typing import ( + Annotated, + Any, + TypeVar, + get_args, + get_origin, + get_type_hints, +) + from fastapi import Depends T = TypeVar("T") # Safe context storage for active scoped instances -_current_scope_id: ContextVar[Optional[str]] = ContextVar("scope_id", default=None) -_scoped_instances: ContextVar[Dict[Type[Any], Any]] = ContextVar("scoped_instances", default={}) +_current_scope_id: ContextVar[str | None] = ContextVar("scope_id", default=None) +_scoped_instances: ContextVar[dict[type[Any], Any]] = ContextVar( + "scoped_instances", default={} +) class DIException(Exception): """Base exception for Dependency Injection errors in ZCore.""" + pass class CircularDependencyError(DIException): """Raised when a circular dependency loop is detected during resolution.""" + pass @@ -32,7 +45,7 @@ class IoCContainer: """Central Inversion of Control (IoC) container for dependency management. Handles registration and dynamic resolution of classes. Supports transient, - scoped, and singleton lifecycles. Employs caching on inspected constructor signatures + scoped, and singleton lifecycles. Employs caching on inspected constructor signatures to manage resolution overhead. Attributes: @@ -40,21 +53,21 @@ class IoCContainer: _scoped_definitions: Mappings of interfaces to factory functions bound to request contexts. _factories: Mappings of interfaces to factory functions for transient lifecycles. _constructor_cache: Cache storing raw init constructors of target classes. - _dependency_signature_cache: Cache storing evaluated constructor dependencies + _dependency_signature_cache: Cache storing evaluated constructor dependencies of targets. """ def __init__(self) -> None: """Initialize the IoCContainer with clean caches and registries.""" - self._singletons: Dict[Type[Any], Any] = {} - self._scoped_definitions: Dict[Type[Any], Callable[..., Any]] = {} - self._factories: Dict[Type[Any], Callable[..., Any]] = {} - + self._singletons: dict[type[Any], Any] = {} + self._scoped_definitions: dict[type[Any], Callable[..., Any]] = {} + self._factories: dict[type[Any], Callable[..., Any]] = {} + # High-performance caching for resolved type hints and constructors - self._constructor_cache: Dict[Type[Any], Optional[Callable[..., Any]]] = {} - self._dependency_signature_cache: Dict[Type[Any], List[Type[Any]]] = {} + self._constructor_cache: dict[type[Any], Callable[..., Any] | None] = {} + self._dependency_signature_cache: dict[type[Any], list[type[Any]]] = {} - def register_singleton(self, interface: Type[Any], instance: Any) -> None: + def register_singleton(self, interface: type[Any], instance: Any) -> None: """Register a pre-constructed instance as a global singleton. Args: @@ -63,7 +76,7 @@ def register_singleton(self, interface: Type[Any], instance: Any) -> None: """ self._singletons[interface] = instance - def register_scoped(self, interface: Type[Any], implementation: Type[Any]) -> None: + def register_scoped(self, interface: type[Any], implementation: type[Any]) -> None: """Register a class bound to a context-scoped lifecycle. Scoped classes are resolved once per context-scope lifetime and shared across @@ -73,9 +86,11 @@ def register_scoped(self, interface: Type[Any], implementation: Type[Any]) -> No interface: The interface or class type key to map against. implementation: The target implementation class to instantiate. """ - self._scoped_definitions[interface] = lambda stack=None: self._auto_wire(implementation, stack) + self._scoped_definitions[interface] = lambda stack=None: self._auto_wire( + implementation, stack + ) - def register_scoped_instance(self, interface: Type[Any], instance: Any) -> None: + def register_scoped_instance(self, interface: type[Any], instance: Any) -> None: """Register a pre-constructed instance directly into the active request scope. Args: @@ -92,9 +107,13 @@ def register_scoped_instance(self, interface: Type[Any], instance: Any) -> None: new_instances[interface] = instance _scoped_instances.set(new_instances) else: - raise DIException("Cannot register scoped instance outside of an active scope.") + raise DIException( + "Cannot register scoped instance outside of an active scope." + ) - def register_transient(self, interface: Type[Any], implementation: Type[Any]) -> None: + def register_transient( + self, interface: type[Any], implementation: type[Any] + ) -> None: """Register a class bound to a transient lifecycle. Transient classes are constructed as a new instance on every resolution request. @@ -103,9 +122,11 @@ def register_transient(self, interface: Type[Any], implementation: Type[Any]) -> interface: The interface or class type key to map against. implementation: The target implementation class to instantiate. """ - self._factories[interface] = lambda stack=None: self._auto_wire(implementation, stack) + self._factories[interface] = lambda stack=None: self._auto_wire( + implementation, stack + ) - def resolve(self, interface: Type[T], _stack: Optional[Set[Type[Any]]] = None) -> T: + def resolve(self, interface: type[T], _stack: set[type[Any]] | None = None) -> T: """Resolve a specific interface or type dependency. Dynamically evaluates registered bindings (Singleton, Scoped, Transient) or @@ -113,7 +134,7 @@ def resolve(self, interface: Type[T], _stack: Optional[Set[Type[Any]]] = None) - Args: interface: The interface or class type to resolve. - _stack: Internal recursion validation stack representing parent classes active + _stack: Internal recursion validation stack representing parent classes active in the resolution tree. Defaults to None. Returns: @@ -130,7 +151,7 @@ def resolve(self, interface: Type[T], _stack: Optional[Set[Type[Any]]] = None) - current_instances = _scoped_instances.get() if interface in current_instances: return current_instances[interface] - + if interface in self._scoped_definitions: resolved_instance = self._scoped_definitions[interface](_stack) new_instances = dict(current_instances) @@ -143,15 +164,17 @@ def resolve(self, interface: Type[T], _stack: Optional[Set[Type[Any]]] = None) - return self._auto_wire(interface, _stack) - def _auto_wire(self, target_class: Type[T], _stack: Optional[Set[Type[Any]]] = None) -> T: + def _auto_wire( + self, target_class: type[T], _stack: set[type[Any]] | None = None + ) -> T: """Analyze, resolve parameters, and construct a class instance. - Leverages constructor cache values and metadata reflection to construct targets. + Leverages constructor cache values and metadata reflection to construct targets. Utilizes `typing.get_type_hints` to resolve forward-references. Args: target_class: The concrete target class type to auto-wire. - _stack: Recursion validation stack indicating target registration parents. + _stack: Recursion validation stack indicating target registration parents. Defaults to None. Returns: @@ -166,7 +189,10 @@ def _auto_wire(self, target_class: Type[T], _stack: Optional[Set[Type[Any]]] = N # Initialize recursion verification stack _stack = _stack or set() if target_class in _stack: - chain = " -> ".join([c.__name__ for c in _stack]) + f" -> {target_class.__name__}" + chain = ( + " -> ".join([c.__name__ for c in _stack]) + + f" -> {target_class.__name__}" + ) raise CircularDependencyError(f"Circular dependency detected: {chain}") _stack.add(target_class) @@ -175,7 +201,9 @@ def _auto_wire(self, target_class: Type[T], _stack: Optional[Set[Type[Any]]] = N # High-Performance Signature Caching Lookup if target_class in self._dependency_signature_cache: dependencies = self._dependency_signature_cache[target_class] - resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies] + resolved_args = [ + self.resolve(dep, _stack.copy()) for dep in dependencies + ] return target_class(*resolved_args) # Retrieve constructor safely @@ -205,10 +233,10 @@ def _auto_wire(self, target_class: Type[T], _stack: Optional[Set[Type[Any]]] = N annotation = type_hints.get(name, param.annotation) if annotation is inspect.Parameter.empty: continue - + if get_origin(annotation) is Annotated: annotation = get_args(annotation)[0] - + dependencies.append(annotation) # Warm cache for subsequent requests (reduces reflection overhead) @@ -216,7 +244,7 @@ def _auto_wire(self, target_class: Type[T], _stack: Optional[Set[Type[Any]]] = N resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies] return target_class(*resolved_args) - + finally: _stack.remove(target_class) @@ -237,11 +265,11 @@ def clear_scope(self, scope_id: str) -> None: class Injector: """Helper class that acts as a callable resolver wrapper. - Integrates standard container resolution lookups with FastAPI's routing dependency + Integrates standard container resolution lookups with FastAPI's routing dependency injection structure. """ - def __init__(self, interface: Type[Any]): + def __init__(self, interface: type[Any]): """Initialize the Injector instance. Args: @@ -258,13 +286,21 @@ async def __call__(self) -> Any: return container.resolve(self.interface) -def Inject(interface: Type[T]) -> Any: - """Convenience wrapper creating a FastAPI injection dependency parameter. +class Inject: + """Dynamic type marker supporting unified Annotated dependency injection. - Args: - interface: The target type interface dependency to inject. - - Returns: - A FastAPI Dependency representation linked to the active container. + Allows elegant type annotations in FastAPI routers, e.g., `service: Inject[UserService]`. + Under the hood, this converts the parameter metadata using `Annotated` and + binds it to the IoC container's resolver. """ - return Depends(Injector(interface)) \ No newline at end of file + + def __class_getitem__(cls, interface: type[T]) -> Any: + """Map the class generic bracket access to an Annotated dependency representation. + + Args: + interface: The target type interface dependency to resolve. + + Returns: + An Annotated type wrapper containing the resolved target class. + """ + return Annotated[interface, Depends(Injector(interface))] diff --git a/src/zcore/kernel/engine.py b/src/zcore/kernel/engine.py index de6afdf..a91b3f1 100644 --- a/src/zcore/kernel/engine.py +++ b/src/zcore/kernel/engine.py @@ -1,24 +1,25 @@ """Kernel Orchestration Subsystem. -This module coordinates application modules, managing registration, dependency resolution, -and topological sorting of Plugins. It orchestrates async startup and shutdown Lifespan hooks +This module coordinates application modules, managing registration, dependency resolution, +and topological sorting of Plugins. It orchestrates async startup and shutdown Lifespan hooks associated with FastAPI applications. """ import graphlib +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import AsyncGenerator, Dict, List + from fastapi import FastAPI +from zcore.kernel.di import container from zcore.kernel.events import EventDispatcher from zcore.kernel.plugins import Plugin -from zcore.kernel.di import container class Kernel: """The central core coordinator for the application lifespan and plugins. - Assembles registered plugins into a directed acyclic graph (DAG), resolves + Assembles registered plugins into a directed acyclic graph (DAG), resolves inter-plugin dependencies, and handles sequential lifespan transitions. Attributes: @@ -29,9 +30,9 @@ class Kernel: def __init__(self) -> None: """Initialize a new empty Kernel instance.""" - self.plugins: Dict[str, Plugin] = {} + self.plugins: dict[str, Plugin] = {} self.dispatcher = EventDispatcher() - self._sorted_plugins: List[Plugin] = [] + self._sorted_plugins: list[Plugin] = [] def add_plugin(self, plugin: Plugin) -> None: """Register an application plugin instance in the local registry. @@ -41,10 +42,10 @@ def add_plugin(self, plugin: Plugin) -> None: """ self.plugins[plugin.name] = plugin - def _resolve_dependencies(self) -> List[Plugin]: + def _resolve_dependencies(self) -> list[Plugin]: """Resolve and sort application plugins topologically using dependency paths. - Constructs a dependency graph from declared plugin structures and validates the + Constructs a dependency graph from declared plugin structures and validates the integrity of the resolution path. Returns: @@ -54,7 +55,7 @@ def _resolve_dependencies(self) -> List[Plugin]: RuntimeError: If a cyclic reference is detected in the graph, or if a declared plugin dependency is missing from active configurations. """ - graph: Dict[str, List[str]] = {} + graph: dict[str, list[str]] = {} for name, plugin in self.plugins.items(): graph[name] = plugin.dependencies @@ -64,14 +65,16 @@ def _resolve_dependencies(self) -> List[Plugin]: except graphlib.CycleError as e: raise RuntimeError(f"Cyclic dependency detected among plugins: {e}") - resolved: List[Plugin] = [] + resolved: list[Plugin] = [] for name in order: if name in self.plugins: resolved.append(self.plugins[name]) else: for p in self.plugins.values(): if name in p.dependencies: - raise RuntimeError(f"Missing dependency: '{name}' required by plugin '{p.name}'") + raise RuntimeError( + f"Missing dependency: '{name}' required by plugin '{p.name}'" + ) return resolved def setup(self, app: FastAPI) -> None: @@ -93,7 +96,7 @@ def setup(self, app: FastAPI) -> None: async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: """Asynchronous context manager governing the active application lifespan. - Sequentially invokes startup lifecycle phases on sorted plugin units + Sequentially invokes startup lifecycle phases on sorted plugin units and guarantees clean, reversed tear-down triggers upon termination. Args: @@ -118,4 +121,4 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: yield finally: for plugin in reversed(self._sorted_plugins): - await plugin.on_shutdown() \ No newline at end of file + await plugin.on_shutdown() diff --git a/src/zcore/kernel/events.py b/src/zcore/kernel/events.py index dfa66d5..b633ff2 100644 --- a/src/zcore/kernel/events.py +++ b/src/zcore/kernel/events.py @@ -1,15 +1,17 @@ """Asynchronous Event Dispatcher Engine. -This module provides concurrent event execution capabilities. It manages -subscription listings and executes synchronous or asynchronous handlers concurrently +This module provides concurrent event execution capabilities. It manages +subscription listings and executes synchronous or asynchronous handlers concurrently utilizing the asyncio task gather protocols. """ -import inspect import asyncio -import structlog +import inspect from collections import defaultdict -from typing import Callable, Any, Coroutine, TypeVar +from collections.abc import Callable, Coroutine +from typing import Any, TypeVar + +import structlog logger = structlog.getLogger("zcore.events") @@ -17,10 +19,24 @@ EventHandler = Callable[..., Coroutine[Any, Any, Any] | Any] +def on_event(event_name: str) -> Callable[[Any], Any]: + """Decorator to mark a service method as an active subscriber for an event channel. + + Args: + event_name: The target event identifier string. + """ + + def decorator(func: Any) -> Any: + func._event_listener = event_name + return func + + return decorator + + class EventDispatcher: """Coordinates event registration and execution. - Allows disparate application layers to subscribe to and trigger system occurrences + Allows disparate application layers to subscribe to and trigger system occurrences using loosely-coupled event keys. Attributes: @@ -50,11 +66,33 @@ def unsubscribe(self, event_name: str, handler: EventHandler) -> None: if event_name in self._subscribers and handler in self._subscribers[event_name]: self._subscribers[event_name].remove(handler) + def register_listeners(self, cls: type[Any], container: Any) -> None: + """Inspect and dynamically register marked listener methods on the target service class. + + Ensures that the target class is resolved through the IoC container dynamically + at execution time to preserve scoped lifecycles. + + Args: + cls: The target service class type to inspect. + container: The active IoCContainer instance used for class resolution. + """ + for name, method in inspect.getmembers( + cls, predicate=inspect.iscoroutinefunction + ): + event_name = getattr(method, "_event_listener", None) + if event_name: + + async def lazy_handler(*args: Any, method_name: str = name, **kwargs: Any) -> Any: + instance = container.resolve(cls) + return await getattr(instance, method_name)(*args, **kwargs) + + self.subscribe(event_name, lazy_handler) + async def dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> list[Any]: """Asynchronously dispatch an event, executing all handlers concurrently. - Schedules coroutine-based listeners onto the async event loop using `asyncio.gather` - to prevent blocking, while executing standard synchronous subscribers inline. Exceptions + Schedules coroutine-based listeners onto the async event loop using `asyncio.gather` + to prevent blocking, while executing standard synchronous subscribers inline. Exceptions encountered inside callbacks are caught and logged to safeguard dispatch execution flow. Args: @@ -75,22 +113,24 @@ async def dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> list[Any for handler in handlers: try: if inspect.iscoroutinefunction(handler): - # Prepare for concurrent execution async_tasks.append(handler(*args, **kwargs)) else: sync_results.append(handler(*args, **kwargs)) except Exception as e: - logger.error(f"Error preparing event handler {handler.__name__} for event '{event_name}': {e}") + logger.error( + f"Error preparing event handler {handler.__name__} for event '{event_name}': {e}" + ) - # Run async event tasks concurrently to prevent sequential bottlenecking async_results = [] if async_tasks: results = await asyncio.gather(*async_tasks, return_exceptions=True) for res in results: if isinstance(res, Exception): - logger.error(f"Unhandled exception in async event listener for event '{event_name}': {res}") + logger.error( + f"Unhandled exception in async event listener for event '{event_name}': {res}" + ) async_results.append(None) else: async_results.append(res) - return sync_results + async_results \ No newline at end of file + return sync_results + async_results diff --git a/src/zcore/kernel/plugins.py b/src/zcore/kernel/plugins.py index 4afdb97..9544346 100644 --- a/src/zcore/kernel/plugins.py +++ b/src/zcore/kernel/plugins.py @@ -1,10 +1,11 @@ """Plugin Architecture Protocol. -This module defines the structured contract interface that all ZCore plugins must satisfy +This module defines the structured contract interface that all ZCore plugins must satisfy to register with the central application orchestrator Kernel. """ from typing import Protocol, runtime_checkable + from fastapi import FastAPI @@ -12,7 +13,7 @@ class Plugin(Protocol): """Protocol structure modeling standard Plugin lifecycle interfaces. - Any module intended to run as a plugin component within the ZCore kernel context + Any module intended to run as a plugin component within the ZCore kernel context must comply with this runtime-checkable protocol interface. Attributes: @@ -47,4 +48,4 @@ async def after_startup(self) -> None: async def on_shutdown(self) -> None: """Perform cleanup and release external resources during application shutdown.""" - ... \ No newline at end of file + ... diff --git a/src/zcore/logging/__init__.py b/src/zcore/logging/__init__.py index 4dfa2e5..f7aba28 100644 --- a/src/zcore/logging/__init__.py +++ b/src/zcore/logging/__init__.py @@ -1,5 +1,3 @@ from zcore.logging.config import setup_logging -__all__ = [ - "setup_logging" -] \ No newline at end of file +__all__ = ["setup_logging"] diff --git a/src/zcore/logging/config.py b/src/zcore/logging/config.py index 3b47fa0..2037050 100644 --- a/src/zcore/logging/config.py +++ b/src/zcore/logging/config.py @@ -1,53 +1,53 @@ """ZCore Structured Logging Configuration. -This module initializes the application's logging pipeline. It integrates standard -Python `logging` with `structlog` to provide structured, context-aware diagnostics. -It dynamically formats output as developer-friendly, colorized text on interactive terminals +This module initializes the application's logging pipeline. It integrates standard +Python `logging` with `structlog` to provide structured, context-aware diagnostics. +It dynamically formats output as developer-friendly, colorized text on interactive terminals or serialized JSON records on production stream targets. """ import logging -import sys -import structlog - -LOG_LEVEL = "INFO" - -logging.basicConfig( - level=LOG_LEVEL, - format="%(message)s", - stream=sys.stderr, -) -# Shared structlog processors executing transformation steps sequentially -shared_processors = [ - structlog.contextvars.merge_contextvars, - structlog.processors.add_log_level, - structlog.processors.format_exc_info, - structlog.processors.TimeStamper(fmt="iso"), - structlog.stdlib.PositionalArgumentsFormatter(), -] +import structlog -# Render interactive ANSI terminal streams locally, or serialized JSON in production -renderer = ( - structlog.dev.ConsoleRenderer() - if sys.stderr.isatty() - else structlog.processors.JSONRenderer() -) +from zcore.config import settings def setup_logging() -> None: """Configure the global structlog logging engine. - Applies the shared processor chain, hooks standard library logging factories - for consistency across external packages, resolves the appropriate rendering format - based on the terminal environment context, and caches loggers to reduce downstream - reflection overhead. + Suppresses duplicate logging handlers from third-party libraries (like uvicorn or sqlalchemy) + and routes all framework telemetry through a unified structlog pipeline. """ + for logger_name in ("uvicorn", "uvicorn.access", "sqlalchemy.engine"): + log = logging.getLogger(logger_name) + log.handlers = [] + log.propagate = True + + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.format_exc_info, + structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.PositionalArgumentsFormatter(), + ] + + if getattr(settings, "DEBUG", False): + try: + from rich.traceback import install + + install(show_locals=False) + except ImportError: + pass + renderer = structlog.dev.ConsoleRenderer(colors=True) + log_level = logging.DEBUG + else: + renderer = structlog.processors.JSONRenderer() + log_level = logging.INFO + structlog.configure( - processors=shared_processors + [renderer], - wrapper_class=structlog.make_filtering_bound_logger( - getattr(logging, LOG_LEVEL) - ), + processors=[*shared_processors, renderer], + wrapper_class=structlog.make_filtering_bound_logger(log_level), logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, - ) \ No newline at end of file + ) diff --git a/src/zcore/security/__init__.py b/src/zcore/security/__init__.py index b0e719f..52c136d 100644 --- a/src/zcore/security/__init__.py +++ b/src/zcore/security/__init__.py @@ -1,17 +1,14 @@ -from zcore.security.protocols import UserProtocol +from zcore.security.auth import BaseAuth from zcore.security.dependencies import get_current_user_stub -from zcore.security.hashing import verify_password, get_password_hash -from zcore.security.jwt import create_token, decode_token, is_token_expired from zcore.security.permissions import BasePermission, HasScopes +from zcore.security.protocols import UserProtocol +from zcore.security.security import Security __all__ = [ - "UserProtocol", - "get_current_user_stub", - "verify_password", - "get_password_hash", - "create_token", - "decode_token", - "is_token_expired", + "BaseAuth", "BasePermission", "HasScopes", -] \ No newline at end of file + "Security", + "UserProtocol", + "get_current_user_stub", +] diff --git a/src/zcore/security/auth.py b/src/zcore/security/auth.py new file mode 100644 index 0000000..aac0384 --- /dev/null +++ b/src/zcore/security/auth.py @@ -0,0 +1,113 @@ +"""Dynamic and Generic Authentication Base Layer. + +This module provides the generic `BaseAuth` class to coordinate token decoding, +caching logic, and dynamic context injection during FastAPI request cycles. +""" + +from typing import Any, Generic, TypeVar + +from fastapi import Depends, Request +from fastapi.security import OAuth2PasswordBearer +from pydantic import BaseModel + +from zcore.cache import BaseCache +from zcore.context import ctx +from zcore.exceptions import AuthError +from zcore.security.security import Security + +T = TypeVar("T", bound=BaseModel) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +class BaseAuth(Generic[T]): + """Generic Template-Method authentication class. + + Encapsulates core verification paths, automated caching lookups, active-state assertions, + and dynamically binds schema attributes directly onto the shared ZContext. + """ + + def __init__( + self, + user_schema: type[T], + identity_claim: str = "sub", + token_type: str = "access", + cache_prefix: str = "auth", + cache_ttl: int = 300, + ) -> None: + """Initialize the BaseAuth instance. + + Args: + user_schema: The validated Pydantic model representation of the user. + identity_claim: The claim attribute denoting user identity. Defaults to "sub". + token_type: Target validated string within claims. Defaults to "access". + cache_prefix: Cache prefix namespace. Defaults to "auth". + cache_ttl: Expiration lifespan of cache items. Defaults to 300. + """ + self.user_schema = user_schema + self.identity_claim = identity_claim + self.token_type = token_type + self.cache = BaseCache(prefix=cache_prefix) + self.cache_ttl = cache_ttl + + async def fetch_user(self, identity: str) -> Any: + """Fetch the active user model from persistent storage. + + Must be implemented by concrete subclass implementations in the application layer. + + Args: + identity: Uniquely identifying string value (e.g. username or phone number). + + Raises: + NotImplementedError: If not overridden by the subclass. + """ + raise NotImplementedError + + async def __call__( + self, request: Request, token: str = Depends(oauth2_scheme) + ) -> T: + """Execute core request interception authentication workflow. + + Args: + request: The incoming FastAPI request instance. + token: The extracted string token from authorization headers. + + Returns: + The parsed Pydantic schema model representing the user. + + Raises: + AuthError: If signature evaluation, user status validation, or type checks fail. + """ + try: + payload = Security.decode_jwt(token) + identity = payload.get(self.identity_claim) + if not identity or payload.get("type") != self.token_type: + raise AuthError(message="Invalid token structure or type.") + except Exception: + raise AuthError(message="Invalid token or token has expired.") + + cache_key = f"user:{identity}" + user_data = await self.cache.get(cache_key, target_type=self.user_schema) + + if not user_data: + db_user = await self.fetch_user(identity) + if not db_user or not getattr(db_user, "is_active", True): + raise AuthError(message="User inactive or not found.") + + user_data = self.user_schema.model_validate(db_user) + await self.cache.set( + cache_key, user_data.model_dump(mode="json"), ttl=self.cache_ttl + ) + + if not getattr(user_data, "is_active", True): + raise AuthError(message="User inactive") + + for field_name in type(user_data).model_fields: + value = getattr(user_data, field_name) + if field_name == "id": + ctx.user_id = value + elif field_name == "all_restricted_fields": + ctx.restricted_fields = frozenset(value) if value else frozenset() + else: + ctx.set(field_name, value) + + return user_data diff --git a/src/zcore/security/dependencies.py b/src/zcore/security/dependencies.py index 1edbbee..3c98546 100644 --- a/src/zcore/security/dependencies.py +++ b/src/zcore/security/dependencies.py @@ -1,7 +1,7 @@ """Security Authentication Dependencies. -This module provides placeholder stub dependencies for route-level dependency injection. -These stubs are designed to be overridden during application setup with concrete +This module provides placeholder stub dependencies for route-level dependency injection. +These stubs are designed to be overridden during application setup with concrete authentication implementations. """ @@ -11,7 +11,7 @@ async def get_current_user_stub() -> UserProtocol: """Stub dependency for retrieving the currently authenticated user. - This function acts as a dependency injection anchor. Downstream applications + This function acts as a dependency injection anchor. Downstream applications must override this stub using `app.dependency_overrides` during bootstrapping. Returns: @@ -20,4 +20,6 @@ async def get_current_user_stub() -> UserProtocol: Raises: NotImplementedError: If invoked directly without a configured dependency override. """ - raise NotImplementedError("Dependency override is required for get_current_user_stub") \ No newline at end of file + raise NotImplementedError( + "Dependency override is required for get_current_user_stub" + ) diff --git a/src/zcore/security/hashing.py b/src/zcore/security/hashing.py deleted file mode 100644 index 58f0132..0000000 --- a/src/zcore/security/hashing.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Cryptographic Password Hashing Utilities. - -This module manages password security utilizing the Argon2id hashing algorithm. -Configuration parameters (memory cost, time iterations, and parallelism) are dynamically -resolved from system settings with safe, cryptographically sound defaults. -""" - -import structlog -from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError - -try: - from argon2.exceptions import InvalidHashError -except ImportError: - from argon2.exceptions import InvalidHash as InvalidHashError - -from zcore.config import settings - -logger = structlog.get_logger() - -# Dynamically construct Argon2 parameters from config settings with safe fallbacks -_memory_cost = getattr(settings, "ARGON2_MEMORY_COST", 65536) # 64 MB -_time_cost = getattr(settings, "ARGON2_TIME_COST", 3) # 3 iterations -_parallelism = getattr(settings, "ARGON2_PARALLELISM", 4) # 4 threads - -ph = PasswordHasher( - memory_cost=_memory_cost, - time_cost=_time_cost, - parallelism=_parallelism -) - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a plain-text password against a stored Argon2 hash. - - Suppresses expected cryptographic mismatches or invalid hash string errors, - returning boolean outcomes safely to prevent timing side-channel attacks. - - Args: - plain_password: The unhashed candidate password. - hashed_password: The valid stored Argon2 hash string. - - Returns: - True if the candidate password matches the hash, False otherwise. - """ - try: - return ph.verify(hashed_password, plain_password) - except (VerifyMismatchError, InvalidHashError): - return False - except Exception as e: - logger.error(f"Password verification encountered an unexpected failure: {e}") - return False - - -def get_password_hash(password: str) -> str: - """Generate a secure Argon2 hash from a plain-text password. - - Args: - password: The plain-text password to hash. - - Returns: - The computed Argon2 cryptographic hash string. - - Raises: - RuntimeError: If the underlying Argon2 generator encounters an unexpected error. - """ - try: - return ph.hash(password) - except Exception as e: - logger.error(f"Password hashing failed: {e}") - raise RuntimeError("Cryptographic error while processing password.") \ No newline at end of file diff --git a/src/zcore/security/jwt.py b/src/zcore/security/jwt.py deleted file mode 100644 index 2889af9..0000000 --- a/src/zcore/security/jwt.py +++ /dev/null @@ -1,117 +0,0 @@ -"""JSON Web Token (JWT) Utility Functions. - -This module manages JSON Web Token operations, supporting both Symmetric (HMAC) -and Asymmetric (RSA/ECDSA) signature algorithms. It includes automated security -checks to prevent production starts with insecure secret configurations. -""" - -import jwt -import structlog -from datetime import datetime, timezone, timedelta -from typing import Union - -from zcore.config import settings -from zcore.exceptions.base import AuthError - -logger = structlog.get_logger() - - -def _get_signing_keys() -> tuple[Union[str, bytes], Union[str, bytes], str]: - """Resolve active private and public signing keys from config settings. - - Verifies configured environments. Aborts application startup in production settings - if default fallback secrets are active. - - Returns: - A tuple of (private_key, public_key, algorithm_name) used for signing operations. - - Raises: - RuntimeError: If a production context is configured with the default, insecure - fallback secret key. - """ - # If Asymmetric keys are provided via settings - private_key = getattr(settings, "JWT_PRIVATE_KEY", None) - public_key = getattr(settings, "JWT_PUBLIC_KEY", None) - - if private_key and public_key: - return private_key, public_key, settings.ALGORITHM - - # Default to Symmetric signing - is_prod = getattr(settings, "ENVIRONMENT", "production").lower() == "production" - is_fallback = settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" - - if is_prod and is_fallback: - raise RuntimeError( - "FATAL SECURITY VIOLATION: You are running in PRODUCTION environment " - "using the insecure default fallback SECRET_KEY. Application startup aborted." - ) - - return settings.SECRET_KEY, settings.SECRET_KEY, settings.ALGORITHM - - -def create_token(data: dict, expires_delta: timedelta|None = None) -> str: - """Create a signed JWT access token. - - Supports HMAC symmetric signing or asymmetric cryptographic keypair signatures, - adding standard expiration metadata to token claims. - - Args: - data: The payload claims to encode into the token. - expires_delta: Optional custom lifetime duration. Defaults to settings configurations. - - Returns: - The encoded and signed JWT string. - - Raises: - AuthError: If encoding fails due to internal errors. - """ - private_key, _, algorithm = _get_signing_keys() - to_encode = data.copy() - - expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) - to_encode.update({"exp": expire}) - - try: - return jwt.encode(to_encode, private_key, algorithm=algorithm) - except Exception as e: - logger.error(f"Failed to encode JWT token: {e}") - raise AuthError(message="Token creation failed due to internal error.") - - -def decode_token(token: str) -> dict: - """Decode and validate a signed JWT token string. - - Args: - token: The signed JWT token string to evaluate. - - Returns: - A dictionary of decoded payload claims. - - Raises: - AuthError: If the token signature is expired or structurally invalid. - """ - _, public_key, algorithm = _get_signing_keys() - try: - return jwt.decode(token, public_key, algorithms=[algorithm]) - except jwt.ExpiredSignatureError as e: - raise AuthError(message="Token expired") from e - except jwt.InvalidTokenError as e: - raise AuthError(message="Invalid token structure") from e - - -def is_token_expired(token_exp: int) -> bool: - """Check if a Unix timestamp claim has expired. - - Args: - token_exp: Unix epoch timestamp representing expiration claim. - - Returns: - True if the current time matches or exceeds the timestamp, False otherwise. - """ - if not token_exp: - return True - - expire_time = datetime.fromtimestamp(token_exp, tz=timezone.utc) - now = datetime.now(timezone.utc) - - return now >= expire_time \ No newline at end of file diff --git a/src/zcore/security/permissions.py b/src/zcore/security/permissions.py index 6c0ea0e..1a16e65 100644 --- a/src/zcore/security/permissions.py +++ b/src/zcore/security/permissions.py @@ -1,17 +1,19 @@ """Fine-Grained Role and Scope Permission Controls. -This module implements base permission abstractions for FastAPI endpoints. It supports +This module implements base permission abstractions for FastAPI endpoints. It supports checking general permissions, object-level checks, and validating active OAuth/OIDC scopes, with optional bypass configurations for superusers. """ from abc import ABC, abstractmethod from typing import Any -from fastapi import Request, Depends -from zcore.exceptions.base import ForbiddenError, AuthError -from zcore.security.protocols import UserProtocol +from fastapi import Depends, Request + +from zcore.context import ctx +from zcore.exceptions import AuthError, ForbiddenError from zcore.security.dependencies import get_current_user_stub +from zcore.security.protocols import UserProtocol class BasePermission(ABC): @@ -33,7 +35,9 @@ async def has_permission(self, request: Request, user: UserProtocol) -> bool: """ pass - async def has_object_permission(self, request: Request, user: UserProtocol, obj: Any) -> bool: + async def has_object_permission( + self, request: Request, user: UserProtocol, obj: Any + ) -> bool: """Evaluate if the current user has access to a specific object resource. Args: @@ -47,13 +51,11 @@ async def has_object_permission(self, request: Request, user: UserProtocol, obj: return True async def __call__( - self, - request: Request, - user: UserProtocol = Depends(get_current_user_stub) + self, request: Request, user: UserProtocol = Depends(get_current_user_stub) ) -> UserProtocol: """FastAPI Dependency call hook orchestrating standard security assertions. - Ensures that user identities exist, are marked active, and pass declared + Ensures that user identities exist, are marked active, and pass declared permission checks. Args: @@ -69,13 +71,13 @@ async def __call__( """ if not user: raise AuthError(message="Authentication required") - + if not user.is_active: raise AuthError(message="User is inactive") - + if not await self.has_permission(request, user): raise ForbiddenError(message="Access denied") - + return user @@ -104,16 +106,26 @@ def __init__(self, *required_scopes: str, allow_superuser: bool = True): async def has_permission(self, request: Request, user: UserProtocol) -> bool: """Validate user scopes against the required set of scopes. + Resolves scopes dynamically from the context scope mapping or user properties. + Args: request: The active incoming FastAPI HTTP request. user: The user model conforming to UserProtocol. Returns: - True if all required scopes are matching or if the user is a superuser, + True if all required scopes are matching or if the user is a superuser, False otherwise. """ if self.allow_superuser and getattr(user, "is_superuser", False): return True - - user_scopes = getattr(user, "all_scopes", set()) - return self.required_scopes.issubset(user_scopes) \ No newline at end of file + + # Dynamically fetch scopes from the requests' ZContext or the user object directly + user_scopes = ctx.get("scopes") or getattr(user, "scopes", None) or set() + + # Coerce scope structures to ensure compatible validation checks + if isinstance(user_scopes, (list, tuple, frozenset)): + user_scopes = set(user_scopes) + elif isinstance(user_scopes, str): + user_scopes = {user_scopes} + + return self.required_scopes.issubset(user_scopes) diff --git a/src/zcore/security/protocols.py b/src/zcore/security/protocols.py index 42729fc..3c322c4 100644 --- a/src/zcore/security/protocols.py +++ b/src/zcore/security/protocols.py @@ -1,6 +1,6 @@ """Security Protocol Definitions. -This module defines the runtime-checkable `UserProtocol` which establishes the +This module defines the runtime-checkable `UserProtocol` which establishes the structural contract required of ZCore user entities to integrate with authorization systems. """ @@ -12,7 +12,7 @@ class UserProtocol(Protocol): """Protocol establishing standard user characteristics. - Any user model within the ZCore framework must implement this protocol to + Any user model within the ZCore framework must implement this protocol to integrate with security middleware and route decorators. Attributes: @@ -24,12 +24,3 @@ class UserProtocol(Protocol): id: uuid.UUID is_active: bool is_superuser: bool - - @property - def all_scopes(self) -> set[str]: - """Retrieve the compiled set of security scopes granted to this user. - - Returns: - A set of unique scope key strings. - """ - ... \ No newline at end of file diff --git a/src/zcore/security/security.py b/src/zcore/security/security.py new file mode 100644 index 0000000..add68f7 --- /dev/null +++ b/src/zcore/security/security.py @@ -0,0 +1,171 @@ +"""Unified Security Services Module. + +This module coordinates password hashing (via Argon2id), JSON Web Token (JWT) lifecycle operations, +secure hexadecimal token generation, and cryptographic hashing digests under a single coherent interface. +""" + +import hashlib +import secrets +from datetime import UTC, datetime, timedelta + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +try: + from argon2.exceptions import InvalidHashError +except ImportError: + from argon2.exceptions import InvalidHash as InvalidHashError + +import jwt +import structlog + +from zcore.config import settings +from zcore.exceptions import AuthError + +logger = structlog.get_logger() + +# Dynamically construct Argon2 parameters from config settings with safe fallbacks +_memory_cost = getattr(settings, "ARGON2_MEMORY_COST", 65536) # 64 MB +_time_cost = getattr(settings, "ARGON2_TIME_COST", 3) # 3 iterations +_parallelism = getattr(settings, "ARGON2_PARALLELISM", 4) # 4 threads + +ph = PasswordHasher( + memory_cost=_memory_cost, time_cost=_time_cost, parallelism=_parallelism +) + + +class Security: + """Unified Cryptographic and Security Services Coordinator.""" + + @staticmethod + def hash_password(password: str) -> str: + """Generate a secure Argon2id hash from a plain-text password. + + Args: + password: The plain-text password to hash. + + Returns: + The computed Argon2id cryptographic hash string. + """ + try: + return ph.hash(password) + except Exception as e: + logger.error(f"Password hashing failed: {e}") + raise RuntimeError("Cryptographic error while processing password.") + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain-text password against a stored Argon2id hash. + + Args: + plain_password: The unhashed candidate password. + hashed_password: The valid stored Argon2id hash string. + + Returns: + True if the candidate password matches the hash, False otherwise. + """ + try: + return ph.verify(hashed_password, plain_password) + except (VerifyMismatchError, InvalidHashError): + return False + except Exception as e: + logger.error(f"Password verification encountered unexpected failure: {e}") + return False + + @staticmethod + def generate_secure_token(length: int = 32) -> str: + """Generate a cryptographically secure hex token of specified length. + + Args: + length: Target byte capacity to encode. Defaults to 32. + + Returns: + A cryptographically secure hex string. + """ + return secrets.token_hex(length) + + @staticmethod + def hash_sha256(data: str) -> str: + """Generate a SHA-256 hex digest of the provided string data. + + Args: + data: Raw text parameters to digest. + + Returns: + A 64-character hexadecimal SHA-256 hash. + """ + return hashlib.sha256(data.encode()).hexdigest() + + @staticmethod + def _get_signing_keys() -> tuple[str | bytes, str | bytes, str]: + """Resolve active private and public signing keys from config settings.""" + private_key = getattr(settings, "JWT_PRIVATE_KEY", None) + public_key = getattr(settings, "JWT_PUBLIC_KEY", None) + + if private_key and public_key: + return private_key, public_key, settings.ALGORITHM + + is_prod = getattr(settings, "DEBUG", False) is False + is_fallback = ( + settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" + ) + + if is_prod and is_fallback: + raise RuntimeError( + "FATAL SECURITY VIOLATION: You are running in PRODUCTION environment " + "using the insecure default fallback SECRET_KEY. Application startup aborted." + ) + + return settings.SECRET_KEY, settings.SECRET_KEY, settings.ALGORITHM + + @classmethod + def create_jwt(cls, data: dict, expires_delta: timedelta | None = None) -> str: + """Create a signed JWT access token. + + Args: + data: The payload claims to encode. + expires_delta: Optional custom lifetime duration. + + Returns: + The signed JWT token string. + """ + private_key, _, algorithm = cls._get_signing_keys() + to_encode = data.copy() + + expire = datetime.now(UTC) + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire}) + + try: + return jwt.encode(to_encode, private_key, algorithm=algorithm) + except Exception as e: + logger.error(f"Failed to encode JWT token: {e}") + raise AuthError(message="Token creation failed due to internal error.") + + @classmethod + def decode_jwt(cls, token: str) -> dict: + """Decode and validate a signed JWT token string. + + Args: + token: The token string to decode. + + Returns: + Decoded payload claims dict. + """ + _, public_key, algorithm = cls._get_signing_keys() + try: + return jwt.decode(token, public_key, algorithms=[algorithm]) + except jwt.ExpiredSignatureError as e: + raise AuthError(message="Token expired") from e + except jwt.InvalidTokenError as e: + raise AuthError(message="Invalid token structure") from e + + @staticmethod + def is_token_expired(token_exp: int) -> bool: + """Check if a Unix timestamp claim has expired.""" + if not token_exp: + return True + expire_time = datetime.fromtimestamp(token_exp, tz=UTC) + now = datetime.now(UTC) + return now >= expire_time diff --git a/src/zcore/service/__init__.py b/src/zcore/service/__init__.py index c499c57..60643f1 100644 --- a/src/zcore/service/__init__.py +++ b/src/zcore/service/__init__.py @@ -1,6 +1,6 @@ -from zcore.service.base import BaseService, AbstractService +from zcore.service.base import AbstractService, BaseService __all__ = [ - "BaseService", "AbstractService", -] \ No newline at end of file + "BaseService", +] diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index d3f0f18..8402b66 100644 --- a/src/zcore/service/base.py +++ b/src/zcore/service/base.py @@ -1,25 +1,30 @@ """ZCore Core Business Service Layer. -This module provides the structural base service abstractions and mixins for orchestration -of domain logic in the ZCore framework. It coordinates transactional operations, integrates -lifecycle hooks (pre- and post-execution stages), and facilitates safe unit-of-work +This module provides the structural base service abstractions and mixins for orchestration +of domain logic in the ZCore framework. It coordinates transactional operations, integrates +lifecycle hooks (pre- and post-execution stages), and facilitates safe unit-of-work commits to prevent premature transactional boundaries from breaking transaction atomicity. """ +from collections.abc import Sequence +from typing import ( + TYPE_CHECKING, + Any, + Generic, + TypeVar, +) + from pydantic import BaseModel -from typing import Generic, TypeVar, Type, Any, Sequence, Optional, List, Dict, TYPE_CHECKING from sqlalchemy.orm.interfaces import ExecutableOption -from zcore.exceptions.base import EntityNotFound -from zcore.db.setup import Base from zcore.db.search import SearchRequest +from zcore.db.setup import Base +from zcore.exceptions.base import EntityNotFound if TYPE_CHECKING: from zcore.db.repository import BaseRepository -ModelType = TypeVar("ModelType", bound=Base) -CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) -UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) +ModelType = TypeVar("ModelType", bound=Base) class AbstractService(Generic[ModelType]): @@ -31,34 +36,33 @@ class AbstractService(Generic[ModelType]): """ repository: "BaseRepository" - model: Type[ModelType] + model: type[ModelType] async def get( - self, - id: Any, - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + *criterion: Any, + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, + **filters: Any, ) -> ModelType: - """Fetch a single domain entity by its primary key. + """Fetch a single domain entity by dynamic filters. Args: - id: The primary key of the target entity. + *criterion: Dynamic positional conditions. fields: Selective list of model fields to return. Defaults to None. options: Execution configurations such as load options. Defaults to None. + **filters: Dynamic keyword filters. Returns: The retrieved model instance. - - Raises: - NotImplementedError: If the method is not implemented by the subclass. """ raise NotImplementedError async def get_by_ids( - self, - ids: List[Any], - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + ids: list[Any], + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, ) -> Sequence[ModelType]: """Fetch a sequence of domain entities matching the list of keys. @@ -69,65 +73,39 @@ async def get_by_ids( Returns: A sequence of retrieved database model instances. - - Raises: - NotImplementedError: If the method is not implemented by the subclass. """ raise NotImplementedError class ReadServiceMixin(AbstractService[ModelType]): - """Mixin implementing standardized read-only orchestration routines. - - Enforces extension interceptors (`post_get`, `post_get_multi`) allowing downstream - services to inspect, validate, or enrich entities after retrieval. - """ + """Mixin implementing standardized read-only orchestration routines.""" async def post_get(self, model: ModelType) -> ModelType: - """Extension interceptor executed after a single entity is retrieved. - - Args: - model: The retrieved database model instance. - - Returns: - The processed database model instance. - """ + """Extension interceptor executed after a single entity is retrieved.""" return model async def post_get_multi(self, models: Sequence[ModelType]) -> Sequence[ModelType]: - """Extension interceptor executed after a batch sequence is retrieved. - - Args: - models: The retrieved database model instances. - - Returns: - The processed database model instances. - """ + """Extension interceptor executed after a batch sequence is retrieved.""" return models - async def exist(self, id: Any) -> bool: - """Verify the existence of a target entity identifier in the database. - - Args: - id: The primary key of the target entity. - - Returns: - True if the entity exists, False otherwise. - """ - return await self.repository.exist(id) + async def exist(self, *criterion: Any, **filters: Any) -> bool: + """Verify the existence of a target entity in the database.""" + return await self.repository.exist(*criterion, **filters) async def get( - self, - id: Any, - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + *criterion: Any, + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, + **filters: Any, ) -> ModelType: """Fetch a single domain entity, applying post-retrieval hooks. Args: - id: The primary key of the target entity. + *criterion: Dynamic positional conditions. fields: Selective list of model fields to load. Defaults to None. options: Additional execution options. Defaults to None. + **filters: Dynamic keyword filters. Returns: The processed database model instance. @@ -135,70 +113,61 @@ async def get( Raises: EntityNotFound: If the target entity identifier is not found in the database. """ - result = await self.repository.get(id, fields, options) + result = await self.repository.get( + *criterion, fields=fields, options=options, **filters + ) if not result: raise EntityNotFound(message=f"{self.model.__name__} not found.") return await self.post_get(result) async def get_by_ids( - self, - ids: List[Any], - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + ids: list[Any], + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, ) -> Sequence[ModelType]: - """Fetch multiple domain entities by their identifiers, applying post-retrieval hooks. - - Args: - ids: List of target entity keys to retrieve. - fields: Selective list of model fields to load. Defaults to None. - options: Additional execution options. Defaults to None. - - Returns: - A sequence of processed database model instances. - """ + """Fetch multiple domain entities by their identifiers, applying post-retrieval hooks.""" result = await self.repository.get_by_ids(ids, fields, options) return await self.post_get_multi(result) async def get_list( - self, - pagination: Any = None, - fields: Optional[List[Any]] = None, - options: Optional[List[ExecutableOption]] = None + self, + pagination: Any = None, + fields: list[Any] | None = None, + options: list[ExecutableOption] | None = None, + *criterion: Any, + **filters: Any, ) -> Any: - """Fetch a paginated or complete listing of entities, applying post-retrieval hooks. - - Args: - pagination: Pagination settings mapping to standard structures. Defaults to None. - fields: Selective list of model fields to load. Defaults to None. - options: Additional execution options. Defaults to None. - - Returns: - A PaginatedResult envelope containing post-get processed data, - or a raw sequence of processed database model instances. - """ - result = await self.repository.get_list(pagination, fields, options) + """Fetch a paginated or complete listing of entities, applying post-retrieval hooks.""" + result = await self.repository.get_list( + pagination, fields, options, *criterion, **filters + ) if pagination is None: return await self.post_get_multi(result) result.data = await self.post_get_multi(result.data) return result + async def count(self, *criterion: Any, **filters: Any) -> int: + """Count the volume of entities matching dynamic criteria.""" + return await self.repository.count(*criterion, **filters) -class WriteServiceMixin(Generic[ModelType, CreateSchemaType, UpdateSchemaType], AbstractService[ModelType]): - """Mixin implementing standardized mutation and persistence orchestration routines. - Coordinates transactional integrity and encapsulates lifecycle hooks triggered - prior to and succeeding database mutations. - """ +class WriteServiceMixin(Generic[ModelType], AbstractService[ModelType]): + """Mixin implementing standardized mutation and persistence orchestration routines.""" - async def pre_create(self, schema: CreateSchemaType) -> None: - """Hook triggered prior to single-record database insertion.""" - pass + async def pre_create(self, schema: BaseModel) -> dict[str, Any] | None: + """Hook triggered prior to single-record database insertion. + + Returns: + An optional dictionary containing values to merge with the creation payload. + """ + return None async def post_create(self, model: ModelType) -> None: """Hook triggered after single-record database insertion.""" pass - - async def pre_create_multi(self, schemas: List[CreateSchemaType]) -> None: + + async def pre_create_multi(self, schemas: list[BaseModel]) -> None: """Hook triggered prior to batch database insertions.""" pass @@ -206,15 +175,21 @@ async def post_create_multi(self, models: Sequence[ModelType]) -> None: """Hook triggered after batch database insertions.""" pass - async def pre_update(self, id: Any, schema: UpdateSchemaType, partial: bool) -> None: - """Hook triggered prior to modifying a single record.""" - pass + async def pre_update( + self, id: Any, schema: BaseModel, partial: bool + ) -> dict[str, Any] | None: + """Hook triggered prior to modifying a single record. + + Returns: + An optional dictionary containing values to merge with the update payload. + """ + return None async def post_update(self, model: ModelType) -> None: """Hook triggered after modifying a single record.""" pass - - async def pre_update_multi(self, data: Dict[Any, UpdateSchemaType], partial: bool) -> None: + + async def pre_update_multi(self, data: dict[Any, BaseModel], partial: bool) -> None: """Hook triggered prior to updating a batch of records.""" pass @@ -229,8 +204,8 @@ async def pre_delete(self, id: Any) -> None: async def post_delete(self, model: ModelType) -> None: """Hook triggered after deleting a single record.""" pass - - async def pre_delete_multi(self, ids: List[Any]) -> None: + + async def pre_delete_multi(self, ids: list[Any]) -> None: """Hook triggered prior to deleting a batch of records.""" pass @@ -239,15 +214,7 @@ async def post_delete_multi(self, models: Sequence[ModelType]) -> None: pass async def _safe_commit(self) -> None: - """Execute a session commit if transaction state is not managed externally. - - This method inspects session metadata. If the session is wrapped inside an outer - UnitOfWork transaction context block, explicit commits are bypassed to avoid premature - boundary updates and preserve transaction atomicity. - - Raises: - Exception: If the commit fails, rolling back the database session. - """ + """Execute a session commit if transaction state is not managed externally.""" session_info = self.repository.db.info if not session_info.get("uow_managed", False): try: @@ -256,92 +223,77 @@ async def _safe_commit(self) -> None: await self.repository.db.rollback() raise - async def on_create(self, schema: CreateSchemaType) -> ModelType: - """Execute core single-record creation in database. + async def on_create(self, schema: BaseModel, **extra_data: Any) -> ModelType: + """Execute core single-record creation in database.""" + return await self.repository.create(schema, **extra_data) - Subclasses may override this method to customize core creation behavior. - """ - return await self.repository.create(schema) - - async def on_create_multi(self, schemas: List[CreateSchemaType], refresh: bool = False) -> Sequence[ModelType]: - """Execute core batch-record creation in database. - - Subclasses may override this method to customize core batch creation behavior. - """ + async def on_create_multi( + self, schemas: list[BaseModel], refresh: bool = False + ) -> Sequence[ModelType]: + """Execute core batch-record creation in database.""" return await self.repository.create_multi(schemas, refresh=refresh) - async def on_update(self, id: Any, schema: UpdateSchemaType, partial: bool = False) -> Optional[ModelType]: - """Execute core single-record update in database. - - Subclasses may override this method to customize core update behavior. - """ - return await self.repository.update(id, schema, partial) + async def on_update( + self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any + ) -> ModelType | None: + """Execute core single-record update in database.""" + return await self.repository.update(id, schema, partial, **extra_data) async def on_update_multi( - self, - data: Dict[Any, UpdateSchemaType], - partial: bool = False, - refresh: bool = False + self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False ) -> Sequence[ModelType]: - """Execute core batch-record updates in database. - - Subclasses may override this method to customize core batch update behavior. - """ + """Execute core batch-record updates in database.""" return await self.repository.update_multi(data, partial, refresh=refresh) - async def on_delete(self, id: Any) -> Optional[ModelType]: - """Execute core single-record deletion in database. - - Subclasses may override this method to customize core deletion behavior. - """ + async def on_delete(self, id: Any) -> ModelType | None: + """Execute core single-record deletion in database.""" return await self.repository.delete(id) - async def on_delete_multi(self, ids: List[Any]) -> Sequence[ModelType]: - """Execute core batch-record deletions in database. - - Subclasses may override this method to customize core batch deletion behavior. - """ + async def on_delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: + """Execute core batch-record deletions in database.""" return await self.repository.delete_multi(ids) - async def create(self, schema: CreateSchemaType) -> ModelType: + async def create(self, schema: BaseModel, **extra_data: Any) -> ModelType: """Orchestrate the creation and persistence of a new domain entity. + Merges pre-create dictionary output with the creation payload. + Args: schema: Validated parameters containing fields for the new record. + **extra_data: Extra fields to append during initialization. Returns: The created and processed database model instance. """ - await self.pre_create(schema) - result = await self.on_create(schema) + hook_data = await self.pre_create(schema) or {} + combined_extra = {**hook_data, **extra_data} + result = await self.on_create(schema, **combined_extra) await self.post_create(result) await self._safe_commit() return result - async def create_multi(self, schemas: List[CreateSchemaType], refresh: bool = False) -> Sequence[ModelType]: - """Orchestrate the batch creation and persistence of multiple domain entities. - - Args: - schemas: Collection of validated schemas to build and insert. - refresh: If True, executes individual model refreshes after flush. - Defaults to False. - - Returns: - A sequence of created and processed database model instances. - """ + async def create_multi( + self, schemas: list[BaseModel], refresh: bool = False + ) -> Sequence[ModelType]: + """Orchestrate the batch creation and persistence of multiple domain entities.""" await self.pre_create_multi(schemas) result = await self.on_create_multi(schemas, refresh=refresh) await self.post_create_multi(result) await self._safe_commit() return result - async def update(self, id: Any, schema: UpdateSchemaType, partial: bool = False) -> ModelType: + async def update( + self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any + ) -> ModelType: """Orchestrate modifications to an existing domain entity. + Merges pre-update dictionary output with the update payload. + Args: id: The primary key of the record to update. schema: Validated fields representing the modifications. partial: If True, applies changes as a partial patch. Defaults to False. + **extra_data: Extra fields to append. Returns: The updated and processed database model instance. @@ -349,8 +301,9 @@ async def update(self, id: Any, schema: UpdateSchemaType, partial: bool = False) Raises: EntityNotFound: If the target entity identifier is not found in the database. """ - await self.pre_update(id, schema, partial) - result = await self.on_update(id, schema, partial) + hook_data = await self.pre_update(id, schema, partial) or {} + combined_extra = {**hook_data, **extra_data} + result = await self.on_update(id, schema, partial, **combined_extra) if not result: raise EntityNotFound(message=f"{self.model.__name__} not found.") await self.post_update(result) @@ -358,21 +311,9 @@ async def update(self, id: Any, schema: UpdateSchemaType, partial: bool = False) return result async def update_multi( - self, - data: Dict[Any, UpdateSchemaType], - partial: bool = False, - refresh: bool = False + self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False ) -> Sequence[ModelType]: - """Orchestrate batch modifications to multiple existing domain entities. - - Args: - data: Mapping dictionary matching entity primary keys to update schemas. - partial: If True, fields omitted in the schema payload are skipped. Defaults to False. - refresh: If True, executes individual refreshes after flushing. Defaults to False. - - Returns: - A sequence containing the updated and processed database model instances. - """ + """Orchestrate batch modifications to multiple existing domain entities.""" await self.pre_update_multi(data, partial) result = await self.on_update_multi(data, partial, refresh=refresh) await self.post_update_multi(result) @@ -380,17 +321,7 @@ async def update_multi( return result async def delete(self, id: Any) -> ModelType: - """Orchestrate the deletion and cleanup of a single domain entity. - - Args: - id: The primary key value of the target entity to delete. - - Returns: - The deleted and processed database model instance. - - Raises: - EntityNotFound: If the target entity identifier is not found in the database. - """ + """Orchestrate the deletion and cleanup of a single domain entity.""" await self.pre_delete(id) result = await self.on_delete(id) if not result: @@ -399,15 +330,8 @@ async def delete(self, id: Any) -> ModelType: await self._safe_commit() return result - async def delete_multi(self, ids: List[Any]) -> Sequence[ModelType]: - """Orchestrate batch deletions of multiple database records. - - Args: - ids: Collection of primary keys to find and delete. - - Returns: - A sequence containing the deleted and processed database model instances. - """ + async def delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: + """Orchestrate batch deletions of multiple database records.""" await self.pre_delete_multi(ids) result = await self.on_delete_multi(ids) await self.post_delete_multi(result) @@ -416,11 +340,7 @@ async def delete_multi(self, ids: List[Any]) -> Sequence[ModelType]: class SearchServiceMixin(AbstractService[ModelType]): - """Mixin implementing dynamic, policy-validated search operations. - - Exposes pre- and post-search interceptor hooks to allow query inspection and filtering - operations before and after search execution. - """ + """Mixin implementing dynamic, policy-validated search operations.""" async def pre_search(self, search_in: SearchRequest) -> None: """Hook triggered prior to dynamic search query compilation.""" @@ -455,17 +375,14 @@ async def search(self, search_in: SearchRequest, pagination: Any = None) -> Any: class BaseService( - Generic[ModelType, CreateSchemaType, UpdateSchemaType], + Generic[ModelType], ReadServiceMixin[ModelType], - WriteServiceMixin[ModelType, CreateSchemaType, UpdateSchemaType], - SearchServiceMixin[ModelType] + WriteServiceMixin[ModelType], + SearchServiceMixin[ModelType], ): - """The default business service implementation. - - Integrates Read, Write, and Search capabilities with custom lifecycle hooks. - """ + """The default business service implementation.""" - def __init__(self, model: Type[ModelType], repository: "BaseRepository") -> None: + def __init__(self, model: type[ModelType], repository: "BaseRepository") -> None: """Initialize the BaseService. Args: @@ -473,4 +390,4 @@ def __init__(self, model: Type[ModelType], repository: "BaseRepository") -> None repository: The data persistence layer coordinating model access. """ self.model = model - self.repository = repository \ No newline at end of file + self.repository = repository diff --git a/src/zcore/storage/__init__.py b/src/zcore/storage/__init__.py index fdebc13..c72b5d2 100644 --- a/src/zcore/storage/__init__.py +++ b/src/zcore/storage/__init__.py @@ -8,11 +8,11 @@ ) __all__ = [ - "StorageProvider", - "get_storage_provider", - "LocalStorageProvider", "BaseStorageValidator", "FileExtensionValidator", + "LocalStorageProvider", "MaxFileSizeValidator", "SafeMimeTypeValidator", -] \ No newline at end of file + "StorageProvider", + "get_storage_provider", +] diff --git a/src/zcore/storage/base.py b/src/zcore/storage/base.py index dcdf1c0..30e221b 100644 --- a/src/zcore/storage/base.py +++ b/src/zcore/storage/base.py @@ -1,12 +1,13 @@ """Storage Provider Base Interface. -This module defines the primary storage contract for the ZCore framework, facilitating -file uploads, raw binary streaming, and secure asset deletions. It also provides +This module defines the primary storage contract for the ZCore framework, facilitating +file uploads, raw binary streaming, and secure asset deletions. It also provides a FastAPI dependency stub to dynamically resolve storage providers from the IoC container. """ from abc import ABC, abstractmethod -from typing import AsyncGenerator +from collections.abc import AsyncGenerator + from fastapi import UploadFile from zcore.kernel.di import Inject @@ -15,7 +16,7 @@ class StorageProvider(ABC): """Abstract Base Class specifying standard storage platform capabilities. - Implementations must inherit from this class to manage file operations (e.g., local + Implementations must inherit from this class to manage file operations (e.g., local filesystems, AWS S3, or Google Cloud Storage) within the ZCore framework. """ @@ -34,10 +35,7 @@ async def upload(self, file: UploadFile, folder: str) -> str: @abstractmethod async def upload_stream( - self, - file_stream: AsyncGenerator[bytes, None], - filename: str, - folder: str + self, file_stream: AsyncGenerator[bytes, None], filename: str, folder: str ) -> str: """Stream binary raw chunks directly to the storage platform. @@ -64,9 +62,7 @@ async def delete(self, file_path: str) -> bool: pass -async def get_storage_provider( - provider: StorageProvider = Inject(StorageProvider) -) -> StorageProvider: +async def get_storage_provider(provider: Inject[StorageProvider]) -> StorageProvider: """FastAPI dependency to resolve the active storage provider. Args: @@ -75,4 +71,4 @@ async def get_storage_provider( Returns: The active storage provider instance. """ - return provider \ No newline at end of file + return provider diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index 5d44479..512053f 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -1,21 +1,22 @@ """Local Filesystem Storage Provider. -This module implements storage operations targeting the host machine's filesystem. -It protects against path traversal attacks by enforcing strict path bounds checks and +This module implements storage operations targeting the host machine's filesystem. +It protects against path traversal attacks by enforcing strict path bounds checks and evaluating user-supplied files through configurable security validators. """ import uuid -import structlog -from typing import AsyncGenerator, List, Optional -from anyio import Path +from collections.abc import AsyncGenerator from pathlib import PureWindowsPath + import aiofiles +import structlog +from anyio import Path from fastapi import UploadFile +from zcore.exceptions.base import AppException from zcore.storage.base import StorageProvider from zcore.storage.validators import BaseStorageValidator -from zcore.exceptions.base import AppException logger = structlog.get_logger() @@ -23,7 +24,7 @@ class LocalStorageProvider(StorageProvider): """Storage provider targeting the host filesystem. - Saves incoming files to local directories, utilizing randomized naming structures + Saves incoming files to local directories, utilizing randomized naming structures to prevent file name collisions and path validation to prevent directory traversal. Attributes: @@ -32,9 +33,7 @@ class LocalStorageProvider(StorageProvider): """ def __init__( - self, - base_path: str, - validators: Optional[List[BaseStorageValidator]] = None + self, base_path: str, validators: list[BaseStorageValidator] | None = None ) -> None: """Initialize the LocalStorageProvider. @@ -62,20 +61,22 @@ async def generate_path(self, filename: str, related_type: str) -> str: """ base = await Path(self.base_path).resolve(strict=False) normalized_type = PureWindowsPath(related_type).as_posix() - target_folder = await (Path(self.base_path) / normalized_type).resolve(strict=False) - + target_folder = await (Path(self.base_path) / normalized_type).resolve( + strict=False + ) + if not target_folder.is_relative_to(base): raise AppException("Path traversal attempt detected") - + uuid_file_name = str(uuid.uuid4())[:15] ext = Path(filename).suffix.lower() - + new_file_name = f"{uuid_file_name}{ext}" final_path = target_folder / new_file_name - + if not await target_folder.exists(): await target_folder.mkdir(parents=True, exist_ok=True) - + return final_path.as_posix() def _validate_file(self, file: UploadFile) -> None: @@ -90,7 +91,7 @@ def _validate_file(self, file: UploadFile) -> None: async def upload(self, file: UploadFile, related_type: str) -> str: """Upload a file to the local directory path. - Applies configured file validation, generates a secure path, and streams + Applies configured file validation, generates a secure path, and streams the raw file chunks to disk asynchronously. Args: @@ -104,7 +105,7 @@ async def upload(self, file: UploadFile, related_type: str) -> str: AppException: If path traversal is detected or a filesystem write failure occurs. """ self._validate_file(file) - + try: path = await self.generate_path(file.filename or "file", related_type) async with aiofiles.open(path, "wb") as buffer: @@ -113,15 +114,14 @@ async def upload(self, file: UploadFile, related_type: str) -> str: except AppException as e: raise e except Exception as e: - logger.error(f"Failed to upload file to local storage due to system error: {e}") + logger.error( + f"Failed to upload file to local storage due to system error: {e}" + ) raise AppException("Error saving file") return path async def upload_stream( - self, - file_stream: AsyncGenerator[bytes, None], - filename: str, - related_type: str + self, file_stream: AsyncGenerator[bytes, None], filename: str, related_type: str ) -> str: """Directly stream binary data chunks to a local file destination. @@ -144,14 +144,16 @@ async def upload_stream( except AppException as e: raise e except Exception as e: - logger.error(f"Failed to stream upload file to local storage due to system error: {e}") + logger.error( + f"Failed to stream upload file to local storage due to system error: {e}" + ) raise AppException("Error saving file") return path async def delete(self, file_path: str) -> bool: """Securely remove a file from local storage. - Verifies that the target path resides within the configured root directory + Verifies that the target path resides within the configured root directory before unlinking the asset to prevent arbitrary file deletion exploits. Args: @@ -164,13 +166,15 @@ async def delete(self, file_path: str) -> bool: base = await Path(self.base_path).resolve(strict=False) normalized_path = PureWindowsPath(file_path).as_posix() path_to_delete = await Path(normalized_path).resolve(strict=False) - + if not path_to_delete.is_relative_to(base): - logger.warning(f"Prevented arbitrary file deletion attempt outside base path: {file_path}") + logger.warning( + f"Prevented arbitrary file deletion attempt outside base path: {file_path}" + ) return False - + await path_to_delete.unlink(missing_ok=True) return True except Exception as e: logger.error(f"Failed to delete file: {file_path}. Error: {e}") - return False \ No newline at end of file + return False diff --git a/src/zcore/storage/validators.py b/src/zcore/storage/validators.py index f1e1448..0401cfa 100644 --- a/src/zcore/storage/validators.py +++ b/src/zcore/storage/validators.py @@ -1,15 +1,16 @@ """Storage Security Validators. -This module provides validation controls to protect server integrity during file uploads. -It includes file extension checks, file size limit enforcement to defend against Denial -of Service (DoS) attacks, and MIME-type verification using magic bytes to detect +This module provides validation controls to protect server integrity during file uploads. +It includes file extension checks, file size limit enforcement to defend against Denial +of Service (DoS) attacks, and MIME-type verification using magic bytes to detect masqueraded executable scripts. """ import mimetypes + import structlog -from typing import List, Set, Union, Optional from fastapi import UploadFile + from zcore.exceptions.base import ValidationError logger = structlog.get_logger() @@ -49,7 +50,11 @@ class FileExtensionValidator(BaseStorageValidator): message: Diagnostic warning message dispatched on failures. """ - def __init__(self, allowed_extensions: Union[List[str], Set[str]], message: Optional[str] = None): + def __init__( + self, + allowed_extensions: list[str] | set[str], + message: str | None = None, + ): """Initialize the FileExtensionValidator. Args: @@ -57,10 +62,13 @@ def __init__(self, allowed_extensions: Union[List[str], Set[str]], message: Opti message: Custom validation warning. Defaults to None. """ self.allowed_extensions = { - ext.lower() if ext.startswith(".") else f".{ext.lower()}" + ext.lower() if ext.startswith(".") else f".{ext.lower()}" for ext in allowed_extensions } - self.message = message or f"File extension not allowed. Allowed extensions are: {', '.join(sorted(self.allowed_extensions))}" + self.message = ( + message + or f"File extension not allowed. Allowed extensions are: {', '.join(sorted(self.allowed_extensions))}" + ) def __call__(self, file: UploadFile) -> None: """Check the uploaded file's extension against the allowed list. @@ -73,7 +81,7 @@ def __call__(self, file: UploadFile) -> None: """ filename = file.filename or "" ext = f".{filename.split('.')[-1].lower()}" if "." in filename else "" - + if ext not in self.allowed_extensions: raise ValidationError(message=self.message) @@ -87,7 +95,7 @@ class MaxFileSizeValidator(BaseStorageValidator): message: Diagnostic warning message dispatched on failures. """ - def __init__(self, max_size_mb: float, message: Optional[str] = None): + def __init__(self, max_size_mb: float, message: str | None = None): """Initialize the MaxFileSizeValidator. Args: @@ -109,7 +117,7 @@ def __call__(self, file: UploadFile) -> None: """ # Utilize Starlette's native file.size if populated, fallback to seek evaluation size = getattr(file, "size", None) - + if size is None: try: # Fallback seeking for chunked multipart parsers @@ -128,7 +136,7 @@ class SafeMimeTypeValidator(BaseStorageValidator): """MIME-type validator utilizing Magic Byte verification. Reads the header bytes of the file payload to verify that the file's content - matches its declared format. Blocks high-risk patterns such as PHP tag injections, + matches its declared format. Blocks high-risk patterns such as PHP tag injections, HTML script tags, Unix shebang scripts, and Windows executables. Attributes: @@ -136,7 +144,7 @@ class SafeMimeTypeValidator(BaseStorageValidator): message: Diagnostic warning message dispatched on failures. """ - def __init__(self, allowed_mimes: Union[List[str], Set[str]], message: Optional[str] = None): + def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = None): """Initialize the SafeMimeTypeValidator. Args: @@ -144,7 +152,10 @@ def __init__(self, allowed_mimes: Union[List[str], Set[str]], message: Optional[ message: Custom validation warning. Defaults to None. """ self.allowed_mimes = set(allowed_mimes) - self.message = message or "Uploaded file content is corrupted or its MIME-type is not allowed." + self.message = ( + message + or "Uploaded file content is corrupted or its MIME-type is not allowed." + ) def __call__(self, file: UploadFile) -> None: """Analyze file headers to detect masquerading extensions or script injections. @@ -153,7 +164,7 @@ def __call__(self, file: UploadFile) -> None: file: The uploaded file payload to inspect. Raises: - ValidationError: If the file content violates security policies or contains + ValidationError: If the file content violates security policies or contains unauthorized file signatures. """ try: @@ -164,9 +175,18 @@ def __call__(self, file: UploadFile) -> None: raise ValidationError(message="Failed to validate file signatures.") # Blocks PHP block tags, HTML script tags, Unix shebang scripts and Windows Executables - if b" None: detected_mime, _ = mimetypes.guess_type(file.filename) if not detected_mime or detected_mime not in self.allowed_mimes: - logger.warning(f"File validation failed. Detected MIME '{detected_mime}' not in allowed list.") - raise ValidationError(message=self.message) \ No newline at end of file + logger.warning( + f"File validation failed. Detected MIME '{detected_mime}' not in allowed list." + ) + raise ValidationError(message=self.message) diff --git a/src/zcore/testing/__init__.py b/src/zcore/testing/__init__.py new file mode 100644 index 0000000..457a610 --- /dev/null +++ b/src/zcore/testing/__init__.py @@ -0,0 +1,22 @@ +from zcore.testing.client import BaseZTest, ZTestClient +from zcore.testing.fixtures import ( + AppLifespan, + ContainerSandbox, + DatabaseRollback, + DependencyOverride, + UserContext, + ZTest, + ZTestFixture, +) + +__all__ = [ + "AppLifespan", + "BaseZTest", + "ContainerSandbox", + "DatabaseRollback", + "DependencyOverride", + "UserContext", + "ZTest", + "ZTestClient", + "ZTestFixture", +] diff --git a/src/zcore/testing/client.py b/src/zcore/testing/client.py new file mode 100644 index 0000000..2ba745d --- /dev/null +++ b/src/zcore/testing/client.py @@ -0,0 +1,105 @@ +import uuid +from abc import ABC +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +import httpx +from fastapi import FastAPI + +from zcore.security import get_current_user_stub +from zcore.testing.fixtures import ( + AppLifespan, + ContainerSandbox, + DatabaseRollback, + DependencyOverride, + UserContext, + ZTest, +) + + +class ZTestClient: + def __init__( + self, + app: FastAPI, + user_id: Any | None = None, + scopes: list[str] | None = None, + is_superuser: bool = False, + use_db: bool = True, + extra_context: dict[str, Any] | None = None, + extra_user_attrs: dict[str, Any] | None = None, + ) -> None: + self.app = app + self._client = None + + fixtures = [ContainerSandbox(), AppLifespan(app)] + + if use_db: + fixtures.append(DatabaseRollback()) + + if user_id: + scopes_list = scopes or [] + fixtures.append( + UserContext( + user_id=user_id, scopes=scopes_list, extra_context=extra_context + ) + ) + + async def get_mock_user() -> Any: + user_id_val = user_id + scopes_val = scopes_list + is_superuser_val = is_superuser + + class GenericMockUser: + id = user_id_val + is_active = True + is_superuser = is_superuser_val + scopes = scopes_val + + def __init__(self, extra_attrs: dict[str, Any]): + for k, v in extra_attrs.items(): + setattr(self, k, v) + + return GenericMockUser(extra_user_attrs or {}) + + fixtures.append( + DependencyOverride(app, get_current_user_stub, get_mock_user) + ) + + self._orchestrator = ZTest(*fixtures) + + async def __aenter__(self) -> httpx.AsyncClient: + await self._orchestrator.setUp() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), base_url="http://test" + ) + await self._client.__aenter__() + return self._client + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if self._client: + await self._client.__aexit__(exc_type, exc_val, exc_tb) + await self._orchestrator.tearDown() + + +class BaseZTest(ABC): + app: FastAPI = None + user_id: Any = None + is_active: bool = True + is_superuser: bool = False + scopes: list[str] = None + extra_user_attrs: dict[str, Any] = None + extra_context: dict[str, Any] = None + + @asynccontextmanager + async def run(self) -> AsyncGenerator[httpx.AsyncClient, None]: + user_id_val = self.user_id or uuid.uuid4() + async with ZTestClient( + app=self.app, + user_id=user_id_val, + scopes=self.scopes, + is_superuser=self.is_superuser, + extra_context=self.extra_context, + extra_user_attrs=self.extra_user_attrs, + ) as client: + yield client diff --git a/src/zcore/testing/fixtures.py b/src/zcore/testing/fixtures.py new file mode 100644 index 0000000..991d41c --- /dev/null +++ b/src/zcore/testing/fixtures.py @@ -0,0 +1,155 @@ +import uuid +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncSession + +from zcore.context.context import _request_context_store +from zcore.db.setup import db_manager +from zcore.kernel.di import _current_scope_id, container + + +class ZTestFixture(ABC): + @abstractmethod + async def setUp(self) -> None: + pass + + @abstractmethod + async def tearDown(self) -> None: + pass + + +class ContainerSandbox(ZTestFixture): + def __init__(self) -> None: + self._singletons = {} + self._scoped = {} + self._factories = {} + + async def setUp(self) -> None: + self._singletons = dict(container._singletons) + self._scoped = dict(container._scoped_definitions) + self._factories = dict(container._factories) + + async def tearDown(self) -> None: + container._singletons = self._singletons + container._scoped_definitions = self._scoped + container._factories = self._factories + + +class DatabaseRollback(ZTestFixture): + def __init__(self) -> None: + self.connection = None + self.transaction = None + self.session = None + self._scope_token = None + self._original_session_method = None + + async def setUp(self) -> None: + if not db_manager._engine: + raise RuntimeError("DatabaseManager engine is uninitialized.") + + self.connection = await db_manager._engine.connect() + self.transaction = await self.connection.begin() + self.session = AsyncSession( + bind=self.connection, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + ) + + scope_id = str(uuid.uuid4()) + self._scope_token = _current_scope_id.set(scope_id) + container.register_scoped_instance(AsyncSession, self.session) + + @asynccontextmanager + async def mock_session_manager() -> AsyncGenerator[AsyncSession, None]: + yield self.session + + self._original_session_method = db_manager.session + db_manager.session = mock_session_manager + + async def tearDown(self) -> None: + if self._original_session_method: + db_manager.session = self._original_session_method + + if self._scope_token: + scope_id = _current_scope_id.get() + if scope_id: + container.clear_scope(scope_id) + _current_scope_id.reset(self._scope_token) + + if self.session: + await self.session.close() + if self.transaction: + await self.transaction.rollback() + if self.connection: + await self.connection.close() + + +class UserContext(ZTestFixture): + def __init__( + self, + user_id: Any, + scopes: list[str] | None = None, + extra_context: dict[str, Any] | None = None, + ) -> None: + self.user_id = user_id + self.scopes = scopes or [] + self.extra_context = extra_context or {} + self._token = None + + async def setUp(self) -> None: + current_store = _request_context_store.get() + new_store = dict(current_store) + new_store["user_id"] = self.user_id + new_store["scopes"] = self.scopes + for key, val in self.extra_context.items(): + new_store[key] = val + self._token = _request_context_store.set(new_store) + + async def tearDown(self) -> None: + if self._token: + _request_context_store.reset(self._token) + + +class DependencyOverride(ZTestFixture): + def __init__(self, app: FastAPI, stub: Any, override_func: Any) -> None: + self.app = app + self.stub = stub + self.override_func = override_func + + async def setUp(self) -> None: + self.app.dependency_overrides[self.stub] = self.override_func + + async def tearDown(self) -> None: + if self.stub in self.app.dependency_overrides: + del self.app.dependency_overrides[self.stub] + + +class AppLifespan(ZTestFixture): + def __init__(self, app: FastAPI) -> None: + self.app = app + self.lifespan_ctx = None + + async def setUp(self) -> None: + self.lifespan_ctx = self.app.router.lifespan_context(self.app) + await self.lifespan_ctx.__aenter__() + + async def tearDown(self) -> None: + if self.lifespan_ctx: + await self.lifespan_ctx.__aexit__(None, None, None) + + +class ZTest(ZTestFixture): + def __init__(self, *fixtures: ZTestFixture) -> None: + self.fixtures = list(fixtures) + + async def setUp(self) -> None: + for fixture in self.fixtures: + await fixture.setUp() + + async def tearDown(self) -> None: + for fixture in reversed(self.fixtures): + await fixture.tearDown() diff --git a/src/zcore/utils/helpers.py b/src/zcore/utils/helpers.py index 51ecb19..432de8c 100644 --- a/src/zcore/utils/helpers.py +++ b/src/zcore/utils/helpers.py @@ -1,17 +1,17 @@ """Common Utility Helper Functions. -This module provides common utility helper functions, including a custom JSON encoder -that safely processes dates, decimals, and UUIDs, corresponding serialization/deserialization +This module provides common utility helper functions, including a custom JSON encoder +that safely processes dates, decimals, and UUIDs, corresponding serialization/deserialization wrappers, and a text transformation utility to generate URL-safe slugs. """ +import json import re import uuid -import json - -from datetime import datetime, date, time +from datetime import date, datetime, time from decimal import Decimal from typing import Annotated, Any + from pydantic import HttpUrl, PlainSerializer # Serializes HttpUrl objects into clean strings dynamically for Pydantic exports @@ -21,7 +21,7 @@ def slugify(text: str) -> str: """Transform a text string into an URL-safe slug representation. - Strips surrounding whitespace, normalizes casing to lowercase, removes + Strips surrounding whitespace, normalizes casing to lowercase, removes non-alphanumeric characters, and replaces spaces and underscores with single dashes. Args: @@ -31,17 +31,17 @@ def slugify(text: str) -> str: The URL-safe, sanitized, and hyphenated slug string. """ text = text.lower().strip() - text = re.sub(r'[^\w\s-]', '', text) - text = re.sub(r'[\s_-]+', '-', text) - text = re.sub(r'^-+|-+$', '', text) + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"[\s_-]+", "-", text) + text = re.sub(r"^-+|-+$", "", text) return text class CustomJSONEncoder(json.JSONEncoder): """JSON encoder capable of serializing advanced Python data types. - Extends the standard library JSONEncoder to serialize instances of UUID, - datetime/date/time, and Decimal, falling back to a string representation + Extends the standard library JSONEncoder to serialize instances of UUID, + datetime/date/time, and Decimal, falling back to a string representation for other unrecognized objects to prevent serialization failures. """ @@ -89,4 +89,4 @@ def json_loads(s: str | bytes, **kwargs: Any) -> Any: Returns: The parsed Python primitive values or collection structures. """ - return json.loads(s, **kwargs) \ No newline at end of file + return json.loads(s, **kwargs) diff --git a/src/zcore/utils/validators.py b/src/zcore/utils/validators.py index 1beeabe..f6f914d 100644 --- a/src/zcore/utils/validators.py +++ b/src/zcore/utils/validators.py @@ -1,31 +1,32 @@ """JSON Schema Validation Utility. -This module provides tools to validate JSON documents. It integrates standard -`jsonschema` Draft-7 schemas to evaluate payload boundaries or check the structural +This module provides tools to validate JSON documents. It integrates standard +`jsonschema` Draft-7 schemas to evaluate payload boundaries or check the structural validity of dynamically defined schemas. """ -from typing import Any, Optional -from jsonschema import validate, Draft7Validator, exceptions +from typing import Any + +from jsonschema import Draft7Validator, exceptions, validate from zcore.exceptions.base import ValidationError -def validate_json_schema(data: Any, schema: Optional[dict[str, Any]] = None) -> None: +def validate_json_schema(data: Any, schema: dict[str, Any] | None = None) -> None: """Validate JSON data against a Draft-7 schema. - If no schema dictionary is supplied, this function evaluates the `data` itself - as a schema definition, verifying that it is structurally valid according to the + If no schema dictionary is supplied, this function evaluates the `data` itself + as a schema definition, verifying that it is structurally valid according to the Draft-7 meta-schema. Args: - data: The target data instance to evaluate, or a schema dictionary to check + data: The target data instance to evaluate, or a schema dictionary to check if the `schema` parameter is None. - schema: The target JSON Schema dictionary to validate the data against. + schema: The target JSON Schema dictionary to validate the data against. Defaults to None. Raises: - ValidationError: If the data violates schema constraints or the target + ValidationError: If the data violates schema constraints or the target schema dictionary is structurally invalid. """ if data is None: @@ -38,10 +39,10 @@ def validate_json_schema(data: Any, schema: Optional[dict[str, Any]] = None) -> except exceptions.ValidationError as e: raise ValidationError( message=f"JSON Schema validation failed: {e.message}", - payload={"path": list(e.path), "schema": e.schema} + payload={"path": list(e.path), "schema": e.schema}, ) except exceptions.SchemaError as e: raise ValidationError( message="Internal System Error: The defined schema is corrupted or invalid.", - payload={"error": e.message} - ) \ No newline at end of file + payload={"error": e.message}, + ) diff --git a/src/zcore/web/__init__.py b/src/zcore/web/__init__.py index 64b2d70..0889362 100644 --- a/src/zcore/web/__init__.py +++ b/src/zcore/web/__init__.py @@ -1,5 +1,5 @@ +from zcore.web.api_router import ZCoreAPIRoute, ZCoreJSONResponse, ZCoreRequest from zcore.web.base_router import BaseRouter, RouteKey -from zcore.web.api_router import ZCoreAPIRoute, ZCoreRequest, ZCoreJSONResponse from zcore.web.middleware import RequestLogMiddleware, ScopedDependencyMiddleware from zcore.web.projection import Zchema from zcore.web.response import ResponseWrapper @@ -7,14 +7,14 @@ __all__ = [ "BaseRouter", + "RequestLogMiddleware", + "ResponseWrapper", "RouteKey", + "ScopedDependencyMiddleware", + "StreamManager", "ZCoreAPIRoute", - "ZCoreRequest", "ZCoreJSONResponse", - "RequestLogMiddleware", - "ScopedDependencyMiddleware", + "ZCoreRequest", "Zchema", - "ResponseWrapper", - "StreamManager", "init_stream_redis", -] \ No newline at end of file +] diff --git a/src/zcore/web/api_router.py b/src/zcore/web/api_router.py index f8c75fe..96f5f68 100644 --- a/src/zcore/web/api_router.py +++ b/src/zcore/web/api_router.py @@ -1,19 +1,21 @@ """Custom API Route and Serialization. This module provides specialized request, response, and routing behaviors for the ZCore -web layer. It delegates dynamic schema pruning and input validation to the bock-end +web layer. It delegates dynamic schema pruning and input validation to the bock-end Zchema infrastructure. """ -from typing import Callable, Coroutine, Any, get_origin, get_args +from collections.abc import Callable, Coroutine +from typing import Any, get_args, get_origin + from fastapi import Request, Response -from fastapi.routing import APIRoute from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute from pydantic import BaseModel +from zcore.context.context import ctx from zcore.utils.helpers import json_dumps from zcore.web.response import ResponseWrapper -from zcore.context.context import get_restricted_fields class ZCoreRequest(Request): @@ -38,8 +40,8 @@ async def body(self) -> bytes: class ZCoreJSONResponse(JSONResponse): """Custom JSONResponse that utilizes the unified serialization stream. - Since Zchema models recursively handle their own pruning during model - serialization natively, this class simply serializes the payload using + Since Zchema models recursively handle their own pruning during model + serialization natively, this class simply serializes the payload using our custom encoder. """ @@ -51,7 +53,9 @@ def render(self, content: Any) -> bytes: def find_input_schema(dependant: Any) -> type[BaseModel] | None: """Analyze FastAPI dependency models to find the body input Pydantic schema.""" for param in getattr(dependant, "body_params", []): - annotation = getattr(param.field_info, "annotation", None) or getattr(param, "type_", None) + annotation = getattr(param.field_info, "annotation", None) or getattr( + param, "type_", None + ) if isinstance(annotation, type) and issubclass(annotation, BaseModel): return annotation return None @@ -77,7 +81,7 @@ def find_output_schema(response_model: Any) -> type[BaseModel] | None: class ZCoreAPIRoute(APIRoute): """Custom API Route extending standard FastAPI route executions. - Configures routes to use the `ZCoreJSONResponse` class and dynamically generates + Configures routes to use the `ZCoreJSONResponse` class and dynamically generates security-pruned schema definitions on demand when `?schema=true` is requested. """ @@ -86,7 +90,7 @@ class ZCoreAPIRoute(APIRoute): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - + self.expose_schema = False if self.openapi_extra and isinstance(self.openapi_extra, dict): if self.openapi_extra.get("expose_schema"): @@ -95,14 +99,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.target_model = None if self.expose_schema: - if self.methods and any(m in ["POST", "PUT", "PATCH"] for m in self.methods): + if self.methods and any( + m in ["POST", "PUT", "PATCH"] for m in self.methods + ): self.target_model = find_input_schema(self.dependant) else: self.target_model = find_output_schema(self.response_model) current_class = self.response_class if hasattr(current_class, "value"): - current_class = getattr(current_class, "value") + current_class = current_class.value if current_class == JSONResponse: self.response_class = ZCoreJSONResponse @@ -112,41 +118,74 @@ def get_route_handler(self) -> Callable[[Request], Coroutine[None, None, Respons async def custom_route_handler(request: Request) -> Response: zcore_request = ZCoreRequest(request.scope, request._receive) - - if self.expose_schema and zcore_request.query_params.get("schema") == "true": + + custom_action = None + if self.openapi_extra and isinstance(self.openapi_extra, dict): + custom_action = self.openapi_extra.get("action") + + if not custom_action: + method_action_map = { + "post": "create", + "get": "view", + "put": "update", + "patch": "patch", + "delete": "delete", + } + current_method = request.method.lower() + custom_action = method_action_map.get(current_method, current_method) + + ctx.set("action", custom_action) + + if ( + self.expose_schema + and zcore_request.query_params.get("schema") == "true" + ): if not self.target_model: return JSONResponse( status_code=400, - content={"success": False, "message": "No schema defined for this endpoint."} + content={ + "success": False, + "message": "No schema defined for this endpoint.", + }, ) - + # Dynamic generation dynamically invokes __get_pydantic_json_schema__ under active context schema_dict = self.target_model.model_json_schema() - + response_payload = ResponseWrapper( success=True, message="Schema generated successfully", data=schema_dict, - meta={"restricted_fields": list(get_restricted_fields())} + meta={"restricted_fields": list(ctx.restricted_fields)}, ) return JSONResponse(content=response_payload.model_dump()) response: Response = await original_route_handler(zcore_request) - - hidden_fields = get_restricted_fields() - if hidden_fields and "application/json" in response.headers.get("content-type", "").lower(): + + hidden_fields = ctx.restricted_fields + if ( + hidden_fields + and "application/json" + in response.headers.get("content-type", "").lower() + ): vary = response.headers.get("vary", "") target_vary_headers = ["Authorization", "Cookie"] - existing_vary = [v.strip().lower() for v in vary.split(",")] if vary else [] - - new_vary_elements = [v for v in target_vary_headers if v.lower() not in existing_vary] + existing_vary = ( + [v.strip().lower() for v in vary.split(",")] if vary else [] + ) + + new_vary_elements = [ + v for v in target_vary_headers if v.lower() not in existing_vary + ] if new_vary_elements: if vary: - response.headers["vary"] = f"{vary}, {', '.join(new_vary_elements)}" + response.headers["vary"] = ( + f"{vary}, {', '.join(new_vary_elements)}" + ) else: response.headers["vary"] = ", ".join(new_vary_elements) return response - + return response - return custom_route_handler \ No newline at end of file + return custom_route_handler diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py index 929dff5..ab44906 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -1,27 +1,28 @@ """Automated Web Router Scaffolding. -This module provides the generic `BaseRouter` interface, which scaffolds -standard security-aware CRUD endpoints (POST, GET, GET_ALL, SEARCH, UPDATE, PATCH, DELETE) +This module provides the generic `BaseRouter` interface, which scaffolds +standard security-aware CRUD endpoints (POST, GET, GET_ALL, SEARCH, UPDATE, PATCH, DELETE) and integrates them with services, schemas, dependency requirements, and pagination handlers. """ import uuid from enum import StrEnum -from typing import TypeVar, Generic, Type, Any, Optional, Union, TYPE_CHECKING -from pydantic import BaseModel -from fastapi import APIRouter, status, Depends -from fastapi.routing import APIRoute +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from fastapi import APIRouter, Depends, status from fastapi.params import Depends as DependsClass +from fastapi.routing import APIRoute +from pydantic import BaseModel -from zcore.security.permissions import HasScopes -from zcore.web.response import ResponseWrapper -from zcore.web.api_router import ZCoreAPIRoute from zcore.db.search import SearchRequest +from zcore.kernel.di import Injector +from zcore.security.permissions import HasScopes from zcore.service.base import BaseService -from zcore.kernel.di import Inject +from zcore.web.api_router import ZCoreAPIRoute +from zcore.web.response import ResponseWrapper if TYPE_CHECKING: - from zcore.db.pagination import BasePagination + from zcore.db.pagination import BasePagination CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) @@ -59,19 +60,19 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): expose_schemas: Exposes target endpoint schemas dynamically. """ - model: Type[Any] - create_schema: Optional[Type[CreateSchemaType]] = None - update_schema: Optional[Type[UpdateSchemaType]] = None - schema_out: Optional[Type[BaseModel]] = None + model: type[Any] + create_schema: type[CreateSchemaType] | None = None + update_schema: type[UpdateSchemaType] | None = None + schema_out: type[BaseModel] | None = None service: Any = None prefix: str = "" - tags: Optional[list[str]] = None - exclude: Optional[set[RouteKey]] = None - pagination_class: Optional[Type["BasePagination"]] = None - route_class: Type[APIRoute] = ZCoreAPIRoute + tags: list[str] | None = None + exclude: set[RouteKey] | None = None + pagination_class: type["BasePagination"] | None = None + route_class: type[APIRoute] = ZCoreAPIRoute - expose_schemas: Union[set[RouteKey], bool] = False + expose_schemas: set[RouteKey] | bool = False def __init__(self) -> None: """Initialize the BaseRouter. @@ -83,12 +84,12 @@ def __init__(self) -> None: """ # Fail-Fast Startup Verification if not self.service: - raise ValueError(f"Service class must be defined in '{self.__class__.__name__}'.") - + raise ValueError( + f"Service class must be defined in '{self.__class__.__name__}'." + ) + self.router = APIRouter( - prefix=self.prefix, - tags=self.tags or [], - route_class=self.route_class + prefix=self.prefix, tags=self.tags or [], route_class=self.route_class ) self.exclude = self.exclude or set() self._validate_schema_configurations() @@ -101,16 +102,24 @@ def _validate_schema_configurations(self) -> None: ValueError: If an active endpoint lacks required schema configurations. """ if RouteKey.POST not in self.exclude and self.create_schema is None: - raise ValueError(f"POST route is enabled in '{self.__class__.__name__}', but 'create_schema' is None.") - if (RouteKey.UPDATE not in self.exclude or RouteKey.PATCH not in self.exclude) and self.update_schema is None: - raise ValueError(f"UPDATE/PATCH route is enabled in '{self.__class__.__name__}', but 'update_schema' is None.") - + raise ValueError( + f"POST route is enabled in '{self.__class__.__name__}', but 'create_schema' is None." + ) + if ( + RouteKey.UPDATE not in self.exclude or RouteKey.PATCH not in self.exclude + ) and self.update_schema is None: + raise ValueError( + f"UPDATE/PATCH route is enabled in '{self.__class__.__name__}', but 'update_schema' is None." + ) + if not getattr(self, "model", None): active_standard_routes = set(RouteKey) - (self.exclude or set()) if active_standard_routes: - raise ValueError(f"Model class must be defined in '{self.__class__.__name__}' to resolve route actions.") + raise ValueError( + f"Model class must be defined in '{self.__class__.__name__}' to resolve route actions." + ) - def _get_openapi_extra(self, route_key: RouteKey) -> Optional[dict[str, Any]]: + def _get_openapi_extra(self, route_key: RouteKey) -> dict[str, Any] | None: """Construct OpenAPI specifications for dynamic schema endpoints. Args: @@ -123,12 +132,12 @@ def _get_openapi_extra(self, route_key: RouteKey) -> Optional[dict[str, Any]]: expose = self.expose_schemas else: expose = route_key in (self.expose_schemas or set()) - + if expose: return {"expose_schema": True} return None - def _normalize_dependencies(self, raw_deps: Union[list[Any], Any]) -> list[DependsClass]: + def _normalize_dependencies(self, raw_deps: list[Any] | Any) -> list[DependsClass]: """Normalize raw classes or parameters into FastAPI Depends structures. Args: @@ -139,16 +148,16 @@ def _normalize_dependencies(self, raw_deps: Union[list[Any], Any]) -> list[Depen """ if raw_deps is None: return [] - + deps_list = raw_deps if isinstance(raw_deps, list) else [raw_deps] normalized: list[DependsClass] = [] - + for dep in deps_list: if isinstance(dep, DependsClass): normalized.append(dep) else: normalized.append(Depends(dep)) - + return normalized def get_route_action(self, route_key: RouteKey) -> str: @@ -161,8 +170,10 @@ def get_route_action(self, route_key: RouteKey) -> str: The calculated permission action identifier string. """ if not self.model: - raise ValueError(f"Model class must be defined in {self.__class__.__name__} to resolve route actions.") - + raise ValueError( + f"Model class must be defined in {self.__class__.__name__} to resolve route actions." + ) + action_map = { RouteKey.POST: self.model.actions().CREATE, RouteKey.GET: self.model.actions().VIEW, @@ -204,53 +215,81 @@ def _get_route_dependencies(self, route_key: RouteKey) -> list[DependsClass]: action = self.get_route_action(route_key) except (ValueError, AttributeError): action = "" - + dependencies = self.get_route_dependencies(route_key, action) return self._normalize_dependencies(dependencies) + def _sort_routes(self) -> None: + """Sort routes to place parameterized dynamic paths at the end of the routing table. + + This avoids dynamic routes (e.g., /{id:uuid}) from shadowing static sibling endpoints (e.g., /search). + """ + self.router.routes.sort(key=lambda r: "{" in r.path) + def _register_routes(self) -> None: """Dynamically generate and bind endpoints to the APIRouter.""" service_callable = self.service - service_dependency = Inject(service_callable) + service_dependency = Depends(Injector(service_callable)) if RouteKey.POST not in self.exclude: c_schema = self.create_schema - async def _create_endpoint(data_in: c_schema, service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.create_endpoint(data_in, service_inst) - + + async def _create_endpoint( + data_in: c_schema, + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.create_endpoint(data_in, service_inst, **kwargs) + self.router.add_api_route( path="/", endpoint=_create_endpoint, methods=["POST"], dependencies=self._get_route_dependencies(RouteKey.POST), status_code=status.HTTP_201_CREATED, - response_model=ResponseWrapper[self.schema_out] if self.schema_out else ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.POST) + response_model=ResponseWrapper[self.schema_out] + if self.schema_out + else ResponseWrapper, + openapi_extra=self._get_openapi_extra(RouteKey.POST), ) if RouteKey.GET not in self.exclude: - async def _get_endpoint(id: uuid.UUID, service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.get_endpoint(id, service_inst) + + async def _get_endpoint( + id: uuid.UUID, + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.get_endpoint(id, service_inst, **kwargs) self.router.add_api_route( - path="/{id}", + path="/{id:uuid}", endpoint=_get_endpoint, methods=["GET"], dependencies=self._get_route_dependencies(RouteKey.GET), status_code=status.HTTP_200_OK, - response_model=ResponseWrapper[self.schema_out] if self.schema_out else ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.GET) + response_model=ResponseWrapper[self.schema_out] + if self.schema_out + else ResponseWrapper, + openapi_extra=self._get_openapi_extra(RouteKey.GET), ) if RouteKey.GET_ALL not in self.exclude: if self.pagination_class: params_class = self.pagination_class.params_class - - async def _get_all_endpoint(params: params_class = Depends(), service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.get_all_endpoint(service_inst, params) + + async def _get_all_endpoint( + params: params_class = Depends(), + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.get_all_endpoint(service_inst, params, **kwargs) else: - async def _get_all_endpoint(service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.get_all_endpoint(service_inst) + + async def _get_all_endpoint( + service_inst: BaseService = service_dependency, **kwargs: Any + ) -> ResponseWrapper: + return await self.get_all_endpoint(service_inst, **kwargs) self.router.add_api_route( path="/", @@ -258,13 +297,20 @@ async def _get_all_endpoint(service_inst: BaseService = service_dependency) -> R methods=["GET"], dependencies=self._get_route_dependencies(RouteKey.GET_ALL), status_code=status.HTTP_200_OK, - response_model=ResponseWrapper[list[self.schema_out]] if self.schema_out else ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.GET_ALL) + response_model=ResponseWrapper[list[self.schema_out]] + if self.schema_out + else ResponseWrapper, + openapi_extra=self._get_openapi_extra(RouteKey.GET_ALL), ) if RouteKey.SEARCH not in self.exclude: - async def _search_endpoint(search_in: SearchRequest, service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.search_endpoint(search_in, service_inst) + + async def _search_endpoint( + search_in: SearchRequest, + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.search_endpoint(search_in, service_inst, **kwargs) self.router.add_api_route( path="/search", @@ -272,110 +318,147 @@ async def _search_endpoint(search_in: SearchRequest, service_inst: BaseService = methods=["POST"], dependencies=self._get_route_dependencies(RouteKey.SEARCH), status_code=status.HTTP_200_OK, - response_model=ResponseWrapper[list[self.schema_out]] if self.schema_out else ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.SEARCH) + response_model=ResponseWrapper[list[self.schema_out]] + if self.schema_out + else ResponseWrapper, + openapi_extra=self._get_openapi_extra(RouteKey.SEARCH), ) if RouteKey.UPDATE not in self.exclude: u_schema = self.update_schema - - async def _update_endpoint(id: uuid.UUID, data_in: u_schema, service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.update_endpoint(id, data_in, service_inst) + + async def _update_endpoint( + id: uuid.UUID, + data_in: u_schema, + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.update_endpoint(id, data_in, service_inst, **kwargs) self.router.add_api_route( - path="/{id}", + path="/{id:uuid}", endpoint=_update_endpoint, methods=["PUT"], dependencies=self._get_route_dependencies(RouteKey.UPDATE), status_code=status.HTTP_200_OK, - response_model=ResponseWrapper[self.schema_out] if self.schema_out else ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.UPDATE) + response_model=ResponseWrapper[self.schema_out] + if self.schema_out + else ResponseWrapper, + openapi_extra=self._get_openapi_extra(RouteKey.UPDATE), ) if RouteKey.PATCH not in self.exclude: u_schema = self.update_schema - - async def _patch_endpoint(id: uuid.UUID, data_in: u_schema, service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.patch_endpoint(id, data_in, service_inst) - + + async def _patch_endpoint( + id: uuid.UUID, + data_in: u_schema, + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.patch_endpoint(id, data_in, service_inst, **kwargs) + self.router.add_api_route( - path="/{id}", + path="/{id:uuid}", endpoint=_patch_endpoint, methods=["PATCH"], dependencies=self._get_route_dependencies(RouteKey.PATCH), status_code=status.HTTP_200_OK, - response_model=ResponseWrapper[self.schema_out] if self.schema_out else ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.PATCH) + response_model=ResponseWrapper[self.schema_out] + if self.schema_out + else ResponseWrapper, + openapi_extra=self._get_openapi_extra(RouteKey.PATCH), ) if RouteKey.DELETE not in self.exclude: - async def _delete_endpoint(id: uuid.UUID, service_inst: BaseService = service_dependency) -> ResponseWrapper: - return await self.delete_endpoint(id, service_inst) + + async def _delete_endpoint( + id: uuid.UUID, + service_inst: BaseService = service_dependency, + **kwargs: Any, + ) -> ResponseWrapper: + return await self.delete_endpoint(id, service_inst, **kwargs) self.router.add_api_route( - path="/{id}", + path="/{id:uuid}", endpoint=_delete_endpoint, methods=["DELETE"], dependencies=self._get_route_dependencies(RouteKey.DELETE), status_code=status.HTTP_200_OK, response_model=ResponseWrapper, - openapi_extra=self._get_openapi_extra(RouteKey.DELETE) + openapi_extra=self._get_openapi_extra(RouteKey.DELETE), ) - - async def create_endpoint(self, data_in: CreateSchemaType, service: BaseService) -> ResponseWrapper: + + self._sort_routes() + + async def create_endpoint( + self, data_in: CreateSchemaType, service: BaseService, **kwargs: Any + ) -> ResponseWrapper: """Execute the POST creation transaction. Args: data_in: Validated input schema containing creation properties. service: Active business service instance. + **kwargs: Extra dynamic route arguments. Returns: The created entity wrapped in a ResponseWrapper. """ data = await service.create(data_in) return ResponseWrapper(data=data) - - async def get_endpoint(self, id: uuid.UUID, service: BaseService) -> ResponseWrapper: + + async def get_endpoint( + self, id: uuid.UUID, service: BaseService, **kwargs: Any + ) -> ResponseWrapper: """Execute a single-record query lookup. Args: id: The primary key of the target entity. service: Active business service instance. + **kwargs: Extra dynamic route arguments. Returns: The resolved model record wrapped in a ResponseWrapper. """ - data = await service.get(id) + data = await service.get(id=id) return ResponseWrapper(data=data) - - async def get_all_endpoint(self, service: BaseService, pagination: Any = None) -> ResponseWrapper: + + async def get_all_endpoint( + self, service: BaseService, pagination: Any = None, **kwargs: Any + ) -> ResponseWrapper: """Execute batch query listings, applying optional page boundaries. Args: service: Active business service instance. pagination: Optional offset or keyset cursor parameters. Defaults to None. + **kwargs: Extra dynamic route arguments. Returns: The list of resolved model records wrapped in a ResponseWrapper. """ result = await service.get_list(pagination) from zcore.db.pagination import PaginatedResult + if isinstance(result, PaginatedResult): return ResponseWrapper(data=result.data, meta=result.meta) return ResponseWrapper(data=result) - - async def search_endpoint(self, search_in: SearchRequest, service: BaseService) -> ResponseWrapper: + + async def search_endpoint( + self, search_in: SearchRequest, service: BaseService, **kwargs: Any + ) -> ResponseWrapper: """Execute dynamic filter searches, applying mapped page limits. Args: search_in: Target filtering limits request parameters. service: Active business service instance. + **kwargs: Extra dynamic route arguments. Returns: The matching model records wrapped in a ResponseWrapper. """ pagination = None - from zcore.db.pagination import PageNumberParams, CursorParams + from zcore.db.pagination import CursorParams, PageNumberParams + if self.pagination_class: if self.pagination_class.params_class == CursorParams: pagination = CursorParams(cursor=search_in.cursor, size=search_in.size) @@ -384,47 +467,65 @@ async def search_endpoint(self, search_in: SearchRequest, service: BaseService) result = await service.search(search_in, pagination) from zcore.db.pagination import PaginatedResult + if isinstance(result, PaginatedResult): return ResponseWrapper(data=result.data, meta=result.meta) return ResponseWrapper(data=result) - - async def update_endpoint(self, id: uuid.UUID, data_in: UpdateSchemaType, service: BaseService) -> ResponseWrapper: + + async def update_endpoint( + self, + id: uuid.UUID, + data_in: UpdateSchemaType, + service: BaseService, + **kwargs: Any, + ) -> ResponseWrapper: """Execute a full-record entity update transaction. Args: id: Target entity identifier to update. data_in: Validated schema containing updated properties. service: Active business service instance. + **kwargs: Extra dynamic route arguments. Returns: The updated model record wrapped in a ResponseWrapper. """ data = await service.update(id, data_in) return ResponseWrapper(data=data) - - async def patch_endpoint(self, id: uuid.UUID, data_in: UpdateSchemaType, service: BaseService) -> ResponseWrapper: + + async def patch_endpoint( + self, + id: uuid.UUID, + data_in: UpdateSchemaType, + service: BaseService, + **kwargs: Any, + ) -> ResponseWrapper: """Execute a partial record update (PATCH) transaction. Args: id: Target entity identifier to patch. data_in: Validated schema containing partial changes. service: Active business service instance. + **kwargs: Extra dynamic route arguments. Returns: The updated model record wrapped in a ResponseWrapper. """ data = await service.update(id, data_in, partial=True) return ResponseWrapper(data=data) - - async def delete_endpoint(self, id: uuid.UUID, service: BaseService) -> ResponseWrapper: + + async def delete_endpoint( + self, id: uuid.UUID, service: BaseService, **kwargs: Any + ) -> ResponseWrapper: """Execute a single-record delete transaction. Args: id: Target entity identifier to delete. service: Active business service instance. + **kwargs: Extra dynamic route arguments. Returns: A success response wrapped in a ResponseWrapper. """ await service.delete(id) - return ResponseWrapper(message="Deleted successfully") \ No newline at end of file + return ResponseWrapper(message="Deleted successfully") diff --git a/src/zcore/web/middleware.py b/src/zcore/web/middleware.py index 3b60e1d..60d0bb7 100644 --- a/src/zcore/web/middleware.py +++ b/src/zcore/web/middleware.py @@ -1,22 +1,23 @@ """Web Middleware Implementations. -This module provides ASGI middleware to coordinate request lifecycles. It includes -`RequestLogMiddleware` to trace execution durations and manage request correlation headers, -and `ScopedDependencyMiddleware` to manage the lifecycle of request-scoped dependency injection +This module provides ASGI middleware to coordinate request lifecycles. It includes +`RequestLogMiddleware` to trace execution durations and manage request correlation headers, +and `ScopedDependencyMiddleware` to manage the lifecycle of request-scoped dependency injection container boundaries. """ import re -import uuid import time -import structlog +import uuid from typing import Any -from starlette.types import ASGIApp, Scope, Receive, Send -from zcore.kernel.di import _current_scope_id, container -from zcore.context.context import request_context -from zcore.db.setup import db_manager +import structlog from sqlalchemy.ext.asyncio import AsyncSession +from starlette.types import ASGIApp, Receive, Scope, Send + +from zcore.context.context import ctx +from zcore.db.setup import db_manager +from zcore.kernel.di import _current_scope_id, container log = structlog.get_logger() REQUEST_ID_PATTERN = re.compile(r"^[a-zA-Z0-9\-\.\_\:]{8,64}$") @@ -25,8 +26,8 @@ class RequestLogMiddleware: """ASGI middleware to manage request correlation IDs and log HTTP transaction metrics. - Intercepts HTTP requests, extracts or generates correlation IDs, binds them to - structured logging contextvars, appends the correlation ID to response headers, + Intercepts HTTP requests, extracts or generates correlation IDs, binds them to + structured logging contextvars, appends the correlation ID to response headers, and logs request durations. Attributes: @@ -55,20 +56,24 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: s_time = time.perf_counter() structlog.contextvars.clear_contextvars() - + raw_request_id = b"" for name, value in scope.get("headers", []): if name == b"x-request-id": # Standard lowercased header in ASGI spec raw_request_id = value break - request_id_str = raw_request_id.decode("utf-8", errors="ignore").strip() if raw_request_id else "" - + request_id_str = ( + raw_request_id.decode("utf-8", errors="ignore").strip() + if raw_request_id + else "" + ) + if request_id_str and REQUEST_ID_PATTERN.match(request_id_str): request_id = request_id_str else: request_id = str(uuid.uuid4()) - + structlog.contextvars.bind_contextvars(request_id=request_id) async def send_wrapper(message: dict[str, Any]) -> None: @@ -83,32 +88,34 @@ async def send_wrapper(message: dict[str, Any]) -> None: message["headers"] = headers await send(message) - with request_context(user_id=None, fields=None): - try: - await self.app(scope, receive, send_wrapper) - duration = (time.perf_counter() - s_time) * 1000 - log.info( - "http request", - method=scope.get("method"), - path=scope.get("path"), - duration_ms=round(duration, 2), - ) - except Exception: - duration = (time.perf_counter() - s_time) * 1000 - log.exception( - "http request failed", - method=scope.get("method"), - path=scope.get("path"), - duration_ms=round(duration, 2), - ) - raise - + token = ctx.initialize() + try: + await self.app(scope, receive, send_wrapper) + duration = (time.perf_counter() - s_time) * 1000 + log.info( + "http request", + method=scope.get("method"), + path=scope.get("path"), + duration_ms=round(duration, 2), + ) + except Exception: + duration = (time.perf_counter() - s_time) * 1000 + log.exception( + "http request failed", + method=scope.get("method"), + path=scope.get("path"), + duration_ms=round(duration, 2), + ) + raise + finally: + ctx.reset(token) + class ScopedDependencyMiddleware: """ASGI middleware to isolate request-scoped dependencies. - Initializes a new context scope key on incoming requests, binds it to the DI - context variables, and executes a cleanup sweep of the scope's registered instances + Initializes a new context scope key on incoming requests, binds it to the DI + context variables, and executes a cleanup sweep of the scope's registered instances upon connection closure. Attributes: @@ -137,7 +144,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: scope_id = str(uuid.uuid4()) token = _current_scope_id.set(scope_id) - + # Capture background tasks context if integrated with FastAPI # We defer scope teardown if there are pending background tasks try: @@ -147,4 +154,4 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: finally: # Safely clear the active IoC scope container.clear_scope(scope_id) - _current_scope_id.reset(token) \ No newline at end of file + _current_scope_id.reset(token) diff --git a/src/zcore/web/projection.py b/src/zcore/web/projection.py index d654255..dfa27bc 100644 --- a/src/zcore/web/projection.py +++ b/src/zcore/web/projection.py @@ -1,46 +1,59 @@ """Unified Schema and Response Pruning Hub. -This module provides the core `Zchema` base class, which integrates Pydantic V2 -dynamic JSON schema generation, input validation, and response serialization +This module provides the core `Zchema` base class, which integrates Pydantic V2 +dynamic JSON schema generation, input validation, and response serialization filtering based on domain-isolated context restriction definitions. """ -from typing import Any, Optional, ClassVar, Set -from pydantic import BaseModel, model_validator, model_serializer +from typing import Any, ClassVar -from zcore.context.context import get_restricted_fields +from pydantic import BaseModel, model_serializer, model_validator + +from zcore.context.context import ctx class Zchema(BaseModel): """Unified, domain-aware security schema base class. - Subclasses specify their unique database domain mapping via the `__model__` - class attribute. This enables contextual, recursive pruning across schema generation, + Subclasses specify their unique database domain mapping via the `__model__` + class attribute. This enables contextual, recursive pruning across schema generation, input validation, and response serialization. """ - __model__: ClassVar[Optional[str]] = None + __model__: ClassVar[str | None] = None @classmethod - def _get_relative_restricted_paths(cls) -> Set[str]: + def _get_relative_restricted_paths(cls) -> set[str]: """Extract and normalize restricted field paths mapped to this schema's domain.""" model_name = getattr(cls, "__model__", None) if not model_name: return set() - prefix = f"{model_name}." - restricted = get_restricted_fields() + action = ctx.get("action") + restricted = ctx.restricted_fields relative_paths = set() - + + action_prefix = f"{model_name}.{action}." if action else None + global_prefix = f"{model_name}." + for path in restricted: - if path.startswith(prefix): - relative_paths.add(path[len(prefix):]) + if action_prefix and path.startswith(action_prefix): + relative_paths.add(path[len(action_prefix) :]) + elif action and path == f"{model_name}.{action}": + relative_paths.add("*") + elif path.startswith(global_prefix): + remaining = path[len(global_prefix) :] + parts = remaining.split(".", 1) + if action and parts[0] == action: + continue + relative_paths.add(remaining) elif path == model_name: relative_paths.add("*") + return relative_paths @classmethod - def _prune_data(cls, data: Any, relative_paths: Set[str]) -> Any: + def _prune_data(cls, data: Any, relative_paths: set[str]) -> Any: """Recursively strip restricted attributes from dict representations.""" if not relative_paths or not isinstance(data, dict): return data @@ -50,7 +63,7 @@ def _prune_data(cls, data: Any, relative_paths: Set[str]) -> Any: return data # Group remaining nested relative paths by top-level keys - nested_restrictions: dict[str, Set[str]] = {} + nested_restrictions: dict[str, set[str]] = {} for path in relative_paths: parts = path.split(".", 1) if len(parts) == 1: @@ -68,7 +81,9 @@ def _prune_data(cls, data: Any, relative_paths: Set[str]) -> Any: data[key] = cls._prune_data(data[key], remaining_paths) elif isinstance(data[key], list): data[key] = [ - cls._prune_data(item, remaining_paths) if isinstance(item, dict) else item + cls._prune_data(item, remaining_paths) + if isinstance(item, dict) + else item for item in data[key] ] return data @@ -83,7 +98,7 @@ def __get_pydantic_json_schema__( if not relative_paths: return json_schema - def prune_schema(schema: dict[str, Any], paths: Set[str]) -> None: + def prune_schema(schema: dict[str, Any], paths: set[str]) -> None: if not isinstance(schema, dict): return @@ -95,7 +110,7 @@ def prune_schema(schema: dict[str, Any], paths: Set[str]) -> None: required = schema.get("required") if isinstance(properties, dict): - nested_restrictions: dict[str, Set[str]] = {} + nested_restrictions: dict[str, set[str]] = {} for path in paths: parts = path.split(".", 1) if len(parts) == 1: @@ -139,4 +154,4 @@ def secure_serializer(self, handler: Any) -> Any: if isinstance(serialized, dict): serialized_copy = dict(serialized) return self._prune_data(serialized_copy, relative_paths) - return serialized \ No newline at end of file + return serialized diff --git a/src/zcore/web/response.py b/src/zcore/web/response.py index 32afe85..dec42fc 100644 --- a/src/zcore/web/response.py +++ b/src/zcore/web/response.py @@ -1,10 +1,11 @@ """Unified API Response Envelope. -This module provides the generic `ResponseWrapper` schema, which formats both +This module provides the generic `ResponseWrapper` schema, which formats both success and failure API responses consistently across the ZCore framework. """ -from typing import Generic, TypeVar, Optional, Any +from typing import Any, Generic, TypeVar + from zcore.web.projection import Zchema T = TypeVar("T") @@ -24,11 +25,13 @@ class ResponseWrapper(Zchema, Generic[T]): success: bool = True message: str = "Success" - data: Optional[T] = None - meta: Optional[dict[str, Any]] = None + data: T | None = None + meta: dict[str, Any] | None = None @classmethod - def success_response(cls, data: T, message: str = "Success", meta: dict = None) -> "ResponseWrapper[T]": + def success_response( + cls, data: T, message: str = "Success", meta: dict | None = None + ) -> "ResponseWrapper[T]": """Construct a successful ResponseWrapper instance. Args: @@ -39,4 +42,4 @@ def success_response(cls, data: T, message: str = "Success", meta: dict = None) Returns: A populated ResponseWrapper container. """ - return cls(success=True, message=message, data=data, meta=meta) \ No newline at end of file + return cls(success=True, message=message, data=data, meta=meta) diff --git a/src/zcore/web/streams.py b/src/zcore/web/streams.py index 9568798..fae94b1 100644 --- a/src/zcore/web/streams.py +++ b/src/zcore/web/streams.py @@ -1,15 +1,18 @@ """Redis PubSub Streaming Engine. -This module coordinates multi-subscriber real-time event streaming. It maps active -user subscription queues locally, handles cluster-wide message propagation +This module coordinates multi-subscriber real-time event streaming. It maps active +user subscription queues locally, handles cluster-wide message propagation via Redis PubSub, and safely unregisters listeners on connection termination. """ -import uuid import asyncio -import structlog -from typing import Any, Optional, AsyncGenerator +import uuid +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from typing import Any + +import structlog + from zcore.utils.helpers import json_dumps, json_loads logger = structlog.get_logger() @@ -30,11 +33,11 @@ def init_stream_redis(client: Any) -> None: class StreamManager: """Coordinates active listener queues and Redis PubSub operations. - Supports cluster-wide event routing via subscription channels formatted as + Supports cluster-wide event routing via subscription channels formatted as `stream:user:`, falling back to local memory queues if Redis is unavailable. Attributes: - users_queues: In-memory mapping of active user IDs to list of active + users_queues: In-memory mapping of active user IDs to list of active asyncio listener queues. _pubsub_task: The background asyncio Task processing incoming Redis PubSub frames. _pubsub: The active Redis PubSub subscription connection context. @@ -44,7 +47,7 @@ class StreamManager: def __init__(self) -> None: """Initialize the StreamManager instance.""" self.users_queues: dict[uuid.UUID, list[asyncio.Queue[Any]]] = {} - self._pubsub_task: Optional[asyncio.Task[None]] = None + self._pubsub_task: asyncio.Task[None] | None = None self._pubsub: Any = None self._lock = asyncio.Lock() @@ -60,7 +63,7 @@ def redis_client(self) -> Any: async def start_listening(self) -> None: """Initiate background Redis PubSub pattern-matching subscribers. - Subscribes to cluster-wide channels of pattern `stream:user:*` and schedules + Subscribes to cluster-wide channels of pattern `stream:user:*` and schedules the active listener task in the event loop. """ client = self.redis_client @@ -76,7 +79,7 @@ async def start_listening(self) -> None: async def _listen_to_redis(self) -> None: """Background loop reading and routing incoming PubSub messages. - Unpacks incoming messages, parses user scope identifiers, and forwards + Unpacks incoming messages, parses user scope identifiers, and forwards parsed payloads to matching active local queues. """ try: @@ -106,7 +109,7 @@ async def _listen_to_redis(self) -> None: async def subscribe(self, user_id: uuid.UUID) -> asyncio.Queue[Any]: """Subscribe a user, returning a bounded async listener queue. - Initializes background Redis listeners if this is the first active subscription + Initializes background Redis listeners if this is the first active subscription for a user in this node. Args: @@ -128,7 +131,7 @@ async def subscribe(self, user_id: uuid.UUID) -> asyncio.Queue[Any]: async def unsubscribe(self, user_id: uuid.UUID, queue: asyncio.Queue[Any]) -> None: """Unsubscribe a user's listener queue. - Pops the active queue, and shuts down the background task if no active + Pops the active queue, and shuts down the background task if no active listener queues remain in the system. Args: @@ -141,12 +144,18 @@ async def unsubscribe(self, user_id: uuid.UUID, queue: asyncio.Queue[Any]) -> No self.users_queues[user_id].remove(queue) if not self.users_queues[user_id]: del self.users_queues[user_id] - if not self.users_queues and self._pubsub_task and not self._pubsub_task.done(): + if ( + not self.users_queues + and self._pubsub_task + and not self._pubsub_task.done() + ): self._pubsub_task.cancel() self._pubsub_task = None @asynccontextmanager - async def subscription(self, user_id: uuid.UUID) -> AsyncGenerator[asyncio.Queue[Any], None]: + async def subscription( + self, user_id: uuid.UUID + ) -> AsyncGenerator[asyncio.Queue[Any], None]: """Context manager safely wrapping active user event streams. Guarantees cleanup and unregistration of the queue upon block exit. @@ -166,7 +175,7 @@ async def subscription(self, user_id: uuid.UUID) -> AsyncGenerator[asyncio.Queue async def publish(self, user_id: uuid.UUID, data: dict[str, Any]) -> None: """Publish an event payload to a target user's stream. - Routes the message through Redis PubSub if configured to broadcast to + Routes the message through Redis PubSub if configured to broadcast to all active nodes, falling back to local memory delivery if unconfigured. Args: @@ -176,10 +185,7 @@ async def publish(self, user_id: uuid.UUID, data: dict[str, Any]) -> None: client = self.redis_client if client: try: - await client.publish( - f"stream:user:{user_id}", - json_dumps(data) - ) + await client.publish(f"stream:user:{user_id}", json_dumps(data)) return except Exception as e: logger.error(f"Redis publish failed: {e}") @@ -205,4 +211,4 @@ async def _local_publish(self, user_id: uuid.UUID, data: dict[str, Any]) -> None if queue in queues: queues.remove(queue) if not self.users_queues[user_id]: - del self.users_queues[user_id] \ No newline at end of file + del self.users_queues[user_id] diff --git a/tests/conftest.py b/tests/conftest.py index 639c3b9..b5be98d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ from zcore.kernel.di import container, _current_scope_id from zcore.kernel.engine import Kernel from zcore.cache.base import close_cache -from zcore.config import ZCoreCoreSettings, initialize_settings +from zcore.config import Settings, initialize_settings class MockSessionContext: def __init__(self, session: AsyncSession) -> None: @@ -124,10 +124,9 @@ def reset_di_container(test_db_url: str, test_storage_dir: str) -> Generator[Non container._constructor_cache.clear() container._dependency_signature_cache.clear() _current_scope_id.set(None) - test_settings = ZCoreCoreSettings( + test_settings = Settings( DATABASE_URL=test_db_url, SECRET_KEY=os.getenv("TEST_SECRET_KEY", "test-secret-key-12345678901234567890123456789012"), - ENVIRONMENT="testing", STORAGE_PATH=test_storage_dir ) initialize_settings(test_settings) diff --git a/tests/test_cache.py b/tests/test_cache.py index 6b2263f..e83c900 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,12 +1,14 @@ import asyncio import time +import gc +import threading from typing import Any, Type from unittest.mock import AsyncMock import pytest from pydantic import BaseModel -from zcore.cache.base import BaseCache -from zcore.cache.ttllru_cache import TTLLRUCache +from zcore.cache.base import BaseCache, init_cache, close_cache +from zcore.cache.ttllru_cache import TTLLRUCache, _active_caches class SampleCachedModel(BaseModel): id: int @@ -29,6 +31,7 @@ def test_ttllru_eviction_and_cleanup( ) -> None: current_time = 1000.0 monkeypatch.setattr("time.time", lambda: current_time) + monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) cache = TTLLRUCache(maxsize=10) cache.set("a", "value_a", ttl=ttl_a) @@ -106,4 +109,160 @@ async def test_cache_deserialization_types( assert retrieved.id == payload["id"] assert retrieved.name == payload["name"] else: - assert retrieved == payload \ No newline at end of file + assert retrieved == payload + +def test_ttllru_lru_eviction() -> None: + cache = TTLLRUCache(maxsize=3) + cache.set("k1", "v1") + cache.set("k2", "v2") + cache.set("k3", "v3") + cache.get("k1") + cache.set("k4", "v4") + assert cache.get("k1") == "v1" + assert cache.get("k2") is None + assert cache.get("k3") == "v3" + assert cache.get("k4") == "v4" + +def test_ttllru_inline_eviction_on_get(monkeypatch: pytest.MonkeyPatch) -> None: + current_time = 1000.0 + monkeypatch.setattr("time.time", lambda: current_time) + monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) + + cache = TTLLRUCache(maxsize=10) + cache.set("key", "val", ttl=5) + + current_time += 10 + assert cache.get("key") is None + + with cache._lock: + assert "key" not in cache.cache + +def test_ttllru_thread_safety() -> None: + cache = TTLLRUCache(maxsize=100) + + def worker(worker_id: int) -> None: + for i in range(50): + key = f"key_{worker_id}_{i}" + cache.set(key, f"val_{i}", ttl=1) + cache.get(key) + if i % 5 == 0: + cache.delete(key) + cache.evict_expired() + time.sleep(0.001) + + threads = [] + for t_id in range(5): + t = threading.Thread(target=worker, args=(t_id,)) + threads.append(t) + t.start() + + for t in threads: + t.join() + +def test_ttllru_weakref_gc() -> None: + gc.collect() + initial_count = len(list(_active_caches)) + + def create_temporary_cache() -> None: + temp_cache = TTLLRUCache(maxsize=5) + assert len(list(_active_caches)) == initial_count + 1 + + create_temporary_cache() + gc.collect() + assert len(list(_active_caches)) == initial_count + +def test_ttllru_overwrite_resets_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + current_time = 1000.0 + monkeypatch.setattr("time.time", lambda: current_time) + monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) + + cache = TTLLRUCache() + cache.set("k1", "v1", ttl=5) + cache.set("k1", "v2", ttl=100) + + current_time += 10 + assert cache.get("k1") == "v2" + +def test_ttllru_explicit_delete() -> None: + cache = TTLLRUCache() + cache.set("k1", "v1") + assert cache.get("k1") == "v1" + cache.delete("k1") + assert cache.get("k1") is None + + with cache._lock: + assert "k1" not in cache.cache + +@pytest.mark.anyio +async def test_base_cache_prefix_isolation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) + + cache_users = BaseCache[str](prefix="users") + cache_products = BaseCache[str](prefix="products") + + await cache_users.set("1", "alice") + await cache_products.set("1", "laptop") + + assert await cache_users.get("1") == "alice" + assert await cache_products.get("1") == "laptop" + +@pytest.mark.anyio +async def test_cache_global_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: + import zcore.cache.base as base_module + monkeypatch.setattr(base_module, "_shared_redis_client", None) + monkeypatch.setattr(base_module, "_eviction_task", None) + + init_cache(redis_url="redis://localhost:6379") + + assert base_module._eviction_task is not None + assert not base_module._eviction_task.done() + + await close_cache() + + assert base_module._eviction_task is None + assert base_module._shared_redis_client is None + +@pytest.mark.anyio +async def test_cache_double_init(monkeypatch: pytest.MonkeyPatch) -> None: + import zcore.cache.base as base_module + monkeypatch.setattr(base_module, "_shared_redis_client", None) + monkeypatch.setattr(base_module, "_eviction_task", None) + + init_cache() + task1 = base_module._eviction_task + assert task1 is not None + + init_cache() + task2 = base_module._eviction_task + assert task2 is task1 + + await close_cache() + +@pytest.mark.anyio +@pytest.mark.parametrize("redis_healthy", [True, False]) +async def test_base_cache_delete_fallback(monkeypatch: pytest.MonkeyPatch, redis_healthy: bool) -> None: + cache = BaseCache[str](prefix="del_test") + await cache.set("k1", "v1") + + if redis_healthy: + mock_client = AsyncMock() + monkeypatch.setattr("zcore.cache.base._shared_redis_client", mock_client) + await cache.delete("k1") + mock_client.delete.assert_called_once_with("del_test:k1") + else: + mock_client = AsyncMock() + mock_client.delete.side_effect = Exception("Redis connection lost") + mock_client.get.side_effect = Exception("Redis connection lost") + monkeypatch.setattr("zcore.cache.base._shared_redis_client", mock_client) + await cache.delete("k1") + assert await cache.get("k1") is None + +@pytest.mark.anyio +async def test_base_cache_corrupt_json_deserialization(monkeypatch: pytest.MonkeyPatch) -> None: + cache = BaseCache[Any](prefix="corrupt_test") + monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) + + cache._local_cache.set("corrupt_test:corrupt_key", "{invalid_json_string}") + + val = await cache.get("corrupt_key") + assert val is None \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index ec4fe73..96dd6c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,10 @@ -import os -from pathlib import Path import re import sys -from unittest.mock import patch import pytest +from pathlib import Path +from unittest.mock import patch + from zcore.cli import main @pytest.fixture @@ -12,7 +12,6 @@ def run_in_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.chdir(tmp_path) return tmp_path -@pytest.mark.anyio @pytest.mark.parametrize( "project_name", [ @@ -20,7 +19,7 @@ def run_in_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: "enterprise_service", ] ) -async def test_cli_init_command(run_in_tmp_path: Path, project_name: str) -> None: +def test_cli_init_command(run_in_tmp_path: Path, project_name: str) -> None: test_args = ["zc", "init", project_name] with patch.object(sys, "argv", test_args): @@ -35,12 +34,10 @@ async def test_cli_init_command(run_in_tmp_path: Path, project_name: str) -> Non env_content = (project_dir / ".env").read_text() assert f'PROJECT_NAME="{project_name}"' in env_content - assert "ENVIRONMENT=development" in env_content secret_key_match = re.search(r'SECRET_KEY="([a-f0-9]{64})"', env_content) assert secret_key_match is not None -@pytest.mark.anyio @pytest.mark.parametrize( "app_name, with_template, expected_pascal_name", [ @@ -48,8 +45,9 @@ async def test_cli_init_command(run_in_tmp_path: Path, project_name: str) -> Non ("order_management", False, "OrderManagement"), ] ) -async def test_cli_startapp_scaffolding( +def test_cli_startapp_scaffolding( run_in_tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, app_name: str, with_template: bool, expected_pascal_name: str @@ -59,7 +57,7 @@ async def test_cli_startapp_scaffolding( with patch.object(sys, "argv", init_args): main() - os.chdir(run_in_tmp_path / project_name) + monkeypatch.chdir(run_in_tmp_path / project_name) startapp_args = ["zc", "startapp", app_name] if with_template: @@ -110,4 +108,138 @@ def test_cli_invalid_module_names(run_in_tmp_path: Path, command: str, invalid_n with patch.object(sys, "argv", test_args): with pytest.raises(SystemExit) as exc_info: main() - assert exc_info.value.code == 1 \ No newline at end of file + assert exc_info.value.code == 1 + +def test_cli_version_command(capsys) -> None: + test_args = ["zc", "--version"] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "ZCore Framework - Version 0.1.0-Beta" in captured.out + +def test_cli_help_command(capsys) -> None: + test_args = ["zc"] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Available Framework Orchestration Commands" in captured.out + +def test_cli_init_collision(run_in_tmp_path: Path) -> None: + project_name = "collision_project" + project_dir = run_in_tmp_path / project_name + project_dir.mkdir() + test_args = ["zc", "init", project_name] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + +def test_cli_startapp_collision(run_in_tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + project_name = "test_project" + init_args = ["zc", "init", project_name] + with patch.object(sys, "argv", init_args): + main() + monkeypatch.chdir(run_in_tmp_path / project_name) + app_name = "collision_app" + app_dir = run_in_tmp_path / project_name / app_name + app_dir.mkdir() + startapp_args = ["zc", "startapp", app_name] + with patch.object(sys, "argv", startapp_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + +@pytest.mark.parametrize("with_template", [True, False]) +def test_cli_startapp_with_test( + run_in_tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + with_template: bool +) -> None: + project_name = "test_project" + init_args = ["zc", "init", project_name] + with patch.object(sys, "argv", init_args): + main() + monkeypatch.chdir(run_in_tmp_path / project_name) + app_name = "test_app" + startapp_args = ["zc", "startapp", app_name, "--test"] + if with_template: + startapp_args.append("-t") + with patch.object(sys, "argv", startapp_args): + main() + app_dir = run_in_tmp_path / project_name / app_name + assert (app_dir / "tests.py").is_file() + test_content = (app_dir / "tests.py").read_text() + if with_template: + assert "test_get_test_app_list" in test_content + else: + assert test_content == "" + +def test_cli_genenv_default(run_in_tmp_path: Path) -> None: + test_args = ["zc", "genenv"] + with patch.object(sys, "argv", test_args): + main() + assert (run_in_tmp_path / ".env.example").is_file() + content = (run_in_tmp_path / ".env.example").read_text() + assert "DATABASE_URL=" in content + assert "DEBUG=True" in content + +def test_cli_genenv_custom_output(run_in_tmp_path: Path) -> None: + test_args = ["zc", "genenv", "-o", "custom.env"] + with patch.object(sys, "argv", test_args): + main() + assert (run_in_tmp_path / "custom.env").is_file() + content = (run_in_tmp_path / "custom.env").read_text() + assert "DATABASE_URL=" in content + +def test_cli_genenv_collision_and_force(run_in_tmp_path: Path) -> None: + existing_file = run_in_tmp_path / ".env.example" + existing_file.write_text("old_content") + test_args = ["zc", "genenv"] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert existing_file.read_text() == "old_content" + + force_args = ["zc", "genenv", "--force"] + with patch.object(sys, "argv", force_args): + main() + assert existing_file.read_text() != "old_content" + assert "DATABASE_URL=" in existing_file.read_text() + +def test_cli_run_outside_project_root(run_in_tmp_path: Path) -> None: + test_args = ["zc", "run"] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + +def test_cli_run_with_uvicorn_mock(run_in_tmp_path: Path) -> None: + (run_in_tmp_path / "main.py").touch() + (run_in_tmp_path / ".env").write_text("HOST=127.0.0.1\nPORT=8080\n") + test_args = ["zc", "run"] + with patch("subprocess.run") as mock_run, patch.object(sys, "argv", test_args): + main() + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + cmd = args[0] + assert any("uvicorn" in item for item in cmd) + assert any("main:app" in item for item in cmd) + assert any("--host=127.0.0.1" in item for item in cmd) + assert any("--port=8080" in item for item in cmd) + assert any("--reload" in item for item in cmd) + assert "PYTHONPATH" in kwargs.get("env", {}) + +def test_cli_gensecret(capsys) -> None: + test_args = ["zc", "gensecret"] + with patch.object(sys, "argv", test_args): + main() + captured = capsys.readouterr() + assert "Generated Cryptographic Secret Key:" in captured.out + secret = captured.out.splitlines()[-1].strip() + assert len(secret) == 64 + assert re.match(r"^[a-f0-9]{64}$", secret) is not None \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 7b1213b..26d70ff 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,10 @@ import os +import sys +import tempfile from typing import Any import pytest -from zcore.config import ZCoreCoreSettings, get_settings, initialize_settings, settings +from zcore.config import Settings, get_settings, initialize_settings, settings from zcore.kernel.di import container @pytest.mark.parametrize( @@ -20,9 +22,7 @@ def test_settings_environmental_loading( check_attr: str ) -> None: monkeypatch.setenv(env_key, env_val) - container._singletons.clear() - fresh_settings = get_settings() assert getattr(fresh_settings, check_attr) == expected_val assert fresh_settings.DATABASE_URL == "sqlite+aiosqlite:///zcore.db" @@ -36,10 +36,86 @@ def test_settings_environmental_loading( def test_settings_proxy_resolution(secret_a: str, secret_b: str) -> None: container._singletons.clear() - settings_a = ZCoreCoreSettings(SECRET_KEY=secret_a) + settings_a = Settings(SECRET_KEY=secret_a) initialize_settings(settings_a) assert settings.SECRET_KEY == secret_a - settings_b = ZCoreCoreSettings(SECRET_KEY=secret_b) + settings_b = Settings(SECRET_KEY=secret_b) initialize_settings(settings_b) - assert settings.SECRET_KEY == secret_b \ No newline at end of file + assert settings.SECRET_KEY == secret_b + +def test_settings_type_coercion(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEBUG", "False") + monkeypatch.setenv("POOL_SIZE", "15") + container._singletons.clear() + fresh_settings = get_settings() + assert fresh_settings.DEBUG is False + assert fresh_settings.POOL_SIZE == 15 + +def test_settings_case_sensitivity(monkeypatch: pytest.MonkeyPatch) -> None: + if sys.platform == "win32": + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("secret_key=lowercased-key-should-be-ignored\n") + temp_filename = f.name + try: + container._singletons.clear() + fresh_settings = Settings(_env_file=temp_filename) + assert fresh_settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" + finally: + os.unlink(temp_filename) + else: + monkeypatch.setenv("secret_key", "lowercased-key-should-be-ignored") + container._singletons.clear() + fresh_settings = get_settings() + assert fresh_settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" + +def test_settings_extra_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("RANDOM_ZCORE_ENVIRONMENT_VARIABLE", "ignore-me") + container._singletons.clear() + fresh_settings = get_settings() + assert not hasattr(fresh_settings, "RANDOM_ZCORE_ENVIRONMENT_VARIABLE") + +def test_settings_custom_env_file(monkeypatch: pytest.MonkeyPatch) -> None: + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("SECRET_KEY=env-file-custom-secret-999\nPROJECT_NAME=EnvFileApp") + temp_filename = f.name + try: + container._singletons.clear() + fresh_settings = Settings(_env_file=temp_filename) + initialize_settings(fresh_settings) + resolved = get_settings() + assert resolved.SECRET_KEY == "env-file-custom-secret-999" + assert resolved.PROJECT_NAME == "EnvFileApp" + finally: + os.unlink(temp_filename) + +def test_settings_subclass_di_registration() -> None: + container._singletons.clear() + class CustomProjectSettings(Settings): + CUSTOM_FIELD: str = "custom_value" + custom_inst = CustomProjectSettings() + initialize_settings(custom_inst) + resolved_parent = container.resolve(Settings) + resolved_child = container.resolve(CustomProjectSettings) + assert resolved_parent is custom_inst + assert resolved_child is custom_inst + assert getattr(resolved_parent, "CUSTOM_FIELD") == "custom_value" + +def test_settings_get_settings_fallback() -> None: + container._singletons.clear() + assert Settings not in container._singletons + inst = get_settings() + assert Settings in container._singletons + assert container.resolve(Settings) is inst + +def test_settings_proxy_missing_attribute() -> None: + container._singletons.clear() + with pytest.raises(AttributeError): + _ = settings.NON_EXISTING_DATABASE_PORT + +def test_settings_proxy_lazy_evaluation() -> None: + container._singletons.clear() + assert settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" + new_inst = Settings(SECRET_KEY="lazy-evaluated-new-secret") + initialize_settings(new_inst) + assert settings.SECRET_KEY == "lazy-evaluated-new-secret" \ No newline at end of file diff --git a/tests/test_context.py b/tests/test_context.py index a6b63bd..c978569 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,18 +1,9 @@ import uuid +import asyncio from typing import Any, Type import pytest -from zcore.context.context import ( - _current_user_id, - _restricted_fields, - get_current_user_id, - get_restricted_fields, - request_context, - restricted_fields_context, - set_current_user_id, - set_restricted_fields, - user_context, -) +from zcore.context.context import ctx, _request_context_store @pytest.mark.parametrize( "user_id_input, expected_output, expected_exception", @@ -29,13 +20,16 @@ def test_set_current_user_id_validation( expected_output: uuid.UUID | None, expected_exception: Type[Exception] | None ) -> None: - if expected_exception: - with pytest.raises(expected_exception): - set_current_user_id(user_id_input) - else: - token = set_current_user_id(user_id_input) - assert get_current_user_id() == expected_output - _current_user_id.reset(token) + token = _request_context_store.set({}) + try: + if expected_exception: + with pytest.raises(expected_exception): + ctx.user_id = user_id_input + else: + ctx.user_id = user_id_input + assert ctx.user_id == expected_output + finally: + _request_context_store.reset(token) @pytest.mark.parametrize( "fields_input, expected_output", @@ -50,39 +44,136 @@ def test_set_restricted_fields_immutability( fields_input: Any, expected_output: frozenset[str] ) -> None: - token = set_restricted_fields(fields_input) - retrieved = get_restricted_fields() - assert retrieved == expected_output - assert isinstance(retrieved, frozenset) - assert not hasattr(retrieved, "add") - assert not hasattr(retrieved, "remove") - _restricted_fields.reset(token) + token = _request_context_store.set({}) + try: + ctx.restricted_fields = fields_input + retrieved = ctx.restricted_fields + assert retrieved == expected_output + assert isinstance(retrieved, frozenset) + assert not hasattr(retrieved, "add") + assert not hasattr(retrieved, "remove") + finally: + _request_context_store.reset(token) def test_context_managers_cleanup() -> None: - initial_user = uuid.uuid4() - initial_fields = {"email"} - - set_current_user_id(initial_user) - set_restricted_fields(initial_fields) - - new_user = uuid.uuid4() - with pytest.raises(RuntimeError): - with user_context(new_user): - assert get_current_user_id() == new_user - raise RuntimeError("Error inside user_context") - assert get_current_user_id() == initial_user - - new_fields = {"password"} - with pytest.raises(RuntimeError): - with restricted_fields_context(new_fields): - assert get_restricted_fields() == frozenset(new_fields) - raise RuntimeError("Error inside restricted_fields_context") - assert get_restricted_fields() == frozenset(initial_fields) + token = _request_context_store.set({}) + try: + initial_user = uuid.uuid4() + initial_fields = {"email"} + + ctx.user_id = initial_user + ctx.restricted_fields = initial_fields + + new_user = uuid.uuid4() + new_fields = {"password"} + + with pytest.raises(RuntimeError): + with ctx.scope(user_id=new_user, restricted_fields=new_fields): + assert ctx.user_id == new_user + assert ctx.restricted_fields == frozenset(new_fields) + raise RuntimeError("Error inside scope") + + assert ctx.user_id == initial_user + assert ctx.restricted_fields == frozenset(initial_fields) + + with ctx.scope(custom_key="custom_value"): + assert ctx.get("custom_key") == "custom_value" + + assert ctx.get("custom_key") is None + finally: + _request_context_store.reset(token) + +def test_context_core_get_set_remove() -> None: + token = _request_context_store.set({}) + try: + ctx.set("theme", "dark") + assert ctx.get("theme") == "dark" + assert ctx.get("language", "en") == "en" + ctx.remove("theme") + assert ctx.get("theme") is None + ctx.remove("nonexistent") + finally: + _request_context_store.reset(token) + +def test_context_default_property_fallbacks() -> None: + token = _request_context_store.set({}) + try: + assert ctx.user_id is None + assert ctx.restricted_fields == frozenset() + finally: + _request_context_store.reset(token) + +@pytest.mark.anyio +async def test_context_async_task_isolation() -> None: + token = _request_context_store.set({}) + try: + user_id_a = uuid.uuid4() + user_id_b = uuid.uuid4() + + async def task_a() -> None: + _request_context_store.set({}) + ctx.user_id = user_id_a + await asyncio.sleep(0.01) + assert ctx.user_id == user_id_a + + async def task_b() -> None: + _request_context_store.set({}) + ctx.user_id = user_id_b + await asyncio.sleep(0.01) + assert ctx.user_id == user_id_b + + await asyncio.gather(task_a(), task_b()) + finally: + _request_context_store.reset(token) + +def test_context_nested_scopes() -> None: + token = _request_context_store.set({}) + try: + initial_user = uuid.uuid4() + user_u1 = uuid.uuid4() + user_u2 = uuid.uuid4() + + ctx.user_id = initial_user + + with ctx.scope(user_id=user_u1, custom="V1"): + assert ctx.user_id == user_u1 + assert ctx.get("custom") == "V1" + + with ctx.scope(user_id=user_u2, custom="V2"): + assert ctx.user_id == user_u2 + assert ctx.get("custom") == "V2" + + assert ctx.user_id == user_u1 + assert ctx.get("custom") == "V1" + + assert ctx.user_id == initial_user + assert ctx.get("custom") is None + finally: + _request_context_store.reset(token) + +def test_context_scope_property_and_fallback_attribute() -> None: + token = _request_context_store.set({}) + try: + user_val = uuid.uuid4() + with ctx.scope(user_id=user_val, tenant_id="tenant-123"): + assert ctx.user_id == user_val + assert ctx.get("tenant_id") == "tenant-123" + + assert ctx.user_id is None + assert ctx.get("tenant_id") is None + finally: + _request_context_store.reset(token) - with pytest.raises(RuntimeError): - with request_context(new_user, new_fields): - assert get_current_user_id() == new_user - assert get_restricted_fields() == frozenset(new_fields) - raise RuntimeError("Error inside request_context") - assert get_current_user_id() == initial_user - assert get_restricted_fields() == frozenset(initial_fields) \ No newline at end of file +def test_context_manual_initialize_and_reset() -> None: + token1 = _request_context_store.set({}) + try: + ctx.set("session_id", "session-active") + assert ctx.get("session_id") == "session-active" + + token2 = ctx.initialize() + assert ctx.get("session_id") is None + + ctx.reset(token2) + assert ctx.get("session_id") == "session-active" + finally: + _request_context_store.reset(token1) \ No newline at end of file diff --git a/tests/test_db_lifecycle.py b/tests/test_db_lifecycle.py index b567e6a..1ef7d64 100644 --- a/tests/test_db_lifecycle.py +++ b/tests/test_db_lifecycle.py @@ -1,12 +1,12 @@ import uuid +import pytest + from contextlib import asynccontextmanager from typing import Any, AsyncGenerator, Type -from unittest.mock import AsyncMock, MagicMock -import pytest +from unittest.mock import AsyncMock, MagicMock, patch from zcore.db.setup import DatabaseManager from zcore.db.uow import UnitOfWork -from zcore.kernel.events import EventDispatcher @pytest.mark.parametrize( "db_url, pool_size, max_overflow", @@ -130,4 +130,150 @@ async def test_uow_rollback_clears_events( assert len(uow._pending_events) == 0 dispatcher.dispatch.assert_not_called() - session.rollback.assert_called() \ No newline at end of file + session.rollback.assert_called() + +@pytest.mark.anyio +async def test_db_manager_uninitialized_access() -> None: + manager = DatabaseManager() + with pytest.raises(RuntimeError): + async with manager.session() as session: + pass + +@pytest.mark.anyio +async def test_db_manager_sql_logger_timing() -> None: + listeners = {} + + def mock_listens_for(target, identifier): + def decorator(fn): + listeners[identifier] = fn + return fn + return decorator + + with patch("sqlalchemy.event.listens_for", mock_listens_for), \ + patch("structlog.get_logger") as mock_get_logger: + + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + manager = DatabaseManager() + sync_engine_mock = MagicMock() + sync_engine_mock.dialect.name = "postgresql" + + manager._register_query_logger(sync_engine_mock) + + assert "before_cursor_execute" in listeners + assert "after_cursor_execute" in listeners + + mock_context = MagicMock() + listeners["before_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) + assert hasattr(mock_context, "_query_start_time") + + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) + mock_logger.info.assert_called_once() + + mock_logger.reset_mock() + listeners["after_cursor_execute"](None, None, "SELECT * FROM pg_catalog.pg_tables", {}, mock_context, False) + mock_logger.info.assert_not_called() + +@pytest.mark.anyio +async def test_db_manager_session_propagates_exception() -> None: + manager = DatabaseManager() + mock_session = AsyncMock() + + @asynccontextmanager + async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: + yield mock_session + + manager._session_factory = mock_session_ctx + + with pytest.raises(ValueError, match="Internal Error"): + async with manager.session() as session: + raise ValueError("Internal Error") + + mock_session.rollback.assert_called_once() + +@pytest.mark.anyio +async def test_uow_event_handler_failure_gracefulness() -> None: + session = AsyncMock() + dispatcher = AsyncMock() + uow = UnitOfWork(session, dispatcher) + + uow.register_event("e1", {}) + uow.register_event("e2", {}) + + call_order = [] + + async def mock_dispatch(event: str, payload: Any) -> list[Any]: + call_order.append(event) + if event == "e1": + raise ValueError("E1 handler failed") + return [] + + dispatcher.dispatch.side_effect = mock_dispatch + + await uow.commit() + + assert call_order == ["e1", "e2"] + assert len(uow._pending_events) == 0 + +@pytest.mark.anyio +async def test_uow_db_commit_failure() -> None: + session = AsyncMock() + dispatcher = AsyncMock() + uow = UnitOfWork(session, dispatcher) + + session.commit.side_effect = Exception("DB error") + uow.register_event("e1", {}) + + with pytest.raises(Exception, match="DB error"): + await uow.commit() + + session.rollback.assert_called_once() + dispatcher.dispatch.assert_not_called() + assert len(uow._pending_events) == 1 + +@pytest.mark.anyio +async def test_uow_managed_flag() -> None: + session = AsyncMock() + session.info = {} + dispatcher = AsyncMock() + uow = UnitOfWork(session, dispatcher) + + assert session.info.get("uow_managed") is not True + + async with uow: + assert session.info.get("uow_managed") is True + + assert session.info.get("uow_managed") is False + +@pytest.mark.anyio +async def test_uow_context_manager_auto_commit() -> None: + session = AsyncMock() + session.info = {} + dispatcher = AsyncMock() + uow = UnitOfWork(session, dispatcher) + + uow.register_event("e1", {}) + + async with uow: + pass + + session.commit.assert_called_once() + dispatcher.dispatch.assert_called_once_with("e1", {}) + +@pytest.mark.anyio +async def test_uow_context_manager_auto_rollback() -> None: + session = AsyncMock() + session.info = {} + dispatcher = AsyncMock() + uow = UnitOfWork(session, dispatcher) + + uow.register_event("e1", {}) + + with pytest.raises(ValueError, match="Err"): + async with uow: + raise ValueError("Err") + + session.rollback.assert_called_once() + dispatcher.dispatch.assert_not_called() + assert len(uow._pending_events) == 0 \ No newline at end of file diff --git a/tests/test_di.py b/tests/test_di.py index 5a4f743..51c0476 100644 --- a/tests/test_di.py +++ b/tests/test_di.py @@ -1,14 +1,20 @@ import asyncio import uuid -from contextlib import contextmanager -from typing import Any, Generator, Type import pytest +from contextlib import contextmanager +from typing import Any, Generator, Type, get_origin, get_args, Annotated +from abc import ABC +from fastapi.params import Depends as DependsClass + from zcore.kernel.di import ( container, _current_scope_id, _scoped_instances, CircularDependencyError, + DIException, + Injector, + Inject, ) class IService: @@ -38,6 +44,44 @@ class CircB: def __init__(self, a: "CircA") -> None: self.a = a +class AbstractService(ABC): + pass + +class ConcreteService(AbstractService): + def __init__(self) -> None: + self.id = uuid.uuid4() + +class AutoZ: + def __init__(self) -> None: + self.id = uuid.uuid4() + +class AutoY: + def __init__(self, z: AutoZ) -> None: + self.z = z + +class AutoX: + def __init__(self, y: AutoY) -> None: + self.y = y + +class SimpleNoInit: + pass + +class DepWithAnnotated: + def __init__(self, dep: Annotated[ForwardB, "meta"]) -> None: + self.dep = dep + +class DeepA: + def __init__(self, b: "DeepB") -> None: + self.b = b + +class DeepB: + def __init__(self, c: "DeepC") -> None: + self.c = c + +class DeepC: + def __init__(self, a: "DeepA") -> None: + self.a = a + @contextmanager def di_scope(scope_id: str) -> Generator[None, None, None]: token_id = _current_scope_id.set(scope_id) @@ -48,6 +92,14 @@ def di_scope(scope_id: str) -> Generator[None, None, None]: _current_scope_id.reset(token_id) _scoped_instances.reset(token_instances) +@pytest.fixture(autouse=True) +def reset_container() -> None: + container._singletons.clear() + container._scoped_definitions.clear() + container._factories.clear() + container._constructor_cache.clear() + container._dependency_signature_cache.clear() + @pytest.mark.parametrize( "interface, implementation", [ @@ -158,4 +210,94 @@ async def run_task(task_id: str) -> tuple[str, Any]: for task_id, instance in results: assert instance.id not in seen_ids seen_ids.add(instance.id) - assert len(seen_ids) == num_tasks \ No newline at end of file + assert len(seen_ids) == num_tasks + +def test_register_scoped_instance_success() -> None: + instance = ServiceImpl() + with di_scope("active_scope"): + container.register_scoped_instance(IService, instance) + resolved = container.resolve(IService) + assert resolved is instance + +def test_register_scoped_instance_outside_scope_error() -> None: + instance = ServiceImpl() + with pytest.raises(DIException) as exc_info: + container.register_scoped_instance(IService, instance) + assert "Cannot register scoped instance outside of an active scope" in str(exc_info.value) + +def test_auto_wire_implicit_resolution() -> None: + resolved = container.resolve(ForwardB) + assert isinstance(resolved, ForwardB) + +def test_auto_wire_recursive() -> None: + resolved = container.resolve(AutoX) + assert isinstance(resolved, AutoX) + assert isinstance(resolved.y, AutoY) + assert isinstance(resolved.y.z, AutoZ) + +def test_constructor_and_signature_cache() -> None: + assert ForwardA not in container._constructor_cache + assert ForwardA not in container._dependency_signature_cache + container.register_transient(ForwardB, ForwardB) + container.register_transient(ForwardA, ForwardA) + container.resolve(ForwardA) + assert ForwardA in container._constructor_cache + assert ForwardA in container._dependency_signature_cache + assert container._dependency_signature_cache[ForwardA] == [ForwardB] + +@pytest.mark.anyio +async def test_injector_callable_helper() -> None: + container.register_transient(IService, ServiceImpl) + injector_instance = Injector(IService) + resolved = await injector_instance() + assert isinstance(resolved, ServiceImpl) + +def test_inject_syntactic_sugar() -> None: + injected = Inject[IService] + assert get_origin(injected) is Annotated + args = get_args(injected) + assert args[0] is IService + depends_dep = args[1] + assert isinstance(depends_dep, DependsClass) + assert isinstance(depends_dep.dependency, Injector) + assert depends_dep.dependency.interface is IService + +def test_interface_to_implementation_binding() -> None: + container.register_transient(AbstractService, ConcreteService) + resolved = container.resolve(AbstractService) + assert isinstance(resolved, ConcreteService) + +def test_class_no_init_or_object_init() -> None: + resolved = container.resolve(SimpleNoInit) + assert isinstance(resolved, SimpleNoInit) + assert SimpleNoInit in container._dependency_signature_cache + assert container._dependency_signature_cache[SimpleNoInit] == [] + +def test_constructor_annotated_parameters() -> None: + container.register_transient(ForwardB, ForwardB) + container.register_transient(DepWithAnnotated, DepWithAnnotated) + resolved = container.resolve(DepWithAnnotated) + assert isinstance(resolved, DepWithAnnotated) + assert isinstance(resolved.dep, ForwardB) + +def test_clear_scope_explicit() -> None: + container.register_scoped(IService, ServiceImpl) + scope_id = "test_clear" + with di_scope(scope_id): + res1 = container.resolve(IService) + res2 = container.resolve(IService) + assert res1 is res2 + container.clear_scope(scope_id) + res3 = container.resolve(IService) + assert res3 is not res1 + +def test_deep_circular_dependency() -> None: + container.register_transient(DeepA, DeepA) + container.register_transient(DeepB, DeepB) + container.register_transient(DeepC, DeepC) + with pytest.raises(CircularDependencyError) as exc_info: + container.resolve(DeepA) + assert "Circular dependency detected" in str(exc_info.value) + assert "DeepA" in str(exc_info.value) + assert "DeepB" in str(exc_info.value) + assert "DeepC" in str(exc_info.value) \ No newline at end of file diff --git a/tests/test_events.py b/tests/test_events.py index 7f4ad88..d3352e5 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -4,7 +4,37 @@ from typing import Any import pytest -from zcore.kernel.events import EventDispatcher +from zcore.kernel.events import EventDispatcher, on_event + +class DummyContainer: + def __init__(self) -> None: + self.registry = {} + self.resolutions = 0 + + def register(self, cls: Any, instance_factory: Any) -> None: + self.registry[cls] = instance_factory + + def resolve(self, cls: Any) -> Any: + self.resolutions += 1 + if cls in self.registry: + return self.registry[cls]() + return cls() + +class SampleService: + def __init__(self) -> None: + self.called = False + + @on_event("order.completed") + async def handle_order(self, order_id: int) -> str: + self.called = True + return f"handled_{order_id}" + + def normal_method(self) -> str: + return "normal" + + @on_event("sync.event") + def sync_method(self) -> str: + return "sync_val" @pytest.mark.anyio @pytest.mark.parametrize("event_name", [f"evt_{uuid.uuid4().hex[:6]}" for _ in range(2)]) @@ -99,4 +129,153 @@ async def async_ok_handler() -> str: assert "ok" in results assert "async_ok_val" in results - assert None in results \ No newline at end of file + assert None in results + +@pytest.mark.anyio +async def test_register_listeners_success() -> None: + dispatcher = EventDispatcher() + container = DummyContainer() + instance = SampleService() + container.register(SampleService, lambda: instance) + + dispatcher.register_listeners(SampleService, container) + results = await dispatcher.dispatch("order.completed", 42) + + assert instance.called is True + assert results == ["handled_42"] + +@pytest.mark.anyio +async def test_register_listeners_lazy_resolution() -> None: + dispatcher = EventDispatcher() + container = DummyContainer() + + instances = [] + def factory() -> SampleService: + inst = SampleService() + instances.append(inst) + return inst + + container.register(SampleService, factory) + dispatcher.register_listeners(SampleService, container) + + await dispatcher.dispatch("order.completed", 1) + await dispatcher.dispatch("order.completed", 2) + + assert container.resolutions == 2 + assert len(instances) == 2 + assert instances[0] is not instances[1] + +@pytest.mark.anyio +async def test_register_listeners_ignores_non_decorated() -> None: + dispatcher = EventDispatcher() + container = DummyContainer() + dispatcher.register_listeners(SampleService, container) + + results = await dispatcher.dispatch("normal_method") + assert results == [] + +@pytest.mark.anyio +async def test_register_listeners_ignores_sync_decorated() -> None: + dispatcher = EventDispatcher() + container = DummyContainer() + dispatcher.register_listeners(SampleService, container) + + results = await dispatcher.dispatch("sync.event") + assert results == [] + +@pytest.mark.anyio +async def test_dispatch_arguments_propagation() -> None: + dispatcher = EventDispatcher() + received_args = [] + received_kwargs = [] + + def sync_h(*args: Any, **kwargs: Any) -> str: + received_args.append(args) + received_kwargs.append(kwargs) + return "sync" + + async def async_h(*args: Any, **kwargs: Any) -> str: + received_args.append(args) + received_kwargs.append(kwargs) + return "async" + + dispatcher.subscribe("test.args", sync_h) + dispatcher.subscribe("test.args", async_h) + + results = await dispatcher.dispatch("test.args", 1, "two", foo="bar") + assert set(results) == {"sync", "async"} + assert len(received_args) == 2 + assert len(received_kwargs) == 2 + for args in received_args: + assert args == (1, "two") + for kwargs in received_kwargs: + assert kwargs == {"foo": "bar"} + +@pytest.mark.anyio +async def test_dispatch_unregistered_event() -> None: + dispatcher = EventDispatcher() + results = await dispatcher.dispatch("missing.event") + assert results == [] + +@pytest.mark.anyio +async def test_unsubscribe_robustness() -> None: + dispatcher = EventDispatcher() + def dummy() -> None: + pass + dispatcher.unsubscribe("missing.event", dummy) + + dispatcher.subscribe("exists", dummy) + dispatcher.unsubscribe("exists", lambda: None) + + results = await dispatcher.dispatch("exists") + assert results == [None] + +@pytest.mark.anyio +async def test_double_subscription() -> None: + dispatcher = EventDispatcher() + calls = 0 + def dummy() -> int: + nonlocal calls + calls += 1 + return calls + + dispatcher.subscribe("event", dummy) + dispatcher.subscribe("event", dummy) + + results = await dispatcher.dispatch("event") + assert calls == 2 + assert set(results) == {1, 2} + +@pytest.mark.anyio +async def test_nested_event_dispatching() -> None: + dispatcher = EventDispatcher() + + async def sub_handler(val: int) -> int: + return val * 2 + + async def main_handler(val: int) -> int: + sub_results = await dispatcher.dispatch("sub", val) + return sub_results[0] + 10 + + dispatcher.subscribe("sub", sub_handler) + dispatcher.subscribe("main", main_handler) + + results = await dispatcher.dispatch("main", 5) + assert results == [20] + +@pytest.mark.anyio +async def test_error_during_sync_preparation() -> None: + dispatcher = EventDispatcher() + + def bad_sync() -> None: + raise RuntimeError("Fail during preparation") + + async def good_async() -> str: + return "success" + + dispatcher.subscribe("event", bad_sync) + dispatcher.subscribe("event", good_async) + + results = await dispatcher.dispatch("event") + assert "success" in results + assert len(results) == 1 \ No newline at end of file diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 92f7fbd..794bf2f 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,7 +1,12 @@ +import uuid +import pytest + +from datetime import datetime +from decimal import Decimal from typing import Any, Type +from unittest.mock import patch from fastapi import FastAPI from httpx import ASGITransport, AsyncClient -import pytest from zcore.exceptions.base import ( AppException, @@ -12,6 +17,10 @@ ValidationError, ) from zcore.exceptions.handlers import app_exception_handler +from zcore.web.response import ResponseWrapper + +class CustomPaymentFailed(AppException): + status_code = 402 @pytest.mark.parametrize( "exc_class, status_code, message, payload", @@ -67,4 +76,105 @@ def trigger_error() -> None: assert body["message"] == expected_msg assert body["data"] is None assert body["meta"]["error_type"] == exc_to_raise.__class__.__name__ - assert body["meta"]["payload"] == expected_meta_payload \ No newline at end of file + assert body["meta"]["payload"] == expected_meta_payload + +@pytest.mark.anyio +async def test_exception_logging_integration() -> None: + app = FastAPI() + app.add_exception_handler(AppException, app_exception_handler) + + @app.get("/trigger-log") + def trigger_log() -> None: + raise ValidationError("Failed validation", payload={"field": "username"}) + + transport = ASGITransport(app=app) + with patch("zcore.exceptions.handlers.log") as mock_log: + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.get("/trigger-log") + mock_log.warning.assert_called_once_with( + "AppException raised", + type="ValidationError", + status_code=400, + message="Failed validation", + payload={"field": "username"}, + path="/trigger-log", + method="GET" + ) + +@pytest.mark.anyio +async def test_custom_app_exception_subclass() -> None: + app = FastAPI() + app.add_exception_handler(AppException, app_exception_handler) + + @app.get("/payment") + def trigger_payment_error() -> None: + raise CustomPaymentFailed("Payment declined", payload={"reason": "insufficient_funds"}) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/payment") + assert response.status_code == 402 + body = response.json() + assert body["success"] is False + assert body["message"] == "Payment declined" + assert body["meta"]["error_type"] == "CustomPaymentFailed" + assert body["meta"]["payload"] == {"reason": "insufficient_funds"} + +@pytest.mark.anyio +async def test_complex_payload_serialization() -> None: + app = FastAPI() + app.add_exception_handler(AppException, app_exception_handler) + + uid = uuid.uuid4() + now = datetime.fromisoformat("2026-07-30T16:25:00") + dec = Decimal("123.45") + + @app.get("/complex") + def trigger_complex() -> None: + raise ValidationError("Complex error", payload={"uuid": uid, "time": now, "decimal": dec}) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/complex") + assert response.status_code == 400 + body = response.json() + assert body["meta"]["payload"]["uuid"] == str(uid) + assert body["meta"]["payload"]["time"] == now.isoformat() + assert body["meta"]["payload"]["decimal"] == "123.45" + +@pytest.mark.anyio +async def test_boundary_null_values() -> None: + app = FastAPI() + app.add_exception_handler(AppException, app_exception_handler) + + @app.get("/null-error") + def trigger_null() -> None: + raise AppException("", payload=None) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/null-error") + assert response.status_code == 500 + body = response.json() + assert body["success"] is False + assert body["message"] == "" + assert body["meta"]["payload"] is None + +@pytest.mark.anyio +async def test_strict_response_wrapper_compliance() -> None: + app = FastAPI() + app.add_exception_handler(AppException, app_exception_handler) + + @app.get("/compliance") + def trigger_compliance() -> None: + raise ValidationError("Validate me", payload={"some": "error"}) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/compliance") + body = response.json() + validated = ResponseWrapper[None].model_validate(body) + assert validated.success is False + assert validated.message == "Validate me" + assert validated.meta is not None + assert validated.meta["error_type"] == "ValidationError" \ No newline at end of file diff --git a/tests/test_kernel.py b/tests/test_kernel.py index 4fac056..f55c0cf 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -1,31 +1,48 @@ -import asyncio import uuid -from typing import Any, Generator -from fastapi import FastAPI import pytest +from fastapi import FastAPI from zcore.kernel.engine import Kernel +from zcore.kernel.events import EventDispatcher +from zcore.kernel.di import container class TrackedPlugin: - def __init__(self, name: str, dependencies: list[str], log: list[str]) -> None: + def __init__( + self, + name: str, + dependencies: list[str], + log: list[str], + fail_phase: str | None = None + ) -> None: self.name = name self.version = f"1.0.{uuid.uuid4().hex[:4]}" self.dependencies = dependencies self.log = log + self.fail_phase = fail_phase def setup(self, app: FastAPI) -> None: + if self.fail_phase == "setup": + raise ValueError("setup failure") self.log.append(f"{self.name}:setup") async def before_startup(self) -> None: + if self.fail_phase == "before_startup": + raise ValueError("before_startup failure") self.log.append(f"{self.name}:before_startup") async def on_startup(self) -> None: + if self.fail_phase == "on_startup": + raise ValueError("on_startup failure") self.log.append(f"{self.name}:on_startup") async def after_startup(self) -> None: + if self.fail_phase == "after_startup": + raise ValueError("after_startup failure") self.log.append(f"{self.name}:after_startup") async def on_shutdown(self) -> None: + if self.fail_phase == "on_shutdown": + raise ValueError("on_shutdown failure") self.log.append(f"{self.name}:on_shutdown") @pytest.mark.parametrize( @@ -113,4 +130,150 @@ async def test_plugin_lifespan_lifecycle(graph: dict[str, list[str]]) -> None: assert log == [ "C:on_shutdown", "B:on_shutdown", "A:on_shutdown" - ] \ No newline at end of file + ] + +@pytest.mark.anyio +async def test_empty_kernel() -> None: + kernel = Kernel() + app = FastAPI() + kernel.setup(app) + assert kernel._resolve_dependencies() == [] + async with kernel.lifespan(app): + pass + +def test_self_dependency_error() -> None: + kernel = Kernel() + log: list[str] = [] + kernel.add_plugin(TrackedPlugin("A", ["A"], log)) + with pytest.raises(RuntimeError) as exc_info: + kernel._resolve_dependencies() + assert "Cyclic dependency" in str(exc_info.value) + +def test_multiple_independent_chains() -> None: + kernel = Kernel() + log: list[str] = [] + kernel.add_plugin(TrackedPlugin("B", ["A"], log)) + kernel.add_plugin(TrackedPlugin("A", [], log)) + kernel.add_plugin(TrackedPlugin("D", ["C"], log)) + kernel.add_plugin(TrackedPlugin("C", [], log)) + resolved = kernel._resolve_dependencies() + names = [p.name for p in resolved] + assert names.index("A") < names.index("B") + assert names.index("C") < names.index("D") + +def test_redundant_dependencies() -> None: + kernel = Kernel() + log: list[str] = [] + kernel.add_plugin(TrackedPlugin("A", [], log)) + kernel.add_plugin(TrackedPlugin("B", ["A"], log)) + kernel.add_plugin(TrackedPlugin("C", ["A", "B"], log)) + resolved = kernel._resolve_dependencies() + names = [p.name for p in resolved] + assert names == ["A", "B", "C"] + +@pytest.mark.anyio +async def test_exception_in_before_startup() -> None: + kernel = Kernel() + log: list[str] = [] + app = FastAPI() + kernel.add_plugin(TrackedPlugin("A", [], log)) + kernel.add_plugin(TrackedPlugin("B", ["A"], log, fail_phase="before_startup")) + kernel.setup(app) + with pytest.raises(ValueError, match="before_startup failure"): + async with kernel.lifespan(app): + pass + assert "A:before_startup" in log + assert "B:before_startup" not in log + assert "A:on_startup" not in log + +@pytest.mark.anyio +async def test_exception_in_on_startup() -> None: + kernel = Kernel() + log: list[str] = [] + app = FastAPI() + kernel.add_plugin(TrackedPlugin("A", [], log, fail_phase="on_startup")) + kernel.setup(app) + with pytest.raises(ValueError, match="on_startup failure"): + async with kernel.lifespan(app): + pass + assert "A:before_startup" in log + assert "A:on_startup" not in log + assert "A:after_startup" not in log + +@pytest.mark.anyio +async def test_exception_in_after_startup() -> None: + kernel = Kernel() + log: list[str] = [] + app = FastAPI() + kernel.add_plugin(TrackedPlugin("A", [], log, fail_phase="after_startup")) + kernel.setup(app) + with pytest.raises(ValueError, match="after_startup failure"): + async with kernel.lifespan(app): + pass + assert "A:on_startup" in log + assert "A:after_startup" not in log + +@pytest.mark.anyio +async def test_shutdown_failures_robustness() -> None: + kernel = Kernel() + log: list[str] = [] + app = FastAPI() + kernel.add_plugin(TrackedPlugin("A", [], log)) + kernel.add_plugin(TrackedPlugin("B", ["A"], log, fail_phase="on_shutdown")) + kernel.setup(app) + with pytest.raises(ValueError, match="on_shutdown failure"): + async with kernel.lifespan(app): + pass + assert "B:on_shutdown" not in log + assert "A:on_shutdown" not in log + +def test_event_dispatcher_registration() -> None: + kernel = Kernel() + app = FastAPI() + kernel.setup(app) + resolved_dispatcher = container.resolve(EventDispatcher) + assert resolved_dispatcher is kernel.dispatcher + +@pytest.mark.anyio +async def test_lifespan_lazy_resolution() -> None: + kernel = Kernel() + log: list[str] = [] + app = FastAPI() + kernel.add_plugin(TrackedPlugin("A", [], log)) + async with kernel.lifespan(app): + assert "A:before_startup" in log + +@pytest.mark.anyio +async def test_inter_plugin_event_dispatching() -> None: + kernel = Kernel() + log: list[str] = [] + app = FastAPI() + + class EventPlugin: + def __init__(self, name: str, kernel_inst: Kernel, log_list: list[str]) -> None: + self.name = name + self.version = "1.0.0" + self.dependencies: list[str] = [] + self.kernel = kernel_inst + self.log = log_list + + def setup(self, app_inst: FastAPI) -> None: + pass + async def before_startup(self) -> None: + pass + async def on_startup(self) -> None: + await self.kernel.dispatcher.dispatch("custom_event", "event_payload") + async def after_startup(self) -> None: + pass + async def on_shutdown(self) -> None: + pass + + async def handler(payload: str) -> None: + log.append(payload) + + kernel.dispatcher.subscribe("custom_event", handler) + kernel.add_plugin(EventPlugin("A", kernel, log)) + kernel.setup(app) + + async with kernel.lifespan(app): + assert log == ["event_payload"] \ No newline at end of file diff --git a/tests/test_logging.py b/tests/test_logging.py index 8f56488..cd3e465 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,10 +1,13 @@ import importlib +import logging import sys from typing import Any, Type -from unittest.mock import patch +from unittest.mock import patch, MagicMock import pytest import structlog +from zcore.config import settings + @pytest.mark.parametrize( "is_atty, expected_renderer_cls", [ @@ -18,15 +21,136 @@ def test_logging_format_by_environment( expected_renderer_cls: Type[Any] ) -> None: monkeypatch.setattr(sys.stderr, "isatty", lambda: is_atty) + monkeypatch.setattr(settings, "DEBUG", is_atty) import zcore.logging.config as logging_config importlib.reload(logging_config) - assert isinstance(logging_config.renderer, expected_renderer_cls) - with patch("structlog.configure") as mock_configure: logging_config.setup_logging() mock_configure.assert_called_once() configured_processors = mock_configure.call_args[1].get("processors", []) assert len(configured_processors) > 0 - assert isinstance(configured_processors[-1], expected_renderer_cls) \ No newline at end of file + assert isinstance(configured_processors[-1], expected_renderer_cls) + +def test_suppress_third_party_duplicate_handlers(monkeypatch: pytest.MonkeyPatch) -> None: + loggers = ["uvicorn", "uvicorn.access", "sqlalchemy.engine"] + dummy_handlers = {name: [logging.NullHandler()] for name in loggers} + + for name in loggers: + logger = logging.getLogger(name) + logger.handlers = list(dummy_handlers[name]) + logger.propagate = False + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure"): + logging_config.setup_logging() + + for name in loggers: + logger = logging.getLogger(name) + assert len(logger.handlers) == 0 + assert logger.propagate is True + +@pytest.mark.parametrize("rich_installed", [True, False]) +def test_rich_integration_debug_mode(monkeypatch: pytest.MonkeyPatch, rich_installed: bool) -> None: + monkeypatch.setattr(settings, "DEBUG", True) + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + mock_install = MagicMock() + + def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "rich.traceback": + if rich_installed: + mock_module = MagicMock() + mock_module.install = mock_install + return mock_module + raise ImportError() + return original_import(name, *args, **kwargs) + + import builtins + original_import = builtins.__import__ + monkeypatch.setattr(builtins, "__import__", mock_import) + + with patch("structlog.configure"): + logging_config.setup_logging() + + if rich_installed: + mock_install.assert_called_once_with(show_locals=False) + else: + mock_install.assert_not_called() + +def test_shared_processors_chain_integrity(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "DEBUG", False) + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure") as mock_configure: + logging_config.setup_logging() + configured_processors = mock_configure.call_args[1].get("processors", []) + + assert structlog.contextvars.merge_contextvars in configured_processors + assert structlog.processors.add_log_level in configured_processors + assert structlog.processors.format_exc_info in configured_processors + + assert any(isinstance(p, structlog.stdlib.PositionalArgumentsFormatter) for p in configured_processors) + assert any(isinstance(p, structlog.processors.TimeStamper) for p in configured_processors) + +@pytest.mark.parametrize( + "debug_flag, expected_level", + [ + (True, logging.DEBUG), + (False, logging.INFO), + ] +) +def test_dynamic_log_level_configuration( + monkeypatch: pytest.MonkeyPatch, + debug_flag: bool, + expected_level: int +) -> None: + monkeypatch.setattr(settings, "DEBUG", debug_flag) + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure"), \ + patch("structlog.make_filtering_bound_logger") as mock_make_filter: + + logging_config.setup_logging() + mock_make_filter.assert_called_once_with(expected_level) + +def test_log_filtering_enforcement_in_action() -> None: + filtering_bound_logger_cls = structlog.make_filtering_bound_logger(logging.INFO) + mock_underlying = MagicMock() + logger = filtering_bound_logger_cls(mock_underlying, [], {}) + + logger.info("info_msg") + assert mock_underlying.info.called + + mock_underlying.reset_mock() + logger.debug("debug_msg") + assert not mock_underlying.debug.called + +def test_contextvars_binding_verification() -> None: + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars(request_id="test_req_xyz") + + event_dict = structlog.contextvars.merge_contextvars(None, None, {"event": "hello"}) + assert event_dict.get("request_id") == "test_req_xyz" + + structlog.contextvars.clear_contextvars() + +def test_factory_and_caching_configurations(monkeypatch: pytest.MonkeyPatch) -> None: + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure") as mock_configure: + logging_config.setup_logging() + + kwargs = mock_configure.call_args[1] + assert isinstance(kwargs.get("logger_factory"), structlog.stdlib.LoggerFactory) + assert kwargs.get("cache_logger_on_first_use") is True \ No newline at end of file diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 0d73492..42943b2 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,9 +1,10 @@ -import datetime import uuid -from typing import Any, AsyncGenerator, Type import pytest +import datetime import pytest_asyncio -from sqlalchemy import Column, DateTime, Integer, String, select + +from typing import Any, AsyncGenerator +from sqlalchemy import Column, DateTime, Integer, String, select, UUID from zcore.db.pagination import ( CursorPagination, @@ -20,6 +21,12 @@ class PaginationTestModel(Base): name = Column(String) created_at = Column(DateTime) +class PaginationUUIDTestModel(Base): + __tablename__ = f"pagination_uuid_test_{uuid.uuid4().hex[:6]}" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String) + created_at = Column(DateTime) + @pytest_asyncio.fixture(autouse=True) async def setup_pagination_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: @@ -59,6 +66,82 @@ async def test_page_number_pagination( assert result.meta["total_pages"] == expected_pages assert result.meta["has_next"] is True +@pytest.mark.anyio +async def test_page_number_pagination_sorting(db_session: Any) -> None: + records = [ + PaginationTestModel(id=1, name="Item C", created_at=datetime.datetime.now()), + PaginationTestModel(id=2, name="Item A", created_at=datetime.datetime.now()), + PaginationTestModel(id=3, name="Item B", created_at=datetime.datetime.now()), + ] + db_session.add_all(records) + await db_session.flush() + + paginator = PageNumberPagination() + params_asc = PageNumberParams(page=1, size=10, sort_by="name", sort_order="asc") + result_asc = await paginator.paginate(db_session, select(PaginationTestModel), params_asc, PaginationTestModel) + assert result_asc.data[0].name == "Item A" + assert result_asc.data[2].name == "Item C" + + params_desc = PageNumberParams(page=1, size=10, sort_by="name", sort_order="desc") + result_desc = await paginator.paginate(db_session, select(PaginationTestModel), params_desc, PaginationTestModel) + assert result_desc.data[0].name == "Item C" + assert result_desc.data[2].name == "Item A" + +@pytest.mark.anyio +async def test_page_number_pagination_invalid_sort(db_session: Any) -> None: + paginator = PageNumberPagination() + params = PageNumberParams(page=1, size=10, sort_by="unreal_field") + with pytest.raises(ValidationError) as exc_info: + await paginator.paginate(db_session, select(PaginationTestModel), params, PaginationTestModel) + assert "Invalid sort field: 'unreal_field'" in str(exc_info.value) + +@pytest.mark.anyio +async def test_page_number_pagination_empty_db(db_session: Any) -> None: + paginator = PageNumberPagination() + params = PageNumberParams(page=1, size=10, include_count=True) + result = await paginator.paginate(db_session, select(PaginationTestModel), params, PaginationTestModel) + assert result.data == [] + assert result.meta["total"] == 0 + assert result.meta["total_pages"] == 0 + assert result.meta["has_next"] is False + assert result.meta["has_prev"] is False + +@pytest.mark.anyio +async def test_page_number_pagination_out_of_bounds(db_session: Any) -> None: + records = [PaginationTestModel(id=i, name=f"Item {i}", created_at=datetime.datetime.now()) for i in range(1, 11)] + db_session.add_all(records) + await db_session.flush() + + paginator = PageNumberPagination() + params_with_count = PageNumberParams(page=5, size=5, include_count=True) + result_wc = await paginator.paginate(db_session, select(PaginationTestModel), params_with_count, PaginationTestModel) + assert result_wc.data == [] + assert result_wc.meta["total"] == 10 + assert result_wc.meta["total_pages"] == 2 + assert result_wc.meta["has_next"] is False + assert result_wc.meta["has_prev"] is True + + params_no_count = PageNumberParams(page=5, size=5, include_count=False) + result_nc = await paginator.paginate(db_session, select(PaginationTestModel), params_no_count, PaginationTestModel) + assert result_nc.data == [] + assert result_nc.meta["has_next"] is False + assert result_nc.meta["has_prev"] is True + +@pytest.mark.anyio +async def test_page_number_pagination_exact_multiple(db_session: Any) -> None: + records = [PaginationTestModel(id=i, name=f"Item {i}", created_at=datetime.datetime.now()) for i in range(1, 21)] + db_session.add_all(records) + await db_session.flush() + + paginator = PageNumberPagination() + params = PageNumberParams(page=2, size=10, include_count=True) + result = await paginator.paginate(db_session, select(PaginationTestModel), params, PaginationTestModel) + assert len(result.data) == 10 + assert result.meta["total"] == 20 + assert result.meta["total_pages"] == 2 + assert result.meta["has_next"] is False + assert result.meta["has_prev"] is True + @pytest.mark.anyio @pytest.mark.parametrize("order", ["asc", "desc"]) async def test_cursor_pagination_datetime(db_session: Any, order: str) -> None: @@ -93,6 +176,116 @@ async def test_cursor_pagination_datetime(db_session: Any, order: str) -> None: expected_remaining_ids = [2, 1] if order == "desc" else [2, 3] assert [item.id for item in page2.data] == expected_remaining_ids +@pytest.mark.anyio +async def test_cursor_pagination_empty_db(db_session: Any) -> None: + paginator = CursorPagination(cursor_field="id") + params = CursorParams(size=5) + result = await paginator.paginate(db_session, select(PaginationTestModel), params, PaginationTestModel) + assert result.data == [] + assert result.meta["has_more"] is False + assert result.meta["next_cursor"] is None + +@pytest.mark.anyio +async def test_cursor_pagination_invalid_field(db_session: Any) -> None: + paginator = CursorPagination(cursor_field="unreal_field") + params = CursorParams(size=5) + with pytest.raises(ValidationError) as exc_info: + await paginator.paginate(db_session, select(PaginationTestModel), params, PaginationTestModel) + assert "Invalid cursor field: 'unreal_field'" in str(exc_info.value) + +@pytest.mark.anyio +async def test_cursor_pagination_exact_transitions(db_session: Any) -> None: + records = [PaginationTestModel(id=i, name=f"Item {i}", created_at=datetime.datetime.now()) for i in range(1, 11)] + db_session.add_all(records) + await db_session.flush() + + paginator = CursorPagination(cursor_field="id", order="asc") + params1 = CursorParams(size=5) + page1 = await paginator.paginate(db_session, select(PaginationTestModel), params1, PaginationTestModel) + assert len(page1.data) == 5 + assert page1.meta["has_more"] is True + assert page1.meta["next_cursor"] is not None + + params2 = CursorParams(size=5, cursor=page1.meta["next_cursor"]) + page2 = await paginator.paginate(db_session, select(PaginationTestModel), params2, PaginationTestModel) + assert len(page2.data) == 5 + assert page2.meta["has_more"] is False + assert page2.meta["next_cursor"] is None + +@pytest.mark.anyio +async def test_cursor_pagination_uuid_primary_key(db_session: Any) -> None: + id1 = uuid.uuid4() + id2 = uuid.uuid4() + id3 = uuid.uuid4() + + dt1 = datetime.datetime(2026, 1, 1, 10, 0, 0) + dt2 = datetime.datetime(2026, 1, 1, 11, 0, 0) + dt3 = datetime.datetime(2026, 1, 1, 12, 0, 0) + + records = [ + PaginationUUIDTestModel(id=id1, name="Item 1", created_at=dt1), + PaginationUUIDTestModel(id=id2, name="Item 2", created_at=dt2), + PaginationUUIDTestModel(id=id3, name="Item 3", created_at=dt3), + ] + db_session.add_all(records) + await db_session.flush() + + paginator = CursorPagination(cursor_field="created_at", order="desc") + params1 = CursorParams(size=1) + page1 = await paginator.paginate(db_session, select(PaginationUUIDTestModel), params1, PaginationUUIDTestModel) + assert len(page1.data) == 1 + assert page1.data[0].id == id3 + assert page1.meta["has_more"] is True + + params2 = CursorParams(size=2, cursor=page1.meta["next_cursor"]) + page2 = await paginator.paginate(db_session, select(PaginationUUIDTestModel), params2, PaginationUUIDTestModel) + assert len(page2.data) == 2 + assert page2.data[0].id == id2 + assert page2.data[1].id == id1 + assert page2.meta["has_more"] is False + +@pytest.mark.anyio +async def test_cursor_pagination_timezone_normalization(db_session: Any) -> None: + tz_naive = datetime.datetime(2026, 6, 1, 12, 0, 0) + tz_aware = datetime.datetime(2026, 6, 1, 15, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=3))) + + records = [ + PaginationTestModel(id=1, name="Naive", created_at=tz_naive), + PaginationTestModel(id=2, name="Aware", created_at=tz_aware), + ] + db_session.add_all(records) + await db_session.flush() + + paginator = CursorPagination(cursor_field="created_at", order="asc") + params1 = CursorParams(size=1) + page1 = await paginator.paginate(db_session, select(PaginationTestModel), params1, PaginationTestModel) + assert page1.data[0].id == 1 + + params2 = CursorParams(size=1, cursor=page1.meta["next_cursor"]) + page2 = await paginator.paginate(db_session, select(PaginationTestModel), params2, PaginationTestModel) + assert page2.data[0].id == 2 + +@pytest.mark.anyio +async def test_cursor_pagination_string_field(db_session: Any) -> None: + records = [ + PaginationTestModel(id=1, name="Banana", created_at=datetime.datetime.now()), + PaginationTestModel(id=2, name="Apple", created_at=datetime.datetime.now()), + PaginationTestModel(id=3, name="Cherry", created_at=datetime.datetime.now()), + ] + db_session.add_all(records) + await db_session.flush() + + paginator = CursorPagination(cursor_field="name", order="asc") + params1 = CursorParams(size=1) + page1 = await paginator.paginate(db_session, select(PaginationTestModel), params1, PaginationTestModel) + assert page1.data[0].name == "Apple" + + params2 = CursorParams(size=2, cursor=page1.meta["next_cursor"]) + page2 = await paginator.paginate(db_session, select(PaginationTestModel), params2, PaginationTestModel) + assert len(page2.data) == 2 + assert page2.data[0].name == "Banana" + assert page2.data[1].name == "Cherry" + @pytest.mark.parametrize( "corrupted_cursor", [ @@ -109,4 +302,27 @@ async def test_malformed_cursor_error(db_session: Any, corrupted_cursor: str) -> with pytest.raises(ValidationError) as exc_info: await paginator.paginate(db_session, select(PaginationTestModel), params, PaginationTestModel) - assert "Malformed cursor parameter provided." in str(exc_info.value) \ No newline at end of file + assert "Malformed cursor parameter provided." in str(exc_info.value) + +@pytest.mark.anyio +async def test_cursor_pagination_drift_prevention(db_session: Any) -> None: + records = [ + PaginationTestModel(id=10, name="Item 10", created_at=datetime.datetime(2026, 1, 1, 10, 0, 0)), + PaginationTestModel(id=20, name="Item 20", created_at=datetime.datetime(2026, 1, 1, 11, 0, 0)), + ] + db_session.add_all(records) + await db_session.flush() + + paginator = CursorPagination(cursor_field="created_at", order="desc") + params1 = CursorParams(size=1) + page1 = await paginator.paginate(db_session, select(PaginationTestModel), params1, PaginationTestModel) + assert page1.data[0].id == 20 + + new_record = PaginationTestModel(id=30, name="Item 30", created_at=datetime.datetime(2026, 1, 1, 12, 0, 0)) + db_session.add(new_record) + await db_session.flush() + + params2 = CursorParams(size=1, cursor=page1.meta["next_cursor"]) + page2 = await paginator.paginate(db_session, select(PaginationTestModel), params2, PaginationTestModel) + assert len(page2.data) == 1 + assert page2.data[0].id == 10 \ No newline at end of file diff --git a/tests/test_repository.py b/tests/test_repository.py index dee825a..13ddc48 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,14 +1,18 @@ import uuid -from typing import Any, AsyncGenerator -from unittest.mock import AsyncMock import pytest import pytest_asyncio + from pydantic import BaseModel +from unittest.mock import AsyncMock +from typing import Any, AsyncGenerator from sqlalchemy import Column, Integer, String -from sqlalchemy.orm import load_only from zcore.db.repository import BaseRepository from zcore.db.setup import Base +from zcore.db.pagination import PageNumberParams, CursorParams +from zcore.db.search import SearchRequest, FilterItem +from zcore.context.context import ctx +from zcore.exceptions.base import ValidationError, ForbiddenError class RepoTestModel(Base): __tablename__ = f"repo_test_model_{uuid.uuid4().hex[:6]}" @@ -16,6 +20,12 @@ class RepoTestModel(Base): name = Column(String, nullable=False) description = Column(String, nullable=True) + @classmethod + def scope_query(cls, query: Any) -> Any: + if getattr(cls, "_scoped_mode", False): + return query.where(cls.name.startswith("Active")) + return query + class RepoTestCreateSchema(BaseModel): name: str description: str | None = None @@ -24,7 +34,7 @@ class RepoTestUpdateSchema(BaseModel): name: str | None = None description: str | None = None -class RepoTestRepository(BaseRepository[RepoTestModel, RepoTestCreateSchema, RepoTestUpdateSchema]): +class RepoTestRepository(BaseRepository[RepoTestModel]): def __init__(self, db: Any) -> None: super().__init__(RepoTestModel, db) @@ -53,7 +63,7 @@ async def test_repo_create_and_get(db_session: Any, name: str, description: str assert created.name == name assert created.description == description - fetched_with_fields = await repo.get(created.id, fields=[RepoTestModel.name]) + fetched_with_fields = await repo.get(id=created.id, fields=[RepoTestModel.name]) assert fetched_with_fields is not None assert fetched_with_fields.name == name @@ -132,5 +142,196 @@ async def test_repo_delete_multi(db_session: Any, non_existent_id: int) -> None: assert item1.id in deleted_ids assert item2.id in deleted_ids - assert await repo.get(item1.id) is None - assert await repo.get(item2.id) is None \ No newline at end of file + assert await repo.get(id=item1.id) is None + assert await repo.get(id=item2.id) is None + +@pytest.mark.anyio +async def test_repo_exist(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + await repo.create(RepoTestCreateSchema(name="UniqueItem", description="Desc")) + assert await repo.exist(name="UniqueItem") is True + assert await repo.exist(RepoTestModel.name == "UniqueItem") is True + assert await repo.exist(name="NonExistent") is False + +@pytest.mark.anyio +async def test_repo_count(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + await repo.create(RepoTestCreateSchema(name="ItemA")) + await repo.create(RepoTestCreateSchema(name="ItemB")) + assert await repo.count() == 2 + assert await repo.count(RepoTestModel.name == "ItemA") == 1 + +@pytest.mark.anyio +async def test_repo_get_by_ids_empty_and_options(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + res = await repo.get_by_ids([]) + assert res == [] + + item = await repo.create(RepoTestCreateSchema(name="Test")) + res2 = await repo.get_by_ids([item.id], fields=[RepoTestModel.name]) + assert len(res2) == 1 + assert res2[0].name == "Test" + +@pytest.mark.anyio +async def test_repo_update_multi(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item1 = await repo.create(RepoTestCreateSchema(name="A", description="D1")) + item2 = await repo.create(RepoTestCreateSchema(name="B", description="D2")) + + data = { + item1.id: RepoTestUpdateSchema(name="A_New"), + item2.id: RepoTestUpdateSchema(name="B_New", description="D2_New"), + 99999: RepoTestUpdateSchema(name="C_New") + } + + updated = await repo.update_multi(data, partial=True, refresh=True) + assert len(updated) == 2 + + fetched1 = await repo.get(id=item1.id) + fetched2 = await repo.get(id=item2.id) + assert fetched1.name == "A_New" + assert fetched1.description == "D1" + assert fetched2.name == "B_New" + assert fetched2.description == "D2_New" + +@pytest.mark.anyio +async def test_repo_delete_single(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item = await repo.create(RepoTestCreateSchema(name="DelMe")) + + deleted = await repo.delete(item.id) + assert deleted is not None + assert deleted.name == "DelMe" + + assert await repo.get(id=item.id) is None + assert await repo.delete(99999) is None + +@pytest.mark.anyio +async def test_repo_create_multi_no_refresh(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + schemas = [ + RepoTestCreateSchema(name="NR1"), + RepoTestCreateSchema(name="NR2") + ] + results = await repo.create_multi(schemas, refresh=False) + assert len(results) == 2 + assert results[0].name == "NR1" + +@pytest.mark.anyio +async def test_repo_page_number_pagination(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + for i in range(15): + await repo.create(RepoTestCreateSchema(name=f"Item_{i:02d}")) + + params = PageNumberParams(page=2, size=5, sort_by="name", sort_order="desc") + res = await repo.get_list(pagination=params) + + assert res.meta["total"] == 15 + assert res.meta["page"] == 2 + assert res.meta["total_pages"] == 3 + assert res.meta["has_next"] is True + assert res.meta["has_prev"] is True + assert len(res.data) == 5 + assert res.data[0].name == "Item_09" + +@pytest.mark.anyio +async def test_repo_page_number_sorting_validation(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + params = PageNumberParams(page=1, size=5, sort_by="invalid_col") + with pytest.raises(ValidationError): + await repo.get_list(pagination=params) + +@pytest.mark.anyio +async def test_repo_cursor_pagination(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + items = [] + for i in range(5): + item = await repo.create(RepoTestCreateSchema(name=f"C_{i}")) + items.append(item) + + params1 = CursorParams(size=2) + res1 = await repo.get_list(pagination=params1) + assert len(res1.data) == 2 + assert res1.meta["has_more"] is True + assert res1.meta["next_cursor"] is not None + + params2 = CursorParams(size=2, cursor=res1.meta["next_cursor"]) + res2 = await repo.get_list(pagination=params2) + assert len(res2.data) == 2 + assert res2.meta["has_more"] is True + + params3 = CursorParams(size=2, cursor=res2.meta["next_cursor"]) + res3 = await repo.get_list(pagination=params3) + assert len(res3.data) == 1 + assert res3.meta["has_more"] is False + assert res3.meta["next_cursor"] is None + +@pytest.mark.anyio +async def test_repo_cursor_invalid(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + params = CursorParams(size=5, cursor="!!!invalid_b64!!!") + with pytest.raises(ValidationError): + await repo.get_list(pagination=params) + +@pytest.mark.anyio +async def test_repo_search_engine_complex(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + await repo.create(RepoTestCreateSchema(name="Apple", description="Fruit")) + await repo.create(RepoTestCreateSchema(name="Banana", description="Fruit")) + await repo.create(RepoTestCreateSchema(name="Carrot", description="Vegetable")) + + req = SearchRequest( + filters=[ + FilterItem( + op="and", + items=[ + FilterItem(field="description", op="eq", value="Fruit"), + FilterItem(field="name", op="ilike", value="ba") + ] + ) + ] + ) + res = await repo.search(req) + assert len(res) == 1 + assert res[0].name == "Banana" + +@pytest.mark.anyio +async def test_repo_search_security_violation(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + + token = ctx.initialize() + table_suffix = RepoTestModel.__tablename__.split('_')[-1] + ctx.restricted_fields = frozenset([ + "description", + f"repo_test_model_{table_suffix}.description" + ]) + + req = SearchRequest( + filters=[ + FilterItem(field="description", op="eq", value="Fruit") + ] + ) + + try: + with pytest.raises(ForbiddenError): + await repo.search(req) + finally: + ctx.reset(token) + +@pytest.mark.anyio +async def test_repo_scoped_row_level_isolation(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + await repo.create(RepoTestCreateSchema(name="Active_1")) + await repo.create(RepoTestCreateSchema(name="Active_2")) + await repo.create(RepoTestCreateSchema(name="Inactive_1")) + + assert await repo.count() == 3 + + RepoTestModel._scoped_mode = True + try: + assert await repo.count() == 2 + res = await repo.get_list() + for item in res: + assert item.name.startswith("Active") + finally: + RepoTestModel._scoped_mode = False \ No newline at end of file diff --git a/tests/test_search.py b/tests/test_search.py index eaacf74..d17a634 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,44 +1,53 @@ -import uuid -from typing import Any, AsyncGenerator, Generator, Type +import uuid as uuid_pkg import pytest import pytest_asyncio -from sqlalchemy import Column, ForeignKey, Integer, String +from datetime import datetime + from sqlalchemy.orm import relationship +from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean, Uuid +from typing import Any, AsyncGenerator, Generator, Type -from zcore.db.search import FilterItem, SearchEngine, SearchRequest, SortItem from zcore.db.setup import Base +from zcore.db.search import FilterItem, SearchEngine, SearchRequest, SortItem from zcore.exceptions.base import ForbiddenError, ValidationError class SearchUser(Base): - __tablename__ = f"search_users_{uuid.uuid4().hex[:6]}" + __tablename__ = f"search_users_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) username = Column(String) password = Column(String) + created_at = Column(DateTime) + is_active = Column(Boolean) + uid = Column(Uuid) class SearchProfile(Base): - __tablename__ = f"search_profiles_{uuid.uuid4().hex[:6]}" + __tablename__ = f"search_profiles_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey(f"{SearchUser.__tablename__}.id")) bio = Column(String) user = relationship("SearchUser") + posts = relationship("SearchPost", back_populates="profile") class SearchPost(Base): - __tablename__ = f"search_posts_{uuid.uuid4().hex[:6]}" + __tablename__ = f"search_posts_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) profile_id = Column(Integer, ForeignKey(f"{SearchProfile.__tablename__}.id")) title = Column(String) - profile = relationship("SearchProfile") + profile = relationship("SearchProfile", back_populates="posts") class SearchComment(Base): - __tablename__ = f"search_comments_{uuid.uuid4().hex[:6]}" + __tablename__ = f"search_comments_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) post_id = Column(Integer, ForeignKey(f"{SearchPost.__tablename__}.id")) body = Column(String) post = relationship("SearchPost") @pytest.fixture(autouse=True) -def mock_restricted_fields(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("zcore.db.search.get_restricted_fields", lambda: {"password", "SearchUser.password"}) +def mock_restricted_fields() -> Generator[None, None, None]: + from zcore.context.context import ctx + ctx.restricted_fields = {"password", "SearchUser.password"} + yield + ctx.restricted_fields = None @pytest_asyncio.fixture(autouse=True) async def setup_search_tables(test_engine: Any) -> AsyncGenerator[None, None]: @@ -50,10 +59,44 @@ async def setup_search_tables(test_engine: Any) -> AsyncGenerator[None, None]: @pytest_asyncio.fixture async def seed_data(db_session: Any) -> None: - user1 = SearchUser(id=1, username="admin%", password="foo") - user2 = SearchUser(id=2, username="user_special", password="bar") - user3 = SearchUser(id=3, username="guest", password="baz") + user1 = SearchUser( + id=1, + username="admin%", + password="foo", + created_at=datetime(2026, 1, 1, 12, 0, 0), + is_active=True, + uid=uuid_pkg.UUID("11111111-1111-1111-1111-111111111111") + ) + user2 = SearchUser( + id=2, + username="user_special", + password="bar", + created_at=datetime(2026, 1, 2, 12, 0, 0), + is_active=False, + uid=uuid_pkg.UUID("22222222-2222-2222-2222-222222222222") + ) + user3 = SearchUser( + id=3, + username="guest", + password="baz", + created_at=datetime(2026, 1, 3, 12, 0, 0), + is_active=True, + uid=uuid_pkg.UUID("33333333-3333-3333-3333-333333333333") + ) db_session.add_all([user1, user2, user3]) + + profile1 = SearchProfile(id=1, user_id=1, bio="admin bio") + profile2 = SearchProfile(id=2, user_id=2, bio=None) + profile3 = SearchProfile(id=3, user_id=3, bio="guest bio") + db_session.add_all([profile1, profile2, profile3]) + + post1 = SearchPost(id=1, profile_id=1, title="Hello admin") + post2 = SearchPost(id=2, profile_id=3, title="Hello guest") + db_session.add_all([post1, post2]) + + comment1 = SearchComment(id=1, post_id=1, body="nice comment") + db_session.add_all([comment1]) + await db_session.flush() @pytest.mark.parametrize( @@ -149,4 +192,169 @@ def test_search_include_depth_and_relation( assert error_message in str(exc_info.value) else: query = engine.build_base_query(request) - assert query is not None \ No newline at end of file + assert query is not None + +@pytest.mark.anyio +async def test_search_lt_le_ge(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + + req_lt = SearchRequest(filters=[FilterItem(field="id", op="lt", value=2)]) + res_lt = await db_session.execute(engine.build_base_query(req_lt)) + assert {u.id for u in res_lt.scalars().all()} == {1} + + req_le = SearchRequest(filters=[FilterItem(field="id", op="le", value=2)]) + res_le = await db_session.execute(engine.build_base_query(req_le)) + assert {u.id for u in res_le.scalars().all()} == {1, 2} + + req_ge = SearchRequest(filters=[FilterItem(field="id", op="ge", value=2)]) + res_ge = await db_session.execute(engine.build_base_query(req_ge)) + assert {u.id for u in res_ge.scalars().all()} == {2, 3} + +@pytest.mark.anyio +async def test_search_in_operator(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req = SearchRequest(filters=[FilterItem(field="id", op="in", value=[1, 3])]) + res = await db_session.execute(engine.build_base_query(req)) + assert {u.id for u in res.scalars().all()} == {1, 3} + +@pytest.mark.anyio +async def test_search_is_null_operator(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchProfile) + + req_null = SearchRequest(filters=[FilterItem(field="bio", op="is_null", value=True)]) + res_null = await db_session.execute(engine.build_base_query(req_null)) + assert {p.id for p in res_null.scalars().all()} == {2} + + req_not_null = SearchRequest(filters=[FilterItem(field="bio", op="is_null", value=False)]) + res_not_null = await db_session.execute(engine.build_base_query(req_not_null)) + assert {p.id for p in res_not_null.scalars().all()} == {1, 3} + +@pytest.mark.anyio +async def test_search_wildcard_escaping(db_session: Any, seed_data: None) -> None: + user4 = SearchUser( + id=4, + username="user1special", + password="qux", + created_at=datetime(2026, 1, 4, 12, 0, 0), + is_active=True, + uid=uuid_pkg.UUID("44444444-4444-4444-4444-444444444444") + ) + db_session.add(user4) + await db_session.flush() + + engine = SearchEngine(SearchUser) + + req_percent = SearchRequest(filters=[FilterItem(field="username", op="ilike", value="admin%")]) + res_percent = await db_session.execute(engine.build_base_query(req_percent)) + assert {u.id for u in res_percent.scalars().all()} == {1} + + req_underscore = SearchRequest(filters=[FilterItem(field="username", op="ilike", value="user_special")]) + res_underscore = await db_session.execute(engine.build_base_query(req_underscore)) + results = list(res_underscore.scalars().all()) + assert {u.id for u in results} == {2} + +@pytest.mark.anyio +async def test_search_type_coercion_date_time(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req = SearchRequest(filters=[FilterItem(field="created_at", op="eq", value="2026-01-02T12:00:00")]) + res = await db_session.execute(engine.build_base_query(req)) + assert {u.id for u in res.scalars().all()} == {2} + +@pytest.mark.anyio +async def test_search_type_coercion_uuid(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req = SearchRequest(filters=[FilterItem(field="uid", op="eq", value="33333333-3333-3333-3333-333333333333")]) + res = await db_session.execute(engine.build_base_query(req)) + assert {u.id for u in res.scalars().all()} == {3} + +@pytest.mark.anyio +async def test_search_type_coercion_bool(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req_true = SearchRequest(filters=[FilterItem(field="is_active", op="eq", value="true")]) + res_true = await db_session.execute(engine.build_base_query(req_true)) + assert {u.id for u in res_true.scalars().all()} == {1, 3} + +@pytest.mark.anyio +async def test_search_relation_one_to_one(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchProfile) + req = SearchRequest(filters=[FilterItem(field="user.username", op="eq", value="guest")]) + res = await db_session.execute(engine.build_base_query(req)) + assert {p.id for p in res.scalars().all()} == {3} + +@pytest.mark.anyio +async def test_search_relation_one_to_many_any(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchProfile) + req = SearchRequest(filters=[FilterItem(field="posts.title", op="eq", value="Hello guest")]) + res = await db_session.execute(engine.build_base_query(req)) + assert {p.id for p in res.scalars().all()} == {3} + +@pytest.mark.anyio +async def test_search_custom_handler(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + engine.register_handler("username", lambda val: SearchUser.username == "guest") + req = SearchRequest(filters=[FilterItem(field="username", op="eq", value="anything")]) + res = await db_session.execute(engine.build_base_query(req)) + assert {u.id for u in res.scalars().all()} == {3} + +def test_search_nested_restricted_field() -> None: + from zcore.context.context import ctx + ctx.restricted_fields = {"post.profile.user.password"} + engine = SearchEngine(SearchComment) + req = SearchRequest(filters=[FilterItem(field="post.profile.user.password", op="eq", value="foo")]) + with pytest.raises(ForbiddenError) as exc_info: + engine.build_base_query(req) + assert "restricted" in str(exc_info.value).lower() + +def test_search_restricted_relationship_path() -> None: + from zcore.context.context import ctx + ctx.restricted_fields = {"SearchComment.post", "post"} + engine = SearchEngine(SearchComment) + req = SearchRequest(filters=[FilterItem(field="post.title", op="eq", value="anything")]) + with pytest.raises(ForbiddenError) as exc_info: + engine.build_base_query(req) + assert "restricted" in str(exc_info.value).lower() + +def test_search_invalid_sort_fields() -> None: + engine = SearchEngine(SearchUser) + req = SearchRequest(sort=[SortItem(field="non_existent_field", order="asc")]) + with pytest.raises(ValidationError) as exc_info: + engine.build_base_query(req) + assert "invalid sort field" in str(exc_info.value).lower() + +@pytest.mark.anyio +async def test_search_pagination_build_query(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req = SearchRequest(size=2, page=2) + query = engine.build_query(req) + res = await db_session.execute(query) + items = list(res.scalars().all()) + assert len(items) == 1 + +@pytest.mark.anyio +async def test_search_empty_request(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req = SearchRequest() + query = engine.build_base_query(req) + res = await db_session.execute(query) + items = list(res.scalars().all()) + assert len(items) == 3 + +@pytest.mark.anyio +async def test_search_complex_combined_logical_filters(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + filter_item = FilterItem( + op="or", + items=[ + FilterItem(field="id", op="eq", value=1), + FilterItem( + op="and", + items=[ + FilterItem(field="id", op="eq", value=3), + FilterItem(field="username", op="eq", value="guest") + ] + ) + ] + ) + req = SearchRequest(filters=[filter_item]) + res = await db_session.execute(engine.build_base_query(req)) + assert {u.id for u in res.scalars().all()} == {1, 3} \ No newline at end of file diff --git a/tests/test_security.py b/tests/test_security.py index dcbcb7e..3ca1b87 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,24 +1,41 @@ import uuid -from typing import Any, Type -from unittest.mock import MagicMock +import time import pytest +from typing import Any, Type +from datetime import timedelta +from pydantic import BaseModel +from unittest.mock import MagicMock, AsyncMock, patch + from zcore.config import settings -from zcore.exceptions.base import AuthError, ForbiddenError -from zcore.security.hashing import get_password_hash, verify_password -from zcore.security.jwt import _get_signing_keys, create_token, decode_token -from zcore.security.permissions import HasScopes +from zcore.context import ctx +from zcore.exceptions import AuthError, ForbiddenError +from zcore.security import BaseAuth, HasScopes, Security class MockUser: - def __init__(self, user_id: uuid.UUID, is_active: bool, is_superuser: bool, scopes: set[str]) -> None: + def __init__(self, user_id: uuid.UUID, is_active: bool, is_superuser: bool, scopes: Any, all_restricted_fields: list[str] = None) -> None: self.id = user_id self.is_active = is_active self.is_superuser = is_superuser - self._scopes = scopes + self.scopes = scopes + self.all_restricted_fields = all_restricted_fields or [] - @property - def all_scopes(self) -> set[str]: - return self._scopes +class SampleUserModel(BaseModel): + id: uuid.UUID + is_active: bool = True + is_superuser: bool = False + scopes: list[str] = [] + all_restricted_fields: list[str] = [] + + model_config = {"from_attributes": True} + +class MyAuth(BaseAuth[SampleUserModel]): + async def fetch_user(self, identity: str) -> Any: + if identity == "active_user": + return MockUser(uuid.uuid4(), is_active=True, is_superuser=False, scopes=["read:items"]) + elif identity == "inactive_user": + return MockUser(uuid.uuid4(), is_active=False, is_superuser=False, scopes=[]) + return None @pytest.mark.parametrize( "plain_pwd, candidate_pwd, is_match", @@ -28,10 +45,10 @@ def all_scopes(self) -> set[str]: ] ) def test_argon2_hashing(plain_pwd: str, candidate_pwd: str, is_match: bool) -> None: - hashed = get_password_hash(plain_pwd) - assert verify_password(candidate_pwd, hashed) is is_match - assert verify_password(plain_pwd, "invalid_hash_structure") is False - assert verify_password(plain_pwd, "$argon2id$v=19$m=65536,t=3,p=4$corruptpayload") is False + hashed = Security.hash_password(plain_pwd) + assert Security.verify_password(candidate_pwd, hashed) is is_match + assert Security.verify_password(plain_pwd, "invalid_hash_structure") is False + assert Security.verify_password(plain_pwd, "$argon2id$v=19$m=65536,t=3,p=4$corruptpayload") is False @pytest.mark.parametrize( "payload", @@ -40,17 +57,17 @@ def test_argon2_hashing(plain_pwd: str, candidate_pwd: str, is_match: bool) -> N ] ) def test_jwt_symmetric_flow(payload: dict[str, Any]) -> None: - token = create_token(payload) - decoded = decode_token(token) + token = Security.create_jwt(payload) + decoded = Security.decode_jwt(token) assert decoded["sub"] == payload["sub"] assert decoded["scopes"] == payload["scopes"] assert "exp" in decoded def test_jwt_production_safety_fallback(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "ENVIRONMENT", "production") + monkeypatch.setattr(settings, "DEBUG", False) monkeypatch.setattr(settings, "SECRET_KEY", "zcore-insecure-fallback-secret-key-must-be-changed") with pytest.raises(RuntimeError) as exc_info: - _get_signing_keys() + Security._get_signing_keys() assert "FATAL SECURITY VIOLATION" in str(exc_info.value) @pytest.mark.anyio @@ -84,4 +101,180 @@ async def test_scope_permissions( await permission(mock_request, user=user) else: resolved_user = await permission(mock_request, user=user) - assert resolved_user is user \ No newline at end of file + assert resolved_user is user + +@pytest.mark.anyio +async def test_base_auth_success(monkeypatch: pytest.MonkeyPatch) -> None: + token = Security.create_jwt({"sub": "active_user", "type": "access"}) + auth_instance = MyAuth(user_schema=SampleUserModel) + + mock_cache = AsyncMock() + mock_cache.get.return_value = None + monkeypatch.setattr(auth_instance, "cache", mock_cache) + + mock_request = MagicMock() + user_data = await auth_instance(mock_request, token=token) + + assert user_data.is_active is True + assert ctx.user_id == user_data.id + assert "read:items" in ctx.get("scopes") + mock_cache.set.assert_called_once() + +@pytest.mark.anyio +async def test_base_auth_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None: + user_id = uuid.uuid4() + cached_user = SampleUserModel(id=user_id, is_active=True, scopes=["read:items"]) + auth_instance = MyAuth(user_schema=SampleUserModel) + + mock_cache = AsyncMock() + mock_cache.get.return_value = cached_user + monkeypatch.setattr(auth_instance, "cache", mock_cache) + + token = Security.create_jwt({"sub": "active_user", "type": "access"}) + mock_request = MagicMock() + + spy_fetch = AsyncMock(wraps=auth_instance.fetch_user) + monkeypatch.setattr(auth_instance, "fetch_user", spy_fetch) + + user_data = await auth_instance(mock_request, token=token) + assert user_data.id == user_id + spy_fetch.assert_not_called() + +@pytest.mark.anyio +async def test_base_auth_inactive_db_user(monkeypatch: pytest.MonkeyPatch) -> None: + token = Security.create_jwt({"sub": "inactive_user", "type": "access"}) + auth_instance = MyAuth(user_schema=SampleUserModel) + + mock_cache = AsyncMock() + mock_cache.get.return_value = None + monkeypatch.setattr(auth_instance, "cache", mock_cache) + + mock_request = MagicMock() + with pytest.raises(AuthError) as exc_info: + await auth_instance(mock_request, token=token) + assert "User inactive" in str(exc_info.value) + +@pytest.mark.anyio +async def test_base_auth_invalid_token_type() -> None: + token = Security.create_jwt({"sub": "active_user", "type": "refresh"}) + auth_instance = MyAuth(user_schema=SampleUserModel) + mock_request = MagicMock() + with pytest.raises(AuthError) as exc_info: + await auth_instance(mock_request, token=token) + assert "Invalid token or token has expired." in str(exc_info.value) + +@pytest.mark.anyio +async def test_base_auth_malformed_token() -> None: + auth_instance = MyAuth(user_schema=SampleUserModel) + mock_request = MagicMock() + with pytest.raises(AuthError) as exc_info: + await auth_instance(mock_request, token="malformed_jwt") + assert "Invalid token" in str(exc_info.value) + +def test_ctx_scope_isolation() -> None: + ctx.user_id = None + uid = uuid.uuid4() + with ctx.scope(user_id=uid): + assert ctx.user_id == uid + assert ctx.user_id is None + +def test_jwt_asymmetric_flow_mock(monkeypatch: pytest.MonkeyPatch) -> None: + private_key = "fake_private_key" + public_key = "fake_public_key" + monkeypatch.setattr(Security, "_get_signing_keys", lambda: (private_key, public_key, "RS256")) + + with patch("jwt.encode") as mock_encode: + Security.create_jwt({"sub": "user1"}) + mock_encode.assert_called_once() + args, kwargs = mock_encode.call_args + assert args[1] == private_key + assert kwargs["algorithm"] == "RS256" + +def test_token_expiration_helper() -> None: + now_ts = int(time.time()) + assert Security.is_token_expired(now_ts - 10) is True + assert Security.is_token_expired(now_ts + 100) is False + assert Security.is_token_expired(0) is True + +def test_jwt_decode_expired() -> None: + token = Security.create_jwt({"sub": "123"}, expires_delta=timedelta(seconds=-10)) + with pytest.raises(AuthError) as exc_info: + Security.decode_jwt(token) + assert "Token expired" in str(exc_info.value) + +def test_jwt_decode_invalid_structure() -> None: + with pytest.raises(AuthError) as exc_info: + Security.decode_jwt("completely_invalid_token") + assert "Invalid token structure" in str(exc_info.value) + +def test_cryptographic_helpers() -> None: + token1 = Security.generate_secure_token(16) + token2 = Security.generate_secure_token(16) + assert len(token1) == 32 + assert token1 != token2 + + digest1 = Security.hash_sha256("test") + digest2 = Security.hash_sha256("test") + assert len(digest1) == 64 + assert digest1 == digest2 + +def test_argoncustom_parameters(monkeypatch: pytest.MonkeyPatch) -> None: + from zcore.config import Settings, initialize_settings + from zcore.kernel.di import container + + original_singletons = dict(container._singletons) + + class CustomSettings(Settings): + ARGON2_MEMORY_COST: int = 32768 + ARGON2_TIME_COST: int = 2 + ARGON2_PARALLELISM: int = 2 + + custom_inst = CustomSettings() + initialize_settings(custom_inst) + + try: + from importlib import reload + import zcore.security.security + reload(zcore.security.security) + + hashed = zcore.security.security.Security.hash_password("pass") + assert zcore.security.security.Security.verify_password("pass", hashed) is True + finally: + container._singletons = original_singletons + from importlib import reload + import zcore.security.security + reload(zcore.security.security) + +@pytest.mark.anyio +@pytest.mark.parametrize( + "coerced_scopes", + [ + ("read:items", "write:items"), + ["read:items", "write:items"], + "read:items", + ] +) +async def test_scope_formatting_coercion(coerced_scopes: Any) -> None: + permission = HasScopes("read:items") + mock_request = MagicMock() + user = MockUser(uuid.uuid4(), is_active=True, is_superuser=False, scopes=coerced_scopes) + resolved_user = await permission(mock_request, user=user) + assert resolved_user is user + +@pytest.mark.anyio +async def test_base_permission_object_level() -> None: + class DummyPermission(HasScopes): + pass + perm = DummyPermission("read:items") + mock_request = MagicMock() + user = MockUser(uuid.uuid4(), is_active=True, is_superuser=False, scopes={"read:items"}) + assert await perm.has_object_permission(mock_request, user, {}) is True + +@pytest.mark.anyio +async def test_superuser_scope_bypass_disabled() -> None: + permission = HasScopes("read:items", allow_superuser=False) + mock_request = MagicMock() + user = MockUser(uuid.uuid4(), is_active=True, is_superuser=True, scopes=set()) + with pytest.raises(ForbiddenError) as exc_info: + await permission(mock_request, user=user) + assert "Access denied" in str(exc_info.value) \ No newline at end of file diff --git a/tests/test_service.py b/tests/test_service.py index a417c45..e573fda 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,11 +1,15 @@ import uuid -from typing import Any, Type -from unittest.mock import AsyncMock, MagicMock import pytest from pydantic import BaseModel + +from typing import Any, Type, Sequence, Optional +from unittest.mock import AsyncMock, MagicMock from sqlalchemy import Column, Integer, String from zcore.db.setup import Base +from zcore.db.search import SearchRequest +from zcore.db.pagination import PaginatedResult +from zcore.exceptions.base import EntityNotFound from zcore.service.base import BaseService class ServiceTestModel(Base): @@ -19,27 +23,85 @@ class ServiceTestCreateSchema(BaseModel): class ServiceTestUpdateSchema(BaseModel): name: str -class HookTrackingService(BaseService[ServiceTestModel, ServiceTestCreateSchema, ServiceTestUpdateSchema]): +class HookTrackingService(BaseService[ServiceTestModel]): def __init__(self, model: Type[ServiceTestModel], repository: Any) -> None: super().__init__(model, repository) self.hooks_called: list[str] = [] - async def pre_create(self, schema: ServiceTestCreateSchema) -> None: + async def post_get(self, model: ServiceTestModel) -> ServiceTestModel: + self.hooks_called.append("post_get") + model.name = model.name + "_GETHOOKED" + return model + + async def post_get_multi(self, models: Sequence[ServiceTestModel]) -> Sequence[ServiceTestModel]: + self.hooks_called.append("post_get_multi") + for m in models: + m.name = m.name + "_GETMULTIHOCKED" + return models + + async def pre_create(self, schema: ServiceTestCreateSchema) -> Optional[dict[str, Any]]: self.hooks_called.append("pre_create") schema.name = "HOOKED_" + schema.name + if schema.name == "HOOKED_Base": + return {"audit_flag": True} + return None async def post_create(self, model: ServiceTestModel) -> None: self.hooks_called.append("post_create") model.name = model.name + "_AUDITED" - async def pre_update(self, id: Any, schema: ServiceTestUpdateSchema, partial: bool) -> None: + async def pre_create_multi(self, schemas: list[BaseModel]) -> None: + self.hooks_called.append("pre_create_multi") + for s in schemas: + s.name = "BULK_" + s.name + + async def post_create_multi(self, models: Sequence[ServiceTestModel]) -> None: + self.hooks_called.append("post_create_multi") + for m in models: + m.name = m.name + "_BULKAUDITED" + + async def pre_update(self, id: Any, schema: ServiceTestUpdateSchema, partial: bool) -> Optional[dict[str, Any]]: self.hooks_called.append("pre_update") schema.name = "UPDATED_HOOK_" + schema.name + return None async def post_update(self, model: ServiceTestModel) -> None: self.hooks_called.append("post_update") model.name = model.name + "_UPDATED" + async def pre_update_multi(self, data: dict[Any, BaseModel], partial: bool) -> None: + self.hooks_called.append("pre_update_multi") + for k, v in data.items(): + v.name = "BULK_UPD_" + v.name + + async def post_update_multi(self, models: Sequence[ServiceTestModel]) -> None: + self.hooks_called.append("post_update_multi") + for m in models: + m.name = m.name + "_BULKUPDATED" + + async def pre_delete(self, id: Any) -> None: + self.hooks_called.append("pre_delete") + + async def post_delete(self, model: ServiceTestModel) -> None: + self.hooks_called.append("post_delete") + model.name = model.name + "_DELETED" + + async def pre_delete_multi(self, ids: list[Any]) -> None: + self.hooks_called.append("pre_delete_multi") + + async def post_delete_multi(self, models: Sequence[ServiceTestModel]) -> None: + self.hooks_called.append("post_delete_multi") + for m in models: + m.name = m.name + "_BULKDELETED" + + async def pre_search(self, search_in: SearchRequest) -> None: + self.hooks_called.append("pre_search") + + async def post_search(self, models: Sequence[ServiceTestModel]) -> None: + self.hooks_called.append("post_search") + for m in models: + m.name = m.name + "_SEARCHED" + @pytest.mark.anyio @pytest.mark.parametrize( "initial_name, expected_repo_name, expected_final_name", @@ -59,7 +121,7 @@ async def test_service_pre_post_hooks( mock_repo = AsyncMock() mock_repo.db = mock_db - async def fake_create(schema: ServiceTestCreateSchema) -> ServiceTestModel: + async def fake_create(schema: ServiceTestCreateSchema, **extra: Any) -> ServiceTestModel: return ServiceTestModel(id=1, name=schema.name) mock_repo.create.side_effect = fake_create @@ -118,4 +180,235 @@ async def test_service_safe_commit_scenarios( mock_db.rollback.assert_not_called() else: mock_db.commit.assert_not_called() - mock_db.rollback.assert_not_called() \ No newline at end of file + mock_db.rollback.assert_not_called() + +@pytest.mark.anyio +async def test_service_post_get_hook() -> None: + mock_repo = AsyncMock() + record = ServiceTestModel(id=1, name="Database") + mock_repo.get.return_value = record + + service = HookTrackingService(ServiceTestModel, mock_repo) + result = await service.get(id=1) + + assert "post_get" in service.hooks_called + assert result.name == "Database_GETHOOKED" + +@pytest.mark.anyio +async def test_service_post_get_multi_hook() -> None: + mock_repo = AsyncMock() + records = [ServiceTestModel(id=1, name="R1"), ServiceTestModel(id=2, name="R2")] + mock_repo.get_by_ids.return_value = records + + service = HookTrackingService(ServiceTestModel, mock_repo) + results = await service.get_by_ids(ids=[1, 2]) + + assert "post_get_multi" in service.hooks_called + assert results[0].name == "R1_GETMULTIHOCKED" + assert results[1].name == "R2_GETMULTIHOCKED" + +@pytest.mark.anyio +async def test_service_get_entity_not_found() -> None: + mock_repo = AsyncMock() + mock_repo.get.return_value = None + + service = HookTrackingService(ServiceTestModel, mock_repo) + with pytest.raises(EntityNotFound): + await service.get(id=999) + + assert "post_get" not in service.hooks_called + +@pytest.mark.anyio +async def test_service_get_list_pagination() -> None: + mock_repo = AsyncMock() + records = [ServiceTestModel(id=1, name="R1"), ServiceTestModel(id=2, name="R2")] + paginated = PaginatedResult(data=records, meta={"page": 1}) + mock_repo.get_list.return_value = paginated + + service = HookTrackingService(ServiceTestModel, mock_repo) + result = await service.get_list(pagination=MagicMock()) + + assert "post_get_multi" in service.hooks_called + assert result.data[0].name == "R1_GETMULTIHOCKED" + assert result.data[1].name == "R2_GETMULTIHOCKED" + assert result.meta["page"] == 1 + +@pytest.mark.anyio +async def test_service_pre_create_extra_data_merge() -> None: + mock_db = MagicMock() + mock_db.info = {"uow_managed": True} + mock_repo = AsyncMock() + mock_repo.db = mock_db + + async def fake_create(schema: ServiceTestCreateSchema, **extra: Any) -> ServiceTestModel: + assert extra.get("audit_flag") is True + assert extra.get("extra_field") == "yes" + return ServiceTestModel(id=1, name=schema.name) + + mock_repo.create.side_effect = fake_create + + service = HookTrackingService(ServiceTestModel, mock_repo) + schema = ServiceTestCreateSchema(name="Base") + await service.create(schema, extra_field="yes") + + assert "pre_create" in service.hooks_called + +@pytest.mark.anyio +async def test_service_create_multi_hooks() -> None: + mock_db = MagicMock() + mock_db.info = {"uow_managed": True} + mock_repo = AsyncMock() + mock_repo.db = mock_db + + async def fake_create_multi(schemas: list[BaseModel], refresh: bool = False) -> Sequence[ServiceTestModel]: + return [ServiceTestModel(id=i, name=s.name) for i, s in enumerate(schemas, 1)] + + mock_repo.create_multi.side_effect = fake_create_multi + + service = HookTrackingService(ServiceTestModel, mock_repo) + schemas = [ServiceTestCreateSchema(name="S1"), ServiceTestCreateSchema(name="S2")] + results = await service.create_multi(schemas) + + assert "pre_create_multi" in service.hooks_called + assert "post_create_multi" in service.hooks_called + assert results[0].name == "BULK_S1_BULKAUDITED" + assert results[1].name == "BULK_S2_BULKAUDITED" + +@pytest.mark.anyio +async def test_service_update_multi_hooks() -> None: + mock_db = MagicMock() + mock_db.info = {"uow_managed": True} + mock_repo = AsyncMock() + mock_repo.db = mock_db + + async def fake_update_multi(data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False) -> Sequence[ServiceTestModel]: + return [ServiceTestModel(id=k, name=v.name) for k, v in data.items()] + + mock_repo.update_multi.side_effect = fake_update_multi + + service = HookTrackingService(ServiceTestModel, mock_repo) + updates = { + 1: ServiceTestUpdateSchema(name="U1"), + 2: ServiceTestUpdateSchema(name="U2") + } + results = await service.update_multi(updates) + + assert "pre_update_multi" in service.hooks_called + assert "post_update_multi" in service.hooks_called + assert results[0].name == "BULK_UPD_U1_BULKUPDATED" + assert results[1].name == "BULK_UPD_U2_BULKUPDATED" + +@pytest.mark.anyio +async def test_service_update_entity_not_found() -> None: + mock_repo = AsyncMock() + mock_repo.update.return_value = None + + service = HookTrackingService(ServiceTestModel, mock_repo) + schema = ServiceTestUpdateSchema(name="Fresh") + + with pytest.raises(EntityNotFound): + await service.update(id=999, schema=schema) + + assert "post_update" not in service.hooks_called + +@pytest.mark.anyio +async def test_service_delete_hooks() -> None: + mock_db = MagicMock() + mock_db.info = {"uow_managed": True} + mock_repo = AsyncMock() + mock_repo.db = mock_db + + record = ServiceTestModel(id=1, name="ToKill") + mock_repo.delete.return_value = record + + service = HookTrackingService(ServiceTestModel, mock_repo) + result = await service.delete(id=1) + + assert "pre_delete" in service.hooks_called + assert "post_delete" in service.hooks_called + assert result.name == "ToKill_DELETED" + +@pytest.mark.anyio +async def test_service_delete_multi_hooks() -> None: + mock_db = MagicMock() + mock_db.info = {"uow_managed": True} + mock_repo = AsyncMock() + mock_repo.db = mock_db + + records = [ServiceTestModel(id=1, name="K1"), ServiceTestModel(id=2, name="K2")] + mock_repo.delete_multi.return_value = records + + service = HookTrackingService(ServiceTestModel, mock_repo) + results = await service.delete_multi(ids=[1, 2]) + + assert "pre_delete_multi" in service.hooks_called + assert "post_delete_multi" in service.hooks_called + assert results[0].name == "K1_BULKDELETED" + assert results[1].name == "K2_BULKDELETED" + +@pytest.mark.anyio +async def test_service_delete_entity_not_found() -> None: + mock_repo = AsyncMock() + mock_repo.delete.return_value = None + + service = HookTrackingService(ServiceTestModel, mock_repo) + + with pytest.raises(EntityNotFound): + await service.delete(id=999) + + assert "post_delete" not in service.hooks_called + +@pytest.mark.anyio +async def test_service_search_hooks() -> None: + mock_repo = AsyncMock() + records = [ServiceTestModel(id=1, name="Found1"), ServiceTestModel(id=2, name="Found2")] + mock_repo.search.return_value = records + + service = HookTrackingService(ServiceTestModel, mock_repo) + search_request = SearchRequest(filters=[]) + results = await service.search(search_in=search_request) + + assert "pre_search" in service.hooks_called + assert "post_search" in service.hooks_called + assert results[0].name == "Found1_SEARCHED" + assert results[1].name == "Found2_SEARCHED" + +@pytest.mark.anyio +async def test_service_search_pagination() -> None: + mock_repo = AsyncMock() + records = [ServiceTestModel(id=1, name="Found1"), ServiceTestModel(id=2, name="Found2")] + paginated = PaginatedResult(data=records, meta={"page": 1}) + mock_repo.search.return_value = paginated + + service = HookTrackingService(ServiceTestModel, mock_repo) + search_request = SearchRequest(filters=[]) + result = await service.search(search_in=search_request, pagination=MagicMock()) + + assert "pre_search" in service.hooks_called + assert "post_search" in service.hooks_called + assert result.data[0].name == "Found1_SEARCHED" + assert result.data[1].name == "Found2_SEARCHED" + assert result.meta["page"] == 1 + +@pytest.mark.anyio +async def test_service_post_create_error_rollback() -> None: + mock_db = AsyncMock() + mock_db.info = {"uow_managed": False} + mock_repo = AsyncMock() + mock_repo.db = mock_db + + record = ServiceTestModel(id=1, name="Partial") + mock_repo.create.return_value = record + + class ErrorInPostCreateService(HookTrackingService): + async def post_create(self, model: ServiceTestModel) -> None: + raise RuntimeError("Failed after db write") + + service = ErrorInPostCreateService(ServiceTestModel, mock_repo) + schema = ServiceTestCreateSchema(name="Fresh") + + with pytest.raises(RuntimeError): + await service.create(schema) + + mock_db.commit.assert_not_called() + mock_db.rollback.assert_not_called() \ No newline at end of file diff --git a/tests/test_storage.py b/tests/test_storage.py index 437ccea..3380d83 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,11 +1,19 @@ +import os from io import BytesIO from typing import AsyncGenerator import pytest +import aiofiles from fastapi import UploadFile from zcore.exceptions.base import AppException, ValidationError from zcore.storage.local import LocalStorageProvider -from zcore.storage.validators import MaxFileSizeValidator, SafeMimeTypeValidator +from zcore.storage.validators import ( + FileExtensionValidator, + MaxFileSizeValidator, + SafeMimeTypeValidator, +) +from zcore.storage.base import get_storage_provider, StorageProvider +from zcore.kernel.di import container def create_mock_upload_file(content: bytes, filename: str, size: int | None = None) -> UploadFile: file_obj = BytesIO(content) @@ -97,4 +105,126 @@ def test_validator_max_file_size( else: with pytest.raises(ValidationError) as exc_info: validator(file) - assert "exceeds the limit" in str(exc_info.value) \ No newline at end of file + assert "exceeds the limit" in str(exc_info.value) + +@pytest.mark.anyio +async def test_storage_successful_upload(test_storage_dir: str) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + file = create_mock_upload_file(b"test data content", "hello.txt") + path = await provider.upload(file, "text_files") + assert os.path.exists(path) + with open(path, "rb") as f: + assert f.read() == b"test data content" + +@pytest.mark.anyio +async def test_storage_successful_upload_stream(test_storage_dir: str) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + async def fake_stream() -> AsyncGenerator[bytes, None]: + yield b"chunk_one_" + yield b"chunk_two" + path = await provider.upload_stream(fake_stream(), "streamed.bin", "streams") + assert os.path.exists(path) + with open(path, "rb") as f: + assert f.read() == b"chunk_one_chunk_two" + +@pytest.mark.anyio +async def test_storage_collision_prevention(test_storage_dir: str) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + f1 = create_mock_upload_file(b"content 1", "test.txt") + f2 = create_mock_upload_file(b"content 2", "test.txt") + p1 = await provider.upload(f1, "collision") + p2 = await provider.upload(f2, "collision") + assert p1 != p2 + assert os.path.exists(p1) + assert os.path.exists(p2) + +@pytest.mark.anyio +async def test_storage_auto_directory_creation(test_storage_dir: str) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + f = create_mock_upload_file(b"data", "test.txt") + path = await provider.upload(f, "new/nested/folder") + assert os.path.exists(path) + +@pytest.mark.anyio +async def test_storage_successful_delete(test_storage_dir: str) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + f = create_mock_upload_file(b"data", "test.txt") + path = await provider.upload(f, "docs") + assert os.path.exists(path) + res = await provider.delete(path) + assert res is True + assert not os.path.exists(path) + +@pytest.mark.anyio +async def test_storage_delete_non_existent(test_storage_dir: str) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + fake_path = os.path.join(test_storage_dir, "docs", "missing.txt") + res = await provider.delete(fake_path) + assert res is True + +def test_validator_file_extension_case_insensitive() -> None: + validator = FileExtensionValidator(allowed_extensions=["png", "JPEG"]) + f1 = create_mock_upload_file(b"", "image.PNG") + f2 = create_mock_upload_file(b"", "photo.jpeg") + f3 = create_mock_upload_file(b"", "avatar.png") + validator(f1) + validator(f2) + validator(f3) + +def test_validator_file_extension_blocked() -> None: + validator = FileExtensionValidator(allowed_extensions=["pdf"]) + f = create_mock_upload_file(b"", "script.py") + with pytest.raises(ValidationError): + validator(f) + +def test_validator_file_extension_missing() -> None: + validator = FileExtensionValidator(allowed_extensions=["pdf"]) + f = create_mock_upload_file(b"", "config") + with pytest.raises(ValidationError): + validator(f) + +def test_validator_file_extension_double_extension_attack() -> None: + validator = FileExtensionValidator(allowed_extensions=["pdf", "png"]) + f1 = create_mock_upload_file(b"", "doc.pdf.exe") + f2 = create_mock_upload_file(b"", "avatar.png.php") + with pytest.raises(ValidationError): + validator(f1) + with pytest.raises(ValidationError): + validator(f2) + +def test_validator_mime_fallback() -> None: + validator = SafeMimeTypeValidator(allowed_mimes=["application/json", "text/plain"]) + f1 = create_mock_upload_file(b'{"key": "value"}', "data.json") + f2 = create_mock_upload_file(b"plain text", "note.txt") + validator(f1) + validator(f2) + +def test_validator_mime_read_error() -> None: + validator = SafeMimeTypeValidator(allowed_mimes=["image/png"]) + class ErrorFile: + def read(self, *args, **kwargs): + raise OSError("Hardware read error") + def seek(self, *args, **kwargs): + pass + upload_file = UploadFile(file=ErrorFile(), filename="test.png") + with pytest.raises(ValidationError) as exc_info: + validator(upload_file) + assert "Failed to validate file signatures." in str(exc_info.value) + +@pytest.mark.anyio +async def test_storage_os_permission_error(test_storage_dir: str, monkeypatch: pytest.MonkeyPatch) -> None: + provider = LocalStorageProvider(base_path=test_storage_dir) + file = create_mock_upload_file(b"test data", "permission.txt") + def mock_open(*args, **kwargs): + raise OSError("Mock disk error") + monkeypatch.setattr(aiofiles, "open", mock_open) + with pytest.raises(AppException) as exc_info: + await provider.upload(file, "uploads") + assert "Error saving file" in str(exc_info.value) + +@pytest.mark.anyio +async def test_storage_provider_dependency(test_storage_dir: str) -> None: + provider_instance = LocalStorageProvider(base_path=test_storage_dir) + container.register_singleton(StorageProvider, provider_instance) + resolved = await get_storage_provider(provider_instance) + assert resolved is provider_instance \ No newline at end of file diff --git a/tests/test_streams.py b/tests/test_streams.py index 43c6198..cd814eb 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1,14 +1,22 @@ import asyncio import uuid -from typing import Any -from unittest.mock import MagicMock +import json +from typing import Any, AsyncGenerator +from unittest.mock import AsyncMock, MagicMock import pytest -from zcore.web.streams import StreamManager +from zcore.web.streams import StreamManager, init_stream_redis +import zcore.web.streams as streams_module @pytest.fixture(autouse=True) def bypass_redis_pubsub(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("zcore.web.streams._stream_redis_client", None) + monkeypatch.setattr(streams_module, "_stream_redis_client", None) + +def test_init_stream_redis() -> None: + mock_client = MagicMock() + init_stream_redis(mock_client) + assert streams_module._stream_redis_client is mock_client + init_stream_redis(None) @pytest.mark.anyio @pytest.mark.parametrize("num_subscribers", [1, 3]) @@ -73,4 +81,238 @@ async def test_stream_queue_overflow(total_queues: int, overflow_index: int) -> assert received == payload for q in list(manager.users_queues.get(user_id, [])): - await manager.unsubscribe(user_id, q) \ No newline at end of file + await manager.unsubscribe(user_id, q) + +@pytest.mark.anyio +async def test_redis_lazy_background_startup(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = MagicMock() + mock_pubsub = AsyncMock() + + async def mock_listen() -> AsyncGenerator[dict[str, Any], None]: + while True: + await asyncio.sleep(1) + yield {} + + mock_pubsub.listen = mock_listen + mock_redis.pubsub = MagicMock(return_value=mock_pubsub) + monkeypatch.setattr(streams_module, "_stream_redis_client", mock_redis) + + manager = StreamManager() + assert manager._pubsub_task is None + + user_id = uuid.uuid4() + q = await manager.subscribe(user_id) + await asyncio.sleep(0.01) + + assert manager._pubsub_task is not None + assert not manager._pubsub_task.done() + mock_redis.pubsub.assert_called_once() + mock_pubsub.psubscribe.assert_called_once_with("stream:user:*") + + await manager.unsubscribe(user_id, q) + +@pytest.mark.anyio +async def test_redis_clean_teardown_on_empty_queues(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = MagicMock() + mock_pubsub = AsyncMock() + + async def mock_listen() -> AsyncGenerator[dict[str, Any], None]: + while True: + await asyncio.sleep(1) + yield {} + + mock_pubsub.listen = mock_listen + mock_redis.pubsub = MagicMock(return_value=mock_pubsub) + monkeypatch.setattr(streams_module, "_stream_redis_client", mock_redis) + + manager = StreamManager() + user_id = uuid.uuid4() + q = await manager.subscribe(user_id) + await asyncio.sleep(0.01) + + assert manager._pubsub_task is not None + + await manager.unsubscribe(user_id, q) + await asyncio.sleep(0.01) + + assert manager._pubsub_task is None + mock_pubsub.punsubscribe.assert_called_once_with("stream:user:*") + mock_pubsub.close.assert_called_once() + +@pytest.mark.anyio +async def test_redis_message_routing(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = MagicMock() + mock_pubsub = AsyncMock() + mock_redis.pubsub = MagicMock(return_value=mock_pubsub) + monkeypatch.setattr(streams_module, "_stream_redis_client", mock_redis) + + user_id = uuid.uuid4() + payload = {"test": "data"} + + async def mock_listen() -> AsyncGenerator[dict[str, Any], None]: + yield { + "type": "pmessage", + "channel": f"stream:user:{user_id}", + "data": json.dumps(payload) + } + while True: + await asyncio.sleep(1) + + mock_pubsub.listen = mock_listen + + manager = StreamManager() + q = await manager.subscribe(user_id) + + received = await asyncio.wait_for(q.get(), timeout=1.0) + assert received == payload + + await manager.unsubscribe(user_id, q) + +@pytest.mark.anyio +async def test_redis_malformatted_message_robustness(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = MagicMock() + mock_pubsub = AsyncMock() + mock_redis.pubsub = MagicMock(return_value=mock_pubsub) + monkeypatch.setattr(streams_module, "_stream_redis_client", mock_redis) + + user_id = uuid.uuid4() + payload = {"valid": "json"} + + async def mock_listen() -> AsyncGenerator[dict[str, Any], None]: + yield { + "type": "pmessage", + "channel": "stream:user:invalid-uuid-format", + "data": json.dumps(payload) + } + yield { + "type": "pmessage", + "channel": f"stream:user:{user_id}", + "data": json.dumps(payload) + } + while True: + await asyncio.sleep(1) + + mock_pubsub.listen = mock_listen + + manager = StreamManager() + q = await manager.subscribe(user_id) + + received = await asyncio.wait_for(q.get(), timeout=1.0) + assert received == payload + + await manager.unsubscribe(user_id, q) + +@pytest.mark.anyio +async def test_stream_subscription_context_manager_normal_and_error() -> None: + manager = StreamManager() + user_id = uuid.uuid4() + + async with manager.subscription(user_id) as q: + assert q in manager.users_queues[user_id] + await manager.publish(user_id, {"msg": "hi"}) + res = await asyncio.wait_for(q.get(), timeout=1.0) + assert res == {"msg": "hi"} + + assert user_id not in manager.users_queues + + try: + async with manager.subscription(user_id) as q_err: + assert q_err in manager.users_queues[user_id] + raise ValueError("Simulated error") + except ValueError: + pass + + assert user_id not in manager.users_queues + +@pytest.mark.anyio +async def test_stream_multi_user_isolation() -> None: + manager = StreamManager() + user_a = uuid.uuid4() + user_b = uuid.uuid4() + + q_a = await manager.subscribe(user_a) + q_b = await manager.subscribe(user_b) + + payload = {"data": "isolated"} + await manager.publish(user_a, payload) + + received_a = await asyncio.wait_for(q_a.get(), timeout=1.0) + assert received_a == payload + + assert q_b.empty() + + await manager.unsubscribe(user_a, q_a) + await manager.unsubscribe(user_b, q_b) + +@pytest.mark.anyio +async def test_stream_garbage_collection_on_all_queues_overflow() -> None: + manager = StreamManager() + user_id = uuid.uuid4() + + q1 = await manager.subscribe(user_id) + q2 = await manager.subscribe(user_id) + + q1.put_nowait = MagicMock(side_effect=asyncio.QueueFull) + q2.put_nowait = MagicMock(side_effect=asyncio.QueueFull) + + await manager.publish(user_id, {"event": "overflow"}) + + assert user_id not in manager.users_queues + +@pytest.mark.anyio +async def test_stream_robust_unsubscribe_nonexistent() -> None: + manager = StreamManager() + user_id = uuid.uuid4() + fake_queue = asyncio.Queue() + + await manager.unsubscribe(user_id, fake_queue) + + q = await manager.subscribe(user_id) + await manager.unsubscribe(user_id, q) + await manager.unsubscribe(user_id, q) + + assert user_id not in manager.users_queues + +@pytest.mark.anyio +async def test_redis_active_publish(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = MagicMock() + mock_redis.publish = AsyncMock() + monkeypatch.setattr(streams_module, "_stream_redis_client", mock_redis) + + manager = StreamManager() + user_id = uuid.uuid4() + payload = {"event": "test"} + + await manager.publish(user_id, payload) + + mock_redis.publish.assert_called_once_with( + f"stream:user:{user_id}", + json.dumps(payload) + ) + +@pytest.mark.anyio +async def test_redis_publish_fallback_to_local(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = MagicMock() + mock_pubsub = AsyncMock() + + async def mock_listen() -> AsyncGenerator[dict[str, Any], None]: + while True: + await asyncio.sleep(1) + yield {} + + mock_pubsub.listen = mock_listen + mock_redis.pubsub = MagicMock(return_value=mock_pubsub) + mock_redis.publish = AsyncMock(side_effect=Exception("Redis publish error")) + monkeypatch.setattr(streams_module, "_stream_redis_client", mock_redis) + + manager = StreamManager() + user_id = uuid.uuid4() + q = await manager.subscribe(user_id) + + payload = {"event": "fallback"} + await manager.publish(user_id, payload) + + received = await asyncio.wait_for(q.get(), timeout=1.0) + assert received == payload + + await manager.unsubscribe(user_id, q) \ No newline at end of file diff --git a/tests/test_testing_subsystem.py b/tests/test_testing_subsystem.py new file mode 100644 index 0000000..d8cfa24 --- /dev/null +++ b/tests/test_testing_subsystem.py @@ -0,0 +1,300 @@ +import pytest +import asyncio +import uuid + +from contextlib import asynccontextmanager +from fastapi import FastAPI, Depends +from sqlalchemy import String, select, event +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.ext.asyncio import AsyncSession + +from zcore import Base, container, settings, get_db, ctx, db_manager +from zcore.testing import ZTestClient, BaseZTest, ZTestFixture, ZTest, ContainerSandbox, DatabaseRollback +from zcore.security import get_current_user_stub, UserProtocol +from zcore.context.context import _request_context_store + +class DummyTask(Base): + __tablename__ = "dummy_tasks" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + title: Mapped[str] = mapped_column(String(100)) + +dummy_app = FastAPI() + +lifespan_events = [] + +@asynccontextmanager +async def dummy_lifespan_ctx(app: FastAPI): + lifespan_events.append("started") + yield + lifespan_events.append("shutdown") + +lifespan_app = FastAPI(lifespan=dummy_lifespan_ctx) + +@dummy_app.post("/tasks") +async def create_task(title: str, db: AsyncSession = Depends(get_db)): + task = DummyTask(title=title) + db.add(task) + await db.flush() + return {"id": str(task.id), "title": task.title} + +@dummy_app.post("/tasks-commit") +async def create_task_commit(title: str, db: AsyncSession = Depends(get_db)): + task = DummyTask(title=title) + db.add(task) + await db.commit() + return {"id": str(task.id), "title": task.title} + +@dummy_app.get("/me") +async def get_me(user: UserProtocol = Depends(get_current_user_stub)): + return { + "id": str(user.id), + "is_superuser": user.is_superuser, + "scopes": getattr(user, "scopes", []), + "phone": getattr(user, "phone_number", None) + } + +@pytest.fixture(scope="module", autouse=True) +def setup_test_engine(): + db_manager.init_app(db_url=settings.DATABASE_TEST_URL) + + sync_eng = db_manager._engine.sync_engine + + @event.listens_for(sync_eng, "connect") + def do_connect(dbapi_connection, connection_record): + dbapi_connection.isolation_level = None + + @event.listens_for(sync_eng, "begin") + def do_begin(conn): + conn.exec_driver_sql("BEGIN") + + async def create_tables(): + async with db_manager._engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def drop_tables(): + async with db_manager._engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + async def close_manager(): + await db_manager.close() + + asyncio.run(create_tables()) + yield + asyncio.run(drop_tables()) + asyncio.run(close_manager()) + +@pytest.mark.asyncio +async def test_database_rollback_isolation(): + task_id = None + async with ZTestClient(dummy_app, use_db=True) as client: + res = await client.post("/tasks?title=CleanCode") + assert res.status_code == 200 + task_id = res.json()["id"] + + async with ZTestClient(dummy_app, use_db=True) as client: + async with db_manager.session() as session: + res = await session.execute( + select(DummyTask).where(DummyTask.id == uuid.UUID(task_id)) + ) + assert res.scalars().first() is None + +@pytest.mark.asyncio +async def test_container_sandbox(): + class MockService: + pass + + interface = MockService + implementation = MockService() + + async with ZTestClient(dummy_app, use_db=False) as client: + container.register_singleton(interface, implementation) + assert container.resolve(interface) is implementation + + assert container.resolve(interface) is not implementation + +@pytest.mark.asyncio +async def test_user_authentication_mocking(): + uid = uuid.uuid4() + scopes = ["tasks:write", "tasks:read"] + + async with ZTestClient( + dummy_app, + user_id=uid, + scopes=scopes, + is_superuser=True, + use_db=False + ) as client: + res = await client.get("/me") + assert res.status_code == 200 + data = res.json() + assert data["id"] == str(uid) + assert data["is_superuser"] is True + assert data["scopes"] == scopes + +@pytest.mark.asyncio +async def test_extra_user_attributes(): + uid = uuid.uuid4() + phone = "09120000000" + + async with ZTestClient( + dummy_app, + user_id=uid, + use_db=False, + extra_user_attrs={"phone_number": phone} + ) as client: + res = await client.get("/me") + assert res.status_code == 200 + data = res.json() + assert data["phone"] == phone + +@pytest.mark.asyncio +async def test_extra_context_variables(): + uid = uuid.uuid4() + restricted = ["tasks.secret_key"] + + async with ZTestClient( + dummy_app, + user_id=uid, + use_db=False, + extra_context={"restricted_fields": frozenset(restricted)} + ) as client: + assert ctx.restricted_fields == frozenset(restricted) + +@pytest.mark.asyncio +class TestClassBasedExecution(BaseZTest): + app = dummy_app + user_id = uuid.uuid4() + is_superuser = True + scopes = ["admin:delete"] + extra_user_attrs = {"phone_number": "09121111111"} + + async def test_class_based_test_run(self): + async with self.run() as client: + res = await client.get("/me") + assert res.status_code == 200 + data = res.json() + assert data["id"] == str(self.user_id) + assert data["is_superuser"] is True + assert data["scopes"] == self.scopes + assert data["phone"] == "09121111111" + +@pytest.mark.asyncio +async def test_database_rollback_nested_commit(): + task_id = None + async with ZTestClient(dummy_app, use_db=True) as client: + res = await client.post("/tasks-commit?title=ExplicitCommit") + assert res.status_code == 200 + task_id = res.json()["id"] + + async with ZTestClient(dummy_app, use_db=True) as client: + async with db_manager.session() as session: + res = await session.execute( + select(DummyTask).where(DummyTask.id == uuid.UUID(task_id)) + ) + assert res.scalars().first() is None + +@pytest.mark.asyncio +async def test_database_rollback_uninitialized_engine(monkeypatch: pytest.MonkeyPatch): + original_engine = db_manager._engine + monkeypatch.setattr(db_manager, "_engine", None) + + rollback_fixture = DatabaseRollback() + with pytest.raises(RuntimeError) as exc: + await rollback_fixture.setUp() + assert str(exc.value) == "DatabaseManager engine is uninitialized." + + monkeypatch.setattr(db_manager, "_engine", original_engine) + +@pytest.mark.asyncio +async def test_container_sandbox_deep_restore(): + class PreExistingService: + pass + + class OverriddenService: + pass + + container.register_singleton(PreExistingService, "original_singleton_value") + container.register_scoped(OverriddenService, OverriddenService) + + sandbox = ContainerSandbox() + await sandbox.setUp() + + container.register_singleton(PreExistingService, "new_temporary_value") + assert container.resolve(PreExistingService) == "new_temporary_value" + + await sandbox.tearDown() + assert container.resolve(PreExistingService) == "original_singleton_value" + +@pytest.mark.asyncio +async def test_app_lifespan_execution(): + global lifespan_events + lifespan_events.clear() + + async with ZTestClient(lifespan_app, use_db=False) as client: + assert "started" in lifespan_events + assert "shutdown" not in lifespan_events + + assert "shutdown" in lifespan_events + +@pytest.mark.asyncio +async def test_ztest_orchestrator_running_sequence(): + execution_order = [] + + class DummySeqFixture(ZTestFixture): + def __init__(self, identifier: str): + self.identifier = identifier + + async def setUp(self) -> None: + execution_order.append(f"{self.identifier}_setup") + + async def tearDown(self) -> None: + execution_order.append(f"{self.identifier}_teardown") + + fixture_a = DummySeqFixture("A") + fixture_b = DummySeqFixture("B") + + orchestrator = ZTest(fixture_a, fixture_b) + await orchestrator.setUp() + assert execution_order == ["A_setup", "B_setup"] + + await orchestrator.tearDown() + assert execution_order == ["A_setup", "B_setup", "B_teardown", "A_teardown"] + +@pytest.mark.asyncio +async def test_user_context_exception_safety(): + original_store = dict(_request_context_store.get()) + + try: + async with ZTestClient(dummy_app, user_id=uuid.uuid4(), use_db=False) as client: + raise ValueError("Forced error within execution block") + except ValueError: + pass + + assert _request_context_store.get() == original_store + +@pytest.mark.asyncio +async def test_nested_ztest_clients(): + user_id_outer = uuid.uuid4() + user_id_inner = uuid.uuid4() + + async with ZTestClient(dummy_app, user_id=user_id_outer, use_db=False) as client_outer: + assert ctx.user_id == user_id_outer + + async with ZTestClient(dummy_app, user_id=user_id_inner, use_db=False) as client_inner: + assert ctx.user_id == user_id_inner + + assert ctx.user_id == user_id_outer + +@pytest.mark.asyncio +async def test_dependency_override_custom_preservation(): + class DevelopmentCustomDep: + pass + + dummy_app.dependency_overrides[DevelopmentCustomDep] = lambda: "developer_preset" + + async with ZTestClient(dummy_app, user_id=uuid.uuid4(), use_db=False) as client: + assert dummy_app.dependency_overrides.get(DevelopmentCustomDep)() == "developer_preset" + + assert dummy_app.dependency_overrides.get(DevelopmentCustomDep)() == "developer_preset" + del dummy_app.dependency_overrides[DevelopmentCustomDep] \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py index 5b04314..31f4b72 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,6 +8,12 @@ from zcore.utils.helpers import json_dumps, json_loads, slugify from zcore.utils.validators import validate_json_schema +class DummyUser: + def __init__(self, name: str) -> None: + self.name = name + def __str__(self) -> str: + return f"DummyUser({self.name})" + @pytest.mark.parametrize( "input_val, expected_output", [ @@ -41,6 +47,49 @@ def test_custom_json_encoder(input_val: Any, expected_output: str) -> None: serialized = json_dumps(input_val) assert json_loads(serialized) == json_loads(expected_output) +def test_custom_json_encoder_fallback_on_custom_object() -> None: + user = DummyUser("Alice") + serialized = json_dumps(user) + assert json_loads(serialized) == "DummyUser(Alice)" + +def test_custom_json_encoder_nested_mixed_types() -> None: + data = { + "list": [ + uuid.UUID("12345678-1234-5678-1234-567812345678"), + datetime.date(2026, 7, 2), + Decimal("10.50") + ], + "nested": { + "time": datetime.time(14, 30, 0), + "dt": datetime.datetime(2026, 7, 2, 12, 0, 0) + } + } + serialized = json_dumps(data) + deserialized = json_loads(serialized) + assert deserialized["list"][0] == "12345678-1234-5678-1234-567812345678" + assert deserialized["list"][1] == "2026-07-02" + assert deserialized["list"][2] == "10.50" + assert deserialized["nested"]["time"] == "14:30:00" + assert deserialized["nested"]["dt"] == "2026-07-02T12:00:00" + +def test_custom_json_encoder_decimal_precision() -> None: + small_decimal = Decimal("0.00000000000000000001") + serialized = json_dumps(small_decimal) + assert json_loads(serialized) == str(small_decimal) + +def test_custom_json_encoder_circular_reference() -> None: + data: dict[str, Any] = {} + data["self"] = data + with pytest.raises(ValueError): + json_dumps(data) + +def test_custom_json_encoder_kwargs() -> None: + data = {"a": 1, "b": 2} + serialized = json_dumps(data, indent=4) + assert "\n" in serialized + deserialized = json_loads(serialized, parse_float=Decimal) + assert deserialized == data + @pytest.mark.parametrize( "text, expected_slug", [ @@ -54,6 +103,19 @@ def test_custom_json_encoder(input_val: Any, expected_output: str) -> None: def test_slugify_logic(text: str, expected_slug: str) -> None: assert slugify(text) == expected_slug +def test_slugify_unicode_persian() -> None: + assert slugify("ุจุฎุด ู…ู‚ุงู„ุงุช ZCore 2026!") == "ุจุฎุด-ู…ู‚ุงู„ุงุช-zcore-2026" + +@pytest.mark.parametrize("text", ["", " ", " \n\t\r "]) +def test_slugify_empty_whitespaces(text: str) -> None: + assert slugify(text) == "" + +def test_slugify_newlines() -> None: + assert slugify("First\nSecond\rThird") == "first-second-third" + +def test_slugify_multiple_separators() -> None: + assert slugify("hello____world----test") == "hello-world-test" + @pytest.mark.parametrize( "data, schema, should_pass, expected_error_msg", [ @@ -100,4 +162,67 @@ def test_validate_json_schema( else: with pytest.raises(ValidationError) as exc_info: validate_json_schema(data, schema) - assert expected_error_msg in str(exc_info.value) \ No newline at end of file + assert expected_error_msg in str(exc_info.value) + +def test_validate_json_schema_detailed_payload() -> None: + schema = { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "age": {"type": "integer"} + } + } + } + } + data = {"user": {"age": "not_an_integer"}} + with pytest.raises(ValidationError) as exc_info: + validate_json_schema(data, schema) + payload = exc_info.value.payload + assert payload is not None + assert payload["path"] == ["user", "age"] + assert "type" in payload["schema"] + +def test_validate_json_schema_corrupt_schema_payload() -> None: + corrupt_schema = {"type": "invalid_type_definition"} + with pytest.raises(ValidationError) as exc_info: + validate_json_schema({}, corrupt_schema) + payload = exc_info.value.payload + assert payload is not None + assert "error" in payload + +def test_validate_json_schema_array_constraints() -> None: + schema = { + "type": "object", + "properties": { + "tags": { + "type": "array", + "minItems": 2, + "uniqueItems": True, + "items": {"type": "string"} + } + } + } + valid_data = {"tags": ["admin", "user"]} + validate_json_schema(valid_data, schema) + invalid_data_unique = {"tags": ["admin", "admin"]} + with pytest.raises(ValidationError): + validate_json_schema(invalid_data_unique, schema) + invalid_data_len = {"tags": ["admin"]} + with pytest.raises(ValidationError): + validate_json_schema(invalid_data_len, schema) + +def test_validate_json_schema_additional_properties() -> None: + schema = { + "type": "object", + "properties": { + "id": {"type": "integer"} + }, + "additionalProperties": False + } + valid_data = {"id": 1} + validate_json_schema(valid_data, schema) + invalid_data = {"id": 1, "extra": "forbidden"} + with pytest.raises(ValidationError): + validate_json_schema(invalid_data, schema) \ No newline at end of file diff --git a/tests/test_web.py b/tests/test_web.py index db9510b..d18016d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,15 +1,27 @@ import uuid -from typing import Any -from unittest.mock import MagicMock -from fastapi import FastAPI -from httpx import ASGITransport, AsyncClient +import inspect +import asyncio import pytest + +from typing import Any from pydantic import BaseModel +from contextlib import asynccontextmanager +from fastapi import FastAPI, APIRouter +from httpx import ASGITransport, AsyncClient +from unittest.mock import MagicMock, AsyncMock +from sqlalchemy.ext.asyncio import AsyncSession -from zcore.kernel.di import container +from zcore.kernel.di import container, _current_scope_id from zcore.web.base_router import BaseRouter, RouteKey -from zcore.web.middleware import RequestLogMiddleware +from zcore.web.middleware import RequestLogMiddleware, ScopedDependencyMiddleware from zcore.web.projection import Zchema +from zcore.web.api_router import ZCoreRequest +from zcore.web.streams import StreamManager, init_stream_redis +from zcore.context.context import ZContext +from zcore.exceptions.base import EntityNotFound, AppException +from zcore.exceptions.handlers import app_exception_handler +from zcore.db.setup import db_manager +from zcore.db.pagination import PageNumberPagination, PaginatedResult, PageNumberParams class DummyModel: __tablename__ = "dummy" @@ -36,6 +48,77 @@ class DummyOut(Zchema): name: str password: str = "" +class NestedProfile(Zchema): + __model__ = "profile" + phone: str + city: str + +class DummyOutWithNested(Zchema): + __model__ = "dummy" + id: str + name: str + profile: NestedProfile + +class TargetService: + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload + + async def get(self, id: uuid.UUID) -> DummyOut: + return DummyOut(id=str(id), name=self.payload["name"], password=self.payload["password"]) + + async def get_list(self, pagination: Any = None) -> list[Any]: + return [] + +class MockFullService: + def __init__(self) -> None: + pass + + async def create(self, schema: DummyCreate) -> DummyOut: + return DummyOut(id="123", name=schema.name, password="safe") + + async def get(self, id: uuid.UUID) -> DummyOut: + return DummyOut(id=str(id), name="retrieved", password="safe") + + async def get_list(self, pagination: Any = None) -> Any: + data = [DummyOut(id="1", name="n1", password="s1")] + if pagination and isinstance(pagination, PageNumberParams): + return PaginatedResult(data=data, meta={"total": 1, "page": pagination.page}) + return data + + async def search(self, search_in: Any, pagination: Any = None) -> Any: + return [DummyOut(id="1", name="search_res", password="s1")] + + async def update(self, id: uuid.UUID, schema: DummyUpdate, partial: bool = False) -> DummyOut: + name = schema.name if schema.name else "patched" + return DummyOut(id=str(id), name=name, password="safe") + + async def delete(self, id: uuid.UUID) -> None: + pass + +def clean_endpoint_signature(endpoint): + sig = inspect.signature(endpoint) + parameters = list(sig.parameters.values()) + new_params = [p for p in parameters if p.kind != inspect.Parameter.VAR_KEYWORD] + globals_dict = {"_orig_endpoint": endpoint} + param_strs = [] + call_strs = [] + for i, p in enumerate(new_params): + name = p.name + if p.default is not inspect.Parameter.empty: + globals_dict[f"_default_{i}"] = p.default + param_strs.append(f"{name}=_default_{i}") + else: + param_strs.append(name) + call_strs.append(f"{name}={name}") + param_line = ", ".join(param_strs) + call_line = ", ".join(call_strs) + func_code = f"async def clean_endpoint({param_line}):\n return await _orig_endpoint({call_line})" + local_dict = {} + exec(func_code, globals_dict, local_dict) + clean_func = local_dict["clean_endpoint"] + clean_func.__annotations__ = endpoint.__annotations__ + return clean_func + @pytest.mark.parametrize( "router_attrs, expected_error_msg", [ @@ -70,23 +153,11 @@ def test_router_auto_scaffolding_validation_errors(router_attrs: dict[str, Any], "schema_out": DummyOut, } attrs.update(router_attrs) - router_cls = type("TestRouter", (BaseRouter,), attrs) - with pytest.raises(ValueError) as exc_info: router_cls() assert expected_error_msg in str(exc_info.value) -class TargetService: - def __init__(self, payload: dict[str, Any]) -> None: - self.payload = payload - - async def get(self, id: uuid.UUID) -> DummyOut: - return DummyOut(id=str(id), name=self.payload["name"], password=self.payload["password"]) - - async def get_list(self, pagination: Any = None) -> list[Any]: - return [] - @pytest.mark.anyio @pytest.mark.parametrize( "restricted_fields, payload_in, expected_payload_out, expected_vary", @@ -112,14 +183,15 @@ async def test_router_schema_projection_pruning( expected_payload_out: dict[str, Any], expected_vary: list[str] ) -> None: - monkeypatch.setattr("zcore.web.projection.get_restricted_fields", lambda: restricted_fields) - monkeypatch.setattr("zcore.web.api_router.get_restricted_fields", lambda: restricted_fields) - monkeypatch.setattr("zcore.context.context.get_restricted_fields", lambda: restricted_fields) - + original_add_api_route = APIRouter.add_api_route + def patched_add_api_route(self, path, endpoint, *args, **kwargs): + clean_endpoint = clean_endpoint_signature(endpoint) + return original_add_api_route(self, path, clean_endpoint, *args, **kwargs) + monkeypatch.setattr(APIRouter, "add_api_route", patched_add_api_route) + monkeypatch.setattr(ZContext, "restricted_fields", property(lambda self: frozenset(restricted_fields))) app = FastAPI() mock_service = TargetService(payload_in) container.register_singleton(TargetService, mock_service) - class TargetRouter(BaseRouter[DummyCreate, DummyUpdate]): model = DummyModel create_schema = DummyCreate @@ -128,25 +200,19 @@ class TargetRouter(BaseRouter[DummyCreate, DummyUpdate]): service = TargetService prefix = "/items" expose_schemas = True - def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: return [] - router_inst = TargetRouter() app.include_router(router_inst.router) - transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/items/12345678-1234-5678-1234-567812345678") assert response.status_code == 200 - json_data = response.json() assert json_data["data"] == expected_payload_out - vary_header = response.headers.get("vary", "") for header in expected_vary: assert header.lower() in vary_header.lower() - if expected_vary: schema_resp = await client.get("/items/?schema=true") assert schema_resp.status_code == 200 @@ -164,24 +230,230 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: async def test_request_id_middleware(custom_request_id: str | None, expect_valid_uuid: bool) -> None: app = FastAPI() app.add_middleware(RequestLogMiddleware) - @app.get("/health") def health_check() -> dict[str, str]: return {"status": "ok"} - transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: headers = {} if custom_request_id: headers["x-request-id"] = custom_request_id - response = await client.get("/health", headers=headers) assert response.status_code == 200 - response_id = response.headers.get("x-request-id") assert response_id is not None - if expect_valid_uuid: assert uuid.UUID(response_id) else: - assert response_id == custom_request_id \ No newline at end of file + assert response_id == custom_request_id + +@pytest.mark.anyio +async def test_router_full_crud_endpoints(monkeypatch: pytest.MonkeyPatch) -> None: + original_add_api_route = APIRouter.add_api_route + def patched_add_api_route(self, path, endpoint, *args, **kwargs): + return original_add_api_route(self, path, clean_endpoint_signature(endpoint), *args, **kwargs) + monkeypatch.setattr(APIRouter, "add_api_route", patched_add_api_route) + app = FastAPI() + service_inst = MockFullService() + container.register_singleton(MockFullService, service_inst) + class FullCrudRouter(BaseRouter[DummyCreate, DummyUpdate]): + model = DummyModel + create_schema = DummyCreate + update_schema = DummyUpdate + schema_out = DummyOut + service = MockFullService + prefix = "/crud" + pagination_class = PageNumberPagination + def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: + return [] + r = FullCrudRouter() + app.include_router(r.router) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + res = await client.post("/crud/", json={"name": "new_item"}) + assert res.status_code == 201 + assert res.json()["data"]["name"] == "new_item" + res = await client.get("/crud/12345678-1234-5678-1234-567812345678") + assert res.status_code == 200 + assert res.json()["data"]["name"] == "retrieved" + res = await client.get("/crud/?page=2&size=10") + assert res.status_code == 200 + assert res.json()["data"][0]["name"] == "n1" + assert res.json()["meta"]["page"] == 2 + res = await client.post("/crud/search", json={"filters": [], "size": 10}) + assert res.status_code == 200 + assert res.json()["data"][0]["name"] == "search_res" + res = await client.patch("/crud/12345678-1234-5678-1234-567812345678", json={"name": "patched_item"}) + assert res.status_code == 200 + assert res.json()["data"]["name"] == "patched_item" + res = await client.delete("/crud/12345678-1234-5678-1234-567812345678") + assert res.status_code == 200 + assert res.json()["message"] == "Deleted successfully" + +@pytest.mark.anyio +async def test_router_exception_translation_handler() -> None: + app = FastAPI() + app.add_exception_handler(AppException, app_exception_handler) + @app.get("/error") + async def raise_error(): + raise EntityNotFound(message="Missing item", payload={"key": "val"}) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + res = await client.get("/error") + assert res.status_code == 404 + body = res.json() + assert body["success"] is False + assert body["message"] == "Missing item" + assert body["meta"]["error_type"] == "EntityNotFound" + +@pytest.mark.anyio +async def test_zchema_recursive_nested_pruning(monkeypatch: pytest.MonkeyPatch) -> None: + nested = NestedProfile(phone="12345", city="Tehran") + model = DummyOutWithNested(id="1", name="A", profile=nested) + monkeypatch.setattr(ZContext, "restricted_fields", property(lambda self: frozenset({"dummy.profile.phone"}))) + serialized = model.model_dump(mode="json") + assert "phone" not in serialized["profile"] + assert serialized["profile"]["city"] == "Tehran" + +@pytest.mark.anyio +async def test_zchema_wildcard_pruning(monkeypatch: pytest.MonkeyPatch) -> None: + nested = NestedProfile(phone="12345", city="Tehran") + model = DummyOutWithNested(id="1", name="A", profile=nested) + monkeypatch.setattr(ZContext, "restricted_fields", property(lambda self: frozenset({"dummy.*"}))) + serialized = model.model_dump(mode="json") + assert serialized == {} + +@pytest.mark.anyio +async def test_method_schema_exposure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ZContext, "restricted_fields", property(lambda self: frozenset({"dummy.password"}))) + app = FastAPI() + class ExposureRouter(BaseRouter[DummyCreate, DummyUpdate]): + model = DummyModel + create_schema = DummyCreate + update_schema = DummyUpdate + schema_out = DummyOut + service = MockFullService + prefix = "/exposure" + expose_schemas = True + def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: + return [] + r = ExposureRouter() + app.include_router(r.router) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + res_post = await client.post("/exposure/?schema=true") + assert res_post.status_code == 200 + assert "name" in res_post.json()["data"]["properties"] + res_get = await client.get("/exposure/12345678-1234-5678-1234-567812345678?schema=true") + assert res_get.status_code == 200 + assert "password" not in res_get.json()["data"]["properties"] + +@pytest.mark.anyio +@pytest.mark.parametrize( + "bad_id", + [ + "short", + "a" * 70, + "inject