Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,18 @@ On Windows, see the [Makefile](Makefile) and run the commands manually.
The reference implementation is meant to be customized for your facility's IRI implementation. Running the IRI api unmodified will show only fake, test data. The paragraphs below describe how to customize the business logic and appearance of the API for your facility.

### Customizing the business logic for your facility
The IRI API handles the "boilerplate" of setting up the rest API. It delegates to the per-facility business logic via interface definitions. These interfaces are implemented as abstract classes, one per api group (status, account, etc.). Each router directory defines a FacilityAdapter class (eg. [the status adapter](app/routers/status/facility_adapter.py)) that is expected to be implemented by the facility who is exposing an IRI API instance.
The IRI API handles the "boilerplate" of setting up the rest API. It delegates to the per-facility business logic via interface definitions. These interfaces are implemented as abstract classes, one per api group (status, account, etc.). Each versioned router directory defines a FacilityAdapter class (eg. [the v1 status adapter](app/routers/v1/status/facility_adapter.py)) that is expected to be implemented by the facility who is exposing an IRI API instance.

The specific implementations can be specified via the `IRI_API_ADAPTER_*` environment variables. For example the adapter for the `status` api would be given by setting `IRI_API_ADAPTER_status` to the full python module and class implementing `app.routers.status.facility_adapter.FacilityAdapter`. (eg. `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter`)
The specific implementations can be specified via the `IRI_API_ADAPTER_*` environment variables. For example the adapter for the `status` api would be given by setting `IRI_API_ADAPTER_status` to the full python module and class implementing `app.routers.v1.status.facility_adapter.FacilityAdapter`. (eg. `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter`)

As a default implementation, this project supplies the [demo adapter](app/demo_adapter.py) which implements every facility adapter with fake data.

### Versioned router layout

Versioned API route groups live under `app/routers/v1`, `app/routers/v2`, etc. Shared router infrastructure, such as error handling, metadata helpers, and adapter loading, stays directly under `app/routers`.

The current V1 implementation is the baseline surface. Future versions should add only new or changed route groups under their version folder. For example, a future `app/routers/v2/compute` can add V2 compute routes without copying every V1 route group. At startup, the API composes all available route groups up through the version named by `API_URL`; for example, `API_URL=api/v2` loads V1 routes plus any V2 route modules that exist, all under the configured `/api/v2` prefix.

### Customizing the API meta-data
You can optionally override the [FastAPI metadata](https://fastapi.tiangolo.com/tutorial/metadata/), such as `name`, `description`, `terms_of_service`, etc. by providing a valid json object in the `IRI_API_PARAMS` environment variable.

Expand Down Expand Up @@ -85,7 +91,7 @@ OPENTELEMETRY_ENABLED=true OPENTELEMETRY_DEBUG=true
Links to data, created by this api, will concatenate these values producing links, eg: `https://iri.myfacility.com/my_api_prefix/my_api_url/projects/123`

- `IRI_API_PARAMS`: as described above, this is a way to customize the API meta-data
- `IRI_API_ADAPTER_*`: these values specify the business logic for the per-api-group implementation of a facility_adapter. For example: `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter` would load the implementation of the `app.routers.status.facility_adapter.FacilityAdapter` abstract class to handle the `status` business logic for your facility.
- `IRI_API_ADAPTER_*`: these values specify the business logic for the per-api-group implementation of a facility_adapter. For example: `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter` would load the implementation of the `app.routers.v1.status.facility_adapter.FacilityAdapter` abstract class to handle the `status` business logic for your facility.
- `IRI_SHOW_MISSING_ROUTES`: hide api groups that don't have an `IRI_API_ADAPTER_*` environment variable defined, if set to `true`. This way if your facility only wishes to expose some api groups but not others, they can be hidden. (Defaults to `false`.)

### Logging
Expand Down
29 changes: 14 additions & 15 deletions app/demo_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,20 @@
import uuid

from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel

from .routers.account import facility_adapter as account_adapter
from .routers.account import models as account_models
from .routers.compute import facility_adapter as compute_adapter
from .routers.compute import models as compute_models
from .routers.facility import facility_adapter
from .routers.facility import models as facility_models
from .routers.filesystem import facility_adapter as filesystem_adapter
from .routers.filesystem import models as filesystem_models
from .routers.status import facility_adapter as status_adapter
from .routers.status import models as status_models
from .routers.task import facility_adapter as task_adapter
from .routers.task import models as task_models
from .routers.v1.account import facility_adapter as account_adapter
from .routers.v1.account import models as account_models
from .routers.v1.compute import facility_adapter as compute_adapter
from .routers.v1.compute import models as compute_models
from .routers.v1.facility import facility_adapter
from .routers.v1.facility import models as facility_models
from .routers.v1.filesystem import facility_adapter as filesystem_adapter
from .routers.v1.filesystem import models as filesystem_models
from .routers.v1.status import facility_adapter as status_adapter
from .routers.v1.status import models as status_models
from .routers.v1.task import facility_adapter as task_adapter
from .routers.v1.task import models as task_models
from .types.models import Capability
from .types.user import User
from .types.scalars import AllocationUnit
Expand Down Expand Up @@ -365,8 +364,8 @@ async def list_sites(
sites = [s for s in sites if s.last_modified > ms]

o = offset or 0
l = limit or len(sites)
return sites[o : o + l]
list_limit = limit or len(sites)
return sites[o : o + list_limit]

async def get_site(self: "DemoAdapter", site_id: str, modified_since: str | None = None) -> facility_models.Site:
site = next((s for s in self.sites if s.id == site_id), None)
Expand Down
16 changes: 3 additions & 13 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,7 @@
from .request_context import set_api_url_base, _api_url_base

from app.routers.error_handlers import install_error_handlers
from app.routers.facility import facility
from app.routers.status import status
from app.routers.account import account
from app.routers.compute import compute
from app.routers.filesystem import filesystem
from app.routers.task import task
from app.routers.loader import load_routers, version_from_api_url

configure_logging(config.LOG_LEVEL)

Expand Down Expand Up @@ -75,12 +70,7 @@ async def dispatch(self, request: Request, call_next):

api_prefix = f"{config.API_PREFIX}{config.API_URL}"

# Attach routers under the prefix
APP.include_router(facility.router, prefix=api_prefix)
APP.include_router(status.router, prefix=api_prefix)
APP.include_router(account.router, prefix=api_prefix)
APP.include_router(compute.router, prefix=api_prefix)
APP.include_router(filesystem.router, prefix=api_prefix)
APP.include_router(task.router, prefix=api_prefix)
for loaded_router in load_routers(version_from_api_url(config.API_URL)):
APP.include_router(loaded_router.router, prefix=api_prefix)

logging.getLogger().info(f"API path: {api_prefix}")
98 changes: 73 additions & 25 deletions app/routers/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,46 @@
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

from .. import config


def _url_join(*parts: str) -> str:
"""Join URL parts without losing the scheme separator."""
if not parts:
return ""

first = parts[0].rstrip("/")
rest = [stripped for part in parts[1:] if (stripped := part.strip("/"))]
return "/".join([first, *rest])


def _api_example_url(path: str = "") -> str:
return _url_join(config.API_URL_ROOT, config.API_PREFIX, config.API_URL, path)


def _problem_type_url(problem_type: str) -> str:
return _url_join(config.API_URL_ROOT, "problems", problem_type)


class Problem(BaseModel):
model_config = ConfigDict(extra="allow", json_schema_extra={"description": 'Error structure for REST interface based on RFC 9457, "Problem Details for HTTP APIs."'})
type: str = Field(..., description="A URI reference that identifies the problem type.", example="https://example.com/notFound", json_schema_extra={"format": "uri", "default": "about:blank"})
model_config = ConfigDict(
extra="allow",
json_schema_extra={
"description": 'Error structure for REST interface based on RFC 9457, "Problem Details for HTTP APIs."',
"example": {
"type": _problem_type_url("not-found"),
"status": 404,
"title": "Not Found",
"detail": "Descriptive text.",
"instance": _api_example_url("resource/123"),
},
},
)
type: str = Field(..., description="A URI reference that identifies the problem type.", example=_problem_type_url("not-found"), json_schema_extra={"format": "uri", "default": "about:blank"})
status: int = Field(..., ge=100, le=599, description="The HTTP status code for this occurrence.", example=404)
title: str|None = Field(default=None, description="Short human-readable summary.", example="Not Found")
detail: str|None = Field(default=None, description="Human-readable explanation.", example="Descriptive text.")
instance: str = Field(..., description="A URI reference identifying this occurrence.", example="http://localhost/api/v1/resource/123")
instance: str = Field(..., description="A URI reference identifying this occurrence.", example=_api_example_url("resource/123"))


def get_url_base(request: Request) -> str:
Expand Down Expand Up @@ -218,93 +250,99 @@ async def global_handler(request: Request, exc: Exception):


EXAMPLE_400 = {
"type": "https://iri.example.com/problems/invalid-parameter",
"type": _problem_type_url("invalid-parameter"),
"title": "Invalid parameter",
"status": 400,
"detail": "modified_since must be in ISO 8601 format.",
"instance": "/api/v1/status/resources?modified_since=BADVALUE",
"instance": _api_example_url("status/resources?modified_since=BADVALUE"),
"invalid_params": [{"name": "modified_since", "reason": "Invalid datetime format"}],
}

EXAMPLE_401 = {"type": "https://iri.example.com/problems/unauthorized", "title": "Unauthorized", "status": 401, "detail": "Bearer token is missing or invalid.", "instance": "/api/v1/status/resources"}
EXAMPLE_401 = {"type": _problem_type_url("unauthorized"), "title": "Unauthorized", "status": 401, "detail": "Bearer token is missing or invalid.", "instance": _api_example_url("status/resources")}

EXAMPLE_403 = {
"type": "https://iri.example.com/problems/forbidden",
"type": _problem_type_url("forbidden"),
"title": "Forbidden",
"status": 403,
"detail": "Caller is authenticated but lacks required role.",
"instance": "/api/v1/status/resources",
"instance": _api_example_url("status/resources"),
}

EXAMPLE_404 = {
"type": "https://iri.example.com/problems/not-found",
"type": _problem_type_url("not-found"),
"title": "Not Found",
"status": 404,
"detail": "The resource ID 'abc123' does not exist.",
"instance": "/api/v1/status/resources/abc123",
"instance": _api_example_url("status/resources/abc123"),
}

EXAMPLE_405 = {
"type": "https://iri.example.com/problems/method-not-allowed",
"type": _problem_type_url("method-not-allowed"),
"title": "Method Not Allowed",
"status": 405,
"detail": "HTTP method TRACE is not allowed for this endpoint.",
"instance": "/api/v1/status/resources",
"instance": _api_example_url("status/resources"),
}

EXAMPLE_409 = {
"type": "https://iri.example.com/problems/conflict",
"type": _problem_type_url("conflict"),
"title": "Conflict",
"status": 409,
"detail": "A job with this ID already exists.",
"instance": "/api/v1/compute/job/perlmutter/123",
"instance": _api_example_url("compute/job/perlmutter/123"),
}

EXAMPLE_422 = {
"type": "https://iri.example.com/problems/unprocessable-entity",
"type": _problem_type_url("unprocessable-entity"),
"title": "Unprocessable Entity",
"status": 422,
"detail": "The PSIJ JobSpec is syntactically correct but invalid.",
"instance": "/api/v1/compute/job/perlmutter",
"instance": _api_example_url("compute/job/perlmutter"),
"invalid_params": [{"name": "job_spec.executable", "reason": "Executable must be provided"}],
}

EXAMPLE_500 = {
"type": "https://iri.example.com/problems/internal-error",
"type": _problem_type_url("internal-error"),
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred.",
"instance": "/api/v1/status/resources",
"instance": _api_example_url("status/resources"),
}

EXAMPLE_501 = {
"type": "https://iri.example.com/problems/not-implemented",
"type": _problem_type_url("not-implemented"),
"title": "Not Implemented",
"status": 501,
"detail": "This functionality is not implemented.",
"instance": "/api/v1/status/resources",
"instance": _api_example_url("status/resources"),
}

EXAMPLE_503 = {
"type": "https://iri.example.com/problems/service-unavailable",
"type": _problem_type_url("service-unavailable"),
"title": "Service Unavailable",
"status": 503,
"detail": "The service is temporarily unavailable.",
"instance": "/api/v1/status/resources",
"instance": _api_example_url("status/resources"),
}

EXAMPLE_504 = {
"type": "https://iri.example.com/problems/gateway-timeout",
"type": _problem_type_url("gateway-timeout"),
"title": "Gateway Timeout",
"status": 504,
"detail": "The server did not receive a timely response.",
"instance": "/api/v1/status/resources",
"instance": _api_example_url("status/resources"),
}


def _problem_content(example: dict) -> dict:
return {"application/problem+json": {"example": example}}


DEFAULT_RESPONSES = {
400: {
"description": "Invalid request parameters",
"model": Problem,
"content": _problem_content(EXAMPLE_400),
},
401: {
"description": "Unauthorized",
Expand All @@ -315,15 +353,18 @@ async def global_handler(request: Request, exc: Exception):
}
},
"model": Problem,
"content": _problem_content(EXAMPLE_401),

},
403: {
"description": "Forbidden",
"model": Problem,
"content": _problem_content(EXAMPLE_403),
},
404: {
"description": "Not Found",
"model": Problem,
"content": _problem_content(EXAMPLE_404),
},
405: {
"description": "Method Not Allowed",
Expand All @@ -334,30 +375,37 @@ async def global_handler(request: Request, exc: Exception):
}
},
"model": Problem,
"content": _problem_content(EXAMPLE_405),
},
409: {
"description": "Conflict",
"model": Problem,
"content": _problem_content(EXAMPLE_409),
},
422: {
"description": "Unprocessable Entity",
"model": Problem,
"content": _problem_content(EXAMPLE_422),
},
500: {
"description": "Internal Server Error",
"model": Problem,
"content": _problem_content(EXAMPLE_500),
},
501: {
"description": "Not Implemented",
"model": Problem,
"content": _problem_content(EXAMPLE_501),
},
503: {
"description": "Service Unavailable",
"model": Problem,
"content": _problem_content(EXAMPLE_503),
},
504: {
"description": "Gateway Timeout",
"model": Problem,
"content": _problem_content(EXAMPLE_504),
},
304: {"description": "Not Modified"},
}
}
Loading
Loading