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
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ VENV := .venv
BIN := $(VENV)/bin
UV := uv
PIP := $(BIN)/pip
LOG_FILE := runtime-logs.log
IRI_LOG_FILE ?= $(LOG_FILE)

STAMP_VENV := $(VENV)/.created
STAMP_DEPS := $(VENV)/.deps
Expand Down Expand Up @@ -34,6 +36,7 @@ dev: deps
IRI_API_ADAPTER_compute=app.demo_adapter.DemoAdapter \
IRI_API_ADAPTER_filesystem=app.demo_adapter.DemoAdapter \
IRI_API_ADAPTER_task=app.demo_adapter.DemoAdapter \
IRI_LOG_FILE="$${IRI_LOG_FILE:-$${LOG_FILE:-$(IRI_LOG_FILE)}}" \
DEMO_QUEUE_UPDATE_SECS=2 \
OPENTELEMETRY_ENABLED=true \
API_URL_ROOT='http://localhost:8000' fastapi dev
Expand Down Expand Up @@ -78,4 +81,3 @@ ARGS ?=
# call it via: make manage-globus ARGS=scopes-show
manage-globus: deps
@source local.env && $(BIN)/python ./tools/manage_globus.py $(ARGS)

18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,18 @@ On Windows, see the [Makefile](Makefile) and run the commands manually.
The reference implementation is meant to be customized for your facility's IRI implementation. Running the IRI api unmodified will show only fake, test data. The paragraphs below describe how to customize the business logic and appearance of the API for your facility.

### Customizing the business logic for your facility
The IRI API handles the "boilerplate" of setting up the rest API. It delegates to the per-facility business logic via interface definitions. These interfaces are implemented as abstract classes, one per api group (status, account, etc.). Each router directory defines a FacilityAdapter class (eg. [the status adapter](app/routers/status/facility_adapter.py)) that is expected to be implemented by the facility who is exposing an IRI API instance.
The IRI API handles the "boilerplate" of setting up the rest API. It delegates to the per-facility business logic via interface definitions. These interfaces are implemented as abstract classes, one per api group (status, account, etc.). Each versioned router directory defines a FacilityAdapter class (eg. [the v1 status adapter](app/routers/v1/status/facility_adapter.py)) that is expected to be implemented by the facility who is exposing an IRI API instance.

The specific implementations can be specified via the `IRI_API_ADAPTER_*` environment variables. For example the adapter for the `status` api would be given by setting `IRI_API_ADAPTER_status` to the full python module and class implementing `app.routers.status.facility_adapter.FacilityAdapter`. (eg. `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter`)
The specific implementations can be specified via the `IRI_API_ADAPTER_*` environment variables. For example the adapter for the `status` api would be given by setting `IRI_API_ADAPTER_status` to the full python module and class implementing `app.routers.v1.status.facility_adapter.FacilityAdapter`. (eg. `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter`)

As a default implementation, this project supplies the [demo adapter](app/demo_adapter.py) which implements every facility adapter with fake data.

### Versioned router layout

Versioned API route groups live under `app/routers/v1`, `app/routers/v2`, etc. Shared router infrastructure, such as error handling, metadata helpers, and adapter loading, stays directly under `app/routers`.

The current V1 implementation is the baseline surface. Future versions should add only new or changed route groups under their version folder. For example, a future `app/routers/v2/compute` can add V2 compute routes without copying every V1 route group. At startup, the API composes all available route groups up through the version named by `API_URL`; for example, `API_URL=api/v2` loads V1 routes plus any V2 route modules that exist, all under the configured `/api/v2` prefix.

### Customizing the API meta-data
You can optionally override the [FastAPI metadata](https://fastapi.tiangolo.com/tutorial/metadata/), such as `name`, `description`, `terms_of_service`, etc. by providing a valid json object in the `IRI_API_PARAMS` environment variable.

Expand All @@ -57,8 +63,13 @@ If using docker (see next section), your dockerfile could extend this reference
Links to data, created by this api, will concatenate these values producing links, eg: `https://iri.myfacility.com/my_api_prefix/my_api_url/projects/123`

- `IRI_API_PARAMS`: as described above, this is a way to customize the API meta-data
- `IRI_API_ADAPTER_*`: these values specify the business logic for the per-api-group implementation of a facility_adapter. For example: `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter` would load the implementation of the `app.routers.status.facility_adapter.FacilityAdapter` abstract class to handle the `status` business logic for your facility.
- `IRI_API_ADAPTER_*`: these values specify the business logic for the per-api-group implementation of a facility_adapter. For example: `IRI_API_ADAPTER_status=myfacility.MyFacilityStatusAdapter` would load the implementation of the `app.routers.v1.status.facility_adapter.FacilityAdapter` abstract class to handle the `status` business logic for your facility.
- `IRI_SHOW_MISSING_ROUTES`: hide api groups that don't have an `IRI_API_ADAPTER_*` environment variable defined, if set to `true`. This way if your facility only wishes to expose some api groups but not others, they can be hidden. (Defaults to `false`.)
- `LOG_LEVEL`: logging level for the API and adapters. Defaults to `DEBUG`.
- `IRI_LOG_FILE`: file path for API logs. Logs always go to stdout; when this is set, logs also go to the file.
- `LOG_FILE`: fallback file path for API logs when `IRI_LOG_FILE` is not set.

For local development, `make` writes logs to `runtime-logs.log` by default. Use `make LOG_FILE=/tmp/iri-api.log` or `make IRI_LOG_FILE=/tmp/iri-api.log` to choose a different file. You can also put either variable in `local.env`.

## Docker support

Expand Down Expand Up @@ -142,4 +153,3 @@ You can optionally use globus for authorization. Steps to use globus:
- Specify the monitoring endpoint by setting the [OpenTelemetry](https://opentelemetry.io/docs/zero-code/python/) env vars
- Add additional routers for other API-s
- Add authenticated API-s via an [OAuth2 integration](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/)

85 changes: 74 additions & 11 deletions app/apilogger.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,91 @@
"""Logging utilities for the IRI Facility API."""

import logging
import os
import sys
from pathlib import Path

LEVELS = {"FATAL": logging.FATAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG}

DEFAULT_FORMAT = "%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s"
DEFAULT_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S"
IRI_HANDLER_ATTR = "_iri_facility_api_handler"

def get_stream_logger(name: str = __name__, level: str = "DEBUG") -> logging.Logger:
_CONFIGURED = False


def _level(level: str | int | None) -> int:
if isinstance(level, int):
return level
return LEVELS.get(str(level or "INFO").upper(), logging.INFO)


def _log_file_path() -> Path | None:
log_file = os.environ.get("IRI_LOG_FILE") or os.environ.get("LOG_FILE")
return Path(log_file) if log_file else None


def configure_logging(level: str | int | None = None) -> None:
"""
Return a configured Stream logger.
Configure root logging for the API.

Logs always go to stdout. If IRI_LOG_FILE or LOG_FILE is set, logs also go
to that file.
"""
logger = logging.getLogger(name)
global _CONFIGURED

log_level = _level(level or os.environ.get("LOG_LEVEL"))
root = logging.getLogger()
root.setLevel(log_level)

if not logger.handlers:
handler = logging.StreamHandler()
if _CONFIGURED:
for handler in root.handlers:
if getattr(handler, IRI_HANDLER_ATTR, False):
handler.setLevel(log_level)
return

formatter = logging.Formatter("%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s", datefmt="%a, %d %b %Y %H:%M:%S")
formatter = logging.Formatter(DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT)

handler.setFormatter(formatter)
logger.addHandler(handler)
for handler in root.handlers[:]:
if getattr(handler, IRI_HANDLER_ATTR, False):
root.removeHandler(handler)
handler.close()

stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(log_level)
stdout_handler.setFormatter(formatter)
setattr(stdout_handler, IRI_HANDLER_ATTR, True)
root.addHandler(stdout_handler)

log_file = _log_file_path()
if log_file:
if log_file.parent != Path("."):
log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(log_level)
file_handler.setFormatter(formatter)
setattr(file_handler, IRI_HANDLER_ATTR, True)
root.addHandler(file_handler)

_CONFIGURED = True


def get_stream_logger(name: str = __name__, level: str = "DEBUG") -> logging.Logger:
"""
Return a logger using the shared API stdout and optional file logging setup.
"""
configure_logging(level)

logger = logging.getLogger(name)
logger.setLevel(_level(level))
logger.propagate = True

logger.setLevel(LEVELS.get(level, logging.DEBUG))
logger.propagate = False
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()

return logger
return logger
29 changes: 14 additions & 15 deletions app/demo_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,20 @@
import uuid

from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel

from .routers.account import facility_adapter as account_adapter
from .routers.account import models as account_models
from .routers.compute import facility_adapter as compute_adapter
from .routers.compute import models as compute_models
from .routers.facility import facility_adapter
from .routers.facility import models as facility_models
from .routers.filesystem import facility_adapter as filesystem_adapter
from .routers.filesystem import models as filesystem_models
from .routers.status import facility_adapter as status_adapter
from .routers.status import models as status_models
from .routers.task import facility_adapter as task_adapter
from .routers.task import models as task_models
from .routers.v1.account import facility_adapter as account_adapter
from .routers.v1.account import models as account_models
from .routers.v1.compute import facility_adapter as compute_adapter
from .routers.v1.compute import models as compute_models
from .routers.v1.facility import facility_adapter
from .routers.v1.facility import models as facility_models
from .routers.v1.filesystem import facility_adapter as filesystem_adapter
from .routers.v1.filesystem import models as filesystem_models
from .routers.v1.status import facility_adapter as status_adapter
from .routers.v1.status import models as status_models
from .routers.v1.task import facility_adapter as task_adapter
from .routers.v1.task import models as task_models
from .types.models import Capability
from .types.user import User
from .types.scalars import AllocationUnit
Expand Down Expand Up @@ -365,8 +364,8 @@ async def list_sites(
sites = [s for s in sites if s.last_modified > ms]

o = offset or 0
l = limit or len(sites)
return sites[o : o + l]
list_limit = limit or len(sites)
return sites[o : o + list_limit]

async def get_site(self: "DemoAdapter", site_id: str, modified_since: str | None = None) -> facility_models.Site:
site = next((s for s in self.sites if s.id == site_id), None)
Expand Down
26 changes: 7 additions & 19 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,13 @@
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

from app.routers.error_handlers import install_error_handlers
from app.routers.facility import facility
from app.routers.status import status
from app.routers.account import account
from app.routers.compute import compute
from app.routers.filesystem import filesystem
from app.routers.task import task

from . import config
from .apilogger import configure_logging

from app.routers.error_handlers import install_error_handlers
from app.routers.loader import load_routers, version_from_api_url

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
configure_logging(config.LOG_LEVEL)

# ------------------------------------------------------------------
# OpenTelemetry Tracing Configuration
Expand Down Expand Up @@ -55,12 +48,7 @@

api_prefix = f"{config.API_PREFIX}{config.API_URL}"

# 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)
for loaded_router in load_routers(version_from_api_url(config.API_URL)):
APP.include_router(loaded_router.router, prefix=api_prefix)

logging.getLogger().info(f"API path: {api_prefix}")
Loading
Loading