standardize packaging, tooling, docs, CI, and licensing

This commit is contained in:
2026-09-10 18:49:04 +05:30
parent c95de5a6e9
commit 0efe68b052
25 changed files with 667 additions and 74 deletions

43
tests/conftest.py Normal file
View File

@@ -0,0 +1,43 @@
"""Shared fixtures for the openapi-first smoke test suite."""
import json
import pytest
MINIMAL_SPEC = {
"openapi": "3.0.3",
"info": {"title": "Smoke Test API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com/v1"}],
"paths": {
"/health": {
"get": {
"operationId": "get_health",
"responses": {"200": {"description": "OK"}},
}
}
},
}
@pytest.fixture
def minimal_spec() -> dict:
"""Return a minimal valid OpenAPI 3.0.3 specification."""
return json.loads(json.dumps(MINIMAL_SPEC))
@pytest.fixture
def spec_file(tmp_path, minimal_spec):
"""Write the minimal spec to a temp JSON file and return its path."""
def _write(name: str, spec: dict | None = None) -> str:
path = tmp_path / name
payload = spec if spec is not None else minimal_spec
if name.endswith(".json"):
path.write_text(json.dumps(payload), encoding="utf-8")
else:
import yaml
path.write_text(yaml.safe_dump(payload), encoding="utf-8")
return str(path)
return _write

89
tests/test_app.py Normal file
View File

@@ -0,0 +1,89 @@
"""Smoke tests for openapi_first.app."""
import types
import pytest
from fastapi.testclient import TestClient
from openapi_first.app import OpenAPIFirstApp
from openapi_first.errors import MissingOperationHandler
SPEC = {
"openapi": "3.0.3",
"info": {"title": "App API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com/v1"}],
"paths": {
"/health": {
"get": {
"operationId": "get_health",
"responses": {"200": {"description": "OK"}},
}
},
"/greet/{name}": {
"get": {
"operationId": "get_greet",
"parameters": [
{
"name": "name",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"200": {"description": "OK"}},
}
},
},
}
def _routes_module():
module = types.ModuleType("smoke_routes")
def get_health():
return {"status": "ok"}
def get_greet(name: str):
return {"greeting": f"Hello, {name}"}
module.get_health = get_health
module.get_greet = get_greet
return module
@pytest.fixture
def spec_file(tmp_path):
import json
path = tmp_path / "openapi.json"
path.write_text(json.dumps(SPEC), encoding="utf-8")
return str(path)
def test_app_routes_served(spec_file):
app = OpenAPIFirstApp(openapi_path=spec_file, routes_module=_routes_module())
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_app_path_param_route(spec_file):
app = OpenAPIFirstApp(openapi_path=spec_file, routes_module=_routes_module())
client = TestClient(app)
response = client.get("/greet/Ada")
assert response.status_code == 200
assert response.json() == {"greeting": "Hello, Ada"}
def test_app_overrides_openapi_schema(spec_file):
app = OpenAPIFirstApp(openapi_path=spec_file, routes_module=_routes_module())
openapi = app.openapi()
assert openapi["info"]["title"] == "App API"
assert "/health" in openapi["paths"]
def test_app_missing_handler_fails_at_startup(spec_file):
empty = types.ModuleType("empty_routes")
with pytest.raises(MissingOperationHandler):
OpenAPIFirstApp(openapi_path=spec_file, routes_module=empty)

72
tests/test_binder.py Normal file
View File

@@ -0,0 +1,72 @@
"""Smoke tests for openapi_first.binder."""
import types
import pytest
from fastapi import FastAPI
from openapi_first.binder import bind_routes
from openapi_first.errors import MissingOperationHandler
def _routes_module():
module = types.ModuleType("smoke_routes")
def get_health():
return {"status": "ok"}
module.get_health = get_health
return module
def test_bind_routes_registers_matching_routes(minimal_spec):
from fastapi.routing import APIRoute
app = FastAPI()
bind_routes(app, minimal_spec, _routes_module())
bound = [r for r in app.router.routes if isinstance(r, APIRoute)]
assert len(bound) == 1
assert bound[0].name == "get_health"
assert bound[0].path == "/health"
assert "GET" in bound[0].methods
def test_bind_routes_missing_handler(minimal_spec):
app = FastAPI()
module = types.ModuleType("empty_routes")
with pytest.raises(MissingOperationHandler):
bind_routes(app, minimal_spec, module)
def test_bind_routes_missing_operation_id(spec_file):
spec = {
"openapi": "3.0.3",
"info": {"title": "No OpId", "version": "1.0.0"},
"paths": {"/health": {"get": {"responses": {"200": {"description": "OK"}}}}},
}
app = FastAPI()
with pytest.raises(MissingOperationHandler):
bind_routes(app, spec, _routes_module())
def test_bind_routes_ignores_spec_only_paths():
from fastapi.routing import APIRoute
spec = {
"openapi": "3.0.3",
"info": {"title": "Path Item", "version": "1.0.0"},
"paths": {
"/health": {
"parameters": [{"name": "x-trace", "in": "header"}],
"get": {
"operationId": "get_health",
"responses": {"200": {"description": "OK"}},
},
}
},
}
app = FastAPI()
bind_routes(app, spec, _routes_module())
bound = [r for r in app.router.routes if isinstance(r, APIRoute)]
assert len(bound) == 1
assert bound[0].name == "get_health"

130
tests/test_client.py Normal file
View File

@@ -0,0 +1,130 @@
"""Smoke tests for openapi_first.client."""
import types
import pytest
from openapi_first.client import OpenAPIClient, OpenAPIClientError
SPEC = {
"openapi": "3.0.3",
"info": {"title": "Client API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com/v1"}],
"paths": {
"/users/{user_id}": {
"get": {
"operationId": "get_user",
"parameters": [
{
"name": "user_id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {"200": {"description": "OK"}},
}
},
"/users": {
"post": {
"operationId": "create_user",
"requestBody": {
"content": {"application/json": {"schema": {"type": "object"}}}
},
"responses": {"201": {"description": "Created"}},
}
},
},
}
class _RecordingClient:
"""Minimal httpx.Client stand-in that records and routes requests."""
def __init__(self, base_url=None):
self.base_url = base_url
self.requests = []
def request(
self,
*,
method,
url,
params=None,
headers=None,
json=None,
content=None,
timeout=None,
):
self.requests.append(
{
"method": method,
"url": url,
"params": params,
"headers": headers,
"json": json,
"content": content,
"timeout": timeout,
}
)
response = types.SimpleNamespace(status_code=200, json=lambda: {"ok": True})
return response
def test_default_base_url_from_servers():
client = OpenAPIClient(SPEC)
assert client.base_url == "https://api.example.com/v1/"
def test_operation_methods_are_generated(minimal_spec):
client = OpenAPIClient(minimal_spec, base_url="http://test")
assert "get_health" in client.operations()
assert callable(client.get_health)
def test_unknown_operation_raises_attribute_error(minimal_spec):
client = OpenAPIClient(minimal_spec, base_url="http://test")
with pytest.raises(AttributeError):
client.does_not_exist()
def test_path_param_formatting_and_urljoin():
rec = _RecordingClient()
client = OpenAPIClient(SPEC, base_url="https://host", client=rec)
client.get_user(path_params={"user_id": 42})
assert rec.requests[0]["url"] == "https://host/users/42"
def test_missing_required_path_param():
rec = _RecordingClient()
client = OpenAPIClient(SPEC, base_url="https://host", client=rec)
with pytest.raises(OpenAPIClientError):
client.get_user(path_params={})
def test_json_body_defaults_content_type():
rec = _RecordingClient()
client = OpenAPIClient(SPEC, base_url="https://host", client=rec)
client.create_user(body={"name": "Ada"})
req = rec.requests[0]
assert req["json"] == {"name": "Ada"}
assert req["headers"].get("Content-Type") == "application/json"
def test_missing_server_raises():
spec = dict(SPEC)
spec.pop("servers")
with pytest.raises(OpenAPIClientError):
OpenAPIClient(spec)
def test_duplicate_operation_id_raises():
spec = dict(SPEC)
spec["paths"]["/extra"] = {
"get": {
"operationId": "get_user",
"responses": {"200": {"description": "OK"}},
}
}
with pytest.raises(OpenAPIClientError):
OpenAPIClient(spec)

63
tests/test_loader.py Normal file
View File

@@ -0,0 +1,63 @@
"""Smoke tests for openapi_first.loader."""
import pytest
from openapi_first.loader import OpenAPISpecLoadError, load_openapi
def test_load_openapi_json(spec_file, minimal_spec):
path = spec_file("openapi.json")
spec = load_openapi(path)
assert spec == minimal_spec
def test_load_openapi_yaml(spec_file, minimal_spec):
path = spec_file("openapi.yaml")
spec = load_openapi(path)
assert spec == minimal_spec
def test_load_missing_file(tmp_path):
with pytest.raises(OpenAPISpecLoadError):
load_openapi(tmp_path / "missing.json")
def test_load_unsupported_extension(tmp_path):
path = tmp_path / "openapi.txt"
path.write_text("{}", encoding="utf-8")
with pytest.raises(OpenAPISpecLoadError):
load_openapi(path)
def test_load_invalid_spec(tmp_path):
path = tmp_path / "invalid.yaml"
path.write_text("not: [valid: openapi", encoding="utf-8")
with pytest.raises(OpenAPISpecLoadError):
load_openapi(path)
def test_load_validation_failure(tmp_path):
path = tmp_path / "bad.yaml"
path.write_text("openapi: 3.0.3\ninfo: {}\n", encoding="utf-8")
with pytest.raises(OpenAPISpecLoadError):
load_openapi(path)
def test_env_var_resolution(spec_file, monkeypatch):
monkeypatch.setenv("AUTH_HOST", "http://auth.internal")
spec = {
"openapi": "3.0.3",
"info": {"title": "Env API", "version": "1.0.0"},
"servers": [{"url": "{AUTH_HOST}/v1"}],
"paths": {
"/ping": {
"get": {
"operationId": "get_ping",
"responses": {"200": {"description": "OK"}},
}
}
},
}
path = spec_file("env.yaml", spec)
loaded = load_openapi(path)
assert loaded["servers"][0]["url"] == "{AUTH_HOST}/v1"