From f84cf2a54c5cec7c15317c17e1a6c6132608c6e3 Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Thu, 17 Sep 2026 06:06:08 -0500 Subject: [PATCH 1/6] Releationships and hal endpoints. auto links. Based on: https://github.com/doe-iri/iri-facility-api-docs/blob/main/rfc/rfc-resource-operation-affordances.md --- app/routers/compute/compute.py | 11 +++++---- app/routers/filesystem/filesystem.py | 37 ++++++++++++++-------------- app/routers/iri_meta.py | 21 ++++++++++++---- app/routers/status/models.py | 12 +++++++-- app/types/hal.py | 32 ++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 30 deletions(-) diff --git a/app/routers/compute/compute.py b/app/routers/compute/compute.py index aa42825f..dfbe899c 100644 --- a/app/routers/compute/compute.py +++ b/app/routers/compute/compute.py @@ -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 @@ -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, @@ -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, @@ -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["query-job"]]) ) async def get_job_status( resource_id: str, @@ -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["list-jobs"]]) ) async def get_job_statuses( resource_id: str, @@ -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, diff --git a/app/routers/filesystem/filesystem.py b/app/routers/filesystem/filesystem.py index 6c8d773f..6ea2e10c 100644 --- a/app/routers/filesystem/filesystem.py +++ b/app/routers/filesystem/filesystem.py @@ -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 @@ -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["chmod"]]) ) async def post_chmod( resource_id: str, @@ -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["chown"]]) ) async def post_chown( resource_id: str, @@ -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["file"]]) ) async def post_file( resource_id: str, @@ -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"]]) ) async def post_stat( resource_id: str, @@ -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["mkdir"]]) ) async def post_mkdir( resource_id: str, @@ -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["symlink"]]) ) async def post_symlink( resource_id: str, @@ -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["ls"]]) ) async def post_ls( resource_id: str, @@ -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["head"]]) ) async def post_head( resource_id: str, @@ -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"]]) ) async def post_view( resource_id: str, @@ -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["tail"]]) ) async def post_tail( resource_id: str, @@ -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"]]) ) async def post_checksum( resource_id: str, @@ -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["rm"]]) ) async def post_rm( resource_id: str, @@ -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"]]) ) async def post_compress( resource_id: str, @@ -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"]]) ) async def post_extract( resource_id: str, @@ -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["mv"]]) ) async def post_mv( resource_id: str, @@ -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["cp"]]) ) async def post_cp( resource_id: str, @@ -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"]]) ) async def post_download( resource_id: str, @@ -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"]]) ) async def post_upload( resource_id: str, diff --git a/app/routers/iri_meta.py b/app/routers/iri_meta.py index bc97b9f3..0c882768 100644 --- a/app/routers/iri_meta.py +++ b/app/routers/iri_meta.py @@ -9,7 +9,8 @@ "level": "required", "required_if_capability": "dpu" } - } + }, + "x-iri-relation": ["https://iri.science/rels/submit-job"] } """ @@ -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 = {} @@ -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 diff --git a/app/routers/status/models.py b/app/routers/status/models.py index 0d1c3647..1ff5de3f 100644 --- a/app/routers/status/models.py +++ b/app/routers/status/models.py @@ -7,7 +7,14 @@ from ...apilogger import get_stream_logger from ...request_context import get_url_prefix from ...types.base import NamedObject -from ...types.hal import PROFILE_ACCOUNT_CAPABILITY, PROFILE_FACILITY_SITE, PROFILE_STATUS_RESOURCE, RELATION_METADATA, build_hal_link +from ...types.hal import ( + PROFILE_ACCOUNT_CAPABILITY, + PROFILE_FACILITY_SITE, + PROFILE_STATUS_RESOURCE, + RELATION_METADATA, + SERVICE_DESC_MEDIA_TYPE, + build_hal_link, +) from ...types.scalars import ResourceType, ResourceTypeValue, urn_has_complete_prefix, validate_doe_iri_urn LOGGER = get_stream_logger(__name__) @@ -93,7 +100,8 @@ def _extra_links(self) -> dict: build_hal_link(uri, profile=PROFILE_ACCOUNT_CAPABILITY) for uri in self.capability_uris ] if urn_has_complete_prefix("urn:doe-iri:resource:compute:system", self.resource_type) and "compute" in self.supported_endpoints: - links["iri:submit-job"] = {"href": f"{get_url_prefix()}/compute/job/{self.id}"} + links["iri:submit-job"] = build_hal_link(f"{get_url_prefix()}/compute/job/{self.id}", media_type=None) + links["service-desc"] = build_hal_link(f"{get_url_prefix()}/openapi.json", media_type=SERVICE_DESC_MEDIA_TYPE) for relation, target_ids in self.related_resource_ids.items(): if not target_ids: continue diff --git a/app/types/hal.py b/app/types/hal.py index 10731d21..878547e2 100644 --- a/app/types/hal.py +++ b/app/types/hal.py @@ -40,6 +40,38 @@ def resource_type_profile(resource_type: str) -> str | None: return RESOURCE_TYPE_PROFILE.get(resource_type) +# Canonical relation URIs for registered DOE-IRI operation-affordance relations (ADR 0007). +# Bind one of these to an Operation Object via iri_meta.iri_meta_dict(relations=[...]) so +# clients can resolve a HAL relation to its Operation Object without depending on operationId. +OPERATION_RELATIONS: dict[str, str] = { + "submit-job": IRI_RELS_TEMPLATE.format(rel="submit-job"), + "query-job": IRI_RELS_TEMPLATE.format(rel="query-job"), + "list-jobs": IRI_RELS_TEMPLATE.format(rel="list-jobs"), + "update-job": IRI_RELS_TEMPLATE.format(rel="update-job"), + "cancel-job": IRI_RELS_TEMPLATE.format(rel="cancel-job"), + "chmod": IRI_RELS_TEMPLATE.format(rel="chmod"), + "chown": IRI_RELS_TEMPLATE.format(rel="chown"), + "file": IRI_RELS_TEMPLATE.format(rel="file"), + "stat": IRI_RELS_TEMPLATE.format(rel="stat"), + "mkdir": IRI_RELS_TEMPLATE.format(rel="mkdir"), + "symlink": IRI_RELS_TEMPLATE.format(rel="symlink"), + "ls": IRI_RELS_TEMPLATE.format(rel="ls"), + "head": IRI_RELS_TEMPLATE.format(rel="head"), + "view": IRI_RELS_TEMPLATE.format(rel="view"), + "tail": IRI_RELS_TEMPLATE.format(rel="tail"), + "checksum": IRI_RELS_TEMPLATE.format(rel="checksum"), + "rm": IRI_RELS_TEMPLATE.format(rel="rm"), + "compress": IRI_RELS_TEMPLATE.format(rel="compress"), + "extract": IRI_RELS_TEMPLATE.format(rel="extract"), + "mv": IRI_RELS_TEMPLATE.format(rel="mv"), + "cp": IRI_RELS_TEMPLATE.format(rel="cp"), + "download": IRI_RELS_TEMPLATE.format(rel="download"), + "upload": IRI_RELS_TEMPLATE.format(rel="upload"), +} + +SERVICE_DESC_MEDIA_TYPE = "application/vnd.oai.openapi+json" + + def build_hal_link(href: str, profile: str | None = None, media_type: str | None = "application/hal+json") -> dict: """Build one HAL link object, omitting unset optional fields.""" link: dict[str, str] = {"href": href} From cd5e055a78a828d66ffa28284067404799c0b16e Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Thu, 17 Sep 2026 16:13:49 -0500 Subject: [PATCH 2/6] Add compute URN, to be used by drivers, facilities --- app/types/scalars.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/types/scalars.py b/app/types/scalars.py index 2e9fb399..35c81804 100644 --- a/app/types/scalars.py +++ b/app/types/scalars.py @@ -225,3 +225,21 @@ class CompressionType(str, Enum): ) ), ] + +ComputeUrnValue = Annotated[ + str, + BeforeValidator(lambda v: _validate_urn_domain(v, "compute", "compute URN")), + WithJsonSchema( + _domain_urn_schema( + "compute", + "DOE IRI compute-domain URN, e.g. a container capability discovery " + "value (container-runtime, container-image-format, " + "container-acquisition, etc.) or an existing system-capability, " + "cpu-architecture, or gpu-programming-interface value.", + [ + "urn:doe-iri:compute:container-runtime:apptainer", + "urn:doe-iri:compute:container-acquisition:pre-stage-required", + ], + ) + ), +] From cda976c5c0c92ad1b73de2fad9a7945fc79c7306 Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Thu, 17 Sep 2026 16:49:11 -0500 Subject: [PATCH 3/6] Fix logging. also log it into files --- app/apilogger.py | 13 +++++++++++++ app/main.py | 1 + 2 files changed, 14 insertions(+) diff --git a/app/apilogger.py b/app/apilogger.py index 344bd65c..d729aa8d 100644 --- a/app/apilogger.py +++ b/app/apilogger.py @@ -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. @@ -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): diff --git a/app/main.py b/app/main.py index 868a3cbb..3c35e07a 100644 --- a/app/main.py +++ b/app/main.py @@ -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() From 0249ef4f21b7ee18e4ea7e8e130150851685da7f Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Thu, 17 Sep 2026 17:32:01 -0500 Subject: [PATCH 4/6] Advertise job-lifecycle and filesystem operation-affordance HAL links on resources --- app/routers/status/models.py | 24 +++- app/types/hal.py | 11 +- test/test_urn_types.py | 234 ----------------------------------- 3 files changed, 32 insertions(+), 237 deletions(-) delete mode 100644 test/test_urn_types.py diff --git a/app/routers/status/models.py b/app/routers/status/models.py index 1ff5de3f..7885a945 100644 --- a/app/routers/status/models.py +++ b/app/routers/status/models.py @@ -19,6 +19,14 @@ LOGGER = get_stream_logger(__name__) +# RFC: Migrating Resource.supported_endpoints to HAL Operation Affordances, +# Relation short name -> filesystem router path segment (RFC Section 3.2). +_FILESYSTEM_OPERATIONS = ( + "chmod", "chown", "file", "stat", "mkdir", "symlink", "ls", "head", + "view", "tail", "checksum", "rm", "compress", "extract", "mv", "cp", + "download", +) + class Status(enum.Enum): """Represents the status of a resource.""" @@ -99,8 +107,22 @@ def _extra_links(self) -> dict: links["iri:has-capability"] = [ build_hal_link(uri, profile=PROFILE_ACCOUNT_CAPABILITY) for uri in self.capability_uris ] + advertises_operations = False if urn_has_complete_prefix("urn:doe-iri:resource:compute:system", self.resource_type) and "compute" in self.supported_endpoints: - links["iri:submit-job"] = build_hal_link(f"{get_url_prefix()}/compute/job/{self.id}", media_type=None) + compute_base = f"{get_url_prefix()}/compute" + links["iri:submit-job"] = build_hal_link(f"{compute_base}/job/{self.id}", media_type=None) + links["iri:update-job"] = build_hal_link(f"{compute_base}/job/{self.id}/{{job_id}}", media_type=None, templated=True) + links["iri:query-job"] = build_hal_link(f"{compute_base}/status/{self.id}/{{job_id}}", media_type=None, templated=True) + links["iri:list-jobs"] = build_hal_link(f"{compute_base}/status/{self.id}", media_type=None) + links["iri:cancel-job"] = build_hal_link(f"{compute_base}/cancel/{self.id}/{{job_id}}", media_type=None, templated=True) + advertises_operations = True + if "filesystem" in self.supported_endpoints: + fs_base = f"{get_url_prefix()}/filesystem" + for op in _FILESYSTEM_OPERATIONS: + links[f"iri:{op}"] = build_hal_link(f"{fs_base}/{op}/{self.id}", media_type=None) + links["iri:upload"] = build_hal_link(f"{fs_base}/upload/{self.id}{{?path}}", media_type=None, templated=True) + advertises_operations = True + if advertises_operations: links["service-desc"] = build_hal_link(f"{get_url_prefix()}/openapi.json", media_type=SERVICE_DESC_MEDIA_TYPE) for relation, target_ids in self.related_resource_ids.items(): if not target_ids: diff --git a/app/types/hal.py b/app/types/hal.py index 878547e2..64efe65c 100644 --- a/app/types/hal.py +++ b/app/types/hal.py @@ -72,13 +72,20 @@ def resource_type_profile(resource_type: str) -> str | None: SERVICE_DESC_MEDIA_TYPE = "application/vnd.oai.openapi+json" -def build_hal_link(href: str, profile: str | None = None, media_type: str | None = "application/hal+json") -> dict: +def build_hal_link( + href: str, + profile: str | None = None, + media_type: str | None = "application/hal+json", + templated: bool = False, +) -> dict: """Build one HAL link object, omitting unset optional fields.""" - link: dict[str, str] = {"href": href} + link: dict[str, str | bool] = {"href": href} if media_type is not None: link["type"] = media_type if profile is not None: link["profile"] = profile + if templated: + link["templated"] = True return link diff --git a/test/test_urn_types.py b/test/test_urn_types.py deleted file mode 100644 index 99b0727e..00000000 --- a/test/test_urn_types.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""DOE IRI URN type regression tests.""" - -import unittest - -from pydantic import TypeAdapter, ValidationError - -from app.routers.filesystem import models as filesystem_models -from app.routers.status import models as status_models -from app.types.scalars import ( - AllocationUnit, - AllocationUnitValue, - CompressionType, - CompressionTypeValue, - ResourceType, - ResourceTypeValue, - validate_doe_iri_urn, - urn_has_complete_prefix, -) - - -def _resource(**kw): - defaults = dict( - id="r-1", - site_id="site-1", - capability_ids=[], - name="R", - description="desc", - last_modified="2026-05-12T12:00:00Z", - current_status=status_models.Status.up, - ) - return status_models.Resource(**(defaults | kw)) - - -class EnumBehaviorTests(unittest.TestCase): - """str(Enum) contracts: value equality, membership, iteration.""" - - def test_resource_type_is_str(self): - self.assertIsInstance(ResourceType.compute, str) - self.assertEqual(ResourceType.compute, "urn:doe-iri:resource:compute") - - def test_allocation_unit_is_str(self): - self.assertIsInstance(AllocationUnit.node_hours, str) - self.assertEqual(AllocationUnit.node_hours, "urn:doe-iri:allocation:compute:node-hours") - - def test_compression_type_is_str(self): - self.assertIsInstance(CompressionType.gzip, str) - self.assertEqual(CompressionType.gzip, "urn:doe-iri:compression:gzip") - - def test_enum_lookup_by_value(self): - self.assertIs(ResourceType("urn:doe-iri:resource:storage"), ResourceType.storage) - self.assertIs(AllocationUnit("urn:doe-iri:allocation:storage:inodes"), AllocationUnit.inodes) - self.assertIs(CompressionType("urn:doe-iri:compression:bzip2"), CompressionType.bzip2) - - def test_resource_type_members(self): - urns = {m.value for m in ResourceType} - self.assertIn("urn:doe-iri:resource:compute", urns) - self.assertIn("urn:doe-iri:resource:storage", urns) - self.assertIn("urn:doe-iri:service:generic", urns) - - def test_service_lives_in_service_domain(self): - """spec §3.1: legacy 'service' enum → urn:doe-iri:service:generic.""" - self.assertEqual(ResourceType.service, "urn:doe-iri:service:generic") - self.assertTrue(ResourceType.service.startswith("urn:doe-iri:service:")) - - def test_website_lives_in_service_domain(self): - """spec §3.1: legacy 'website' enum → urn:doe-iri:service:website.""" - self.assertEqual(ResourceType.website, "urn:doe-iri:service:website") - self.assertTrue(ResourceType.website.startswith("urn:doe-iri:service:")) - - def test_all_allocation_units_in_allocation_domain(self): - for member in AllocationUnit: - self.assertTrue(member.value.startswith("urn:doe-iri:allocation:"), member.value) - - def test_all_compression_types_in_compression_domain(self): - for member in CompressionType: - self.assertTrue(member.value.startswith("urn:doe-iri:compression:"), member.value) - - -class UrnValidatorTests(unittest.TestCase): - """validate_doe_iri_urn and urn_has_complete_prefix.""" - - def test_valid_urn_passes(self): - self.assertEqual(validate_doe_iri_urn("urn:doe-iri:resource:compute"), "urn:doe-iri:resource:compute") - - def test_domain_specific_string_allows_rfc8141_slash(self): - self.assertEqual( - validate_doe_iri_urn("urn:doe-iri:resource:facility-code/scanner"), - "urn:doe-iri:resource:facility-code/scanner", - ) - - def test_empty_hierarchy_segments_rejected(self): - for bad in [ - "urn:doe-iri:resource::xrootd", - "urn:doe-iri:resource:storage::xrootd", - "urn:doe-iri:resource:storage:", - "urn:doe-iri:resource::", - ]: - with self.subTest(value=bad): - with self.assertRaises(ValueError): - validate_doe_iri_urn(bad) - - def test_prefix_matching_requires_complete_segments(self): - self.assertFalse( - urn_has_complete_prefix( - "urn:doe-iri:resource:stor", - "urn:doe-iri:resource:storage:filesystem:scratch", - ) - ) - - def test_prefix_matching_exact(self): - self.assertTrue( - urn_has_complete_prefix( - "urn:doe-iri:resource:storage", - "urn:doe-iri:resource:storage", - ) - ) - - def test_prefix_matching_parent(self): - self.assertTrue( - urn_has_complete_prefix( - "urn:doe-iri:resource:storage", - "urn:doe-iri:resource:storage:filesystem:scratch", - ) - ) - - -class ResourceTypeFieldTests(unittest.TestCase): - """ResourceTypeValue: open to any valid DOE IRI URN.""" - - def test_canonical_enum_value_accepted(self): - r = _resource(resource_type=ResourceType.compute) - self.assertEqual(r.resource_type, ResourceType.compute) - - def test_raw_canonical_string_accepted(self): - r = _resource(resource_type="urn:doe-iri:resource:compute") - self.assertEqual(r.resource_type, "urn:doe-iri:resource:compute") - - def test_service_urn_accepted_despite_service_domain(self): - """ResourceTypeValue must accept service domain URNs (spec §3.1 maps service → urn:doe-iri:service:generic).""" - r = _resource(resource_type=ResourceType.service) - self.assertEqual(r.resource_type, "urn:doe-iri:service:generic") - - def test_facility_local_extension_accepted(self): - r = _resource(resource_type="urn:doe-iri:resource:xrootd") - self.assertEqual(r.resource_type, "urn:doe-iri:resource:xrootd") - - def test_short_token_rejected(self): - """Legacy short tokens are no longer accepted.""" - with self.assertRaises(Exception): - _resource(resource_type="compute") - - def test_garbage_rejected(self): - with self.assertRaises(Exception): - _resource(resource_type="not-a-urn") - - def test_prefix_find_matches_subtype(self): - parent = _resource(resource_type="urn:doe-iri:resource:storage:filesystem:scratch") - matches = status_models.Resource.find([parent], resource_type=ResourceType.storage) - self.assertEqual([r.id for r in matches], ["r-1"]) - - def test_prefix_find_unregistered_subtype(self): - r = _resource(resource_type="urn:doe-iri:resource:storage:xrootd") - matches = status_models.Resource.find([r], resource_type=ResourceType.storage) - self.assertEqual([i.id for i in matches], ["r-1"]) - - -class AllocationUnitFieldTests(unittest.TestCase): - """AllocationUnitValue: allocation domain enforced.""" - - def test_canonical_value_accepted(self): - ta = TypeAdapter(AllocationUnitValue) - self.assertEqual(ta.validate_python(AllocationUnit.node_hours), AllocationUnit.node_hours) - - def test_wrong_domain_rejected(self): - ta = TypeAdapter(AllocationUnitValue) - with self.assertRaises((ValueError, ValidationError)): - ta.validate_python(ResourceType.storage) - - def test_short_token_rejected(self): - ta = TypeAdapter(AllocationUnitValue) - with self.assertRaises((ValueError, ValidationError)): - ta.validate_python("node-hours") - - -class CompressionTypeFieldTests(unittest.TestCase): - """CompressionTypeValue: compression domain enforced.""" - - def test_canonical_value_accepted(self): - req = filesystem_models.PostCompressRequest( - path="/tmp/src", - target_path="/tmp/out.tar.gz", - compression=CompressionType.gzip, - ) - self.assertEqual(req.compression, CompressionType.gzip) - - def test_wrong_domain_rejected(self): - with self.assertRaises(Exception): - filesystem_models.PostExtractRequest( - path="/tmp/archive.tar", - target_path="/tmp/out", - compression=ResourceType.storage, - ) - - def test_short_token_rejected(self): - with self.assertRaises(Exception): - filesystem_models.PostCompressRequest( - path="/tmp/src", - target_path="/tmp/out.tar.gz", - compression="gzip", - ) - - -class OpenApiSchemaTests(unittest.TestCase): - """JSON schema hints are emitted correctly.""" - - def test_resource_type_schema(self): - schema = TypeAdapter(ResourceTypeValue).json_schema() - self.assertEqual(schema["type"], "string") - self.assertIn("doe-iri", schema["description"]) - - def test_allocation_unit_schema_has_domain_pattern(self): - schema = TypeAdapter(AllocationUnitValue).json_schema() - self.assertIn("allocation", schema["pattern"]) - self.assertEqual(schema["minLength"], len("urn:doe-iri:allocation:") + 1) - - def test_compression_type_schema_has_domain_pattern(self): - schema = TypeAdapter(CompressionTypeValue).json_schema() - self.assertIn("compression", schema["pattern"]) - self.assertRegex(CompressionType.gzip, schema["pattern"]) - - -if __name__ == "__main__": - unittest.main() From d277c3e7fad9fde493a27edb0e02afa1a878542a Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Mon, 21 Sep 2026 06:38:42 -0500 Subject: [PATCH 5/6] Fix operation-affordance relation names to match the approved registry --- app/routers/compute/compute.py | 4 +-- app/routers/filesystem/filesystem.py | 36 ++++++++++----------- app/routers/status/models.py | 36 +++++++++++++++------ app/routers/storage/storage.py | 5 +-- app/types/hal.py | 48 ++++++++++++++++------------ 5 files changed, 76 insertions(+), 53 deletions(-) diff --git a/app/routers/compute/compute.py b/app/routers/compute/compute.py index dfbe899c..3cda3c8c 100644 --- a/app/routers/compute/compute.py +++ b/app/routers/compute/compute.py @@ -118,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", relations=[OPERATION_RELATIONS["query-job"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["get-job"]]) ) async def get_job_status( resource_id: str, @@ -145,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", relations=[OPERATION_RELATIONS["list-jobs"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["query-jobs"]]) ) async def get_job_statuses( resource_id: str, diff --git a/app/routers/filesystem/filesystem.py b/app/routers/filesystem/filesystem.py index 6ea2e10c..6b97fd71 100644 --- a/app/routers/filesystem/filesystem.py +++ b/app/routers/filesystem/filesystem.py @@ -59,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", relations=[OPERATION_RELATIONS["chmod"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["change-file-mode"]]) ) async def post_chmod( resource_id: str, @@ -87,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", relations=[OPERATION_RELATIONS["chown"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["change-file-owner"]]) ) async def post_chown( resource_id: str, @@ -115,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", relations=[OPERATION_RELATIONS["file"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["identify-file"]]) ) async def post_file( resource_id: str, @@ -143,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", relations=[OPERATION_RELATIONS["stat"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["stat-file"]]) ) async def post_stat( resource_id: str, @@ -171,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", relations=[OPERATION_RELATIONS["mkdir"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["create-directory"]]) ) async def post_mkdir( resource_id: str, @@ -199,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", relations=[OPERATION_RELATIONS["symlink"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["create-symlink"]]) ) async def post_symlink( resource_id: str, @@ -228,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", relations=[OPERATION_RELATIONS["ls"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["list-directory"]]) ) async def post_ls( resource_id: str, @@ -262,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", relations=[OPERATION_RELATIONS["head"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["read-file-head"]]) ) async def post_head( resource_id: str, @@ -297,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", relations=[OPERATION_RELATIONS["view"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["view-file"]]) ) async def post_view( resource_id: str, @@ -327,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", relations=[OPERATION_RELATIONS["tail"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["read-file-tail"]]) ) async def post_tail( resource_id: str, @@ -362,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", relations=[OPERATION_RELATIONS["checksum"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["checksum-file"]]) ) async def post_checksum( resource_id: str, @@ -390,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", relations=[OPERATION_RELATIONS["rm"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["remove-path"]]) ) async def post_rm( resource_id: str, @@ -418,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", relations=[OPERATION_RELATIONS["compress"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["compress-paths"]]) ) async def post_compress( resource_id: str, @@ -446,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", relations=[OPERATION_RELATIONS["extract"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["extract-archive"]]) ) async def post_extract( resource_id: str, @@ -474,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", relations=[OPERATION_RELATIONS["mv"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["move-path"]]) ) async def post_mv( resource_id: str, @@ -502,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", relations=[OPERATION_RELATIONS["cp"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["copy-path"]]) ) async def post_cp( resource_id: str, @@ -530,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", relations=[OPERATION_RELATIONS["download"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["download-file"]]) ) async def post_download( resource_id: str, @@ -558,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", relations=[OPERATION_RELATIONS["upload"]]) + openapi_extra=iri_meta_dict("production", "required", relations=[OPERATION_RELATIONS["upload-file"]]) ) async def post_upload( resource_id: str, diff --git a/app/routers/status/models.py b/app/routers/status/models.py index 7885a945..d86cd312 100644 --- a/app/routers/status/models.py +++ b/app/routers/status/models.py @@ -19,12 +19,28 @@ LOGGER = get_stream_logger(__name__) -# RFC: Migrating Resource.supported_endpoints to HAL Operation Affordances, -# Relation short name -> filesystem router path segment (RFC Section 3.2). +# RFC: Migrating Resource.supported_endpoints to HAL Operation Affordances (Section 3.2). +# (filesystem router path segment, registered relation name) -- see registry/relations/README.md. +# The path segment is the real OpenAPI path/operationId spelling (unchanged); the relation +# name is the registered `iri:*` name, which is not always the same string. _FILESYSTEM_OPERATIONS = ( - "chmod", "chown", "file", "stat", "mkdir", "symlink", "ls", "head", - "view", "tail", "checksum", "rm", "compress", "extract", "mv", "cp", - "download", + ("chmod", "change-file-mode"), + ("chown", "change-file-owner"), + ("file", "identify-file"), + ("stat", "stat-file"), + ("mkdir", "create-directory"), + ("symlink", "create-symlink"), + ("ls", "list-directory"), + ("head", "read-file-head"), + ("view", "view-file"), + ("tail", "read-file-tail"), + ("checksum", "checksum-file"), + ("rm", "remove-path"), + ("compress", "compress-paths"), + ("extract", "extract-archive"), + ("mv", "move-path"), + ("cp", "copy-path"), + ("download", "download-file"), ) @@ -112,15 +128,15 @@ def _extra_links(self) -> dict: compute_base = f"{get_url_prefix()}/compute" links["iri:submit-job"] = build_hal_link(f"{compute_base}/job/{self.id}", media_type=None) links["iri:update-job"] = build_hal_link(f"{compute_base}/job/{self.id}/{{job_id}}", media_type=None, templated=True) - links["iri:query-job"] = build_hal_link(f"{compute_base}/status/{self.id}/{{job_id}}", media_type=None, templated=True) - links["iri:list-jobs"] = build_hal_link(f"{compute_base}/status/{self.id}", media_type=None) + links["iri:get-job"] = build_hal_link(f"{compute_base}/status/{self.id}/{{job_id}}", media_type=None, templated=True) + links["iri:query-jobs"] = build_hal_link(f"{compute_base}/status/{self.id}", media_type=None) links["iri:cancel-job"] = build_hal_link(f"{compute_base}/cancel/{self.id}/{{job_id}}", media_type=None, templated=True) advertises_operations = True if "filesystem" in self.supported_endpoints: fs_base = f"{get_url_prefix()}/filesystem" - for op in _FILESYSTEM_OPERATIONS: - links[f"iri:{op}"] = build_hal_link(f"{fs_base}/{op}/{self.id}", media_type=None) - links["iri:upload"] = build_hal_link(f"{fs_base}/upload/{self.id}{{?path}}", media_type=None, templated=True) + for op, relation in _FILESYSTEM_OPERATIONS: + links[f"iri:{relation}"] = build_hal_link(f"{fs_base}/{op}/{self.id}", media_type=None) + links["iri:upload-file"] = build_hal_link(f"{fs_base}/upload/{self.id}{{?path}}", media_type=None, templated=True) advertises_operations = True if advertises_operations: links["service-desc"] = build_hal_link(f"{get_url_prefix()}/openapi.json", media_type=SERVICE_DESC_MEDIA_TYPE) diff --git a/app/routers/storage/storage.py b/app/routers/storage/storage.py index f726c8cf..7da7f254 100644 --- a/app/routers/storage/storage.py +++ b/app/routers/storage/storage.py @@ -2,6 +2,7 @@ from fastapi import Depends, HTTPException, Query, Request, status as http_status +from ...types.hal import OPERATION_RELATIONS from ...types.http import forbidExtraQueryParams from ...types.user import User from .. import iri_router @@ -36,7 +37,7 @@ response_model=list[models.StorageInstance], responses=DEFAULT_RESPONSES, operation_id="getStorageLocations", - openapi_extra=iri_meta_dict("in_development", "optional"), + openapi_extra=iri_meta_dict("in_development", "optional", relations=[OPERATION_RELATIONS["resolve-storage-locations"]]), ) async def get_locations( resource_id: str, @@ -86,7 +87,7 @@ async def get_locations( response_model_exclude_none=True, responses=DEFAULT_RESPONSES, operation_id="getStorageAccessEndpoints", - openapi_extra=iri_meta_dict("in_development", "required"), + openapi_extra=iri_meta_dict("in_development", "required", relations=[OPERATION_RELATIONS["get-storage-access-endpoints"]]), ) async def get_access_endpoints( resource_id: str, diff --git a/app/types/hal.py b/app/types/hal.py index 64efe65c..c01d7c24 100644 --- a/app/types/hal.py +++ b/app/types/hal.py @@ -40,33 +40,39 @@ def resource_type_profile(resource_type: str) -> str | None: return RESOURCE_TYPE_PROFILE.get(resource_type) -# Canonical relation URIs for registered DOE-IRI operation-affordance relations (ADR 0007). +# Canonical relation URIs for registered DOE-IRI operation-affordance relations +# (rfc-resource-operation-affordances.md). Keys are the exact relation names from +# registry/relations/README.md -- NOT OpenAPI operationIds or router path segments, +# which for the 18 filesystem operations are shorter legacy strings (see +# status/models.py `_FILESYSTEM_OPERATIONS` for that path-segment -> relation mapping). # Bind one of these to an Operation Object via iri_meta.iri_meta_dict(relations=[...]) so # clients can resolve a HAL relation to its Operation Object without depending on operationId. OPERATION_RELATIONS: dict[str, str] = { "submit-job": IRI_RELS_TEMPLATE.format(rel="submit-job"), - "query-job": IRI_RELS_TEMPLATE.format(rel="query-job"), - "list-jobs": IRI_RELS_TEMPLATE.format(rel="list-jobs"), "update-job": IRI_RELS_TEMPLATE.format(rel="update-job"), + "get-job": IRI_RELS_TEMPLATE.format(rel="get-job"), + "query-jobs": IRI_RELS_TEMPLATE.format(rel="query-jobs"), "cancel-job": IRI_RELS_TEMPLATE.format(rel="cancel-job"), - "chmod": IRI_RELS_TEMPLATE.format(rel="chmod"), - "chown": IRI_RELS_TEMPLATE.format(rel="chown"), - "file": IRI_RELS_TEMPLATE.format(rel="file"), - "stat": IRI_RELS_TEMPLATE.format(rel="stat"), - "mkdir": IRI_RELS_TEMPLATE.format(rel="mkdir"), - "symlink": IRI_RELS_TEMPLATE.format(rel="symlink"), - "ls": IRI_RELS_TEMPLATE.format(rel="ls"), - "head": IRI_RELS_TEMPLATE.format(rel="head"), - "view": IRI_RELS_TEMPLATE.format(rel="view"), - "tail": IRI_RELS_TEMPLATE.format(rel="tail"), - "checksum": IRI_RELS_TEMPLATE.format(rel="checksum"), - "rm": IRI_RELS_TEMPLATE.format(rel="rm"), - "compress": IRI_RELS_TEMPLATE.format(rel="compress"), - "extract": IRI_RELS_TEMPLATE.format(rel="extract"), - "mv": IRI_RELS_TEMPLATE.format(rel="mv"), - "cp": IRI_RELS_TEMPLATE.format(rel="cp"), - "download": IRI_RELS_TEMPLATE.format(rel="download"), - "upload": IRI_RELS_TEMPLATE.format(rel="upload"), + "change-file-mode": IRI_RELS_TEMPLATE.format(rel="change-file-mode"), + "change-file-owner": IRI_RELS_TEMPLATE.format(rel="change-file-owner"), + "identify-file": IRI_RELS_TEMPLATE.format(rel="identify-file"), + "stat-file": IRI_RELS_TEMPLATE.format(rel="stat-file"), + "create-directory": IRI_RELS_TEMPLATE.format(rel="create-directory"), + "create-symlink": IRI_RELS_TEMPLATE.format(rel="create-symlink"), + "list-directory": IRI_RELS_TEMPLATE.format(rel="list-directory"), + "read-file-head": IRI_RELS_TEMPLATE.format(rel="read-file-head"), + "view-file": IRI_RELS_TEMPLATE.format(rel="view-file"), + "read-file-tail": IRI_RELS_TEMPLATE.format(rel="read-file-tail"), + "checksum-file": IRI_RELS_TEMPLATE.format(rel="checksum-file"), + "remove-path": IRI_RELS_TEMPLATE.format(rel="remove-path"), + "compress-paths": IRI_RELS_TEMPLATE.format(rel="compress-paths"), + "extract-archive": IRI_RELS_TEMPLATE.format(rel="extract-archive"), + "move-path": IRI_RELS_TEMPLATE.format(rel="move-path"), + "copy-path": IRI_RELS_TEMPLATE.format(rel="copy-path"), + "download-file": IRI_RELS_TEMPLATE.format(rel="download-file"), + "upload-file": IRI_RELS_TEMPLATE.format(rel="upload-file"), + "resolve-storage-locations": IRI_RELS_TEMPLATE.format(rel="resolve-storage-locations"), + "get-storage-access-endpoints": IRI_RELS_TEMPLATE.format(rel="get-storage-access-endpoints"), } SERVICE_DESC_MEDIA_TYPE = "application/vnd.oai.openapi+json" From bcdfcfbc8d02ac9c0cd9af45e34c5f14eac9ec1c Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Mon, 21 Sep 2026 07:21:12 -0500 Subject: [PATCH 6/6] Fix 405 Allow header to reflect the actual route, not a hardcoded value --- app/routers/error_handlers.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/routers/error_handlers.py b/app/routers/error_handlers.py index 42a10ab2..be61ef41 100644 --- a/app/routers/error_handlers.py +++ b/app/routers/error_handlers.py @@ -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 @@ -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: @@ -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( @@ -362,4 +375,4 @@ async def global_handler(request: Request, exc: Exception): "model": Problem, }, 304: {"description": "Not Modified"}, -} \ No newline at end of file +}