From 328461580ad92e2f1553682ffcd2c3793bbcb3e3 Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Tue, 21 Apr 2026 07:51:06 -0500 Subject: [PATCH 1/2] Implement logger to stdout and file --- Makefile | 4 ++- README.md | 6 +++- app/apilogger.py | 85 +++++++++++++++++++++++++++++++++++++++++------- app/main.py | 10 +++--- 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 14195d59..e8ca8cd3 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ VENV := .venv BIN := $(VENV)/bin UV := uv PIP := $(BIN)/pip +LOG_FILE := runtime-logs.log +IRI_LOG_FILE ?= $(LOG_FILE) STAMP_VENV := $(VENV)/.created STAMP_DEPS := $(VENV)/.deps @@ -34,6 +36,7 @@ dev: deps IRI_API_ADAPTER_compute=app.demo_adapter.DemoAdapter \ IRI_API_ADAPTER_filesystem=app.demo_adapter.DemoAdapter \ IRI_API_ADAPTER_task=app.demo_adapter.DemoAdapter \ + IRI_LOG_FILE="$${IRI_LOG_FILE:-$${LOG_FILE:-$(IRI_LOG_FILE)}}" \ DEMO_QUEUE_UPDATE_SECS=2 \ OPENTELEMETRY_ENABLED=true \ API_URL_ROOT='http://localhost:8000' fastapi dev @@ -78,4 +81,3 @@ ARGS ?= # call it via: make manage-globus ARGS=scopes-show manage-globus: deps @source local.env && $(BIN)/python ./tools/manage_globus.py $(ARGS) - diff --git a/README.md b/README.md index b47226df..0f8f49ce 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ Links to data, created by this api, will concatenate these values producing link - `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_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`.) +- `LOG_LEVEL`: logging level for the API and adapters. Defaults to `DEBUG`. +- `IRI_LOG_FILE`: file path for API logs. Logs always go to stdout; when this is set, logs also go to the file. +- `LOG_FILE`: fallback file path for API logs when `IRI_LOG_FILE` is not set. + +For local development, `make` writes logs to `runtime-logs.log` by default. Use `make LOG_FILE=/tmp/iri-api.log` or `make IRI_LOG_FILE=/tmp/iri-api.log` to choose a different file. You can also put either variable in `local.env`. ## Docker support @@ -142,4 +147,3 @@ You can optionally use globus for authorization. Steps to use globus: - Specify the monitoring endpoint by setting the [OpenTelemetry](https://opentelemetry.io/docs/zero-code/python/) env vars - Add additional routers for other API-s - Add authenticated API-s via an [OAuth2 integration](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) - diff --git a/app/apilogger.py b/app/apilogger.py index 80403a9f..1bf7302c 100644 --- a/app/apilogger.py +++ b/app/apilogger.py @@ -1,5 +1,9 @@ """Logging utilities for the IRI Facility API.""" + import logging +import os +import sys +from pathlib import Path LEVELS = {"FATAL": logging.FATAL, "ERROR": logging.ERROR, @@ -7,22 +11,81 @@ "INFO": logging.INFO, "DEBUG": logging.DEBUG} +DEFAULT_FORMAT = "%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s" +DEFAULT_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S" +IRI_HANDLER_ATTR = "_iri_facility_api_handler" -def get_stream_logger(name: str = __name__, level: str = "DEBUG") -> logging.Logger: +_CONFIGURED = False + + +def _level(level: str | int | None) -> int: + if isinstance(level, int): + return level + return LEVELS.get(str(level or "INFO").upper(), logging.INFO) + + +def _log_file_path() -> Path | None: + log_file = os.environ.get("IRI_LOG_FILE") or os.environ.get("LOG_FILE") + return Path(log_file) if log_file else None + + +def configure_logging(level: str | int | None = None) -> None: """ - Return a configured Stream logger. + Configure root logging for the API. + + Logs always go to stdout. If IRI_LOG_FILE or LOG_FILE is set, logs also go + to that file. """ - logger = logging.getLogger(name) + global _CONFIGURED + + log_level = _level(level or os.environ.get("LOG_LEVEL")) + root = logging.getLogger() + root.setLevel(log_level) - if not logger.handlers: - handler = logging.StreamHandler() + if _CONFIGURED: + for handler in root.handlers: + if getattr(handler, IRI_HANDLER_ATTR, False): + handler.setLevel(log_level) + return - formatter = logging.Formatter("%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s", datefmt="%a, %d %b %Y %H:%M:%S") + formatter = logging.Formatter(DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT) - handler.setFormatter(formatter) - logger.addHandler(handler) + for handler in root.handlers[:]: + if getattr(handler, IRI_HANDLER_ATTR, False): + root.removeHandler(handler) + handler.close() + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(log_level) + stdout_handler.setFormatter(formatter) + setattr(stdout_handler, IRI_HANDLER_ATTR, True) + root.addHandler(stdout_handler) + + log_file = _log_file_path() + if log_file: + if log_file.parent != Path("."): + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(log_level) + file_handler.setFormatter(formatter) + setattr(file_handler, IRI_HANDLER_ATTR, True) + root.addHandler(file_handler) + + _CONFIGURED = True + + +def get_stream_logger(name: str = __name__, level: str = "DEBUG") -> logging.Logger: + """ + Return a logger using the shared API stdout and optional file logging setup. + """ + configure_logging(level) + + logger = logging.getLogger(name) + logger.setLevel(_level(level)) + logger.propagate = True - logger.setLevel(LEVELS.get(level, logging.DEBUG)) - logger.propagate = False + for handler in logger.handlers[:]: + logger.removeHandler(handler) + handler.close() - return logger \ No newline at end of file + return logger diff --git a/app/main.py b/app/main.py index 62147590..49317718 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,9 @@ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from . import config +from .apilogger import configure_logging + from app.routers.error_handlers import install_error_handlers from app.routers.facility import facility from app.routers.status import status @@ -19,12 +22,7 @@ from app.routers.filesystem import filesystem from app.routers.task import task -from . import config - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s" -) +configure_logging(config.LOG_LEVEL) # ------------------------------------------------------------------ # OpenTelemetry Tracing Configuration From cdf774ae4d40b8d8a1b903212f9552403612ba35 Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Tue, 21 Apr 2026 09:40:03 -0500 Subject: [PATCH 2/2] V1/V2 separation --- README.md | 12 ++- app/demo_adapter.py | 29 +++--- app/main.py | 16 +-- app/routers/error_handlers.py | 98 ++++++++++++++----- app/routers/loader.py | 90 +++++++++++++++++ app/routers/v1/__init__.py | 2 + app/routers/{ => v1}/account/__init__.py | 0 app/routers/{ => v1}/account/account.py | 14 +-- .../{ => v1}/account/facility_adapter.py | 6 +- app/routers/{ => v1}/account/models.py | 6 +- app/routers/{ => v1}/compute/__init__.py | 0 app/routers/{ => v1}/compute/compute.py | 16 +-- .../{ => v1}/compute/facility_adapter.py | 4 +- app/routers/{ => v1}/compute/models.py | 3 +- app/routers/{ => v1}/facility/__init__.py | 0 app/routers/{ => v1}/facility/facility.py | 10 +- .../{ => v1}/facility/facility_adapter.py | 0 app/routers/{ => v1}/facility/models.py | 4 +- app/routers/{ => v1}/filesystem/__init__.py | 0 .../{ => v1}/filesystem/facility_adapter.py | 6 +- app/routers/{ => v1}/filesystem/filesystem.py | 8 +- app/routers/{ => v1}/filesystem/models.py | 0 app/routers/{ => v1}/status/__init__.py | 0 .../{ => v1}/status/facility_adapter.py | 2 +- app/routers/{ => v1}/status/models.py | 4 +- app/routers/{ => v1}/status/status.py | 10 +- app/routers/{ => v1}/task/__init__.py | 0 app/routers/{ => v1}/task/facility_adapter.py | 6 +- app/routers/{ => v1}/task/models.py | 2 +- app/routers/{ => v1}/task/task.py | 8 +- pyproject.toml | 4 + 31 files changed, 249 insertions(+), 111 deletions(-) create mode 100644 app/routers/loader.py create mode 100644 app/routers/v1/__init__.py rename app/routers/{ => v1}/account/__init__.py (100%) rename app/routers/{ => v1}/account/account.py (96%) rename app/routers/{ => v1}/account/facility_adapter.py (90%) rename app/routers/{ => v1}/account/models.py (97%) rename app/routers/{ => v1}/compute/__init__.py (100%) rename app/routers/{ => v1}/compute/compute.py (95%) rename app/routers/{ => v1}/compute/facility_adapter.py (95%) rename app/routers/{ => v1}/compute/models.py (99%) rename app/routers/{ => v1}/facility/__init__.py (100%) rename app/routers/{ => v1}/facility/facility.py (92%) rename app/routers/{ => v1}/facility/facility_adapter.py (100%) rename app/routers/{ => v1}/facility/models.py (98%) rename app/routers/{ => v1}/filesystem/__init__.py (100%) rename app/routers/{ => v1}/filesystem/facility_adapter.py (97%) rename app/routers/{ => v1}/filesystem/filesystem.py (99%) rename app/routers/{ => v1}/filesystem/models.py (100%) rename app/routers/{ => v1}/status/__init__.py (100%) rename app/routers/{ => v1}/status/facility_adapter.py (98%) rename app/routers/{ => v1}/status/models.py (99%) rename app/routers/{ => v1}/status/status.py (96%) rename app/routers/{ => v1}/task/__init__.py (100%) rename app/routers/{ => v1}/task/facility_adapter.py (97%) rename app/routers/{ => v1}/task/models.py (98%) rename app/routers/{ => v1}/task/task.py (92%) diff --git a/README.md b/README.md index 0f8f49ce..512a43bb 100644 --- a/README.md +++ b/README.md @@ -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. @@ -57,7 +63,7 @@ If using docker (see next section), your dockerfile could extend this reference 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`.) - `LOG_LEVEL`: logging level for the API and adapters. Defaults to `DEBUG`. - `IRI_LOG_FILE`: file path for API logs. Logs always go to stdout; when this is set, logs also go to the file. diff --git a/app/demo_adapter.py b/app/demo_adapter.py index fa9a23ae..35d408a6 100644 --- a/app/demo_adapter.py +++ b/app/demo_adapter.py @@ -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 @@ -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) diff --git a/app/main.py b/app/main.py index 49317718..fee929ff 100644 --- a/app/main.py +++ b/app/main.py @@ -15,12 +15,7 @@ from .apilogger import configure_logging 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) @@ -53,12 +48,7 @@ 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}") diff --git a/app/routers/error_handlers.py b/app/routers/error_handlers.py index a94334da..3aed8cd3 100644 --- a/app/routers/error_handlers.py +++ b/app/routers/error_handlers.py @@ -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: @@ -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", @@ -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", @@ -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"}, -} \ No newline at end of file +} diff --git a/app/routers/loader.py b/app/routers/loader.py new file mode 100644 index 00000000..48373e1b --- /dev/null +++ b/app/routers/loader.py @@ -0,0 +1,90 @@ +"""Dynamic loading for versioned API routers.""" + +import importlib +import pkgutil +import re +from dataclasses import dataclass +from types import ModuleType + +from fastapi import APIRouter + +ROUTER_GROUP_ORDER = ( + "facility", + "status", + "account", + "compute", + "filesystem", + "task", +) + + +@dataclass(frozen=True) +class LoadedRouter: + """A router loaded from a versioned route group.""" + + version: str + group: str + router: APIRouter + + +def version_from_api_url(api_url: str) -> str: + """Extract the semantic API version from a configured API URL.""" + tail = api_url.rstrip("/").split("/")[-1] + if re.fullmatch(r"v\d+", tail): + return tail + return "v1" + + +def _version_number(version: str) -> int: + match = re.fullmatch(r"v(\d+)", version) + if not match: + raise ValueError(f"Unsupported API version name: {version}") + return int(match.group(1)) + + +def _version_packages(max_version: str) -> list[str]: + package = importlib.import_module("app.routers") + target = _version_number(max_version) + versions = [] + + for module_info in pkgutil.iter_modules(package.__path__): + if not module_info.ispkg: + continue + if not re.fullmatch(r"v\d+", module_info.name): + continue + if _version_number(module_info.name) <= target: + versions.append(module_info.name) + + return sorted(versions, key=_version_number) + + +def _group_packages(version_module: ModuleType) -> list[str]: + discovered = { + module_info.name + for module_info in pkgutil.iter_modules(version_module.__path__) + if module_info.ispkg + } + ordered = [group for group in ROUTER_GROUP_ORDER if group in discovered] + ordered.extend(sorted(discovered - set(ROUTER_GROUP_ORDER))) + return ordered + + +def load_routers(max_version: str) -> list[LoadedRouter]: + """ + Load routers up through max_version. + + Version folders are additive: v1 contains the baseline surface, while later + versions can provide only new or changed route groups. + """ + loaded = [] + + for version in _version_packages(max_version): + version_module = importlib.import_module(f"app.routers.{version}") + for group in _group_packages(version_module): + route_module = importlib.import_module(f"app.routers.{version}.{group}.{group}") + router = getattr(route_module, "router", None) + if router is None: + continue + loaded.append(LoadedRouter(version=version, group=group, router=router)) + + return loaded diff --git a/app/routers/v1/__init__.py b/app/routers/v1/__init__.py new file mode 100644 index 00000000..9ea7a9fb --- /dev/null +++ b/app/routers/v1/__init__.py @@ -0,0 +1,2 @@ +"""Version 1 API route groups.""" + diff --git a/app/routers/account/__init__.py b/app/routers/v1/account/__init__.py similarity index 100% rename from app/routers/account/__init__.py rename to app/routers/v1/account/__init__.py diff --git a/app/routers/account/account.py b/app/routers/v1/account/account.py similarity index 96% rename from app/routers/account/account.py rename to app/routers/v1/account/account.py index 35ba9cb8..a3be740b 100644 --- a/app/routers/account/account.py +++ b/app/routers/v1/account/account.py @@ -1,12 +1,12 @@ from fastapi import Depends, HTTPException, Query, Request -from ...types.http import forbidExtraQueryParams -from ...types.models import Capability -from ...types.scalars import StrictDateTime -from ...types.user import User -from .. import iri_router -from ..error_handlers import DEFAULT_RESPONSES -from ..iri_meta import iri_meta_dict +from ....types.http import forbidExtraQueryParams +from ....types.models import Capability +from ....types.scalars import StrictDateTime +from ....types.user import User +from ... import iri_router +from ...error_handlers import DEFAULT_RESPONSES +from ...iri_meta import iri_meta_dict from . import facility_adapter, models router = iri_router.IriRouter( diff --git a/app/routers/account/facility_adapter.py b/app/routers/v1/account/facility_adapter.py similarity index 90% rename from app/routers/account/facility_adapter.py rename to app/routers/v1/account/facility_adapter.py index 109c60bd..6085c34c 100644 --- a/app/routers/account/facility_adapter.py +++ b/app/routers/v1/account/facility_adapter.py @@ -1,8 +1,8 @@ from abc import abstractmethod -from ...types.models import Capability -from ...types.user import User -from ..iri_router import AuthenticatedAdapter +from ....types.models import Capability +from ....types.user import User +from ...iri_router import AuthenticatedAdapter from . import models as account_models diff --git a/app/routers/account/models.py b/app/routers/v1/account/models.py similarity index 97% rename from app/routers/account/models.py rename to app/routers/v1/account/models.py index 0c6beba2..c9a1ee8d 100644 --- a/app/routers/account/models.py +++ b/app/routers/v1/account/models.py @@ -2,9 +2,9 @@ import datetime from pydantic import Field, computed_field, field_validator -from ... import config -from ...types.base import IRIBaseModel -from ...types.scalars import AllocationUnit +from .... import config +from ....types.base import IRIBaseModel +from ....types.scalars import AllocationUnit class Project(IRIBaseModel): diff --git a/app/routers/compute/__init__.py b/app/routers/v1/compute/__init__.py similarity index 100% rename from app/routers/compute/__init__.py rename to app/routers/v1/compute/__init__.py diff --git a/app/routers/compute/compute.py b/app/routers/v1/compute/compute.py similarity index 95% rename from app/routers/compute/compute.py rename to app/routers/v1/compute/compute.py index 71c80b45..12878168 100644 --- a/app/routers/compute/compute.py +++ b/app/routers/v1/compute/compute.py @@ -1,13 +1,13 @@ """Compute resource API router""" -from fastapi import Depends, HTTPException, Query, Request, status - -from ...types.http import forbidExtraQueryParams -from ...types.scalars import StrictHTTPBool -from ...types.user import User -from .. import iri_router -from ..error_handlers import DEFAULT_RESPONSES -from ..iri_meta import iri_meta_dict +from fastapi import Depends, Query, Request, status + +from ....types.http import forbidExtraQueryParams +from ....types.scalars import StrictHTTPBool +from ....types.user import User +from ... import iri_router +from ...error_handlers import DEFAULT_RESPONSES +from ...iri_meta import iri_meta_dict from ..status.status import router as status_router from . import facility_adapter, models diff --git a/app/routers/compute/facility_adapter.py b/app/routers/v1/compute/facility_adapter.py similarity index 95% rename from app/routers/compute/facility_adapter.py rename to app/routers/v1/compute/facility_adapter.py index 32adbdbf..c85ffb73 100644 --- a/app/routers/compute/facility_adapter.py +++ b/app/routers/v1/compute/facility_adapter.py @@ -1,8 +1,8 @@ from abc import abstractmethod -from ...types.user import User +from ....types.user import User from ..status import models as status_models from . import models as compute_models -from ..iri_router import AuthenticatedAdapter +from ...iri_router import AuthenticatedAdapter class FacilityAdapter(AuthenticatedAdapter): diff --git a/app/routers/compute/models.py b/app/routers/v1/compute/models.py similarity index 99% rename from app/routers/compute/models.py rename to app/routers/v1/compute/models.py index cea26492..48898a19 100644 --- a/app/routers/compute/models.py +++ b/app/routers/v1/compute/models.py @@ -1,10 +1,9 @@ """Models for compute router, including job specifications, job status, and related data structures.""" from enum import Enum -from typing import Annotated from pydantic import ConfigDict, Field, StrictBool -from ...types.base import IRIBaseModel +from ....types.base import IRIBaseModel class ResourceSpec(IRIBaseModel): diff --git a/app/routers/facility/__init__.py b/app/routers/v1/facility/__init__.py similarity index 100% rename from app/routers/facility/__init__.py rename to app/routers/v1/facility/__init__.py diff --git a/app/routers/facility/facility.py b/app/routers/v1/facility/facility.py similarity index 92% rename from app/routers/facility/facility.py rename to app/routers/v1/facility/facility.py index f86cd9df..04ad7640 100644 --- a/app/routers/facility/facility.py +++ b/app/routers/v1/facility/facility.py @@ -1,10 +1,10 @@ from fastapi import Depends, Query, Request, HTTPException -from ...types.http import forbidExtraQueryParams -from ...types.scalars import StrictDateTime -from .. import iri_router -from ..error_handlers import DEFAULT_RESPONSES -from ..iri_meta import iri_meta_dict +from ....types.http import forbidExtraQueryParams +from ....types.scalars import StrictDateTime +from ... import iri_router +from ...error_handlers import DEFAULT_RESPONSES +from ...iri_meta import iri_meta_dict from . import facility_adapter, models router = iri_router.IriRouter(facility_adapter.FacilityAdapter, prefix="/facility", tags=["facility"]) diff --git a/app/routers/facility/facility_adapter.py b/app/routers/v1/facility/facility_adapter.py similarity index 100% rename from app/routers/facility/facility_adapter.py rename to app/routers/v1/facility/facility_adapter.py diff --git a/app/routers/facility/models.py b/app/routers/v1/facility/models.py similarity index 98% rename from app/routers/facility/models.py rename to app/routers/v1/facility/models.py index 5d306751..f33e9cb5 100644 --- a/app/routers/facility/models.py +++ b/app/routers/v1/facility/models.py @@ -1,8 +1,8 @@ """Facility-related models.""" from pydantic import Field, HttpUrl, computed_field -from ... import config -from ...types.base import NamedObject +from .... import config +from ....types.base import NamedObject class Site(NamedObject): diff --git a/app/routers/filesystem/__init__.py b/app/routers/v1/filesystem/__init__.py similarity index 100% rename from app/routers/filesystem/__init__.py rename to app/routers/v1/filesystem/__init__.py diff --git a/app/routers/filesystem/facility_adapter.py b/app/routers/v1/filesystem/facility_adapter.py similarity index 97% rename from app/routers/filesystem/facility_adapter.py rename to app/routers/v1/filesystem/facility_adapter.py index df545a19..01c3122c 100644 --- a/app/routers/filesystem/facility_adapter.py +++ b/app/routers/v1/filesystem/facility_adapter.py @@ -1,15 +1,15 @@ import os from abc import abstractmethod -from ...types.user import User +from ....types.user import User from ..status import models as status_models from . import models as filesystem_models -from ..iri_router import AuthenticatedAdapter +from ...iri_router import AuthenticatedAdapter def to_int(name, default_value): try: return int(os.environ.get(name) or default_value) - except: + except (TypeError, ValueError): return default_value diff --git a/app/routers/filesystem/filesystem.py b/app/routers/v1/filesystem/filesystem.py similarity index 99% rename from app/routers/filesystem/filesystem.py rename to app/routers/v1/filesystem/filesystem.py index cd6448a3..55535a04 100644 --- a/app/routers/filesystem/filesystem.py +++ b/app/routers/v1/filesystem/filesystem.py @@ -7,10 +7,10 @@ import base64 from typing import Annotated from fastapi import Depends, HTTPException, status, Query, Request, File, UploadFile -from ...types.user import User -from .. import iri_router -from ..error_handlers import DEFAULT_RESPONSES -from ..iri_meta import iri_meta_dict +from ....types.user import User +from ... import iri_router +from ...error_handlers import DEFAULT_RESPONSES +from ...iri_meta import iri_meta_dict from ..status.status import router as status_router, models as status_models from ..task import facility_adapter as task_facility_adapter, models as task_models from . import models, facility_adapter diff --git a/app/routers/filesystem/models.py b/app/routers/v1/filesystem/models.py similarity index 100% rename from app/routers/filesystem/models.py rename to app/routers/v1/filesystem/models.py diff --git a/app/routers/status/__init__.py b/app/routers/v1/status/__init__.py similarity index 100% rename from app/routers/status/__init__.py rename to app/routers/v1/status/__init__.py diff --git a/app/routers/status/facility_adapter.py b/app/routers/v1/status/facility_adapter.py similarity index 98% rename from app/routers/status/facility_adapter.py rename to app/routers/v1/status/facility_adapter.py index 65b87c4c..fdc3d94c 100644 --- a/app/routers/status/facility_adapter.py +++ b/app/routers/v1/status/facility_adapter.py @@ -1,7 +1,7 @@ import datetime from abc import ABC, abstractmethod -from ...types.models import Capability +from ....types.models import Capability from . import models as status_models diff --git a/app/routers/status/models.py b/app/routers/v1/status/models.py similarity index 99% rename from app/routers/status/models.py rename to app/routers/v1/status/models.py index 357db3a6..89948e02 100644 --- a/app/routers/status/models.py +++ b/app/routers/v1/status/models.py @@ -4,8 +4,8 @@ from pydantic import Field, computed_field, field_validator -from ... import config -from ...types.base import NamedObject +from .... import config +from ....types.base import NamedObject class Status(enum.Enum): diff --git a/app/routers/status/status.py b/app/routers/v1/status/status.py similarity index 96% rename from app/routers/status/status.py rename to app/routers/v1/status/status.py index ce5958e4..583d6a78 100644 --- a/app/routers/status/status.py +++ b/app/routers/v1/status/status.py @@ -2,11 +2,11 @@ from fastapi import Depends, HTTPException, Query, Request -from ...types.http import forbidExtraQueryParams -from ...types.scalars import AllocationUnit, StrictDateTime -from .. import iri_router -from ..error_handlers import DEFAULT_RESPONSES -from ..iri_meta import iri_meta_dict +from ....types.http import forbidExtraQueryParams +from ....types.scalars import AllocationUnit, StrictDateTime +from ... import iri_router +from ...error_handlers import DEFAULT_RESPONSES +from ...iri_meta import iri_meta_dict from . import facility_adapter, models router = iri_router.IriRouter( diff --git a/app/routers/task/__init__.py b/app/routers/v1/task/__init__.py similarity index 100% rename from app/routers/task/__init__.py rename to app/routers/v1/task/__init__.py diff --git a/app/routers/task/facility_adapter.py b/app/routers/v1/task/facility_adapter.py similarity index 97% rename from app/routers/task/facility_adapter.py rename to app/routers/v1/task/facility_adapter.py index bec55bca..393c6cf1 100644 --- a/app/routers/task/facility_adapter.py +++ b/app/routers/v1/task/facility_adapter.py @@ -1,12 +1,12 @@ import traceback from abc import abstractmethod -from ...types.user import User +from ....types.user import User from . import models as task_models from ..status import models as status_models from ..filesystem import models as filesystem_models, facility_adapter as filesystem_adapter -from ..iri_router import AuthenticatedAdapter, IriRouter +from ...iri_router import AuthenticatedAdapter, IriRouter -from ...apilogger import get_stream_logger +from ....apilogger import get_stream_logger logger = get_stream_logger(__name__) diff --git a/app/routers/task/models.py b/app/routers/v1/task/models.py similarity index 98% rename from app/routers/task/models.py rename to app/routers/v1/task/models.py index 7a13ac0e..7014e49d 100644 --- a/app/routers/task/models.py +++ b/app/routers/v1/task/models.py @@ -2,7 +2,7 @@ import enum from pydantic import BaseModel, Field, computed_field -from ... import config +from .... import config class TaskSubmitResponse(BaseModel): diff --git a/app/routers/task/task.py b/app/routers/v1/task/task.py similarity index 92% rename from app/routers/task/task.py rename to app/routers/v1/task/task.py index b3a81166..c7724c18 100644 --- a/app/routers/task/task.py +++ b/app/routers/v1/task/task.py @@ -1,8 +1,8 @@ from fastapi import Request, HTTPException, Depends -from ...types.user import User -from .. import iri_router -from ..error_handlers import DEFAULT_RESPONSES -from ..iri_meta import iri_meta_dict +from ....types.user import User +from ... import iri_router +from ...error_handlers import DEFAULT_RESPONSES +from ...iri_meta import iri_meta_dict from . import models, facility_adapter router = iri_router.IriRouter( diff --git a/pyproject.toml b/pyproject.toml index 4e34d7e4..8c4964fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ dependencies = [ "globus-sdk>=4.3.1", "typer>=0.24.1", ] + +[tool.setuptools.packages.find] +include = ["app*"] + [tool.ruff] line-length = 200 exclude = [".venv", "__pycache__", "build", "dist"]