# iisi-app-core

`iisi-app-core` is a Python library for `punq`-based AWS Lambda applications. It gives you a small set of building blocks for:

- attaching component metadata to classes with decorators
- autoloading application modules before container assembly
- building a `punq.Container` from discovered components
- dispatching API Gateway and EventBridge events
- reading feature flags from the active request container
- working with request-scoped context, authz, logging, and serialization helpers

## Requirements

- Python `>=3.14,<4`

## Installation

```bash
pip install iisi-app-core
```

## Quickstart

The typical flow is:

1. Decorate your application classes.
2. Import your application packages and autoload them.
3. Build a `punq.Container` from the discovered metadata.
4. Register your runtime dispatch wiring.
5. Expose a Lambda handler with `build_lambda_handler(...)`.

### 1. Define components

```python
from iisi_app_core import (
    ApiRoute,
    EventPolicy,
    application,
    driven_adapter,
    driven_port,
    driving_adapter,
    driving_port,
)
from iisi_app_core.rest.responses import ok

UserId = str


@driven_port
class UserRepository:
    def get(self, user_id: UserId) -> dict[str, str]:
        raise NotImplementedError


@driven_adapter(port=UserRepository)
class DynamoUserRepository(UserRepository):
    def get(self, user_id: UserId) -> dict[str, str]:
        return {"id": user_id, "name": "Ada"}


@driving_port
class GetUserProfilePort:
    def get_user_profile(self, user_id: UserId) -> dict[str, str]:
        raise NotImplementedError


@application(port=GetUserProfilePort)
class GetUserProfile(GetUserProfilePort):
    def __init__(self, repo: UserRepository) -> None:
        self.repo = repo

    def get_user_profile(self, user_id: UserId) -> dict[str, str]:
        return self.repo.get(user_id)


@driving_adapter
class GetUserProfileHandler:
    route = ApiRoute(method="GET", resource="/users/{user_id}")

    def __init__(self, app: GetUserProfilePort) -> None:
        self.app = app

    def handle(self, request):
        user_id = request.path_parameters["user_id"]
        return ok(self.app.get_user_profile(user_id))


@driving_adapter
class UserCreatedPolicyHandler:
    policy = EventPolicy(detail_type="user.created", source="app.users")

    def handle(self, event):
        print(f"Handled event for correlation_id={event.correlation_id}")
        return "ok"
```

`driving_port` and `driven_port` make the architectural boundaries explicit. A `driving_adapter` may depend only on `@driving_port` ports, an `application` may depend only on `@driven_port` ports, and `ContainerBuilder.build()` raises `PortsAndAdaptersViolationError` if a decorated component depends on another decorated implementation directly.

### 2. Autoload application modules

```python
import app.users.handlers
import app.users.policies
import app.users.applications

from iisi_app_core import autoload

# Loads all components under users bounded context
modules = (
    *autoload(app.users)
)
```

`autoload(...)` expects an imported package object and recursively imports its submodules so decorator metadata is available before container assembly.

### 3. Build the container

```python
from punq import Container

from iisi_app_core import (
    ApiGatewayEventHandler,
    ComponentRegistry,
    ContainerBuilder,
    DefaultApiExceptionMapper,
    DefaultApiRouteResolver,
    DefaultCorsPolicy,
    DefaultPolicyResolver,
    EventBridgeEventHandler,
    IJwtTokenHandler,
    SystemPrincipalProvider,
)

from app.auth.jwt import JwtAuthenticator
from app.settings import SettingsStore
from app.users.handlers import GetUserProfileHandler
from app.users.policies import UserCreatedPolicyHandler


def build_container() -> Container:
    registry = ComponentRegistry.from_modules(modules)
    container = (
        ContainerBuilder(registry)
        .add_instance(SettingsStore, SettingsStore())
        .add_instance(IJwtTokenHandler, JwtAuthenticator(public_key="replace-me"))
        .build()
    )

    api_handler = ApiGatewayEventHandler(
        route_resolver=DefaultApiRouteResolver([container.resolve(GetUserProfileHandler)]),
        authenticator=container.resolve(IJwtTokenHandler),
        exception_mapper=DefaultApiExceptionMapper(),
        cors_policy=DefaultCorsPolicy(),
    )
    event_handler = EventBridgeEventHandler(
        resolver=DefaultPolicyResolver([container.resolve(UserCreatedPolicyHandler)]),
        principal_provider=SystemPrincipalProvider(),
    )

    container.register(ApiGatewayEventHandler, instance=api_handler)
    container.register(EventBridgeEventHandler, instance=event_handler)
    return container
```

`ContainerBuilder` registers both the declared port and the concrete implementation, so you can resolve either one from `punq`.

### 4. Expose the Lambda entrypoint

```python
from iisi_app_core import build_lambda_handler

from app.bootstrap import build_container


_container = build_container()


def container_provider(event, context):
    del event, context
    return _container


handler = build_lambda_handler(container_provider)
```

`build_lambda_handler(...)` opens a `RequestScope` for every invocation, populates the correlation ID, sets the default principal, and dispatches to either `ApiGatewayEventHandler` or `EventBridgeEventHandler` based on the event shape.

## Feature Flags

Use `feature_enabled(...)` directly on a callable class and pass the settings interface that should be resolved from the active container.

```python
from iisi_app_core import FeatureDisabledError, RequestScope, feature_enabled


class SettingsStore:
    def is_feature_enabled(self, name, default: bool = False) -> bool:
        raise NotImplementedError


@feature_enabled("/feature/auth/register_user", settings_interface=SettingsStore)
class RegisterUser:
    def __call__(self, command) -> str:
        del command
        return "ok"


container = build_container()

with RequestScope(container=container):
    try:
        result = RegisterUser()("payload")
    except FeatureDisabledError:
        result = "disabled"
```

Notes:

- In Lambda code, `build_lambda_handler(...)` creates the request scope for you.
- Outside Lambda, create `RequestScope(container=...)` yourself when code needs access to the active container or request context.
- If your app uses custom setting names or a custom exception type, pass `setting_name_factory=` and `disabled_exception=` to `feature_enabled(...)`.
- Do not rely on global feature-flag configuration. This package expects `settings_interface=...` on the decorator itself.

## Request Context and Authz

Inside a request scope you can access request-local state without manually threading values through every call:

- `correlation_id()` returns the current correlation ID.
- `principal()` returns the current authenticated principal.
- `set_principal(...)` overrides the active principal inside the current scope.
- `require_role(...)`, `at_least(...)`, and `ensure_at_least(...)` help enforce role-based authorization.

For Lambda traffic this scope is created by `build_lambda_handler(...)`, `ApiGatewayEventHandler`, and `EventBridgeEventHandler`. In tests or scripts, use `RequestScope(...)` directly.

## API Overview

Component registration and container assembly:

- `driving_port`, `driven_port`, `driving_adapter`, `driven_adapter`, `application`, `seed`, `register`
- `ComponentDefinition`, `PortDefinition`, `ComponentRegistry`, `ContainerBuilder`
- `ComponentKind`, `PortKind`, `PortsAndAdaptersViolationError`, `component_definition_for`, `port_definition_for`
- `autoload`, `ModuleAutoloadError`

Lambda and event dispatch:

- `ApiRoute`, `EventPolicy`, `ApiRequest`, `EventEnvelope`
- `ApiGatewayEventHandler`, `EventBridgeEventHandler`
- `DefaultApiRouteResolver`, `DefaultPolicyResolver`
- `DefaultApiExceptionMapper`, `DefaultCorsPolicy`, `JwtTokenHandler`
- `build_lambda_handler`

Feature flags and errors:

- `feature_enabled`, `FeatureDisabledError`
- `DomainError`, `DomainValidationError`, `NotFoundError`, `ForbiddenError`, `UnauthorizedError`
- `RouteNotFoundError`, `PolicyNotFoundError`, `AmbiguousRouteError`, `AmbiguousPolicyError`

Context, authz, logging, and utilities:

- `RequestScope`, `current_container`, `current_request_state`, `get_app_context`
- `correlation_id`, `use_correlation_id`, `principal`, `set_principal`
- `Principal`, `Role`, `require_role`, `authorize`, `at_least`, `ensure_at_least`
- `configure_logging`, `get_logger`, `make_log_hook`
- `Serializer`, `to_primitive`, `asdict_primitives`
- `ensure_utc_datetime`, `isoformat_z`, `utc_now`

Response helpers for API Gateway responses are available in `iisi_app_core.rest.responses`.

## Maintainer Notes

Build and publish are intentionally kept separate from normal library usage:

```bash
python3.14 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
python -m build
python -m twine upload -r testpypi dist/*
python -m twine upload -r pypi dist/*
```

`twine` reads credentials from `~/.pypirc`.


### release

# 1) Nosta versio pyproject.toml:issa (esim. 0.1.9 -> 0.1.10)
# [project]
# version = "0.1.10"

# 2) Siivoa vanhat build-artifaktit
rm -rf dist build src/iisi_app_core.egg-info

# 3) Rakenna uudelleen
python -m build

# 4) (valinnainen) tarkista paketit
python -m twine check dist/*

# 5) Lataa vain uusi versio
python -m twine upload dist/iisi_app_core-0.1.10*