standardize packaging, tooling, docs, CI, and licensing
This commit is contained in:
22
.drone.yml
22
.drone.yml
@@ -32,6 +32,26 @@ steps:
|
|||||||
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
|
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: quality-gate
|
||||||
|
image: python:3.13-slim
|
||||||
|
environment:
|
||||||
|
PIP_REPO_URL:
|
||||||
|
from_secret: PIP_REPO_URL
|
||||||
|
PIP_USERNAME:
|
||||||
|
from_secret: PIP_USERNAME
|
||||||
|
PIP_PASSWORD:
|
||||||
|
from_secret: PIP_PASSWORD
|
||||||
|
commands:
|
||||||
|
- pip install --upgrade pip build
|
||||||
|
- |
|
||||||
|
AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple"
|
||||||
|
pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]"
|
||||||
|
- echo "🛡️ Running quality gate..."
|
||||||
|
- python -m black --check .
|
||||||
|
- python -m ruff check .
|
||||||
|
- python -m mypy
|
||||||
|
- python -m pytest
|
||||||
|
|
||||||
- name: build-package
|
- name: build-package
|
||||||
image: python:3.13-slim
|
image: python:3.13-slim
|
||||||
commands:
|
commands:
|
||||||
@@ -126,4 +146,4 @@ steps:
|
|||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
- custom
|
- custom
|
||||||
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `py.typed` marker for PEP 561 type information.
|
||||||
|
- `.drone.yml` CI with a quality-gate step (black, ruff, mypy, pytest).
|
||||||
|
- Canonical `docforge.nav.yml`, generated `mkdocs.yml` and `mcp_docs/` via doc-forge.
|
||||||
|
- MIT `LICENSE`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Standardized `pyproject.toml` (canonical packaging, lint tool config, extras).
|
||||||
|
- `templates/` excluded from black/ruff scans.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stub fixes for typed API surfaces.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Aetoskia Platform
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from . import app as app
|
from . import app as app
|
||||||
from . import binder as binder
|
from . import binder as binder
|
||||||
from . import loader as loader
|
|
||||||
from . import client as client
|
from . import client as client
|
||||||
from . import errors as errors
|
|
||||||
from . import codegen as codegen
|
from . import codegen as codegen
|
||||||
from . import codegen_routes as codegen_routes
|
from . import codegen_routes as codegen_routes
|
||||||
|
from . import errors as errors
|
||||||
|
from . import loader as loader
|
||||||
|
|
||||||
__all__ = ["app", "binder", "loader", "client", "errors", "codegen", "codegen_routes"]
|
__all__ = ["app", "binder", "loader", "client", "errors", "codegen", "codegen_routes"]
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ _env_pattern = re.compile(r"\{(\w+)\}")
|
|||||||
|
|
||||||
def _resolve_env(value: str) -> str:
|
def _resolve_env(value: str) -> str:
|
||||||
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||||
|
|
||||||
def _replace(m: re.Match) -> str:
|
def _replace(m: re.Match) -> str:
|
||||||
return os.environ.get(m.group(1), m.group(0))
|
return os.environ.get(m.group(1), m.group(0))
|
||||||
|
|
||||||
return _env_pattern.sub(_replace, value)
|
return _env_pattern.sub(_replace, value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
class OpenAPIFirstApp(FastAPI):
|
class OpenAPIFirstApp(FastAPI):
|
||||||
def __init__(self, *, openapi_path: str, routes_module: Any, **fastapi_kwargs: Any) -> None: ...
|
def __init__(
|
||||||
|
self, *, openapi_path: str, routes_module: Any, **fastapi_kwargs: Any
|
||||||
|
) -> None: ...
|
||||||
|
|||||||
@@ -85,8 +85,14 @@ def bind_routes(
|
|||||||
paths = spec.get("paths", {})
|
paths = spec.get("paths", {})
|
||||||
security_deps = security_deps or {}
|
security_deps = security_deps or {}
|
||||||
|
|
||||||
|
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
||||||
|
|
||||||
for path, methods in paths.items():
|
for path, methods in paths.items():
|
||||||
for http_method, operation in methods.items():
|
for http_method, operation in methods.items():
|
||||||
|
if http_method.lower() not in http_methods or not isinstance(
|
||||||
|
operation, dict
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
operation_id = operation.get("operationId")
|
operation_id = operation.get("operationId")
|
||||||
if not operation_id:
|
if not operation_id:
|
||||||
@@ -102,7 +108,7 @@ def bind_routes(
|
|||||||
path=path,
|
path=path,
|
||||||
method=http_method,
|
method=http_method,
|
||||||
operation_id=operation_id,
|
operation_id=operation_id,
|
||||||
)
|
) from None
|
||||||
|
|
||||||
key = f"{http_method.upper()}:{path}"
|
key = f"{http_method.upper()}:{path}"
|
||||||
deps = security_deps.get(key, [])
|
deps = security_deps.get(key, [])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
def bind_routes(app: FastAPI, spec: Dict[str, Any], routes_module: Any) -> None: ...
|
def bind_routes(app: FastAPI, spec: dict[str, Any], routes_module: Any) -> None: ...
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ bundled templates packaged with the library.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = "health_app"
|
DEFAULT_TEMPLATE = "health_app"
|
||||||
|
|
||||||
@@ -52,14 +51,15 @@ def copy_template(template: str, target_dir: Path) -> None:
|
|||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
root = resources.files("openapi_first.templates")
|
root = resources.files("openapi_first.templates")
|
||||||
src = root / template
|
|
||||||
|
|
||||||
if not src.exists():
|
if template not in available_templates():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"Template '{template}' not found. "
|
f"Template '{template}' not found. "
|
||||||
f"Available templates: {', '.join(available_templates())}"
|
f"Available templates: {', '.join(available_templates())}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
src = root / template
|
||||||
|
|
||||||
with resources.as_file(src) as path:
|
with resources.as_file(src) as path:
|
||||||
shutil.copytree(path, target_dir, dirs_exist_ok=True)
|
shutil.copytree(path, target_dir, dirs_exist_ok=True)
|
||||||
|
|
||||||
@@ -168,11 +168,11 @@ def main() -> None:
|
|||||||
# Handle the case where someone uses the old CLI style (openapi-first template path)
|
# Handle the case where someone uses the old CLI style (openapi-first template path)
|
||||||
# argparse with subparsers might not automatically handle this if positional args are passed
|
# argparse with subparsers might not automatically handle this if positional args are passed
|
||||||
# but let's assume we want to encourage the new style.
|
# but let's assume we want to encourage the new style.
|
||||||
|
|
||||||
# If no command was provided but positional args were, they might be for scaffolding
|
# If no command was provided but positional args were, they might be for scaffolding
|
||||||
# This is a bit tricky with argparse subparsers.
|
# This is a bit tricky with argparse subparsers.
|
||||||
# For simplicity, let's just support the new explicit commands.
|
# For simplicity, let's just support the new explicit commands.
|
||||||
|
|
||||||
if args.command is None and not any(vars(args).values()):
|
if args.command is None and not any(vars(args).values()):
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ Notes:
|
|||||||
- This module intentionally does NOT: Generate client code, validate request/response schemas, deserialize responses, retry requests, implement authentication helpers, or assume non-2xx responses are failures.
|
- This module intentionally does NOT: Generate client code, validate request/response schemas, deserialize responses, retry requests, implement authentication helpers, or assume non-2xx responses are failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -88,8 +89,8 @@ class OpenAPIClient:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
spec: dict[str, Any],
|
spec: dict[str, Any],
|
||||||
base_url: Optional[str] = None,
|
base_url: str | None = None,
|
||||||
client: Optional[httpx.Client] = None,
|
client: httpx.Client | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the OpenAPI client.
|
Initialize the OpenAPI client.
|
||||||
@@ -110,7 +111,7 @@ class OpenAPIClient:
|
|||||||
self.base_url = base_url or self._resolve_base_url(spec)
|
self.base_url = base_url or self._resolve_base_url(spec)
|
||||||
self.client = client or httpx.Client(base_url=self.base_url)
|
self.client = client or httpx.Client(base_url=self.base_url)
|
||||||
|
|
||||||
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
|
self._operations: dict[str, Callable[..., httpx.Response]] = {}
|
||||||
self._build_operations()
|
self._build_operations()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -123,7 +124,7 @@ class OpenAPIClient:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise AttributeError(f"No such operationId: {name}") from None
|
raise AttributeError(f"No such operationId: {name}") from None
|
||||||
|
|
||||||
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
|
def operations(self) -> dict[str, Callable[..., httpx.Response]]:
|
||||||
return dict(self._operations)
|
return dict(self._operations)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -149,7 +150,13 @@ class OpenAPIClient:
|
|||||||
for path, path_item in paths.items():
|
for path, path_item in paths.items():
|
||||||
for method, operation in path_item.items():
|
for method, operation in path_item.items():
|
||||||
if method.lower() not in {
|
if method.lower() not in {
|
||||||
"get", "post", "put", "patch", "delete", "head", "options"
|
"get",
|
||||||
|
"post",
|
||||||
|
"put",
|
||||||
|
"patch",
|
||||||
|
"delete",
|
||||||
|
"head",
|
||||||
|
"options",
|
||||||
}:
|
}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -181,11 +188,11 @@ class OpenAPIClient:
|
|||||||
|
|
||||||
def call(
|
def call(
|
||||||
*,
|
*,
|
||||||
path_params: Optional[dict[str, Any]] = None,
|
path_params: dict[str, Any] | None = None,
|
||||||
query: Optional[dict[str, Any]] = None,
|
query: dict[str, Any] | None = None,
|
||||||
headers: Optional[dict[str, str]] = None,
|
headers: dict[str, str] | None = None,
|
||||||
body: Optional[Any] = None,
|
body: Any | None = None,
|
||||||
timeout: Optional[float] = None,
|
timeout: float | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
url = self._build_url(path, path_params or {})
|
url = self._build_url(path, path_params or {})
|
||||||
|
|
||||||
@@ -228,9 +235,7 @@ class OpenAPIClient:
|
|||||||
try:
|
try:
|
||||||
formatted_path = path.format(**path_params)
|
formatted_path = path.format(**path_params)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise OpenAPIClientError(
|
raise OpenAPIClientError(f"Missing path parameter: {exc.args[0]}") from exc
|
||||||
f"Missing path parameter: {exc.args[0]}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
from typing import Any, Callable, Dict, Optional
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .errors import OpenAPIFirstError
|
from .errors import OpenAPIFirstError
|
||||||
|
|
||||||
class OpenAPIClientError(OpenAPIFirstError): ...
|
class OpenAPIClientError(OpenAPIFirstError): ...
|
||||||
|
|
||||||
class OpenAPIClient:
|
class OpenAPIClient:
|
||||||
spec: Dict[str, Any]
|
spec: dict[str, Any]
|
||||||
base_url: str
|
base_url: str
|
||||||
client: httpx.Client
|
client: httpx.Client
|
||||||
def __init__(self, spec: Dict[str, Any], base_url: Optional[str] = ..., client: Optional[httpx.Client] = ...) -> None: ...
|
def __init__(
|
||||||
|
self,
|
||||||
|
spec: dict[str, Any],
|
||||||
|
base_url: str | None = ...,
|
||||||
|
client: httpx.Client | None = ...,
|
||||||
|
) -> None: ...
|
||||||
def __getattr__(self, name: str) -> Callable[..., httpx.Response]: ...
|
def __getattr__(self, name: str) -> Callable[..., httpx.Response]: ...
|
||||||
def operations(self) -> Dict[str, Callable[..., httpx.Response]]: ...
|
def operations(self) -> dict[str, Callable[..., httpx.Response]]: ...
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ definitions.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from datamodel_code_generator import (
|
from datamodel_code_generator import (
|
||||||
InputFileType,
|
InputFileType,
|
||||||
PythonVersion,
|
PythonVersion,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def generate_routes(
|
|||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Group paths by resource (first non-param path segment)
|
# Group paths by resource (first non-param path segment)
|
||||||
resources: dict[str, list[tuple[str, str, dict]]] = {}
|
resources_map: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
|
||||||
|
|
||||||
paths = spec.get("paths", {})
|
paths = spec.get("paths", {})
|
||||||
for path, methods in paths.items():
|
for path, methods in paths.items():
|
||||||
@@ -74,18 +74,18 @@ def generate_routes(
|
|||||||
if not segments:
|
if not segments:
|
||||||
continue
|
continue
|
||||||
resource = segments[0]
|
resource = segments[0]
|
||||||
if resource not in resources:
|
if resource not in resources_map:
|
||||||
resources[resource] = []
|
resources_map[resource] = []
|
||||||
|
|
||||||
for http_method, operation in methods.items():
|
for http_method, operation in methods.items():
|
||||||
if http_method.startswith("x-"):
|
if http_method.startswith("x-"):
|
||||||
continue
|
continue
|
||||||
resources[resource].append((path, http_method, operation))
|
resources_map[resource].append((path, http_method, operation))
|
||||||
|
|
||||||
generated_files: list[Path] = []
|
generated_files: list[Path] = []
|
||||||
|
|
||||||
for resource in sorted(resources):
|
for resource in sorted(resources_map):
|
||||||
operations = resources[resource]
|
operations = resources_map[resource]
|
||||||
_validate_operations(resource, operations)
|
_validate_operations(resource, operations)
|
||||||
|
|
||||||
file_path = output_dir / f"{resource}.py"
|
file_path = output_dir / f"{resource}.py"
|
||||||
@@ -116,7 +116,9 @@ _TYPE_MAP: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validate_operations(resource: str, operations: list[tuple[str, str, dict]]) -> None:
|
def _validate_operations(
|
||||||
|
resource: str, operations: list[tuple[str, str, dict[str, Any]]]
|
||||||
|
) -> None:
|
||||||
"""Ensure every operation has an operationId."""
|
"""Ensure every operation has an operationId."""
|
||||||
for path, http_method, operation in operations:
|
for path, http_method, operation in operations:
|
||||||
if not operation.get("operationId"):
|
if not operation.get("operationId"):
|
||||||
@@ -144,7 +146,7 @@ def _get_request_body_schema(operation: dict[str, Any]) -> str | None:
|
|||||||
schema = media_info.get("schema", {})
|
schema = media_info.get("schema", {})
|
||||||
ref = schema.get("$ref", "")
|
ref = schema.get("$ref", "")
|
||||||
if ref:
|
if ref:
|
||||||
return ref.rsplit("/", 1)[-1]
|
return str(ref.rsplit("/", 1)[-1])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +161,7 @@ def _get_success_status(operation: dict[str, Any]) -> str:
|
|||||||
return "200"
|
return "200"
|
||||||
|
|
||||||
|
|
||||||
def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
|
def _needs_any(operations: list[tuple[str, str, dict[str, Any]]]) -> bool:
|
||||||
"""Check if any operation uses a type that requires `from typing import Any`."""
|
"""Check if any operation uses a type that requires `from typing import Any`."""
|
||||||
for _, _, op in operations:
|
for _, _, op in operations:
|
||||||
for param in op.get("parameters", []):
|
for param in op.get("parameters", []):
|
||||||
@@ -176,7 +178,7 @@ def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
|
|||||||
|
|
||||||
def _generate_resource_file(
|
def _generate_resource_file(
|
||||||
resource: str,
|
resource: str,
|
||||||
operations: list[tuple[str, str, dict]],
|
operations: list[tuple[str, str, dict[str, Any]]],
|
||||||
spec_path: str,
|
spec_path: str,
|
||||||
use_models: bool,
|
use_models: bool,
|
||||||
models_module: str,
|
models_module: str,
|
||||||
@@ -205,7 +207,9 @@ def _generate_resource_file(
|
|||||||
if schema:
|
if schema:
|
||||||
schemas_needed.add(schema)
|
schemas_needed.add(schema)
|
||||||
if schemas_needed:
|
if schemas_needed:
|
||||||
lines.append(f"from {models_module} import {', '.join(sorted(schemas_needed))}")
|
lines.append(
|
||||||
|
f"from {models_module} import {', '.join(sorted(schemas_needed))}"
|
||||||
|
)
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -237,7 +241,6 @@ def _generate_handler(
|
|||||||
schema: dict[str, Any] = param.get("schema", {})
|
schema: dict[str, Any] = param.get("schema", {})
|
||||||
param_type: str = _resolve_type(schema)
|
param_type: str = _resolve_type(schema)
|
||||||
required: bool = param.get("required", False)
|
required: bool = param.get("required", False)
|
||||||
description: str = schema.get("description", schema.get("x-description", ""))
|
|
||||||
default_raw = schema.get("default")
|
default_raw = schema.get("default")
|
||||||
|
|
||||||
if param_in == "path":
|
if param_in == "path":
|
||||||
@@ -277,13 +280,15 @@ def _generate_handler(
|
|||||||
# Document parameters
|
# Document parameters
|
||||||
doc_params: list[tuple[str, str, str]] = []
|
doc_params: list[tuple[str, str, str]] = []
|
||||||
for param in operation.get("parameters", []):
|
for param in operation.get("parameters", []):
|
||||||
name: str = param.get("name", "")
|
doc_name: str = param.get("name", "")
|
||||||
param_in: str = param.get("in", "")
|
doc_in: str = param.get("in", "")
|
||||||
schema: dict[str, Any] = param.get("schema", {})
|
doc_schema: dict[str, Any] = param.get("schema", {})
|
||||||
param_type: str = _resolve_type(schema)
|
doc_type: str = _resolve_type(doc_schema)
|
||||||
description: str = param.get("description", schema.get("x-description", ""))
|
doc_description: str = param.get(
|
||||||
if param_in in ("path", "query"):
|
"description", doc_schema.get("x-description", "")
|
||||||
doc_params.append((name, param_type, description))
|
)
|
||||||
|
if doc_in in ("path", "query"):
|
||||||
|
doc_params.append((doc_name, doc_type, doc_description))
|
||||||
|
|
||||||
if doc_params:
|
if doc_params:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Notes:
|
|||||||
These errors should normally cause immediate application failure.
|
These errors should normally cause immediate application failure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OpenAPIFirstError(Exception):
|
class OpenAPIFirstError(Exception):
|
||||||
"""
|
"""
|
||||||
Base exception for all OpenAPI-first enforcement errors.
|
Base exception for all OpenAPI-first enforcement errors.
|
||||||
@@ -31,6 +32,7 @@ class OpenAPIFirstError(Exception):
|
|||||||
- All exceptions raised by the OpenAPI-first core should inherit
|
- All exceptions raised by the OpenAPI-first core should inherit
|
||||||
from this type.
|
from this type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -70,9 +72,6 @@ class MissingOperationHandler(OpenAPIFirstError):
|
|||||||
f"({method.upper()} {path})"
|
f"({method.upper()} {path})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message = (
|
message = f"Missing operationId for operation " f"({method.upper()} {path})"
|
||||||
f"Missing operationId for operation "
|
|
||||||
f"({method.upper()} {path})"
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
class OpenAPIFirstError(Exception): ...
|
class OpenAPIFirstError(Exception): ...
|
||||||
|
|
||||||
class MissingOperationHandler(OpenAPIFirstError):
|
class MissingOperationHandler(OpenAPIFirstError):
|
||||||
def __init__(self, *, path: str, method: str, operation_id: Optional[str] = ...) -> None: ...
|
def __init__(
|
||||||
|
self, *, path: str, method: str, operation_id: str | None = ...
|
||||||
|
) -> None: ...
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class OpenAPISpecLoadError(OpenAPIFirstError):
|
|||||||
- This error indicates that the OpenAPI document is unreadable,
|
- This error indicates that the OpenAPI document is unreadable,
|
||||||
malformed, or violates the OpenAPI 3.x specification.
|
malformed, or violates the OpenAPI 3.x specification.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Union
|
from typing import Any
|
||||||
|
|
||||||
from .errors import OpenAPIFirstError
|
from .errors import OpenAPIFirstError
|
||||||
|
|
||||||
class OpenAPISpecLoadError(OpenAPIFirstError): ...
|
class OpenAPISpecLoadError(OpenAPIFirstError): ...
|
||||||
|
|
||||||
def load_openapi(path: Union[str, Path]) -> Dict[str, Any]: ...
|
def load_openapi(path: str | Path) -> dict[str, Any]: ...
|
||||||
|
|||||||
0
openapi_first/py.typed
Normal file
0
openapi_first/py.typed
Normal file
@@ -8,10 +8,11 @@ FastAPI dependencies for token validation (e.g., Bearer JWT introspection).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, Request, HTTPException
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
_env_pattern = re.compile(r"\{(\w+)\}")
|
_env_pattern = re.compile(r"\{(\w+)\}")
|
||||||
@@ -19,14 +20,16 @@ _env_pattern = re.compile(r"\{(\w+)\}")
|
|||||||
|
|
||||||
def _resolve_env(value: str) -> str:
|
def _resolve_env(value: str) -> str:
|
||||||
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||||
def _replace(m: re.Match) -> str:
|
|
||||||
|
def _replace(m: re.Match[str]) -> str:
|
||||||
return os.environ.get(m.group(1), "")
|
return os.environ.get(m.group(1), "")
|
||||||
|
|
||||||
return _env_pattern.sub(_replace, value)
|
return _env_pattern.sub(_replace, value)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_scheme(scheme: dict) -> dict:
|
def _resolve_scheme(scheme: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Recursively resolve env vars in all string-valued fields of a scheme."""
|
"""Recursively resolve env vars in all string-valued fields of a scheme."""
|
||||||
resolved = {}
|
resolved: dict[str, Any] = {}
|
||||||
for key, value in scheme.items():
|
for key, value in scheme.items():
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
resolved[key] = _resolve_env(value)
|
resolved[key] = _resolve_env(value)
|
||||||
@@ -37,13 +40,13 @@ def _resolve_scheme(scheme: dict) -> dict:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def parse_security_schemes(spec: dict) -> dict[str, dict]:
|
def parse_security_schemes(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
"""Extract and resolve environment variables in security schemes."""
|
"""Extract and resolve environment variables in security schemes."""
|
||||||
raw = spec.get("components", {}).get("securitySchemes", {})
|
raw = spec.get("components", {}).get("securitySchemes", {})
|
||||||
return {name: _resolve_scheme(scheme) for name, scheme in raw.items()}
|
return {name: _resolve_scheme(scheme) for name, scheme in raw.items()}
|
||||||
|
|
||||||
|
|
||||||
def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
def _make_bearer_dependency(introspect_url: str | None) -> Callable[..., Any]:
|
||||||
"""
|
"""
|
||||||
Create a FastAPI dependency that validates a Bearer JWT.
|
Create a FastAPI dependency that validates a Bearer JWT.
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Authentication service unavailable",
|
detail="Authentication service unavailable",
|
||||||
)
|
) from None
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
@@ -92,7 +95,9 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
|||||||
return _bearer_dep
|
return _bearer_dep
|
||||||
|
|
||||||
|
|
||||||
def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
def _build_dependency(
|
||||||
|
scheme_name: str, scheme: dict[str, Any]
|
||||||
|
) -> Callable[..., Any] | None:
|
||||||
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
|
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
|
||||||
scheme_type = scheme.get("type")
|
scheme_type = scheme.get("type")
|
||||||
|
|
||||||
@@ -106,8 +111,8 @@ def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
|||||||
|
|
||||||
|
|
||||||
def make_security_dependencies(
|
def make_security_dependencies(
|
||||||
spec: dict,
|
spec: dict[str, Any],
|
||||||
security_schemes: dict[str, dict],
|
security_schemes: dict[str, dict[str, Any]],
|
||||||
) -> dict[str, list[Any]]:
|
) -> dict[str, list[Any]]:
|
||||||
"""
|
"""
|
||||||
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.
|
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.
|
||||||
|
|||||||
102
pyproject.toml
102
pyproject.toml
@@ -37,9 +37,11 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
"Framework :: FastAPI",
|
"Framework :: FastAPI",
|
||||||
"Topic :: Software Development :: Libraries",
|
"Topic :: Software Development :: Libraries",
|
||||||
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||||
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -66,9 +68,15 @@ openapi-first = "openapi_first.cli:main"
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
|
"black>=23.0.0",
|
||||||
"ruff>=0.3.0",
|
"ruff>=0.3.0",
|
||||||
"mypy>=1.8.0",
|
"mypy>=1.8.0",
|
||||||
|
"build>=1.0.0",
|
||||||
|
"twine>=4.0.0",
|
||||||
|
"pre-commit>=3.4.0",
|
||||||
|
"doc-forge[mcp,mkdocs]>=0.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
@@ -77,6 +85,10 @@ docs = [
|
|||||||
"mkdocstrings[python]>=0.24.0",
|
"mkdocstrings[python]>=0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
all = [
|
||||||
|
"openapi-first[dev,docs]",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://git.aetoskia.com/aetos/openapi-first"
|
Homepage = "https://git.aetoskia.com/aetos/openapi-first"
|
||||||
@@ -90,15 +102,101 @@ Versions = "https://git.aetoskia.com/aetos/openapi-first/tags"
|
|||||||
packages = { find = { include = ["openapi_first*"] } }
|
packages = { find = { include = ["openapi_first*"] } }
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
openapi_first = ["templates/**/*"]
|
openapi_first = ["py.typed", "templates/**/*"]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"--strict-markers",
|
||||||
|
"--strict-config",
|
||||||
|
"--cov=openapi_first",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=html",
|
||||||
|
"--cov-report=xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ["py310", "py311", "py312", "py313"]
|
||||||
|
include = '\.pyi?$'
|
||||||
|
extend-exclude = '''
|
||||||
|
/(
|
||||||
|
\.eggs
|
||||||
|
| \.git
|
||||||
|
| \.hg
|
||||||
|
| \.mypy_cache
|
||||||
|
| \.tox
|
||||||
|
| \.venv
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
| openapi_first/templates
|
||||||
|
)/
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 88
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
|
exclude = ["openapi_first/templates"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E",
|
||||||
|
"W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"B",
|
||||||
|
"C4",
|
||||||
|
"UP",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501",
|
||||||
|
"B008",
|
||||||
|
"C901",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"__init__.py" = ["F401", "I001"]
|
||||||
|
"tests/*" = ["B008"]
|
||||||
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.10"
|
python_version = "3.10"
|
||||||
strict = true
|
strict = true
|
||||||
|
exclude = [
|
||||||
|
"openapi_first/templates/",
|
||||||
|
"tests/",
|
||||||
|
]
|
||||||
|
files = ["openapi_first"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = [
|
||||||
|
"openapi_spec_validator.*",
|
||||||
|
"datamodel_code_generator.*",
|
||||||
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["openapi_first"]
|
||||||
|
omit = [
|
||||||
|
"*/tests/*",
|
||||||
|
"*/test_*.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"@abstractmethod",
|
||||||
|
]
|
||||||
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