Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install ruff
Expand All @@ -30,12 +30,12 @@ jobs:
python-version: ['3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Pages
Expand Down
49 changes: 36 additions & 13 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,55 @@ name: Publish to PyPI
on:
release:
types: [published]
workflow_dispatch:
inputs:
pypi_target:
description: 'PyPI target (pypi or testpypi)'
default: 'pypi'
type: choice
options:
- pypi
- testpypi

permissions:
contents: read
id-token: write

jobs:
deploy:
publish:
runs-on: ubuntu-latest
environment: pypi

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tools
python-version: "3.12"

- name: Install dependencies
run: |
pip install --upgrade pip
pip install build twine
python -m pip install --upgrade pip
pip install build twine ruff

- name: Lint with ruff
run: pip install ruff && ruff check src/ tests/
run: ruff check src/ --target-version py310

- name: Build package
run: python -m build

- name: Check package
run: twine check dist/*

- name: Publish to TestPyPI
if: ${{ inputs.pypi_target == 'testpypi' }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*
if: ${{ inputs.pypi_target == 'pypi' || github.event_name == 'release' }}
uses: pypa/gh-action-pypi-publish@release/v1
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# apighost

## Purpose
CLI tool that reads an OpenAPI spec and spawns a realistic mock API server with VCR cassette recording and replay — OpenAPI spec → mock server with VCR recording.

## Build & Test Commands
- Install: `pip install -e .` or `pip install git+https://github.com/Coding-Dev-Tools/apighost.git`
- Test: `pytest tests/` (or `python -m pytest tests/ -v --tb=short`)
- Lint: `ruff check src/ tests/`
- Build: `pip install build twine && python -m build && twine check dist/*`
- CLI check: `apighost --help`

## Architecture
Key directories:
- `src/apighost/` — Main package (CLI, OpenAPI parser, mock server, VCR recorder, scenarios)
- `tests/` — Test suite
- `.github/workflows/` — CI/CD (auto-code-review.yml, ci.yml, pages.yml, publish.yml)
- `dist/` — Built distributions

## Conventions
- Language: Python 3.10+
- Test framework: pytest
- CI: GitHub Actions (lint job + test job with matrix: Python 3.10, 3.11, 3.12, 3.13)
- Linting: ruff (line-length 120, target py310)
- Formatting: ruff
- Package layout: src/ layout with setuptools
- Dependencies: click, pyyaml, faker, flask, rich, requests, jsonschema, werkzeug
- CLI entry point: apighost.cli:cli
- Master branch: master
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,3 @@ MIT — see [LICENSE](LICENSE)
---

<sub>Part of the [Coding-Dev-Tools](https://github.com/Coding-Dev-Tools) ecosystem — a suite of developer CLI tools. Also check out [API Contract Guardian](https://github.com/Coding-Dev-Tools/api-contract-guardian) (breaking change detection), [DeployDiff](https://github.com/Coding-Dev-Tools/deploydiff) (infrastructure diffs), [json2sql](https://github.com/Coding-Dev-Tools/json2sql) (JSON → SQL), [ConfigDrift](https://github.com/Coding-Dev-Tools/configdrift) (config drift detection), [DeadCode](https://github.com/Coding-Dev-Tools/deadcode) (dead code cleanup), [Envault](https://github.com/Coding-Dev-Tools/envault) (env sync), [SchemaForge](https://github.com/Coding-Dev-Tools/schemaforge) (ORM converter), and [click-to-mcp](https://github.com/Coding-Dev-Tools/click-to-mcp) (CLI → MCP server).</sub>

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"httpx>=0.27.0",
"ruff>=0.4.0",
]

[project.urls]
Expand Down
1 change: 1 addition & 0 deletions src/apighost/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Enable `python -m apighost` CLI execution."""

from apighost.cli import cli

if __name__ == "__main__":
Expand Down
26 changes: 14 additions & 12 deletions src/apighost/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def serve(spec, port, host, scenario, record, cassette_name, latency, watch):
# Run with WSGI
try:
from werkzeug.serving import run_simple

run_simple(host, port, app, use_reloader=False, threaded=True)
except KeyboardInterrupt:
_on_shutdown(recorder, name, spec)
Expand Down Expand Up @@ -130,6 +131,7 @@ def record(spec, output, port, requests):

# Start server in background
from werkzeug.serving import run_simple

thread = threading.Thread(
target=lambda: run_simple("127.0.0.1", port, app, use_reloader=False),
daemon=True,
Expand Down Expand Up @@ -199,8 +201,7 @@ def _replay_handler(path):
full_path = "/" + path
for interaction in cassette_data.interactions:
req_path = interaction.request_path.rstrip("/")
if (interaction.request_method == flask_request.method
and req_path == full_path.rstrip("/")):
if interaction.request_method == flask_request.method and req_path == full_path.rstrip("/"):
return (
interaction.response_body,
interaction.response_status,
Expand All @@ -211,17 +212,19 @@ def _replay_handler(path):

@app.route("/")
def _replay_home():
return jsonify({
"service": f"APIGhost Replay - {cassette_data.name}",
"interactions": len(cassette_data.interactions),
"endpoints": list(set(f"{i.request_method} {i.request_path}"
for i in cassette_data.interactions)),
})
return jsonify(
{
"service": f"APIGhost Replay - {cassette_data.name}",
"interactions": len(cassette_data.interactions),
"endpoints": list(set(f"{i.request_method} {i.request_path}" for i in cassette_data.interactions)),
}
)

click.echo(f"\n Replay server at http://{host}:{port}")
click.echo(" Press Ctrl+C to stop\n")

from werkzeug.serving import run_simple

try:
run_simple(host, port, app, use_reloader=False, threaded=True)
except KeyboardInterrupt:
Expand Down Expand Up @@ -363,9 +366,9 @@ def generate(spec, output, name, max_endpoints):

for ep in endpoints:
key = f"{ep.method} {ep.path}"
body = generate_value(
next(iter(ep.responses.values())).schema_ref if ep.responses else None
) or {"message": f"Auto-generated response for {key}"}
body = generate_value(next(iter(ep.responses.values())).schema_ref if ep.responses else None) or {
"message": f"Auto-generated response for {key}"
}
overrides[key] = {"status": 200, "body": body}
click.echo(f" {ep.method:<6} {ep.path}")

Expand All @@ -389,4 +392,3 @@ def info():

if __name__ == "__main__":
cli()

12 changes: 6 additions & 6 deletions src/apighost/faker_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"phone": lambda: _faker.phone_number(),
"int64": lambda: _faker.random_int(min=0, max=10**12),
"int32": lambda: _faker.random_int(min=0, max=2**31 - 1),
"float": lambda: round(_faker.pyfloat(min_value=-10**6, max_value=10**6), 2),
"double": lambda: _faker.pyfloat(min_value=-10**12, max_value=10**12),
"float": lambda: round(_faker.pyfloat(min_value=-(10**6), max_value=10**6), 2),
"double": lambda: _faker.pyfloat(min_value=-(10**12), max_value=10**12),
"binary": lambda: _faker.binary(16).hex(),
"byte": lambda: _faker.binary(8).hex(),
"password": lambda: _faker.password(),
Expand All @@ -51,9 +51,9 @@
"body": lambda: _faker.paragraph(nb_sentences=4),
"message": lambda: _faker.sentence(),
"error": lambda: _faker.sentence(),
"status": lambda: random.choice(["active", "inactive", "pending", "archived"]),
"type": lambda: random.choice(["user", "admin", "moderator", "guest"]),
"role": lambda: random.choice(["admin", "editor", "viewer", "contributor"]),
"status": lambda: _faker.random_element(["active", "inactive", "pending", "archived"]),
"type": lambda: _faker.random_element(["user", "admin", "moderator", "guest"]),
"role": lambda: _faker.random_element(["admin", "editor", "viewer", "contributor"]),
"slug": lambda: _faker.slug(),
"avatar": lambda: f"https://api.dicebear.com/7.x/avataaars/svg?seed={_faker.user_name()}",
"image": lambda: f"https://picsum.photos/seed/{_faker.random_int()}/400/300",
Expand Down Expand Up @@ -115,7 +115,7 @@ def generate_value(schema: dict | None, property_name: str = "") -> Any:
return _faker.random_int(min=minimum, max=maximum)

if schema_type == "number":
return round(_faker.pyfloat(min_value=-10**6, max_value=10**6), 2)
return round(_faker.pyfloat(min_value=-(10**6), max_value=10**6), 2)

if schema_type == "boolean":
return _faker.boolean()
Expand Down
25 changes: 11 additions & 14 deletions src/apighost/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,7 @@ def _resolve_schema_refs(schema: dict | None, spec: dict) -> dict | None:
elif isinstance(value, dict):
resolved[key] = _resolve_schema_refs(value, spec)
elif isinstance(value, list):
resolved[key] = [
_resolve_schema_refs(item, spec) if isinstance(item, dict) else item
for item in value
]
resolved[key] = [_resolve_schema_refs(item, spec) if isinstance(item, dict) else item for item in value]
else:
resolved[key] = value
return resolved
Expand All @@ -75,13 +72,15 @@ def _parse_parameters(path_item: dict, path: str, spec: dict) -> list[Parameter]
name = resolved.get("name", "")
if name not in seen:
seen.add(name)
params.append(Parameter(
name=name,
location=resolved.get("in", "query"),
required=resolved.get("required", False),
schema_ref=resolved.get("schema", {}),
example=resolved.get("example") or resolved.get("schema", {}).get("example"),
))
params.append(
Parameter(
name=name,
location=resolved.get("in", "query"),
required=resolved.get("required", False),
schema_ref=resolved.get("schema", {}),
example=resolved.get("example") or resolved.get("schema", {}).get("example"),
)
)

return params

Expand Down Expand Up @@ -188,9 +187,7 @@ def parse_spec(path: str | Path) -> ApiSpec:
content = rb.get("content", {})
if content:
content_type = list(content.keys())[0]
endpoint.request_body_schema = _resolve_schema_refs(
content[content_type].get("schema", {}), raw
)
endpoint.request_body_schema = _resolve_schema_refs(content[content_type].get("schema", {}), raw)

spec.endpoints.append(endpoint)

Expand Down
Empty file added src/apighost/py.typed
Empty file.
20 changes: 11 additions & 9 deletions src/apighost/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,22 @@ def list_scenarios() -> list[dict]:
for f in sorted(SCENARIO_DIR.glob("*.json")):
try:
data = json.loads(f.read_text())
scenarios.append({
"name": data.get("name", f.stem),
"description": data.get("description", ""),
"overrides": len(data.get("overrides", {})),
"path": str(f),
})
scenarios.append(
{
"name": data.get("name", f.stem),
"description": data.get("description", ""),
"overrides": len(data.get("overrides", {})),
"path": str(f),
}
)
except (json.JSONDecodeError, OSError):
continue
return scenarios


def save_scenario(name: str, description: str = "",
overrides: dict[str, dict] | None = None,
output_path: str | None = None) -> str:
def save_scenario(
name: str, description: str = "", overrides: dict[str, dict] | None = None, output_path: str | None = None
) -> str:
"""Save a scenario definition.

Args:
Expand Down
7 changes: 7 additions & 0 deletions src/apighost/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@dataclass
class Parameter:
"""An API parameter (path, query, header, cookie)."""

name: str
location: str # path, query, header, cookie
required: bool = False
Expand All @@ -19,6 +20,7 @@ class Parameter:
@dataclass
class Response:
"""An API response definition."""

status_code: int
content_type: str = "application/json"
schema_ref: dict | None = None
Expand All @@ -29,6 +31,7 @@ class Response:
@dataclass
class Endpoint:
"""A single API endpoint parsed from an OpenAPI spec."""

path: str
method: str # GET, POST, PUT, DELETE, PATCH
operation_id: str = ""
Expand All @@ -45,6 +48,7 @@ class Endpoint:
@dataclass
class ApiSpec:
"""Top-level parsed OpenAPI specification."""

title: str = ""
version: str = ""
description: str = ""
Expand All @@ -57,6 +61,7 @@ class ApiSpec:
@dataclass
class CassetteInteraction:
"""A single recorded HTTP interaction."""

request_method: str
request_path: str
request_headers: dict
Expand All @@ -69,6 +74,7 @@ class CassetteInteraction:
@dataclass
class Cassette:
"""VCR-style cassette of recorded interactions."""

name: str
interactions: list[CassetteInteraction] = field(default_factory=list)
spec_path: str = ""
Expand All @@ -77,6 +83,7 @@ class Cassette:
@dataclass
class Scenario:
"""A named scenario with preset responses."""

name: str
description: str = ""
overrides: dict[str, dict] = field(default_factory=dict)
Expand Down
Loading