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
andDIRACX_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 thediracx.services
entrypoint in thepyproject.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:
- Creating a module in
diracx.routers.<service>access_policies.py
- Creating a new class inheriting from
BaseAccessPolicy
- For specific instructions, see
diracx-routers/src/diracx/routers/access_policies.py
- Adding an entry to the
diracx.access_policies
entrypoint.
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 adescription
.
Footnotes
-
The only exception is
/.well-known/
. ↩