Commit aa2cd89f authored by George Papathanail's avatar George Papathanail
Browse files

Merge branch 'fix/fm' into 'develop'

Follow-up fixes to the federation-lifecycle proxy

See merge request !29
parents d568f9aa efecbc63
Loading
Loading
Loading
Loading
Loading
+12 −5
Changes for README.md: 12 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -14,20 +14,27 @@ OEG implements the **Northbound CAMARA APIs** for application providers to inter

- Application metadata lifecycle management (registering, retrieving, deleting apps)
- Application instantiation management (instantation, and termination)
- Federation Management APIs to onboard applications accross partners' operator platforms
- Federation lifecycle APIs for the operator to set up and manage federations with partner operator platforms (proxied to the Federation Manager)

OEG acts as a middleware layer between the CAMARA APIs and the Service Resource Manager (SRM), by enabling a common exposure of heteregeneous infrastructure resources.

<br>

| Edge Cloud Management API  | Federation Management API | Network Exposure API  (QoD & Traffic Influence)|
| Edge Cloud Management API  | Federation Lifecycle API (GSMA OPG.04 v1.4.0) | Network Exposure API  (QoD & Traffic Influence)|
| ------------- | ------------- | ------------- |
| Application Metadata registration  | Create one direction federation   | Create QoD Session |
| App Metadata Removal  | Retrieve details about federation with the partner OP  | Remove QoD Session  |
| App Metadata Retrieval  | Retrieve the existing federationContextId with partner  | Retrieve QoD Session  |
| Application Instantiation  | Remove existing federation with partner OP | Create TrafficeInfluence Resource  |
| Application Instance Retrieval |                                        | Remove TrafficeInfluence Resource  |
| Application Instance Removal  |                                         | Retrieve TrafficeInfluence Resource  |
| Application Instance Retrieval | List the zones the partner OP offers | Remove TrafficeInfluence Resource  |
| Application Instance Removal  | Subscribe to partner OP zones | Retrieve TrafficeInfluence Resource  |
|  | Retrieve details of a subscribed partner zone |  |
|  | Unsubscribe from a partner zone |  |

The federation lifecycle API is served under `/platform/v1/federation/partners/{partnerOpId}/federations/...`,
where `partnerOpId` is the UUID the Federation Manager assigned when the partner was registered.
OEG forwards each request unchanged to the Federation Manager's matching `/internal/...` endpoint
and relays its answer; every error on these routes is a GSMA `application/problem+json` `ProblemDetails`.


## Deployment
@@ -37,7 +44,7 @@ OEG can be deployed in a Kubernetes cluster by executing the file _oeg-deploymen
| **Variable**                | **Description**                                             |
|-----------------------------|-------------------------------------------------------------|
| `SRM_HOST`                  | Base URL of the Service Resource Manager                   |
| `FEDERATION_MANAGER_HOST`   | Base URL of the Federation Manager                         |
| `FM_SETTINGS__BASE_URL`     | Base URL of the Federation Manager (default `http://localhost:8082`) |



+1 −1
Changes for pyproject.toml: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -19,7 +19,7 @@ dependencies = [
    "fastapi[standard]>=0.135.1",
    "nats-py>=2.10.0",
    "python-dotenv>=1.2.2",
    "sqlalchemy>=2.0.48",
    "sqlalchemy[asyncio]>=2.0.48",
    "structlog>=25.5.0",
]

+58 −80
Changes for src/open_exposure_gateway/api/error_handlers.py: 58 added lines, 80 removed lines.
Original line number Diff line number Diff line
import uuid
from collections.abc import Mapping

import structlog
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from starlette.exceptions import HTTPException as StarletteHTTPException

from open_exposure_gateway.api.camara.common import X_CORRELATOR_PATTERN
from open_exposure_gateway.api.gsma.federation_manager.v1_4_0 import problems
from open_exposure_gateway.core.exceptions import (
    BadRequestException,
    ConflictException,
@@ -48,112 +50,94 @@ def x_correlator_header(request: Request) -> str:
    return x_correlator


def _error_response(
    request: Request,
    status: int,
    code: ErrorCode,
    message: str,
    headers: Mapping[str, str] | None = None,
) -> Response:
    """CAMARA ``ErrorInfo``, or on the federation-lifecycle routes a GSMA ``ProblemDetails``.

    §K.6a: every error on those routes is ``application/problem+json``, including the
    ones raised before or outside the route handler that land here. ``message`` is never
    used there -- ProblemDetails carries a fixed ``detail`` per status (§K.6a).
    """
    response_headers = {"x-correlator": x_correlator_header(request), **(headers or {})}
    if problems.is_federation_path(request.url.path):
        return problems.problem_response(
            problems.problem_for_status(status), headers=response_headers
        )
    return JSONResponse(
        status_code=status,
        headers=response_headers,
        content=ErrorInfo(status=status, code=code, message=message).model_dump(),
    )


def register_exception_handlers(app: FastAPI) -> None:
    @app.exception_handler(RequestValidationError)
    async def handle_request_validation_error(
        request: Request, exc: RequestValidationError
    ) -> JSONResponse:
        return JSONResponse(
            status_code=400,
    ) -> Response:
        if problems.is_federation_path(request.url.path):
            return problems.problem_response(
                problems.INVALID_PARAMETERS,
                problems.invalid_params(exc.errors()),
                headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(
                status=400,
                code=ErrorCode.INVALID_ARGUMENT,
                message=str(exc.errors())[:512],
            ).model_dump(),
            )
        return _error_response(request, 400, ErrorCode.INVALID_ARGUMENT, str(exc.errors())[:512])

    @app.exception_handler(BadRequestException)
    async def handle_bad_request(request: Request, exc: BadRequestException) -> JSONResponse:
        return JSONResponse(
            status_code=400,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=400, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_bad_request(request: Request, exc: BadRequestException) -> Response:
        return _error_response(request, 400, exc.error_code, exc.message)

    @app.exception_handler(OutOfRangeException)
    async def handle_out_of_range(request: Request, exc: OutOfRangeException) -> JSONResponse:
        return JSONResponse(
            status_code=400,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=400, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_out_of_range(request: Request, exc: OutOfRangeException) -> Response:
        return _error_response(request, 400, exc.error_code, exc.message)

    @app.exception_handler(UnauthorizedException)
    async def handle_unauthorized(request: Request, exc: UnauthorizedException) -> JSONResponse:
        return JSONResponse(
            status_code=401,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=401, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_unauthorized(request: Request, exc: UnauthorizedException) -> Response:
        return _error_response(request, 401, exc.error_code, exc.message)

    @app.exception_handler(ForbiddenException)
    async def handle_forbidden(request: Request, exc: ForbiddenException) -> JSONResponse:
        return JSONResponse(
            status_code=403,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=403, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_forbidden(request: Request, exc: ForbiddenException) -> Response:
        return _error_response(request, 403, exc.error_code, exc.message)

    @app.exception_handler(NotFoundException)
    async def handle_not_found(request: Request, exc: NotFoundException) -> JSONResponse:
        return JSONResponse(
            status_code=404,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=404, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_not_found(request: Request, exc: NotFoundException) -> Response:
        return _error_response(request, 404, exc.error_code, exc.message)

    @app.exception_handler(ConflictException)
    async def handle_conflict(request: Request, exc: ConflictException) -> JSONResponse:
        return JSONResponse(
            status_code=409,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=409, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_conflict(request: Request, exc: ConflictException) -> Response:
        return _error_response(request, 409, exc.error_code, exc.message)

    @app.exception_handler(NotImplementedException)
    async def handle_not_implemented(
        request: Request, exc: NotImplementedException
    ) -> JSONResponse:
        return JSONResponse(
            status_code=501,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=501, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_not_implemented(request: Request, exc: NotImplementedException) -> Response:
        return _error_response(request, 501, exc.error_code, exc.message)

    @app.exception_handler(DownstreamServiceException)
    async def handle_downstream(request: Request, exc: DownstreamServiceException) -> JSONResponse:
        return JSONResponse(
            status_code=503,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(status=503, code=exc.error_code, message=exc.message).model_dump(),
        )
    async def handle_downstream(request: Request, exc: DownstreamServiceException) -> Response:
        return _error_response(request, 503, exc.error_code, exc.message)

    @app.exception_handler(OEGException)
    async def handle_oeg_exception(request: Request, exc: OEGException) -> JSONResponse:
        return JSONResponse(
            status_code=exc.status_code,
            headers={"x-correlator": x_correlator_header(request)},
            content=ErrorInfo(
                status=exc.status_code, code=exc.error_code, message=exc.message
            ).model_dump(),
        )
    async def handle_oeg_exception(request: Request, exc: OEGException) -> Response:
        return _error_response(request, exc.status_code, exc.error_code, exc.message)

    @app.exception_handler(StarletteHTTPException)
    async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
    async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> Response:
        exc_cls = _HTTP_EXCEPTION_MAP.get(exc.status_code)
        mapped = (
            exc_cls(message=str(exc.detail))
            if exc_cls is not None
            else OEGException(message=str(exc.detail), status_code=exc.status_code)
        )
        response = await handle_oeg_exception(request, mapped)
        if exc.headers:
            response.headers.update(exc.headers)
        return response
        return _error_response(
            request, mapped.status_code, mapped.error_code, mapped.message, exc.headers
        )

    @app.exception_handler(Exception)
    async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
    async def handle_unexpected_exception(request: Request, exc: Exception) -> Response:
        x_correlator = x_correlator_header(request)
        structlog.get_logger().exception(
            "Unhandled exception",
@@ -163,10 +147,4 @@ def register_exception_handlers(app: FastAPI) -> None:
            x_correlator=x_correlator,
            error=str(exc),
        )
        return JSONResponse(
            status_code=500,
            headers={"x-correlator": x_correlator},
            content=ErrorInfo(
                status=500, code=ErrorCode.INTERNAL, message="Internal server error"
            ).model_dump(),
        )
        return _error_response(request, 500, ErrorCode.INTERNAL, "Internal server error")
+175 −0
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/problems.py: 175 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""OEG-authored ``ProblemDetails`` for the federation-lifecycle surface (§K.6a, ADR-0050).

Lives apart from ``router.py`` so ``api/error_handlers.py`` can use it too: the router
imports ``error_handlers``, so ``error_handlers`` importing the router would be circular.
"""

from collections.abc import Iterable, Mapping, Sequence
from http import HTTPStatus
from typing import Any, NamedTuple

from fastapi import Response

from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    InvalidParam,
    ProblemDetails,
)

# OEG's own platform namespace, mirrored 1:1 at FM's `/internal` (FmClient does a
# prefix swap, nothing else -- see fm_client.py).
BASE_PATH = "/platform/v1/federation"


class OegProblem(NamedTuple):
    """Fixed template for one kind of OEG-authored error (§K.6a)."""

    status: int
    type: str
    title: str
    detail: str
    cause: str


# Every error OEG itself raises on this surface. `detail` is a fixed sentence per case
# -- never exception text or upstream diagnostics (§K.6a).
MALFORMED_BODY = OegProblem(
    400,
    "urn:oop:ewbi:error:malformed-request-body",
    "Malformed request body",
    "Request body is not valid JSON.",
    "MALFORMED_REQUEST_BODY",
)
INVALID_BODY = OegProblem(
    400,
    "urn:oop:ewbi:error:invalid-request-body",
    "Request body does not match the expected schema",
    "One or more request body fields are invalid; see invalidParams.",
    "INVALID_REQUEST_BODY",
)
INVALID_PARAMETERS = OegProblem(
    400,
    "urn:oop:ewbi:error:invalid-request-parameters",
    "Invalid request parameters",
    "One or more path, query or header parameters are invalid; see invalidParams.",
    "INVALID_REQUEST_PARAMETERS",
)
UPSTREAM_UNAVAILABLE = OegProblem(
    503,
    "urn:oop:ewbi:error:upstream-unavailable",
    "Federation Manager unavailable",
    "The federation-lifecycle request could not be forwarded to FM.",
    "UPSTREAM_UNAVAILABLE",
)
UPSTREAM_BAD_RESPONSE = OegProblem(
    502,
    "urn:oop:ewbi:error:upstream-bad-response",
    "Federation Manager returned an unusable response",
    "FM's response could not be relayed.",
    "UPSTREAM_BAD_RESPONSE",
)
INTERNAL_ERROR = OegProblem(
    500,
    "urn:oop:ewbi:error:internal-error",
    "Internal server error",
    "The request could not be processed.",
    "INTERNAL_ERROR",
)

# Framework-level errors (raised before or outside a route handler), by status.
_BY_STATUS = {
    401: OegProblem(
        401,
        "urn:oop:ewbi:error:unauthenticated",
        "Unauthenticated",
        "The request lacks valid authentication credentials.",
        "UNAUTHENTICATED",
    ),
    403: OegProblem(
        403,
        "urn:oop:ewbi:error:permission-denied",
        "Permission denied",
        "The caller is not allowed to perform this operation.",
        "PERMISSION_DENIED",
    ),
    404: OegProblem(
        404,
        "urn:oop:ewbi:error:not-found",
        "Not found",
        "No federation-lifecycle resource matches the request path.",
        "NOT_FOUND",
    ),
    405: OegProblem(
        405,
        "urn:oop:ewbi:error:method-not-allowed",
        "Method not allowed",
        "The request method is not supported on this path.",
        "METHOD_NOT_ALLOWED",
    ),
    500: INTERNAL_ERROR,
}


def is_federation_path(path: str) -> bool:
    return path == BASE_PATH or path.startswith(f"{BASE_PATH}/")


def problem_for_status(status: int) -> OegProblem:
    """The template for a framework-level error with this status.

    Statuses without a dedicated template get a generic one built from the HTTP reason
    phrase, so a new status can never fall back to exception text.
    """
    if status in _BY_STATUS:
        return _BY_STATUS[status]
    try:
        phrase = HTTPStatus(status).phrase
    except ValueError:
        return INTERNAL_ERROR._replace(status=status)
    slug = phrase.lower().replace(" ", "-").replace("'", "")
    return OegProblem(
        status,
        f"urn:oop:ewbi:error:{slug}",
        phrase,
        "The request could not be processed.",
        slug.replace("-", "_").upper(),
    )


def problem_response(
    problem: OegProblem,
    invalid_params: list[InvalidParam] | None = None,
    headers: Mapping[str, str] | None = None,
) -> Response:
    body = ProblemDetails(**problem._asdict(), invalidParams=invalid_params)
    return Response(
        content=body.model_dump_json(exclude_none=True),
        status_code=problem.status,
        media_type="application/problem+json",
        headers=headers,
    )


# FastAPI prefixes request-parameter errors with where the value came from.
_REQUEST_LOCATIONS = {"path", "query", "header", "cookie", "body"}


def invalid_params(errors: Iterable[Mapping[str, Any]]) -> list[InvalidParam]:
    """One ``invalidParams`` entry per Pydantic error: field path plus message only.

    ``loc`` becomes a dotted path with list indices in brackets (e.g.
    ``origOPMobileNetworkCodes.mncs[0]``); an error on the body as a whole (empty
    ``loc``) is reported as ``body``. A leading FastAPI location (``path``,
    ``query``, ...) is dropped, so a bad path segment is reported by its name alone
    (e.g. ``partnerOpId``). The rest of the Pydantic error (``input``, ``ctx``,
    ``url``, ``type``) is deliberately dropped.
    """
    params = []
    for error in errors:
        loc: Sequence[str | int] = error["loc"]
        if len(loc) > 1 and loc[0] in _REQUEST_LOCATIONS:
            loc = loc[1:]
        path = ""
        for part in loc:
            path += f"[{part}]" if isinstance(part, int) else f".{part}"
        params.append(InvalidParam(param=path.lstrip(".") or "body", reason=error["msg"]))
    return params
+108 −45

File changed.

Preview size limit exceeded, changes collapsed.

Loading