From 766d1645556d7cd9101beacf314e17b00897499c Mon Sep 17 00:00:00 2001 From: Gabor Torok Date: Mon, 20 Apr 2026 14:58:46 -0700 Subject: [PATCH] test of v2 branch --- app/config.py | 17 ++++++---- app/context.py | 11 +++++++ app/main.py | 59 +++++++++++++++++++++++++++------- app/middleware.py | 32 ++++++++++++++++++ app/routers/account/models.py | 9 +++--- app/routers/facility/models.py | 5 +-- app/routers/status/models.py | 13 ++++---- app/routers/task/models.py | 3 +- app/types/base.py | 3 +- 9 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 app/context.py create mode 100644 app/middleware.py diff --git a/app/config.py b/app/config.py index 35c17c93..2accacdc 100644 --- a/app/config.py +++ b/app/config.py @@ -7,7 +7,8 @@ logger = get_stream_logger(__name__, LOG_LEVEL) -API_VERSION = "1.0.0" +API_V1_VERSION = "1.0.0" # v1 API contract version +API_V2_VERSION = "2.0.0" # v2 API contract version # lines in the description can't have indentation (markup format) description = """ @@ -23,8 +24,7 @@ API_CONFIG = { "title": "IRI Facility API reference implementation", "description": description, - "version": API_VERSION, - "docs_url": "/", + "docs_url": None, "contact": {"name": "Facility API contact", "url": "https://www.somefacility.gov/about/contact-us/"}, "terms_of_service": "https://www.somefacility.gov/terms-of-service", } @@ -38,7 +38,11 @@ API_URL_ROOT = os.environ.get("API_URL_ROOT", "https://api.iri.nersc.gov") API_PREFIX = os.environ.get("API_PREFIX", "/") -API_URL = os.environ.get("API_URL", "api/v1") +API_V1_PATH = os.environ.get("API_V1_PATH", f"{API_PREFIX}api/v1") +API_V2_PATH = os.environ.get("API_V2_PATH", f"{API_PREFIX}api/v2") + +# List of all versioned API paths for middleware detection +API_VERSIONED_PATHS = [API_V1_PATH, API_V2_PATH] OPENTELEMETRY_ENABLED = os.environ.get("OPENTELEMETRY_ENABLED", "false").lower() == "true" OPENTELEMETRY_DEBUG = os.environ.get("OPENTELEMETRY_DEBUG", "false").lower() == "true" @@ -48,11 +52,12 @@ # Print all startup config for debugging logger.info("IRI Facility API starting with config:") logger.info("="*40) -logger.info(f"API_VERSION={API_VERSION}") logger.info(f"API_CONFIG={API_CONFIG}") logger.info(f"API_URL_ROOT={API_URL_ROOT}") logger.info(f"API_PREFIX={API_PREFIX}") -logger.info(f"API_URL={API_URL}") +logger.info(f"API_V1_PATH={API_V1_PATH}") +logger.info(f"API_V2_PATH={API_V2_PATH}") +logger.info(f"API_VERSIONED_PATHS={API_VERSIONED_PATHS}") logger.info(f"LOG_LEVEL={LOG_LEVEL}") logger.info(f"OPENTELEMETRY_ENABLED={OPENTELEMETRY_ENABLED}") logger.info(f"OPENTELEMETRY_DEBUG={OPENTELEMETRY_DEBUG}") diff --git a/app/context.py b/app/context.py new file mode 100644 index 00000000..5c86c649 --- /dev/null +++ b/app/context.py @@ -0,0 +1,11 @@ +"""Request context management for API versioning.""" +from contextvars import ContextVar + +# Context variable to track the current API version path +current_api_version: ContextVar[str] = ContextVar('api_version', default='/api/v1') + + +def get_api_base_url(url_root: str) -> str: + """Get the full API base URL including version from current context.""" + api_version = current_api_version.get() + return f"{url_root}{api_version}" diff --git a/app/main.py b/app/main.py index 62147590..d02dcda1 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ import logging from fastapi import FastAPI +from fastapi.responses import RedirectResponse from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider @@ -18,6 +19,7 @@ from app.routers.compute import compute from app.routers.filesystem import filesystem from app.routers.task import task +from app.middleware import APIVersionMiddleware from . import config @@ -26,11 +28,18 @@ format="%(asctime)s %(levelname)s %(name)s: %(message)s" ) +def _make_config(version): + d = {**config.API_CONFIG} + d["version"] = version + d["title"] = f"{d['title']} - {version}" + d["docs_url"] = "/" + return d + # ------------------------------------------------------------------ # OpenTelemetry Tracing Configuration # ------------------------------------------------------------------ if config.OPENTELEMETRY_ENABLED: - resource = Resource.create({"service.name": "iri-facility-api", "service.version": config.API_VERSION, "service.endpoint": config.API_URL_ROOT}) + resource = Resource.create({"service.name": "iri-facility-api", "service.version": "1.0.0", "service.endpoint": config.API_URL_ROOT}) samplerate = "1.0" if config.OPENTELEMETRY_DEBUG else config.OTEL_SAMPLE_RATE provider = TracerProvider(resource=resource, sampler=ParentBased(TraceIdRatioBased(samplerate))) @@ -46,21 +55,47 @@ tracer = trace.get_tracer(__name__) # ------------------------------------------------------------------ +# Create main app APP = FastAPI(servers=[{"url": config.API_URL_ROOT}], **config.API_CONFIG) +# Create v1 app +app_v1 = FastAPI(**_make_config(config.API_V1_VERSION)) +app_v1.add_middleware(APIVersionMiddleware) +install_error_handlers(app_v1) +if config.OPENTELEMETRY_ENABLED: + FastAPIInstrumentor.instrument_app(app_v1) + +# Attach v1 routers +app_v1.include_router(facility.router) +app_v1.include_router(status.router) +app_v1.include_router(account.router) +app_v1.include_router(compute.router) +app_v1.include_router(filesystem.router) +app_v1.include_router(task.router) + +# Create v2 app (initially identical to v1, modify as needed for breaking changes) +app_v2 = FastAPI(**_make_config(config.API_V2_VERSION)) +app_v2.add_middleware(APIVersionMiddleware) +install_error_handlers(app_v2) if config.OPENTELEMETRY_ENABLED: - FastAPIInstrumentor.instrument_app(APP) + FastAPIInstrumentor.instrument_app(app_v2) -install_error_handlers(APP) +# Attach v2 routers (same as v1 for now) +app_v2.include_router(facility.router) +app_v2.include_router(status.router) +app_v2.include_router(account.router) +app_v2.include_router(compute.router) +app_v2.include_router(filesystem.router) +app_v2.include_router(task.router) -api_prefix = f"{config.API_PREFIX}{config.API_URL}" +# Mount versioned apps to main app +APP.mount(config.API_V1_PATH, app_v1) +APP.mount(config.API_V2_PATH, app_v2) -# Attach routers under the prefix -APP.include_router(facility.router, prefix=api_prefix) -APP.include_router(status.router, prefix=api_prefix) -APP.include_router(account.router, prefix=api_prefix) -APP.include_router(compute.router, prefix=api_prefix) -APP.include_router(filesystem.router, prefix=api_prefix) -APP.include_router(task.router, prefix=api_prefix) +logging.getLogger().info(f"API v1 mounted at: {config.API_V1_PATH}") +logging.getLogger().info(f"API v2 mounted at: {config.API_V2_PATH}") -logging.getLogger().info(f"API path: {api_prefix}") +@APP.get("/") +async def redirect_root(): + # redirect the root swagger docs to the latest version + return RedirectResponse(url=f"{config.API_VERSIONED_PATHS[-1]}") diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 00000000..aeaf6604 --- /dev/null +++ b/app/middleware.py @@ -0,0 +1,32 @@ +"""Middleware for the IRI Facility API.""" +from opentelemetry import trace +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from . import config +from .context import current_api_version + + +class APIVersionMiddleware(BaseHTTPMiddleware): + """Middleware to extract and set the API version context from request path.""" + + async def dispatch(self, request: Request, call_next): + # Extract API version from path by matching against configured versioned paths + request_path = request.url.path + api_version = None + + for versioned_path in config.API_VERSIONED_PATHS: + if request_path.startswith(versioned_path): + api_version = versioned_path + current_api_version.set(versioned_path) + break + + # Add API version to OpenTelemetry span + if api_version and config.OPENTELEMETRY_ENABLED: + span = trace.get_current_span() + if span.is_recording(): + span.set_attribute("api.version", api_version) + + response: Response = await call_next(request) + return response diff --git a/app/routers/account/models.py b/app/routers/account/models.py index 0c6beba2..75e65c58 100644 --- a/app/routers/account/models.py +++ b/app/routers/account/models.py @@ -3,6 +3,7 @@ from pydantic import Field, computed_field, field_validator from ... import config +from ...context import get_api_base_url from ...types.base import IRIBaseModel from ...types.scalars import AllocationUnit @@ -26,7 +27,7 @@ def _norm_dt_field(cls, v): @property def self_uri(self) -> str: """Return the URI for this project resource.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/projects/{self.id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/account/projects/{self.id}" class AllocationEntry(IRIBaseModel): @@ -54,13 +55,13 @@ class ProjectAllocation(IRIBaseModel): @property def project_uri(self) -> str: """Return the URI for the associated project resource.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/projects/{self.project_id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/account/projects/{self.project_id}" @computed_field(description="URI of the associated capability resource") @property def capability_uri(self) -> str: """Return the URI for the associated capability.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/capabilities/{self.capability_id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/account/capabilities/{self.capability_id}" class UserAllocation(IRIBaseModel): @@ -79,4 +80,4 @@ class UserAllocation(IRIBaseModel): @property def project_allocation_uri(self) -> str: """Return the URI for the associated project allocation.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/projects/{self.project_id}/project_allocations/{self.project_allocation_id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/account/projects/{self.project_id}/project_allocations/{self.project_allocation_id}" diff --git a/app/routers/facility/models.py b/app/routers/facility/models.py index 5d306751..2e0c19f4 100644 --- a/app/routers/facility/models.py +++ b/app/routers/facility/models.py @@ -2,6 +2,7 @@ from pydantic import Field, HttpUrl, computed_field from ... import config +from ...context import get_api_base_url from ...types.base import NamedObject @@ -26,7 +27,7 @@ def _self_path(self) -> str: @property def resource_uris(self) -> list[str]: """Return the list of resource URIs for this site.""" - return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/resources/{resource_id}" for resource_id in self.resource_ids] + return [f"{get_api_base_url(config.API_URL_ROOT)}/status/resources/{resource_id}" for resource_id in self.resource_ids] @classmethod def find(cls, items, name=None, description=None, modified_since=None, short_name=None, country_name=None): @@ -53,4 +54,4 @@ def _self_path(self) -> str: @property def site_uris(self) -> list[str]: """Return the list of site URIs for this facility.""" - return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/facility/sites/{site_id}" for site_id in self.site_ids] + return [f"{get_api_base_url(config.API_URL_ROOT)}/facility/sites/{site_id}" for site_id in self.site_ids] diff --git a/app/routers/status/models.py b/app/routers/status/models.py index 357db3a6..ca11371c 100644 --- a/app/routers/status/models.py +++ b/app/routers/status/models.py @@ -5,6 +5,7 @@ from pydantic import Field, computed_field, field_validator from ... import config +from ...context import get_api_base_url from ...types.base import NamedObject @@ -43,13 +44,13 @@ def _self_path(self) -> str: @property def site_uri(self) -> str: """Return the site URI for this resource.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/facility/sites/{self.site_id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/facility/sites/{self.site_id}" @computed_field(description="The list of capabilities in this resource") @property def capability_uris(self) -> list[str]: """Return the list of capability URIs for this resource.""" - return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/capabilities/{e}" for e in self.capability_ids] + return [f"{get_api_base_url(config.API_URL_ROOT)}/account/capabilities/{e}" for e in self.capability_ids] @classmethod def find(cls, items, name=None, description=None, modified_since=None, group=None, resource_type=None, current_status=None, capability=None, site_id=None) -> list: @@ -89,13 +90,13 @@ def _norm_dt_field(cls, v): @property def resource_uri(self) -> str: """Return the resource URI for this event.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/resources/{self.resource_id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/status/resources/{self.resource_id}" @computed_field(description="The event's incident") @property def incident_uri(self) -> str | None: """Return the incident URI for this event.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/incidents/{self.incident_id}" if self.incident_id else None + return f"{get_api_base_url(config.API_URL_ROOT)}/status/incidents/{self.incident_id}" if self.incident_id else None @classmethod def find(cls, items, incident_id=None, name=None, description=None, modified_since=None, resource_id=None, status=None, from_=None, to=None, time_=None) -> list: @@ -162,13 +163,13 @@ def _norm_dt_field(cls, v): @property def event_uris(self) -> list[str]: """Return the list of event URIs for this incident.""" - return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/events/{e}" for e in self.event_ids] + return [f"{get_api_base_url(config.API_URL_ROOT)}/status/events/{e}" for e in self.event_ids] @computed_field(description="The list of resources that may be impacted by this incident") @property def resource_uris(self) -> list[str]: """Return the list of resource URIs for this incident.""" - return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/resources/{r}" for r in self.resource_ids] + return [f"{get_api_base_url(config.API_URL_ROOT)}/status/resources/{r}" for r in self.resource_ids] @classmethod def find(cls, items, name=None, description=None, modified_since=None, status=None, type_=None, from_=None, to=None, time_=None, resource_id=None, resolution=None) -> list: diff --git a/app/routers/task/models.py b/app/routers/task/models.py index 7a13ac0e..b9ecb57f 100644 --- a/app/routers/task/models.py +++ b/app/routers/task/models.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field, computed_field from ... import config +from ...context import get_api_base_url class TaskSubmitResponse(BaseModel): @@ -13,7 +14,7 @@ class TaskSubmitResponse(BaseModel): @property def task_uri(self) -> str: """Return the URI for this task.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/task/{self.task_id}" + return f"{get_api_base_url(config.API_URL_ROOT)}/task/{self.task_id}" class TaskStatus(str, enum.Enum): diff --git a/app/types/base.py b/app/types/base.py index e25734a3..a93a96ea 100644 --- a/app/types/base.py +++ b/app/types/base.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_serializer from .. import config +from ..context import get_api_base_url from .scalars import StrictDateTime @@ -59,7 +60,7 @@ def _norm_dt_field(cls, v): @property def self_uri(self) -> str: """Computed self URI property.""" - return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}{self._self_path()}" + return f"{get_api_base_url(config.API_URL_ROOT)}{self._self_path()}" name: str|None = Field(default=None, description="The long name of the object.", example="Perlmutter GPU") description: str|None = Field(default=None, description="Human-readable description of the object.", example="High-performance GPU compute resource")