Skip to main content

NestJS-like IoC container and decorators for FastAPI

Project description

NasPy ๐Ÿ

NasPy is a NestJS-inspired IoC (Inversion of Control) container and decorator library for FastAPI. It brings familiar patterns like @Injectable, @Controller, @UseGuards, dependency injection, and lifecycle management to Python.


Installation

pip install naspy

Quick Start

from naspy import Injectable, Controller, Injected, IoC, Lifetime
from fastapi import FastAPI

@Injectable()
class GreetingService:
    def greet(self, name: str) -> str:
        return f"Hello, {name}!"

@Controller("/hello")
class HelloController:
    greetingService = Injected(GreetingService)

    def _register_routes(self):
        self.router.add_api_route("/{name}", self.get_hello, methods=["GET"])

    async def get_hello(self, name: str):
        return self.greetingService.greet(name)

app = FastAPI()
app.include_router(IoC.resolve(HelloController).router)

Project Structure

NasPy is opinionated โ€” here is the recommended project structure:

app/
โ”œโ”€โ”€ main.py
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ core/
โ”‚   โ””โ”€โ”€ app_module.py
โ”œโ”€โ”€ domain/
โ”‚   โ””โ”€โ”€ user/
โ”‚       โ”œโ”€โ”€ usecase/
โ”‚       โ”‚   โ””โ”€โ”€ get_user/
โ”‚       โ”‚       โ”œโ”€โ”€ get_user_command.py
โ”‚       โ”‚       โ””โ”€โ”€ get_user_usecase.py
โ”‚       โ””โ”€โ”€ model/
โ”‚           โ””โ”€โ”€ user_model.py
โ”œโ”€โ”€ repository/
โ”‚   โ””โ”€โ”€ user/
โ”‚       โ”œโ”€โ”€ user_repository.py
โ”‚       โ””โ”€โ”€ user_schema.py
โ”œโ”€โ”€ controller/
โ”‚   โ””โ”€โ”€ user/
โ”‚       โ”œโ”€โ”€ user_controller.py
โ”‚       โ””โ”€โ”€ user_dto.py
โ””โ”€โ”€ guards/
    โ””โ”€โ”€ auth_guard.py

Configuration

NasPy provides a base NaspySettings class built on top of pydantic-settings. It automatically reads from your .env file.

.env

DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/mydb
ENVIRONMENT=development

Extending Settings

from naspy.config import NaspySettings

class Settings(NaspySettings):
    SECRET_KEY: str
    DEBUG: bool = False

settings = Settings()

NaspySettings provides two required fields out of the box:

Field Type Required
DATABASE_URL str โœ…
ENVIRONMENT str โœ…

Database

NasPy provides a Database class and a BaseRepository for PostgreSQL using SQLAlchemy async.

Setup

Register the AsyncEngine factory in your module:

# app/core/app_module.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from naspy import IoC, Lifetime
from app.core.settings import settings

class AppModule:
    def register():
        IoC.register_factory(
            AsyncEngine,
            lambda: create_async_engine(settings.DATABASE_URL, echo=True),
            Lifetime.SINGLETON
        )

Database

NasPy exports Database and Base (SQLAlchemy DeclarativeBase) directly:

from naspy.database import Database, Base

Database is a singleton that holds the async_sessionmaker. It is injected automatically into BaseRepository.

Defining a Schema

from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String
from naspy.database import Base

class UserSchema(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String)
    email: Mapped[str] = mapped_column(String, unique=True)

BaseRepository

BaseRepository provides CRUD operations out of the box. Extend it and set the model attribute.

from naspy.database import BaseRepository
from naspy import Injectable
from app.repository.user.user_schema import UserSchema

@Injectable()
class UserRepository(BaseRepository[UserSchema]):
    model = UserSchema

Available methods

Method Signature Description
find_all () -> list[T] Returns all records
find_by_id (id: int) -> T or None Returns a single record by id
save (entity: T) -> T Inserts or updates a record
delete (id: int) -> bool Deletes a record by id

Each method opens its own session from the connection pool and closes it automatically โ€” no manual session management needed.

@Injectable()
class GetUserUseCase:
    repository = Injected(UserRepository)

    async def run(self, id: int):
        return await self.repository.find_by_id(id)

Core Concepts

@Injectable

Marks a class as injectable and registers it in the IoC container.

@Injectable()
class MyService:
    def do_something(self):
        return "done"

Supports all three lifetimes:

@Injectable()                        # SINGLETON (default)
@Injectable(Lifetime.SINGLETON)      # one instance for the entire app lifetime
@Injectable(Lifetime.SCOPED)         # one instance per request
@Injectable(Lifetime.TRANSIENT)      # new instance every time it is resolved
Lifetime NestJS equivalent Behavior
Lifetime.SINGLETON Scope.DEFAULT One instance for the entire app lifetime
Lifetime.SCOPED Scope.REQUEST New instance per request, shared within the same request
Lifetime.TRANSIENT Scope.TRANSIENT New instance every time it is resolved

@Controller

Registers a class as a controller and sets up an APIRouter.

@Controller("/users")
class UserController:

    def _register_routes(self):
        self.router.add_api_route("", self.get_all, methods=["GET"])
        self.router.add_api_route("/{id}", self.get_by_id, methods=["GET"])

    async def get_all(self):
        ...

    async def get_by_id(self, id: int):
        ...

Register the router in your FastAPI app:

app.include_router(IoC.resolve(UserController).router)

Injected

Marks a class attribute as an injectable dependency.

@Injectable()
class UserService:
    repository = Injected(UserRepository)

    async def find(self, id: int):
        return await self.repository.find_by_id(id)

InjectedValue

Injects a registered value (non-class) by token.

# registration
IoC.register_value("MAX_RETRIES", 3)
IoC.register_value("SUPPORTED_CURRENCIES", ["USD", "EUR", "GBP"])

# injection
@Injectable()
class PaymentService:
    max_retries = InjectedValue("MAX_RETRIES")
    currencies = InjectedValue("SUPPORTED_CURRENCIES")

IoC

The IoC container. Manages registration and resolution of dependencies.

# register a factory
IoC.register_factory(IMailService, lambda: MailService(), Lifetime.SINGLETON)

# register a value
IoC.register_value("CONFIG", {"timeout": 30})

# resolve a dependency
service = IoC.resolve(MyService)

# resolve a value
config = IoC.resolve_value("CONFIG")

Factory providers

Equivalent to NestJS useFactory โ€” useful for conditional or dynamic instantiation:

class AppModule:
    def register():
        IoC.register_factory(
            IMailService,
            lambda: IoC.resolve(MailService) if os.getenv("ENVIRONMENT") == "production"
                    else IoC.resolve(FakeMailService),
            Lifetime.SINGLETON
        )

Call register() before creating the FastAPI app:

AppModule.register()
app = create_app()

Guards

Guards control access to routes, equivalent to NestJS @UseGuards.

Define a guard

from naspy import IGuard, Injectable
from fastapi import Request

@Injectable()
class AuthGuard(IGuard):
    async def can_activate(self, request: Request) -> bool:
        token = request.headers.get("Authorization")
        return token is not None

Apply guards at different levels

from naspy import UseGuards, SkipGuards, set_global_guards

# 1. Global โ€” applies to all routes in the app
set_global_guards(AuthGuard)

# 2. Controller โ€” applies to all routes in the controller
@Controller("/users", guards=[AuthGuard])
class UserController:
    ...

# 3. Method โ€” applies to a single route
@UseGuards(RolesGuard)
async def get_by_id(self, id: int):
    ...

# 4. Skip all guards on a specific route
@SkipGuards()
async def get_public(self):
    ...

Guard resolution priority:

Global guards
    โ””โ”€โ”€ Controller guards
            โ”œโ”€โ”€ @SkipGuards    โ†’ skip all guards
            โ”œโ”€โ”€ @UseGuards     โ†’ global + controller + method guards
            โ””โ”€โ”€ (no decorator) โ†’ global + controller guards

Exception Filters

Register a global exception filter to standardize all error responses:

from naspy import register_exception_filters

def create_app() -> FastAPI:
    app = FastAPI()
    register_exception_filters(app)
    ...

All errors will follow this format:

{
    "outcome": false,
    "error": {
        "code": 401,
        "status": "Unauthorized",
        "message": "Invalid or missing token"
    }
}

Scoped Middleware

Add ScopeMiddleware to enable Lifetime.SCOPED dependencies:

from naspy import ScopeMiddleware

def create_app() -> FastAPI:
    app = FastAPI()
    app.add_middleware(ScopeMiddleware)
    ...

Scoped dependencies must be resolved inside request methods using IoC.resolve():

async def get_by_id(self, id: int):
    logger = IoC.resolve(RequestLogger)  # new instance per request
    logger.log(f"Fetching id={id}")
    ...

Full Example

# main.py
from fastapi import FastAPI
from naspy import IoC, set_global_guards, register_exception_filters, ScopeMiddleware
from app.guards.auth_guard import AuthGuard
from app.controller.user.user_controller import UserController
from app.core.app_module import AppModule

def create_app() -> FastAPI:
    app = FastAPI()
    app.add_middleware(ScopeMiddleware)
    register_exception_filters(app)
    set_global_guards(AuthGuard)
    app.include_router(IoC.resolve(UserController).router)
    return app

AppModule.register()
app = create_app()
# app/core/app_module.py
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from naspy import IoC, Lifetime
from naspy.config import NaspySettings

class Settings(NaspySettings):
    SECRET_KEY: str

settings = Settings()

class AppModule:
    def register():
        IoC.register_factory(
            AsyncEngine,
            lambda: create_async_engine(settings.DATABASE_URL, echo=True),
            Lifetime.SINGLETON
        )
# app/repository/user/user_schema.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String
from naspy.database import Base

class UserSchema(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String)
    email: Mapped[str] = mapped_column(String, unique=True)
# app/repository/user/user_repository.py
from naspy import Injectable
from naspy.database import BaseRepository
from app.repository.user.user_schema import UserSchema

@Injectable()
class UserRepository(BaseRepository[UserSchema]):
    model = UserSchema
# app/domain/user/usecase/get_user/get_user_usecase.py
from naspy import Injectable, Injected
from app.repository.user.user_repository import UserRepository

@Injectable()
class GetUserUseCase:
    repository = Injected(UserRepository)

    async def run(self, id: int):
        return await self.repository.find_by_id(id)
# app/controller/user/user_controller.py
from naspy import Controller, Injected, SkipGuards
from app.domain.user.usecase.get_user.get_user_usecase import GetUserUseCase

@Controller("/users")
class UserController:
    getUserUseCase = Injected(GetUserUseCase)

    def _register_routes(self):
        self.router.add_api_route("/public", self.get_public, methods=["GET"])
        self.router.add_api_route("/{id}", self.get_by_id, methods=["GET"])

    @SkipGuards()
    async def get_public(self):
        return {"public": True}

    async def get_by_id(self, id: int):
        return await self.getUserUseCase.run(id)

Comparison with NestJS

NestJS NasPy
@Injectable() @Injectable()
@Controller('/path') @Controller('/path')
@UseGuards(Guard) @UseGuards(Guard)
app.useGlobalGuards() set_global_guards(Guard)
Scope.DEFAULT Lifetime.SINGLETON
Scope.REQUEST Lifetime.SCOPED
Scope.TRANSIENT Lifetime.TRANSIENT
useFactory IoC.register_factory()
useValue IoC.register_value()
AppModule AppModule.register()
ModuleRef.resolve() IoC.resolve()
TypeOrmModule BaseRepository
ConfigService NaspySettings

Requirements

  • Python >= 3.12
  • FastAPI >= 0.100.0
  • SQLAlchemy >= 2.0.0
  • Starlette >= 0.27.0
  • Pydantic >= 2.0.0
  • pydantic-settings >= 2.0.0
  • asyncpg >= 0.27.0

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

naspy_ioc-0.1.1.tar.gz (9.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

naspy_ioc-0.1.1-py3-none-any.whl (13.1 kB view details)

Uploaded Python 3

File details

Details for the file naspy_ioc-0.1.1.tar.gz.

File metadata

  • Download URL: naspy_ioc-0.1.1.tar.gz
  • Upload date:
  • Size: 9.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for naspy_ioc-0.1.1.tar.gz
Algorithm Hash digest
SHA256 609993b93ac5859825f040770c72c5633d1b95b3558063cc8ec32b052d9ed76a
MD5 90abd291c6513ff81d5d243b0c653dd0
BLAKE2b-256 cb1f0aeef0b5d09e9480e8546c1b74c63834fee8bfd149e6cc7fd48831058034

See more details on using hashes here.

File details

Details for the file naspy_ioc-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: naspy_ioc-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 13.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for naspy_ioc-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 90fe4a98c6c760e818fefa9d3a273d9f838bd8cbfd49fa68fb46357845249862
MD5 56fba41097a053c05722b3e400cc9894
BLAKE2b-256 d1c3ae9a0be3c78a782055b056adb0abbab77ddea0a4afbc75ebe8d5aeb56c2f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page