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"
|
||||
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
|
||||
image: python:3.13-slim
|
||||
commands:
|
||||
@@ -126,4 +146,4 @@ steps:
|
||||
|
||||
trigger:
|
||||
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 binder as binder
|
||||
from . import loader as loader
|
||||
from . import client as client
|
||||
from . import errors as errors
|
||||
from . import codegen as codegen
|
||||
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"]
|
||||
|
||||
@@ -46,8 +46,10 @@ _env_pattern = re.compile(r"\{(\w+)\}")
|
||||
|
||||
def _resolve_env(value: str) -> str:
|
||||
"""Replace {ENV_VAR} placeholders with values from os.environ."""
|
||||
|
||||
def _replace(m: re.Match) -> str:
|
||||
return os.environ.get(m.group(1), m.group(0))
|
||||
|
||||
return _env_pattern.sub(_replace, value)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
from typing import Any
|
||||
|
||||
from fastapi import 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", {})
|
||||
security_deps = security_deps or {}
|
||||
|
||||
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
||||
|
||||
for path, methods in paths.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")
|
||||
if not operation_id:
|
||||
@@ -102,7 +108,7 @@ def bind_routes(
|
||||
path=path,
|
||||
method=http_method,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
) from None
|
||||
|
||||
key = f"{http_method.upper()}:{path}"
|
||||
deps = security_deps.get(key, [])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
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 shutil
|
||||
from pathlib import Path
|
||||
from importlib import resources
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
|
||||
root = resources.files("openapi_first.templates")
|
||||
src = root / template
|
||||
|
||||
if not src.exists():
|
||||
if template not in available_templates():
|
||||
raise FileNotFoundError(
|
||||
f"Template '{template}' not found. "
|
||||
f"Available templates: {', '.join(available_templates())}"
|
||||
)
|
||||
|
||||
src = root / template
|
||||
|
||||
with resources.as_file(src) as path:
|
||||
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)
|
||||
# argparse with subparsers might not automatically handle this if positional args are passed
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
|
||||
if args.command is None and not any(vars(args).values()):
|
||||
parser.print_help()
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
@@ -88,8 +89,8 @@ class OpenAPIClient:
|
||||
def __init__(
|
||||
self,
|
||||
spec: dict[str, Any],
|
||||
base_url: Optional[str] = None,
|
||||
client: Optional[httpx.Client] = None,
|
||||
base_url: str | None = None,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the OpenAPI client.
|
||||
@@ -110,7 +111,7 @@ class OpenAPIClient:
|
||||
self.base_url = base_url or self._resolve_base_url(spec)
|
||||
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()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -123,7 +124,7 @@ class OpenAPIClient:
|
||||
except KeyError:
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -149,7 +150,13 @@ class OpenAPIClient:
|
||||
for path, path_item in paths.items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {
|
||||
"get", "post", "put", "patch", "delete", "head", "options"
|
||||
"get",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
"delete",
|
||||
"head",
|
||||
"options",
|
||||
}:
|
||||
continue
|
||||
|
||||
@@ -181,11 +188,11 @@ class OpenAPIClient:
|
||||
|
||||
def call(
|
||||
*,
|
||||
path_params: Optional[dict[str, Any]] = None,
|
||||
query: Optional[dict[str, Any]] = None,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
body: Optional[Any] = None,
|
||||
timeout: Optional[float] = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: Any | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> httpx.Response:
|
||||
url = self._build_url(path, path_params or {})
|
||||
|
||||
@@ -228,9 +235,7 @@ class OpenAPIClient:
|
||||
try:
|
||||
formatted_path = path.format(**path_params)
|
||||
except KeyError as exc:
|
||||
raise OpenAPIClientError(
|
||||
f"Missing path parameter: {exc.args[0]}"
|
||||
) from exc
|
||||
raise OpenAPIClientError(f"Missing path parameter: {exc.args[0]}") from exc
|
||||
|
||||
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
|
||||
|
||||
from .errors import OpenAPIFirstError
|
||||
|
||||
class OpenAPIClientError(OpenAPIFirstError): ...
|
||||
|
||||
class OpenAPIClient:
|
||||
spec: Dict[str, Any]
|
||||
spec: dict[str, Any]
|
||||
base_url: str
|
||||
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 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 datamodel_code_generator import (
|
||||
InputFileType,
|
||||
PythonVersion,
|
||||
|
||||
@@ -66,7 +66,7 @@ def generate_routes(
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 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", {})
|
||||
for path, methods in paths.items():
|
||||
@@ -74,18 +74,18 @@ def generate_routes(
|
||||
if not segments:
|
||||
continue
|
||||
resource = segments[0]
|
||||
if resource not in resources:
|
||||
resources[resource] = []
|
||||
if resource not in resources_map:
|
||||
resources_map[resource] = []
|
||||
|
||||
for http_method, operation in methods.items():
|
||||
if http_method.startswith("x-"):
|
||||
continue
|
||||
resources[resource].append((path, http_method, operation))
|
||||
resources_map[resource].append((path, http_method, operation))
|
||||
|
||||
generated_files: list[Path] = []
|
||||
|
||||
for resource in sorted(resources):
|
||||
operations = resources[resource]
|
||||
for resource in sorted(resources_map):
|
||||
operations = resources_map[resource]
|
||||
_validate_operations(resource, operations)
|
||||
|
||||
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."""
|
||||
for path, http_method, operation in operations:
|
||||
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", {})
|
||||
ref = schema.get("$ref", "")
|
||||
if ref:
|
||||
return ref.rsplit("/", 1)[-1]
|
||||
return str(ref.rsplit("/", 1)[-1])
|
||||
return None
|
||||
|
||||
|
||||
@@ -159,7 +161,7 @@ def _get_success_status(operation: dict[str, Any]) -> str:
|
||||
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`."""
|
||||
for _, _, op in operations:
|
||||
for param in op.get("parameters", []):
|
||||
@@ -176,7 +178,7 @@ def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
|
||||
|
||||
def _generate_resource_file(
|
||||
resource: str,
|
||||
operations: list[tuple[str, str, dict]],
|
||||
operations: list[tuple[str, str, dict[str, Any]]],
|
||||
spec_path: str,
|
||||
use_models: bool,
|
||||
models_module: str,
|
||||
@@ -205,7 +207,9 @@ def _generate_resource_file(
|
||||
if schema:
|
||||
schemas_needed.add(schema)
|
||||
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("")
|
||||
|
||||
@@ -237,7 +241,6 @@ def _generate_handler(
|
||||
schema: dict[str, Any] = param.get("schema", {})
|
||||
param_type: str = _resolve_type(schema)
|
||||
required: bool = param.get("required", False)
|
||||
description: str = schema.get("description", schema.get("x-description", ""))
|
||||
default_raw = schema.get("default")
|
||||
|
||||
if param_in == "path":
|
||||
@@ -277,13 +280,15 @@ def _generate_handler(
|
||||
# Document parameters
|
||||
doc_params: list[tuple[str, str, str]] = []
|
||||
for param in operation.get("parameters", []):
|
||||
name: str = param.get("name", "")
|
||||
param_in: str = param.get("in", "")
|
||||
schema: dict[str, Any] = param.get("schema", {})
|
||||
param_type: str = _resolve_type(schema)
|
||||
description: str = param.get("description", schema.get("x-description", ""))
|
||||
if param_in in ("path", "query"):
|
||||
doc_params.append((name, param_type, description))
|
||||
doc_name: str = param.get("name", "")
|
||||
doc_in: str = param.get("in", "")
|
||||
doc_schema: dict[str, Any] = param.get("schema", {})
|
||||
doc_type: str = _resolve_type(doc_schema)
|
||||
doc_description: str = param.get(
|
||||
"description", doc_schema.get("x-description", "")
|
||||
)
|
||||
if doc_in in ("path", "query"):
|
||||
doc_params.append((doc_name, doc_type, doc_description))
|
||||
|
||||
if doc_params:
|
||||
lines.append("")
|
||||
|
||||
@@ -18,6 +18,7 @@ Notes:
|
||||
These errors should normally cause immediate application failure.
|
||||
"""
|
||||
|
||||
|
||||
class OpenAPIFirstError(Exception):
|
||||
"""
|
||||
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
|
||||
from this type.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -70,9 +72,6 @@ class MissingOperationHandler(OpenAPIFirstError):
|
||||
f"({method.upper()} {path})"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Missing operationId for operation "
|
||||
f"({method.upper()} {path})"
|
||||
)
|
||||
message = f"Missing operationId for operation " f"({method.upper()} {path})"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
class OpenAPIFirstError(Exception): ...
|
||||
|
||||
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,
|
||||
malformed, or violates the OpenAPI 3.x specification.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Union
|
||||
from typing import Any
|
||||
|
||||
from .errors import 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 re
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, Request, HTTPException
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
_env_pattern = re.compile(r"\{(\w+)\}")
|
||||
@@ -19,14 +20,16 @@ _env_pattern = re.compile(r"\{(\w+)\}")
|
||||
|
||||
def _resolve_env(value: str) -> str:
|
||||
"""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 _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."""
|
||||
resolved = {}
|
||||
resolved: dict[str, Any] = {}
|
||||
for key, value in scheme.items():
|
||||
if isinstance(value, str):
|
||||
resolved[key] = _resolve_env(value)
|
||||
@@ -37,13 +40,13 @@ def _resolve_scheme(scheme: dict) -> dict:
|
||||
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."""
|
||||
raw = spec.get("components", {}).get("securitySchemes", {})
|
||||
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.
|
||||
|
||||
@@ -76,7 +79,7 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable",
|
||||
)
|
||||
) from None
|
||||
|
||||
if resp.status_code != 200:
|
||||
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
|
||||
|
||||
|
||||
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``."""
|
||||
scheme_type = scheme.get("type")
|
||||
|
||||
@@ -106,8 +111,8 @@ def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
|
||||
|
||||
|
||||
def make_security_dependencies(
|
||||
spec: dict,
|
||||
security_schemes: dict[str, dict],
|
||||
spec: dict[str, Any],
|
||||
security_schemes: dict[str, dict[str, Any]],
|
||||
) -> dict[str, list[Any]]:
|
||||
"""
|
||||
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.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Framework :: FastAPI",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
|
||||
|
||||
@@ -66,9 +68,15 @@ openapi-first = "openapi_first.cli:main"
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"black>=23.0.0",
|
||||
"ruff>=0.3.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 = [
|
||||
@@ -77,6 +85,10 @@ docs = [
|
||||
"mkdocstrings[python]>=0.24.0",
|
||||
]
|
||||
|
||||
all = [
|
||||
"openapi-first[dev,docs]",
|
||||
]
|
||||
|
||||
|
||||
[project.urls]
|
||||
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*"] } }
|
||||
|
||||
[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]
|
||||
line-length = 100
|
||||
line-length = 88
|
||||
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]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
exclude = [
|
||||
"openapi_first/templates/",
|
||||
"tests/",
|
||||
]
|
||||
files = ["openapi_first"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"openapi_spec_validator.*",
|
||||
"datamodel_code_generator.*",
|
||||
]
|
||||
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