Skip to main content

Architecture of Services

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

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:

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

Dependency Injection

DiracX extensively utilizes FastAPI's dependency injection. For detailed information, see FastAPI's documentation on 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:

@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_KEY
  • DIRACX_SERVICE_AUTH_TOKEN_ALGORITHM
  • DIRACX_SERVICE_AUTH_ACCESS_TOKEN_EXPIRE_MINUTES
  • DIRACX_SERVICE_AUTH_REFRESH_TOKEN_EXPIRE_MINUTES

Usage example:

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

User Info

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

@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:

@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:

from diracx.routers.dependencies import JobDB, JobLoggingDB

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

Refer to the SQLAlchemy documentation 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:

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:

[project.entry-points."diracx.access_policies"]
WMSAccessPolicy = "diracx.routers.job_manager.access_policies:WMSAccessPolicy"
SandboxAccessPolicy = "diracx.routers.job_manager.access_policies:SandboxAccessPolicy"

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

from .access_policies import ActionType, CheckWMSPolicyCallable

@router.post("/")
async def submit_bulk_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:

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.

Footnotes

  1. The only exception is /.well-known/.