Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
62 changes: 37 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -70,9 +84,7 @@ curl http://localhost:8080/query/sysinfo
"memory_used": 8192,
"memory_percent": 50
},
"error": "",
"message": "",
"version": "x.y.z"
"error": ""
}
```

Expand Down Expand Up @@ -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.
49 changes: 31 additions & 18 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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*"]
Expand Down
144 changes: 55 additions & 89 deletions src/apigator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
import subprocess
from datetime import datetime
from enum import Enum
from typing import Any

import httpx
import uvicorn
import yaml
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from fastapi.responses import JSONResponse

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"

Expand All @@ -30,136 +32,100 @@ 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}")
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"),
)


Expand Down
Loading
Loading