standardize packaging, tooling, docs, CI, and licensing
This commit is contained in:
43
tests/conftest.py
Normal file
43
tests/conftest.py
Normal 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
89
tests/test_app.py
Normal 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
72
tests/test_binder.py
Normal 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
130
tests/test_client.py
Normal 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
63
tests/test_loader.py
Normal 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"
|
||||
Reference in New Issue
Block a user