Skip to content
Merged
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
13 changes: 13 additions & 0 deletions app/apilogger.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ def _rotation_days() -> int:
return max(days, 0)


def _route_uvicorn_loggers_to_root(log_level: int) -> None:
"""Strip uvicorn's own handlers from its loggers and let them move to root."""
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
uv_logger = logging.getLogger(name)
for handler in uv_logger.handlers[:]:
uv_logger.removeHandler(handler)
handler.close()
uv_logger.propagate = True
uv_logger.setLevel(log_level)


def configure_logging(level: str | int | None = None) -> None:
"""
Configure root logging for the API.
Expand All @@ -53,6 +64,8 @@ def configure_logging(level: str | int | None = None) -> None:
root = logging.getLogger()
root.setLevel(log_level)

_route_uvicorn_loggers_to_root(log_level)

if _CONFIGURED:
for handler in root.handlers:
if getattr(handler, IRI_HANDLER_ATTR, False):
Expand Down
1 change: 1 addition & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@

@asynccontextmanager
async def _lifespan(app: FastAPI):
configure_logging(config.LOG_LEVEL)
app.state.idempotency_store = create_store()
yield
await app.state.idempotency_store.close()
Expand Down
11 changes: 6 additions & 5 deletions app/routers/compute/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fastapi import Depends, Header, Query, Request, status

from ...idempotency import build_body_hash, build_cache_key, run_with_idempotency
from ...types.hal import OPERATION_RELATIONS
from ...types.http import forbidExtraQueryParams
from ...types.scalars import StrictHTTPBool
from ...types.user import User
Expand Down Expand Up @@ -41,7 +42,7 @@ async def get_resources(
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="launchJob",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["submit-job"]])
)
async def submit_job(
resource_id: str,
Expand Down Expand Up @@ -80,7 +81,7 @@ async def submit_job(
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="updateJob",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["update-job"]])
)
async def update_job(
resource_id: str,
Expand Down Expand Up @@ -117,7 +118,7 @@ async def update_job(
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="getJob",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["get-job"]])
)
async def get_job_status(
resource_id: str,
Expand All @@ -144,7 +145,7 @@ async def get_job_status(
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="getJobs",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["query-jobs"]])
)
async def get_job_statuses(
resource_id: str,
Expand Down Expand Up @@ -174,7 +175,7 @@ async def get_job_statuses(
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="cancelJob",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["cancel-job"]])
)
async def cancel_job(
resource_id: str,
Expand Down
19 changes: 16 additions & 3 deletions app/routers/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ class Problem(BaseModel):
instance: str = Field(..., description="A URI reference identifying this occurrence.", example=f"http://localhost/{config.API_URL}/resource/123")


def allowed_methods_for_path(app: FastAPI, path: str) -> list[str]:
"""Return the sorted union of HTTP methods registered across all routes matching `path`"""
methods: set[str] = set()
for route in app.routes:
route_methods = getattr(route, "methods", None)
path_regex = getattr(route, "path_regex", None)
if route_methods and path_regex is not None and path_regex.match(path):
methods.update(route_methods)
return sorted(methods)


def get_url_base(request: Request) -> str:
"""Return the base URL for the API."""
# If behind a proxy (and x-forwarded-* headers present), use the forwarded host and protocol
Expand Down Expand Up @@ -145,13 +156,14 @@ async def http_exception_handler(request: Request, exc: HTTPException):
)

if exc.status_code == 405:
allow = allowed_methods_for_path(request.app, request.url.path)
return problem_response(
request=request,
status=405,
title="Method Not Allowed",
detail=err_msg or "HTTP method is not allowed for this resource.",
problem_type="method-not-allowed",
extra_headers={"Allow": "GET, HEAD"},
extra_headers={"Allow": ", ".join(allow)} if allow else {},
)

if exc.status_code == 409:
Expand Down Expand Up @@ -189,13 +201,14 @@ async def starlette_handler(request: Request, exc: StarletteHTTPException):
)

if exc.status_code == 405:
allow = allowed_methods_for_path(request.app, request.url.path)
return problem_response(
request=request,
status=405,
title="Method Not Allowed",
detail=err_msg or "HTTP method is not allowed for this resource.",
problem_type="method-not-allowed",
extra_headers={"Allow": "GET, HEAD"},
extra_headers={"Allow": ", ".join(allow)} if allow else {},
)

return problem_response(
Expand Down Expand Up @@ -362,4 +375,4 @@ async def global_handler(request: Request, exc: Exception):
"model": Problem,
},
304: {"description": "Not Modified"},
}
}
37 changes: 19 additions & 18 deletions app/routers/filesystem/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# SPDX-License-Identifier: BSD-3-Clause
import base64
from fastapi import Depends, HTTPException, status, Request, File, UploadFile
from ...types.hal import OPERATION_RELATIONS
from ...types.http import forbidExtraQueryParams
from ...types.user import User
from .. import iri_router
Expand Down Expand Up @@ -58,7 +59,7 @@ async def _user_resource(
response_description="File permissions changed successfully",
responses=DEFAULT_RESPONSES,
operation_id="chmod",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["change-file-mode"]])
)
async def post_chmod(
resource_id: str,
Expand Down Expand Up @@ -86,7 +87,7 @@ async def post_chmod(
response_description="File ownership changed successfully",
responses=DEFAULT_RESPONSES,
operation_id="chown",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["change-file-owner"]])
)
async def post_chown(
resource_id: str,
Expand Down Expand Up @@ -114,7 +115,7 @@ async def post_chown(
response_description="Type returned successfully",
responses=DEFAULT_RESPONSES,
operation_id="file",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["identify-file"]])
)
async def post_file(
resource_id: str,
Expand Down Expand Up @@ -142,7 +143,7 @@ async def post_file(
response_description="Stat returned successfully",
responses=DEFAULT_RESPONSES,
operation_id="stat",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["stat-file"]])
)
async def post_stat(
resource_id: str,
Expand Down Expand Up @@ -170,7 +171,7 @@ async def post_stat(
response_description="Directory created successfully",
responses=DEFAULT_RESPONSES,
operation_id="mkdir",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["create-directory"]])
)
async def post_mkdir(
resource_id: str,
Expand Down Expand Up @@ -198,7 +199,7 @@ async def post_mkdir(
response_description="Symlink created successfully",
responses=DEFAULT_RESPONSES,
operation_id="symlink",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["create-symlink"]])
)
async def post_symlink(
resource_id: str,
Expand Down Expand Up @@ -227,7 +228,7 @@ async def post_symlink(
include_in_schema=router.task_adapter is not None,
responses=DEFAULT_RESPONSES,
operation_id="ls",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["list-directory"]])
)
async def post_ls(
resource_id: str,
Expand Down Expand Up @@ -261,7 +262,7 @@ async def post_ls(
response_description="Head operation finished successfully",
responses=DEFAULT_RESPONSES,
operation_id="head",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["read-file-head"]])
)
async def post_head(
resource_id: str,
Expand Down Expand Up @@ -296,7 +297,7 @@ async def post_head(
response_description="View operation finished successfully",
responses=DEFAULT_RESPONSES,
operation_id="view",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["view-file"]])
)
async def post_view(
resource_id: str,
Expand Down Expand Up @@ -326,7 +327,7 @@ async def post_view(
response_description="`tail` operation finished successfully",
responses=DEFAULT_RESPONSES,
operation_id="tail",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["read-file-tail"]])
)
async def post_tail(
resource_id: str,
Expand Down Expand Up @@ -361,7 +362,7 @@ async def post_tail(
response_description="Checksum returned successfully",
responses=DEFAULT_RESPONSES,
operation_id="checksum",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["checksum-file"]])
)
async def post_checksum(
resource_id: str,
Expand Down Expand Up @@ -389,7 +390,7 @@ async def post_checksum(
response_description="File or directory deleted successfully",
responses=DEFAULT_RESPONSES,
operation_id="rm",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["remove-path"]])
)
async def post_rm(
resource_id: str,
Expand Down Expand Up @@ -417,7 +418,7 @@ async def post_rm(
response_description="File and/or directories compressed successfully",
responses=DEFAULT_RESPONSES,
operation_id="compress",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["compress-paths"]])
)
async def post_compress(
resource_id: str,
Expand Down Expand Up @@ -445,7 +446,7 @@ async def post_compress(
response_description="File extracted successfully",
responses=DEFAULT_RESPONSES,
operation_id="extract",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["extract-archive"]])
)
async def post_extract(
resource_id: str,
Expand Down Expand Up @@ -473,7 +474,7 @@ async def post_extract(
response_description="Move file or directory operation created successfully",
responses=DEFAULT_RESPONSES,
operation_id="mv",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["move-path"]])
)
async def post_mv(
resource_id: str,
Expand Down Expand Up @@ -501,7 +502,7 @@ async def post_mv(
response_description="Copy file or directory operation created successfully",
responses=DEFAULT_RESPONSES,
operation_id="cp",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["copy-path"]])
)
async def post_cp(
resource_id: str,
Expand Down Expand Up @@ -529,7 +530,7 @@ async def post_cp(
response_description="File downloaded successfully",
responses=DEFAULT_RESPONSES,
operation_id="download",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["download-file"]])
)
async def post_download(
resource_id: str,
Expand Down Expand Up @@ -557,7 +558,7 @@ async def post_download(
response_description="File uploaded successfully",
responses=DEFAULT_RESPONSES,
operation_id="upload",
openapi_extra=iri_meta_dict("production", "required")
openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["upload-file"]])
)
async def post_upload(
resource_id: str,
Expand Down
21 changes: 16 additions & 5 deletions app/routers/iri_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"level": "required",
"required_if_capability": "dpu"
}
}
},
"x-iri-relation": ["https://iri.science/rels/submit-job"]
}
"""

Expand All @@ -18,8 +19,13 @@ def iri_meta_dict(
maturity: str | None = None,
implementation_level: str | None = None,
required_if: str | None = None,
relations: list[str] | None = None,
) -> dict:
"""Generate the IRI OpenAPI extension metadata."""
"""Generate the IRI OpenAPI extension metadata.

`relations` binds this operation to one or more registered DOE-IRI operation-affordance
relations (ADR 0007), as canonical relation URIs from app.types.hal.OPERATION_RELATIONS.
"""

out_obj = {}

Expand All @@ -32,7 +38,12 @@ def iri_meta_dict(
if required_if is not None:
out_obj.setdefault("implementation", {})["required_if_capability"] = required_if

if not out_obj:
return {}
extra = {}

if out_obj:
extra["x-iri"] = out_obj

if relations:
extra["x-iri-relation"] = list(relations)

return {"x-iri": out_obj}
return extra
Loading
Loading