standardize packaging, tooling, docs, CI, and licensing
This commit is contained in:
@@ -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(...)``.
|
||||
|
||||
Reference in New Issue
Block a user