From d76f299119758fb195ba02380fc9433bcc1df8c8 Mon Sep 17 00:00:00 2001 From: Niklas Bettgen Date: Thu, 6 Aug 2026 16:47:08 +0200 Subject: [PATCH 1/2] Add basic unit tests --- .vscode/settings.json | 7 +++ pyproject.toml | 1 + src/apigator/main.py | 141 +++++++++++++++--------------------------- tests/test_health.py | 36 +++++++++++ 4 files changed, 95 insertions(+), 90 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 tests/test_health.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3a1838 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/pyproject.toml b/pyproject.toml index 58aa7d8..c42f4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ known_first_party = ["apigator"] src_paths = ["src", "tests"] [tool.pytest.ini_options] +pythonpath = ["src"] testpaths = ["tests"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] diff --git a/src/apigator/main.py b/src/apigator/main.py index e6764f4..f7e5750 100644 --- a/src/apigator/main.py +++ b/src/apigator/main.py @@ -3,19 +3,21 @@ import subprocess from datetime import datetime from enum import Enum +from typing import Any +from fastapi.responses import JSONResponse import httpx import uvicorn import yaml -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI CONFIG_FILE = "/config/config.yaml" -VERSION = os.getenv("APIGATOR_VERSION", "(unknown)") +VERSION = os.getenv("APIGATOR_VERSION") or "(unknown)" config = {} app = FastAPI(title="APIgator") -class ResponseStatus(Enum): +class RspStatus(Enum): SUCCESS = "success" ERROR = "error" @@ -30,99 +32,71 @@ def load_config(): config = yaml.safe_load(config_raw) -def create_response( - status: ResponseStatus, data: dict | None = None, error: str = "", message: str = "" -): +def create_response(status: RspStatus, data: dict | None = None, error: str | None = None): """Standard response format of consistent structure""" return { - "version": VERSION, "status": status.value, "timestamp": datetime.utcnow().isoformat(), - "data": data if data is not None else {}, - "error": error, - "message": message, + "data": data or {}, + "error": error or "" } - -def extract_field(data: dict, path: str): - """Extracts a value from a (nested) object via path, e.g. 'status.cpu.usage'""" - keys = path.split(".") - current = data - for key in keys: - if isinstance(current, dict) and key in current: - current = current[key] - else: - return None - return current +class QueryError(Exception): + def __init__(self, msg): + self.msg: str = msg async def execute_query(query_def): - results = {} - async with httpx.AsyncClient(timeout=10) as client: + results: dict[str, Any] = {} + default_timeout = config.get("default_timeout", 10) + async with httpx.AsyncClient() as client: for endpoint in query_def: + timeout = endpoint.get("timeout", default_timeout) try: response = await client.request( method=endpoint.get("method", "GET"), url=endpoint["url"], headers=endpoint.get("headers"), params=endpoint.get("params"), - content=json.dumps(endpoint.get("body")) if endpoint.get("body") else None, + content=json.dumps(endpoint.get("body")), + timeout=timeout, ) api_data = response.json() fields = endpoint.get("fields", []) - - if not fields: - results[endpoint.get("key", endpoint["url"])] = api_data - else: - for field_def in fields: - for output_key, field_config in field_def.items(): - try: - if isinstance(field_config, str): - path = field_config - jq_filter = None - elif isinstance(field_config, dict): - path = field_config.get("path", "") - jq_filter = field_config.get("filter") - else: - return None, f"Invalid field config for '{output_key}'" - - value = extract_field(api_data, path) - - if jq_filter and value is not None: - result = subprocess.run( - ["jq", jq_filter], - input=json.dumps(value), - capture_output=True, - text=True, - ) - if result.returncode == 0: - value = json.loads(result.stdout) - else: - return ( - None, - f"jq filter failed for '{output_key}': {result.stderr}", - ) - - results[output_key] = value - except Exception as e: - return None, f"Error processing field '{output_key}': {e!s}" + for field in fields: + for output_key, jq_filter in field.items(): + try: + result = subprocess.run( + ("jq", jq_filter), + input=json.dumps(api_data), + capture_output=True, + text=True, + ) + if result.returncode == 0: + value = json.loads(result.stdout) + else: + raise QueryError(f"jq filter failed for '{output_key}': {result.stderr}") + results[output_key] = value + + except Exception as e: + raise QueryError(f"Error processing field '{output_key}': {e!s}") except httpx.ConnectError: - return None, f"Connection failed for '{endpoint['url']}'" + raise QueryError(f"Connection failed for '{endpoint['url']}'") except httpx.TimeoutException: - return None, f"Request timeout for '{endpoint['url']}'" + raise QueryError(f"Request timeout for '{endpoint['url']}'") except json.JSONDecodeError: - return None, f"Invalid JSON response from '{endpoint['url']}'" + raise QueryError(f"Invalid JSON response from '{endpoint['url']}'") except Exception as e: - return None, f"Error processing endpoint '{endpoint['url']}': {e!s}" + raise QueryError(f"Error processing endpoint '{endpoint['url']}': {e!s}") - return results, None + return results @app.get("/health") async def health(): - return create_response(status=ResponseStatus.SUCCESS, message="APIgator is running") + return create_response(RspStatus.SUCCESS, data={"info": "APIgator is up and running! :)", "version": VERSION}) @app.get("/query/{query_name}") @@ -130,36 +104,23 @@ async def get_query(query_name: str): queries = config.get("queries", {}) if query_name not in queries: - raise HTTPException( + return JSONResponse( status_code=404, - detail=create_response( - status=ResponseStatus.ERROR, - error="query_not_found", - message=f"Query '{query_name}' not found", - ), + content=create_response(RspStatus.ERROR, error=f"Query '{query_name}' not found") ) try: - results, error = await execute_query(queries[query_name]) - - if error: - raise HTTPException( - status_code=502, - detail=create_response( - status=ResponseStatus.ERROR, error="upstream_error", message=error - ), - ) - - return create_response(status=ResponseStatus.SUCCESS, data=results) - - except HTTPException: - raise + results = await execute_query(queries[query_name]) + return create_response(status=RspStatus.SUCCESS, data=results) + except QueryError as e: + return JSONResponse( + status_code=502, + content=create_response(status=RspStatus.ERROR, error=e.msg), + ) except Exception: - raise HTTPException( + return JSONResponse( status_code=500, - detail=create_response( - status=ResponseStatus.ERROR, error="internal_error", message="Internal server error" - ), + content=create_response(status=RspStatus.ERROR, error="Internal server error"), ) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..89cb1a9 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,36 @@ +from datetime import datetime +import pytest +from fastapi.testclient import TestClient + +from apigator.main import app + + +@pytest.fixture +def client(): + """Fixture to provide a test client for the FastAPI app.""" + return TestClient(app) + + +class TestHealthEndpoint: + """Test suite for the /health endpoint.""" + + def test_health_returns_200(self, client: TestClient): + """Test that the health endpoint returns a 200 status code.""" + response = client.get("/health") + assert response.status_code == 200 + + def test_health_response_structure(self, client: TestClient): + """Test that the health endpoint returns valid response structure.""" + response = client.get("/health") + data = response.json() + + # Verify required fields exist and have correct types + assert data["status"] == "success" + assert isinstance(data["timestamp"], str) + assert isinstance(data["data"], dict) + assert isinstance(data["error"], str) + + try: + datetime.fromisoformat(data["timestamp"]) + except ValueError: + pytest.fail(f"Invalid ISO 8601 timestamp: {data['timestamp']}") From 8cbf85643cb816e0d47dc12847640412dbc27db2 Mon Sep 17 00:00:00 2001 From: Niklas Bettgen Date: Thu, 6 Aug 2026 16:48:25 +0200 Subject: [PATCH 2/2] Refactor config and parameters --- README.md | 62 ++++++++++++++++++++++++++------------------ config.yaml | 49 +++++++++++++++++++++------------- src/apigator/main.py | 15 +++++++---- tests/test_health.py | 1 + 4 files changed, 79 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index ea1f2d8..b7d8a94 100644 --- a/README.md +++ b/README.md @@ -22,33 +22,47 @@ docker run -d \ ## Configuration -Create a `config.yaml`: +First, create a configuration file: ```yaml -port: 8080 # server configuration -host: "0.0.0.0" - -queries: - sysinfo: # query named "sysinfo" - - url: http://monitoring/api/system # first upstream API - method: GET - headers: - Authorization: "Bearer ${API_TOKEN}" +# config.yaml +host: 0.0.0.0 # server address to listen on +port: 8080 # server port +default_timeout: 10 # default timeout in seconds for all endpoints + +queries: # predefined queries + + # Basic example with two upstream queries + sysinfo: # query named "sysinfo" + - url: http://my.api/status/system # list of upstream APIs to fetch fields: - - cpu_usage: "stats.cpu.usage" # option 1: simple path - - temp: - path: "stats.temperature" - filter: "round" # option 2: with jq filter + cpu_usage: .stats.cpu.usage # fields names and values to gather in our response + temp: .stats.temperature - - url: http://api/memory # seconds upstream API - method: GET + - url: http://another.api/memory fields: - - memory_used: "data.used" - - memory_percent: - path: "data.percent" - filter: "round" + memory_used: .cores[0].used + memory_percent: .cores[0].percent | round # jq filter for rounded value - ... # further queries + - url: http://yet.another.api/all + fields: + result: . # Fetch entire reponse as data + + # Example with optional properties and complex jq filters + full-example: # query named "full-example" + - url: http://complex.example/memory + method: GET # optional HTTP method, defaults to GET + timeout: 15 # optional timeout for this endpoint + headers: # optional headers + Authorization: Bearer ${API_TOKEN} + params: # optional extra params + param1: some value + body: # optional message body + foo: bar + fields: + rounded_2decimals: (. * 100 | round) / 100 # rounds a single float value to two decimals + sum_of_foos: map(.foo) | add # gives the sum of each "foo" item in an array + len_of_array: .somearray | length # gets the length of a given array ``` ## Usage @@ -70,9 +84,7 @@ curl http://localhost:8080/query/sysinfo "memory_used": 8192, "memory_percent": 50 }, - "error": "", - "message": "", - "version": "x.y.z" + "error": "" } ``` @@ -115,7 +127,7 @@ APIgator is intended for internal use only: 1. **SSRF attacks** – Only trusted admins should modify the config. 1. **No HTTPS** – Add TLS via reverse proxy (Traefik, Caddy, ...). 1. **No built-in auth** – Use a reverse proxy with authentication. -1. **Timeouts** – Configure appropriately for slow upstream APIs. +1. **Timeouts** – To prevent freezing, use `default_timeout` and per-endpoint timeouts appropriately for your upstream APIs. When running APIgator in production, use a reverse proxy with authentication, HTTPS, rate limiting and network isolation. diff --git a/config.yaml b/config.yaml index 3331388..0616147 100644 --- a/config.yaml +++ b/config.yaml @@ -1,24 +1,37 @@ -port: 8080 -host: 0.0.0.0 +host: 0.0.0.0 # server address to listen on +port: 8080 # server port +default_timeout: 10 # default timeout in seconds for all endpoints -queries: - sysinfo: - - url: http://monitoring.api/api/v1/cpu - method: GET +queries: # predefined queries + + # Basic example with two upstream queries + sysinfo: # query named "sysinfo" + - url: http://my.api/status/system # list of upstream APIs to fetch fields: - - cpu_usage: stats.cpu.usage - - disk_full: stats.disk.full - - temp: - path: stats.temp - filter: round + cpu_usage: .stats.cpu.usage # fields names and values to gather in our response + temp: .stats.temperature - url: http://another.api/memory - method: GET - headers: + fields: + memory_used: .cores[0].used + memory_percent: .cores[0].percent | round # jq filter for rounded value + + - url: http://yet.another.api/all + fields: + result: . # Fetch entire reponse as data + + # Example with optional properties and complex jq filters + full-example: # query named "full-example" + - url: http://complex.example/memory + method: GET # optional HTTP method, defaults to GET + timeout: 15 # optional timeout for this endpoint + headers: # optional headers Authorization: Bearer ${API_TOKEN} + params: # optional extra params + param1: some value + body: # optional message body + foo: bar fields: - - memory_used: data.used - - memory_total: data.total - - memory_percent: - path: data.percent - filter: round + rounded_2decimals: (. * 100 | round) / 100 # rounds a single float value to two decimals + sum_of_foos: map(.foo) | add # gives the sum of each "foo" item in an array + len_of_array: .somearray | length # gets the length of a given array diff --git a/src/apigator/main.py b/src/apigator/main.py index f7e5750..b59c694 100644 --- a/src/apigator/main.py +++ b/src/apigator/main.py @@ -5,11 +5,11 @@ from enum import Enum from typing import Any -from fastapi.responses import JSONResponse import httpx import uvicorn import yaml from fastapi import FastAPI +from fastapi.responses import JSONResponse CONFIG_FILE = "/config/config.yaml" VERSION = os.getenv("APIGATOR_VERSION") or "(unknown)" @@ -38,9 +38,10 @@ def create_response(status: RspStatus, data: dict | None = None, error: str | No "status": status.value, "timestamp": datetime.utcnow().isoformat(), "data": data or {}, - "error": error or "" + "error": error or "", } + class QueryError(Exception): def __init__(self, msg): self.msg: str = msg @@ -76,7 +77,9 @@ async def execute_query(query_def): if result.returncode == 0: value = json.loads(result.stdout) else: - raise QueryError(f"jq filter failed for '{output_key}': {result.stderr}") + raise QueryError( + f"jq filter failed for '{output_key}': {result.stderr}" + ) results[output_key] = value except Exception as e: @@ -96,7 +99,9 @@ async def execute_query(query_def): @app.get("/health") async def health(): - return create_response(RspStatus.SUCCESS, data={"info": "APIgator is up and running! :)", "version": VERSION}) + return create_response( + RspStatus.SUCCESS, data={"info": "APIgator is up and running! :)", "version": VERSION} + ) @app.get("/query/{query_name}") @@ -106,7 +111,7 @@ async def get_query(query_name: str): if query_name not in queries: return JSONResponse( status_code=404, - content=create_response(RspStatus.ERROR, error=f"Query '{query_name}' not found") + content=create_response(RspStatus.ERROR, error=f"Query '{query_name}' not found"), ) try: diff --git a/tests/test_health.py b/tests/test_health.py index 89cb1a9..8857488 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,4 +1,5 @@ from datetime import datetime + import pytest from fastapi.testclient import TestClient