Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand All @@ -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",
}
Expand All @@ -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"
Expand All @@ -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}")
Expand Down
11 changes: 11 additions & 0 deletions app/context.py
Original file line number Diff line number Diff line change
@@ -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}"
59 changes: 47 additions & 12 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)))
Expand All @@ -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]}")
32 changes: 32 additions & 0 deletions app/middleware.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions app/routers/account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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}"
5 changes: 3 additions & 2 deletions app/routers/facility/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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):
Expand All @@ -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]
13 changes: 7 additions & 6 deletions app/routers/status/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion app/routers/task/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pydantic import BaseModel, Field, computed_field

from ... import config
from ...context import get_api_base_url


class TaskSubmitResponse(BaseModel):
Expand All @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion app/types/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down
Loading