# Architecture of Services

DiracX services are structured as a single FastAPI application, generated by:

```python
diracx.routers.create_app()
```

This setup considers environment variables and installed packages to enable DiracX extensions and configure the desired `DiracxRouter`.

## Environment Variables

- Environment variables can be defined directly, using dotenv files, or a combination of both.
- Dotenv files are loaded in order from `DIRACX_SERVICE_DOTENV` and `DIRACX_SERVICE_DOTENV_<N>`, where `<N>` can be any integer.
- For managing environment variables in a production deployment, refer to the DiracX Helm chart.

## Service Routing

- Services are served under `/api/`[^1]
- A `DiracxRouter` corresponds to a prefix under `/api/<system-name>`, where `<system-name>` is defined by the entry in the `diracx.services` entrypoint in the `pyproject.toml`.
- Services can be disabled by setting `DIRACX_SERVICE_<system-name>_ENABLED=false`.

Example route definition:

```python
@router.post("/search", responses=EXAMPLE_RESPONSES)
```

## Dependency Injection

DiracX extensively utilizes FastAPI's dependency injection. For detailed information, see [FastAPI's documentation on dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/).

## Available Dependencies

### Settings

- **Settings classes** are Pydantic models that load service configuration from the environment and are wrapped with `add_settings_annotation` for FastAPI to handle them.

Example:

```python
@add_settings_annotation
class AuthSettings(ServiceSettingsBase):
    """Settings for the authentication service."""

    model_config = SettingsConfigDict(env_prefix="DIRACX_SERVICE_AUTH_")

    token_key: TokenSigningKey
    token_algorithm: str = "RS256"
    access_token_expire_minutes: int = 20
    refresh_token_expire_minutes: int = 60
```

Available environment variables:

- `DIRACX_SERVICE_AUTH_TOKEN_KEYSTORE`
- `DIRACX_SERVICE_AUTH_TOKEN_ALLOWED_ALGORITHM`
- `DIRACX_SERVICE_AUTH_ACCESS_TOKEN_EXPIRE_MINUTES`
- `DIRACX_SERVICE_AUTH_REFRESH_TOKEN_EXPIRE_MINUTES`

Usage example:

```python
@router.get("/openid-configuration")
async def get_openid_configuration(settings: AuthSettings): ...
```

### User Info

To retrieve information about the current user, depend on `AuthorizedUserInfo`.

```python
@router.get("/userinfo")
async def userinfo(
    user_info: Annotated[AuthorizedUserInfo, Depends(verify_dirac_access_token)],
): ...
```

**TODO:** Consider avoiding the need to manually specify the annotation.

### Configuration

To extract information from the central DIRAC configuration:

```python
@router.post("/summary")
async def summary(config: Annotated[Config, Depends(ConfigSource.create)]): ...
```

The `Config` object is cached efficiently between requests and automatically refreshed. It is strongly typed and immutable for the duration of a request.

**TODO:** Avoid the need to manually specify the annotation.

### SQL Databases

To depend on a SQL-backed database, use the classes in `diracx.routers.dependencies`. The connection is managed through a central pool, with transactions opened for the duration of a request. Successful requests commit the transaction, while requests with HTTP status code `>=400` roll back the transaction. Connections are returned to the pool for reuse.

Example:

```python
from diracx.routers.dependencies import JobDB, JobLoggingDB


@router.delete("/{job_id}")
async def delete_single_job(job_db: JobDB, job_logging_db: JobLoggingDB): ...
```

There are advanced and uncommon scenarios where committing a transaction is necessary even when returning an error response (e.g., revoking tokens in the database and returning an error to a potentially malicious user). In such cases, explicitly committing the transaction before raising an exception is crucial. Without this explicit commit, the intended changes would be rolled back along with the transaction, leading to unintended consequences:

```python
from diracx.routers.dependencies import AuthDB

@router.post("/token")
async def token(auth_db: AuthDB, ...)
    ...
    if refresh_token_attributes["status"] == RefreshTokenStatus.REVOKED:
        # Revoke all the user tokens associated with the subject
        await auth_db.revoke_user_refresh_tokens(sub)

        # Explicitly commit the transaction to ensure the revocation is saved,
        # even though an error will be returned to the user.
        await auth_db.conn.commit()

        # Raise an HTTP exception to signal the error
        raise HTTPException(status_code=401)
```

Refer to the [SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/core/pooling.html) for more details.

### OpenSearch Databases

Connecting to an OpenSearch database is similar to an SQL database, with connections being pooled automatically. However, there is no automatic transaction/rollback behavior.

Example:

```python
from diracx.routers.dependencies import JobParametersDB


@router.post("/search", responses=EXAMPLE_RESPONSES)
async def search(job_parameters_db: JobParametersDB): ...
```

## Permission Management

Permission management in `diracx` is managed by `AccessPolicy`. The idea is that each policy can inject data upon token issuance, and every route will rely on a given policy to check permissions.

The various policies are defined in `diracx-routers/pyproject.toml`:

```toml
[project.entry-points."diracx.access_policies"]
WMSAccessPolicy = "diracx.routers.jobs.access_policies:WMSAccessPolicy"
SandboxAccessPolicy = "diracx.routers.jobs.access_policies:SandboxAccessPolicy"
```

Each route must have a policy as an argument and call it:

```python
from .access_policies import ActionType, CheckWMSPolicyCallable


@router.post("/")
async def submit_jobs(
    job_definitions: Annotated[list[str], Body()],
    job_db: JobDB,
    check_permissions: CheckWMSPolicyCallable,
) -> list[InsertedJob]:
    await check_permissions(action=ActionType.CREATE, job_db=job_db)
    ...
```

Failing to do so will result in a CI error `test_all_routes_have_policy`.

Some routes do not need access permissions, like the authorization ones, in which case they can be marked as such:

```python
from .access_policies import open_access


@open_access
@router.get("/")
async def serve_config(): ...
```

Implementing a new `AccessPolicy` is done by:

1. Creating a module in `diracx.routers.<service>access_policies.py`
2. Creating a new class inheriting from `BaseAccessPolicy`
3. For specific instructions, see `diracx-routers/src/diracx/routers/access_policies.py`
4. Adding an entry to the `diracx.access_policies` entrypoint.

!!! warning

    When running tests, no permission is checked. This is to allow testing the router behavior with respect to the policy behavior. For testing a policy, see for example `diracx-routers/tests/jobs/test_wms_access_policy.py`.

## Adding routes

When routes are defined they're automatically included in the OpenAPI specification.
This is then used to automatically generate client bindings and means that more of the Python code is included in the externally visible interface than is typically expected.
To ensure consistency, the following rules must be followed:

- All routers must be tagged and the first tag becomes the name of the sub-client.
- The name of the route becomes the name of the client method.
- Uses of `fastapi.Form` must specify a `description`.

\[^1\]: The only exception is `/.well-known/`.
