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() diff --git a/app/routers/compute/compute.py b/app/routers/compute/compute.py index aa42825f..3cda3c8c 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["get-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["query-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/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 +} diff --git a/app/routers/filesystem/filesystem.py b/app/routers/filesystem/filesystem.py index 6c8d773f..6b97fd71 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["change-file-mode"]]) ) 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["change-file-owner"]]) ) 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["identify-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-file"]]) ) 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["create-directory"]]) ) 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["create-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["list-directory"]]) ) 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["read-file-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-file"]]) ) 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["read-file-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-file"]]) ) 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["remove-path"]]) ) 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-paths"]]) ) 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-archive"]]) ) 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["move-path"]]) ) 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["copy-path"]]) ) 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-file"]]) ) 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-file"]]) ) 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..d86cd312 100644 --- a/app/routers/status/models.py +++ b/app/routers/status/models.py @@ -7,11 +7,42 @@ 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__) +# 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", "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"), +) + class Status(enum.Enum): """Represents the status of a resource.""" @@ -92,8 +123,23 @@ 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"] = {"href": f"{get_url_prefix()}/compute/job/{self.id}"} + 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: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, 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) for relation, target_ids in self.related_resource_ids.items(): if not target_ids: continue 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 10731d21..c01d7c24 100644 --- a/app/types/hal.py +++ b/app/types/hal.py @@ -40,13 +40,58 @@ def resource_type_profile(resource_type: str) -> str | None: return RESOURCE_TYPE_PROFILE.get(resource_type) -def build_hal_link(href: str, profile: str | None = None, media_type: str | None = "application/hal+json") -> dict: +# 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"), + "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"), + "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" + + +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/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", + ], + ) + ), +] 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()