Skip to content
Merged

Dev #53

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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
LOGS_FILE="object_effects"
# Explicit API roots (e.g. https://host/urban_api) are preserved; bare origins use /api.
URBAN_API="https://urban-api.testing"
MCP_URBAN_API="https://urban-api.testing"
PROMETHEUS_PORT=9464
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/ci-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Kubernetes dev release

on:
push:
branches:
- dev

permissions:
contents: read

jobs:
release:
uses: IDUclub/urban-assistant-deploy/.github/workflows/reusable-application-release.yaml@main
with:
service: object-effects
test-command: |
python -m pip install --disable-pip-version-check uv==0.12.10
uv python install 3.11
uv venv --python 3.11
uv pip install --python .venv/bin/python \
--requirement requirements.txt \
--requirement requirements-auth.txt \
pytest \
pytest-asyncio
cp .env.example .env.test
APP_ENV=test .venv/bin/python -m pytest --verbose tests
secrets: inherit
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
# ObjectEffectsAPI
Repository for evaluation effects by ObjectNat library

### Logs

`GET /logs` downloads the application log file without authorization.
15 changes: 10 additions & 5 deletions app/common/api_handler/api_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import aiohttp
from idu_service_auth import KeycloakTokenClient

from app.common.api_handler.urban_api_url import normalize_urban_api_url
from app.common.exceptions.http_exception_wrapper import http_exception


Expand All @@ -19,7 +20,7 @@ def __init__(
None
"""

self.base_url = base_url
self.base_url = normalize_urban_api_url(base_url)
self.service_auth = service_auth

async def _service_headers(self, headers: dict | None) -> dict[str, str]:
Expand Down Expand Up @@ -99,7 +100,8 @@ async def get(
session=session,
)
headers = await self._service_headers(headers)
url = self.base_url + endpoint_url
endpoint = endpoint_url.lstrip("/").removeprefix("api/")
url = f"{self.base_url}/{endpoint}"
async with session.get(url=url, headers=headers, params=params) as response:
result = await self._check_response_status(response)
if isinstance(result, list):
Expand Down Expand Up @@ -149,7 +151,8 @@ async def post(
session=session,
)
headers = await self._service_headers(headers)
url = self.base_url + endpoint_url
endpoint = endpoint_url.lstrip("/").removeprefix("api/")
url = f"{self.base_url}/{endpoint}"
async with session.post(
url=url,
headers=headers,
Expand Down Expand Up @@ -196,7 +199,8 @@ async def put(
session=session,
)
headers = await self._service_headers(headers)
url = self.base_url + endpoint_url
endpoint = endpoint_url.lstrip("/").removeprefix("api/")
url = f"{self.base_url}/{endpoint}"
async with session.put(
url=url,
headers=headers,
Expand Down Expand Up @@ -243,7 +247,8 @@ async def delete(
session=session,
)
headers = await self._service_headers(headers)
url = self.base_url + endpoint_url
endpoint = endpoint_url.lstrip("/").removeprefix("api/")
url = f"{self.base_url}/{endpoint}"
async with session.delete(
url=url,
headers=headers,
Expand Down
19 changes: 19 additions & 0 deletions app/common/api_handler/urban_api_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Urban API roots for direct connections and load-balancer mounts."""

from urllib.parse import urlsplit, urlunsplit


def normalize_urban_api_url(base_url: str) -> str:
"""Preserve explicit API roots; use /api only for an origin without a path."""
url = urlsplit(base_url.strip())
if (
url.scheme not in {"http", "https"}
or not url.netloc
or url.query
or url.fragment
):
raise ValueError(
"Urban API URL must be an HTTP(S) base URL without query or fragment"
)
path = url.path.rstrip("/") or "/api"
return urlunsplit((url.scheme, url.netloc, path, "", ""))
5 changes: 2 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastmcp.utilities.lifespan import combine_lifespans
from loguru import logger

from .__version__ import APP_VERSION
from .common.auth.service_auth import require_service_token
from .common.middlewares.exception_handler import ExceptionHandlerMiddleware
from .common.middlewares.prometheus_handler import ObservabilityMiddleware
from .dependencies import config, http_exception, service_auth
Expand Down Expand Up @@ -82,7 +81,7 @@ async def read_root():
return {"status": "OK"}


@app.get("/logs", dependencies=[Depends(require_service_token)])
@app.get("/logs")
async def get_logs():
"""
Get logs file from app
Expand Down
4 changes: 1 addition & 3 deletions app/provision/provision_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ async def calc_service_provision(
service_type_id=service_type_id,
target_population=target_population,
)
result = await provision_mcp_service.calculate_provision(
provision_dto, user_id
)
result = await provision_mcp_service.calculate_provision(provision_dto, user_id)
return result.model_dump()
except Exception as e:
tb = traceback.format_exc()
Expand Down
12 changes: 12 additions & 0 deletions tests/test_public_diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from fastapi.testclient import TestClient

from app.main import app


def test_logs_are_public(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / ".log").write_text("diagnostic log\n", encoding="utf-8")
client = TestClient(app)
response = client.get("/logs")
assert response.status_code == 200
assert response.text == "diagnostic log\n"
83 changes: 83 additions & 0 deletions tests/test_urban_api_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from unittest.mock import AsyncMock, MagicMock

import pytest

from app.common.api_handler.api_handler import APIHandler
from app.common.api_handler.urban_api_url import normalize_urban_api_url


@pytest.mark.asyncio
@pytest.mark.parametrize(
"base, api_root",
[
("https://urban.test:8443", "https://urban.test:8443/api"),
("https://urban.test:8443/", "https://urban.test:8443/api"),
("https://urban.test:8443/api", "https://urban.test:8443/api"),
(" https://urban.test:8443/api/// ", "https://urban.test:8443/api"),
(
"https://prostor-api.idu.actocgnitive.org/urban_api",
"https://prostor-api.idu.actocgnitive.org/urban_api",
),
(
"https://prostor-api.idu.actocgnitive.org/urban_api/",
"https://prostor-api.idu.actocgnitive.org/urban_api",
),
(
"https://urban.test/gateway/urban_api/",
"https://urban.test/gateway/urban_api",
),
],
)
@pytest.mark.parametrize("method", ["get", "post", "put", "delete"])
@pytest.mark.parametrize(
"endpoint",
["/api/v1/scenarios/7", "api/v1/scenarios/7", "/v1/scenarios/7", "v1/scenarios/7"],
)
async def test_requests_use_configured_api_root(base, api_root, method, endpoint):
auth = MagicMock()
auth.get_authorization_headers = AsyncMock(
return_value={"Authorization": "Bearer service"}
)
handler = APIHandler(base, auth)
session = MagicMock()
response = MagicMock(status=200)
response.json = AsyncMock(return_value={"ok": True})
request = getattr(session, method)
request.return_value.__aenter__.return_value = response

assert await getattr(handler, method)(
endpoint, session=session, params={"page": 2}
) == {"ok": True}

assert request.call_args.kwargs["url"] == f"{api_root}/v1/scenarios/7"
assert request.call_args.kwargs["params"] == {"page": 2}
assert request.call_args.kwargs["headers"] == {"Authorization": "Bearer service"}


@pytest.mark.parametrize(
"base, expected",
[
("http://api", "http://api/api"),
("https://urban.test/gateway/api/", "https://urban.test/gateway/api"),
("https://urban.test/gateway/", "https://urban.test/gateway"),
("https://urban.test/api/api/", "https://urban.test/api/api"),
],
)
def test_normalization_preserves_authority_and_proxy_path(base, expected):
assert normalize_urban_api_url(base) == expected
assert normalize_urban_api_url(expected) == expected


@pytest.mark.parametrize(
"base",
[
"",
"urban.test",
"ftp://urban.test",
"https://urban.test?x=1",
"https://urban.test#fragment",
],
)
def test_invalid_base_url_is_rejected(base):
with pytest.raises(ValueError, match="Urban API URL"):
normalize_urban_api_url(base)
Loading