function-api-builder 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_builder/__init__.py +5 -0
- api_builder/__main__.py +3 -0
- api_builder/cli.py +31 -0
- api_builder/generator.py +868 -0
- api_builder_runtime/__init__.py +50 -0
- function_api_builder-0.1.0.dist-info/METADATA +72 -0
- function_api_builder-0.1.0.dist-info/RECORD +9 -0
- function_api_builder-0.1.0.dist-info/WHEEL +4 -0
- function_api_builder-0.1.0.dist-info/entry_points.txt +2 -0
api_builder/__init__.py
ADDED
api_builder/__main__.py
ADDED
api_builder/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .generator import build
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="api-builder")
|
|
11
|
+
parser.add_argument("--project-root", type=Path, default=Path("."))
|
|
12
|
+
parser.add_argument("--check", action="store_true")
|
|
13
|
+
parser.add_argument("--force", action="store_true")
|
|
14
|
+
parser.add_argument("--service")
|
|
15
|
+
parser.add_argument("--no-dev-routes", action="store_true")
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
|
|
18
|
+
result = build(
|
|
19
|
+
args.project_root,
|
|
20
|
+
service=args.service,
|
|
21
|
+
dev_routes=not args.no_dev_routes,
|
|
22
|
+
check=args.check,
|
|
23
|
+
force=args.force,
|
|
24
|
+
)
|
|
25
|
+
print(f"generated {len(result.files)} files")
|
|
26
|
+
for route in result.routes:
|
|
27
|
+
print(f"{route.method} {route.path} {route.operation_id}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
api_builder/generator.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import compileall
|
|
4
|
+
import importlib
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, get_type_hints
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
GENERATOR_VERSION = "0.3.0"
|
|
17
|
+
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BuilderError(ValueError):
|
|
21
|
+
def __init__(self, code: str, message: str) -> None:
|
|
22
|
+
super().__init__(f"{code}: {message}")
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class FunctionInfo:
|
|
28
|
+
name: str
|
|
29
|
+
module: str
|
|
30
|
+
service: str
|
|
31
|
+
step: int
|
|
32
|
+
description: str
|
|
33
|
+
dependencies: tuple[str, ...]
|
|
34
|
+
errors: dict[str, int]
|
|
35
|
+
input_model: type[BaseModel]
|
|
36
|
+
output_model: type[BaseModel]
|
|
37
|
+
is_async: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class RouteInfo:
|
|
42
|
+
method: str
|
|
43
|
+
path: str
|
|
44
|
+
operation_id: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class BuildResult:
|
|
49
|
+
files: tuple[Path, ...]
|
|
50
|
+
routes: tuple[RouteInfo, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build(
|
|
54
|
+
project_root: Path = Path("."),
|
|
55
|
+
*,
|
|
56
|
+
service: str | None = None,
|
|
57
|
+
dev_routes: bool = True,
|
|
58
|
+
check: bool = False,
|
|
59
|
+
force: bool = False,
|
|
60
|
+
) -> BuildResult:
|
|
61
|
+
project_root = project_root.resolve()
|
|
62
|
+
functions = discover_functions(project_root)
|
|
63
|
+
if service:
|
|
64
|
+
functions = [info for info in functions if info.service == service]
|
|
65
|
+
services = _group_services(functions)
|
|
66
|
+
_validate_services(services)
|
|
67
|
+
routes = _routes(services, dev_routes)
|
|
68
|
+
if check:
|
|
69
|
+
return BuildResult(files=(), routes=routes)
|
|
70
|
+
|
|
71
|
+
app_dir = project_root / "app"
|
|
72
|
+
files = _generate_app(app_dir, services, dev_routes, force)
|
|
73
|
+
if not compileall.compile_dir(app_dir, quiet=1):
|
|
74
|
+
raise BuilderError("GENERATED_CODE_COMPILE_FAILED", str(app_dir))
|
|
75
|
+
_validate_openapi(project_root)
|
|
76
|
+
return BuildResult(files=tuple(files), routes=routes)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover_functions(project_root: Path) -> list[FunctionInfo]:
|
|
80
|
+
functions_dir = project_root / "functions"
|
|
81
|
+
if not functions_dir.exists():
|
|
82
|
+
raise BuilderError(
|
|
83
|
+
"FUNCTION_DISCOVERY_FAILED", "functions/ directory not found"
|
|
84
|
+
)
|
|
85
|
+
sys.path.insert(0, str(project_root))
|
|
86
|
+
importlib.invalidate_caches()
|
|
87
|
+
discovered: list[FunctionInfo] = []
|
|
88
|
+
for path in sorted(functions_dir.rglob("*.py")):
|
|
89
|
+
if path.name == "__init__.py":
|
|
90
|
+
continue
|
|
91
|
+
module_name = ".".join(path.relative_to(project_root).with_suffix("").parts)
|
|
92
|
+
module = importlib.import_module(module_name)
|
|
93
|
+
for _, obj in sorted(vars(module).items()):
|
|
94
|
+
metadata = getattr(obj, "__api_builder_function__", None)
|
|
95
|
+
if metadata:
|
|
96
|
+
discovered.append(_function_info(module_name, obj, metadata))
|
|
97
|
+
if not discovered:
|
|
98
|
+
raise BuilderError("FUNCTION_DISCOVERY_FAILED", "no @function functions found")
|
|
99
|
+
return discovered
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _function_info(module_name: str, fn: Any, metadata: dict[str, Any]) -> FunctionInfo:
|
|
103
|
+
signature = inspect.signature(fn)
|
|
104
|
+
params = list(signature.parameters.values())
|
|
105
|
+
if len(params) != 3 or [param.name for param in params] != [
|
|
106
|
+
"payload",
|
|
107
|
+
"deps",
|
|
108
|
+
"context",
|
|
109
|
+
]:
|
|
110
|
+
raise BuilderError("INVALID_FUNCTION_SIGNATURE", f"{module_name}.{fn.__name__}")
|
|
111
|
+
hints = get_type_hints(fn)
|
|
112
|
+
input_model = hints.get("payload")
|
|
113
|
+
output_model = hints.get("return")
|
|
114
|
+
if not isinstance(input_model, type) or not issubclass(input_model, BaseModel):
|
|
115
|
+
raise BuilderError(
|
|
116
|
+
"FUNCTION_INPUT_MODEL_MISSING", f"{module_name}.{fn.__name__}"
|
|
117
|
+
)
|
|
118
|
+
if not isinstance(output_model, type) or not issubclass(output_model, BaseModel):
|
|
119
|
+
raise BuilderError(
|
|
120
|
+
"FUNCTION_OUTPUT_MODEL_MISSING", f"{module_name}.{fn.__name__}"
|
|
121
|
+
)
|
|
122
|
+
service = str(metadata["service"])
|
|
123
|
+
if not IDENT.match(service):
|
|
124
|
+
raise BuilderError("FUNCTION_FORMAT_INVALID", f"invalid service: {service}")
|
|
125
|
+
dependencies = tuple(str(dep) for dep in metadata.get("dependencies", ()))
|
|
126
|
+
for dep in dependencies:
|
|
127
|
+
if not IDENT.match(dep):
|
|
128
|
+
raise BuilderError(
|
|
129
|
+
"DEPENDENCY_NOT_REGISTERED", f"invalid dependency name: {dep}"
|
|
130
|
+
)
|
|
131
|
+
return FunctionInfo(
|
|
132
|
+
name=fn.__name__,
|
|
133
|
+
module=module_name,
|
|
134
|
+
service=service,
|
|
135
|
+
step=int(metadata["step"]),
|
|
136
|
+
description=str(metadata.get("description", "")),
|
|
137
|
+
dependencies=dependencies,
|
|
138
|
+
errors={
|
|
139
|
+
str(code): int(status)
|
|
140
|
+
for code, status in metadata.get("errors", {}).items()
|
|
141
|
+
},
|
|
142
|
+
input_model=input_model,
|
|
143
|
+
output_model=output_model,
|
|
144
|
+
is_async=inspect.iscoroutinefunction(fn),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _group_services(functions: list[FunctionInfo]) -> dict[str, list[FunctionInfo]]:
|
|
149
|
+
services: dict[str, list[FunctionInfo]] = defaultdict(list)
|
|
150
|
+
for info in functions:
|
|
151
|
+
services[info.service].append(info)
|
|
152
|
+
return {
|
|
153
|
+
name: sorted(items, key=lambda item: item.step)
|
|
154
|
+
for name, items in sorted(services.items())
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_services(services: dict[str, list[FunctionInfo]]) -> None:
|
|
159
|
+
for service, functions in services.items():
|
|
160
|
+
steps = [info.step for info in functions]
|
|
161
|
+
if len(steps) != len(set(steps)):
|
|
162
|
+
raise BuilderError("DUPLICATE_SERVICE_STEP", service)
|
|
163
|
+
expected = list(range(1, len(steps) + 1))
|
|
164
|
+
if steps != expected:
|
|
165
|
+
raise BuilderError(
|
|
166
|
+
"MISSING_SERVICE_STEP", f"{service}: expected {expected}, got {steps}"
|
|
167
|
+
)
|
|
168
|
+
for previous, current in zip(functions, functions[1:]):
|
|
169
|
+
missing = _required_fields(current.input_model) - set(
|
|
170
|
+
previous.output_model.model_fields
|
|
171
|
+
)
|
|
172
|
+
if missing:
|
|
173
|
+
raise BuilderError(
|
|
174
|
+
"SERVICE_SEQUENCE_INPUT_MISMATCH",
|
|
175
|
+
f"{service}: {previous.name} -> {current.name} missing {sorted(missing)}",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _required_fields(model: type[BaseModel]) -> set[str]:
|
|
180
|
+
return {name for name, field in model.model_fields.items() if field.is_required()}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _routes(
|
|
184
|
+
services: dict[str, list[FunctionInfo]], dev_routes: bool
|
|
185
|
+
) -> tuple[RouteInfo, ...]:
|
|
186
|
+
routes: list[RouteInfo] = []
|
|
187
|
+
seen: set[tuple[str, str]] = set()
|
|
188
|
+
operations: set[str] = set()
|
|
189
|
+
for service, functions in services.items():
|
|
190
|
+
public = RouteInfo(
|
|
191
|
+
"POST", f"/api/v1/{_kebab(service)}", f"run_{service}_service"
|
|
192
|
+
)
|
|
193
|
+
_add_route(public, seen, operations)
|
|
194
|
+
routes.append(public)
|
|
195
|
+
if dev_routes:
|
|
196
|
+
for info in functions:
|
|
197
|
+
route = RouteInfo(
|
|
198
|
+
"POST",
|
|
199
|
+
f"/__dev/functions/{_kebab(service)}/{_kebab(info.name)}",
|
|
200
|
+
f"dev_{info.name}",
|
|
201
|
+
)
|
|
202
|
+
_add_route(route, seen, operations)
|
|
203
|
+
routes.append(route)
|
|
204
|
+
return tuple(routes)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _add_route(
|
|
208
|
+
route: RouteInfo, seen: set[tuple[str, str]], operations: set[str]
|
|
209
|
+
) -> None:
|
|
210
|
+
key = (route.method, route.path)
|
|
211
|
+
if key in seen:
|
|
212
|
+
raise BuilderError("DUPLICATE_ROUTE", f"{route.method} {route.path}")
|
|
213
|
+
if route.operation_id in operations:
|
|
214
|
+
raise BuilderError("DUPLICATE_OPERATION_ID", route.operation_id)
|
|
215
|
+
seen.add(key)
|
|
216
|
+
operations.add(route.operation_id)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _generate_app(
|
|
220
|
+
app_dir: Path,
|
|
221
|
+
services: dict[str, list[FunctionInfo]],
|
|
222
|
+
dev_routes: bool,
|
|
223
|
+
force: bool,
|
|
224
|
+
) -> list[Path]:
|
|
225
|
+
files: list[Path] = []
|
|
226
|
+
dirs = [
|
|
227
|
+
app_dir,
|
|
228
|
+
app_dir / "routers",
|
|
229
|
+
app_dir / "services",
|
|
230
|
+
app_dir / "generated",
|
|
231
|
+
app_dir / "tests",
|
|
232
|
+
]
|
|
233
|
+
for directory in dirs:
|
|
234
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
files.append(_write_generated(directory / "__init__.py", "", force))
|
|
236
|
+
files.extend(
|
|
237
|
+
[
|
|
238
|
+
_write_generated(app_dir / "config.py", _config_py(), force),
|
|
239
|
+
_write_generated(app_dir / "context.py", _context_py(), force),
|
|
240
|
+
_write_generated(app_dir / "dependencies.py", _dependencies_py(), force),
|
|
241
|
+
_write_generated(app_dir / "errors.py", _errors_py(), force),
|
|
242
|
+
_write_generated(app_dir / "responses.py", _responses_py(), force),
|
|
243
|
+
_write_generated(app_dir / "middleware.py", _middleware_py(), force),
|
|
244
|
+
_write_generated(app_dir / "health.py", _health_py(), force),
|
|
245
|
+
_write_generated(app_dir / "main.py", _main_py(dev_routes), force),
|
|
246
|
+
_write_generated(
|
|
247
|
+
app_dir / "routers" / "public.py", _public_router_py(services), force
|
|
248
|
+
),
|
|
249
|
+
_write_generated(
|
|
250
|
+
app_dir / "routers" / "dev.py",
|
|
251
|
+
_dev_router_py(services, dev_routes),
|
|
252
|
+
force,
|
|
253
|
+
),
|
|
254
|
+
_write_generated(
|
|
255
|
+
app_dir / "generated" / "function_registry.py",
|
|
256
|
+
_function_registry_py(services),
|
|
257
|
+
force,
|
|
258
|
+
),
|
|
259
|
+
_write_generated(
|
|
260
|
+
app_dir / "generated" / "service_registry.py",
|
|
261
|
+
_service_registry_py(services),
|
|
262
|
+
force,
|
|
263
|
+
),
|
|
264
|
+
_write_generated(
|
|
265
|
+
app_dir / "generated" / "manifest.py",
|
|
266
|
+
_manifest_py(services, dev_routes),
|
|
267
|
+
force,
|
|
268
|
+
),
|
|
269
|
+
_write_generated(
|
|
270
|
+
app_dir / "generated" / "manifest.json",
|
|
271
|
+
_manifest_json(services, dev_routes),
|
|
272
|
+
force,
|
|
273
|
+
),
|
|
274
|
+
_write_generated(
|
|
275
|
+
app_dir / "tests" / "test_health.py", _test_health_py(), force
|
|
276
|
+
),
|
|
277
|
+
_write_generated(
|
|
278
|
+
app_dir / "tests" / "test_openapi.py", _test_openapi_py(), force
|
|
279
|
+
),
|
|
280
|
+
]
|
|
281
|
+
)
|
|
282
|
+
for service, functions in services.items():
|
|
283
|
+
files.append(
|
|
284
|
+
_write_generated(
|
|
285
|
+
app_dir / "services" / f"{service}.py",
|
|
286
|
+
_service_py(service, functions),
|
|
287
|
+
force,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
return files
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _config_py() -> str:
|
|
294
|
+
return _py(
|
|
295
|
+
"config.py",
|
|
296
|
+
"""
|
|
297
|
+
from __future__ import annotations
|
|
298
|
+
|
|
299
|
+
from dataclasses import dataclass
|
|
300
|
+
from os import getenv
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass(frozen=True, slots=True)
|
|
304
|
+
class Settings:
|
|
305
|
+
environment: str = getenv("ENVIRONMENT", "local")
|
|
306
|
+
internal_dev_token: str = getenv("INTERNAL_DEV_TOKEN", "dev-token")
|
|
307
|
+
""",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _context_py() -> str:
|
|
312
|
+
return _py(
|
|
313
|
+
"context.py",
|
|
314
|
+
"""
|
|
315
|
+
from __future__ import annotations
|
|
316
|
+
|
|
317
|
+
from dataclasses import dataclass, field
|
|
318
|
+
from datetime import datetime
|
|
319
|
+
from typing import Mapping
|
|
320
|
+
from uuid import uuid4
|
|
321
|
+
|
|
322
|
+
from fastapi import Request
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True, slots=True)
|
|
326
|
+
class ExecutionContext:
|
|
327
|
+
request_id: str
|
|
328
|
+
correlation_id: str | None = None
|
|
329
|
+
actor_id: str | None = None
|
|
330
|
+
tenant_id: str | None = None
|
|
331
|
+
deadline: datetime | None = None
|
|
332
|
+
idempotency_key: str | None = None
|
|
333
|
+
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def resolve_context(request: Request) -> ExecutionContext:
|
|
337
|
+
request_id = request.headers.get("X-Request-ID") or f"req_{uuid4().hex}"
|
|
338
|
+
request.state.request_id = request_id
|
|
339
|
+
return ExecutionContext(
|
|
340
|
+
request_id=request_id,
|
|
341
|
+
correlation_id=request.headers.get("X-Correlation-ID"),
|
|
342
|
+
idempotency_key=request.headers.get("Idempotency-Key"),
|
|
343
|
+
)
|
|
344
|
+
""",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _dependencies_py() -> str:
|
|
349
|
+
return _py(
|
|
350
|
+
"dependencies.py",
|
|
351
|
+
"""
|
|
352
|
+
from __future__ import annotations
|
|
353
|
+
|
|
354
|
+
from dataclasses import dataclass, field
|
|
355
|
+
from typing import Any
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True, slots=True)
|
|
359
|
+
class FunctionDependencies:
|
|
360
|
+
function_name: str
|
|
361
|
+
resources: dict[str, Any]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True, slots=True)
|
|
365
|
+
class ServiceDependencies:
|
|
366
|
+
resources: dict[str, Any] = field(default_factory=dict)
|
|
367
|
+
|
|
368
|
+
def for_function(self, function_name: str) -> FunctionDependencies:
|
|
369
|
+
return FunctionDependencies(function_name=function_name, resources=self.resources)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def get_dependencies() -> ServiceDependencies:
|
|
373
|
+
return ServiceDependencies()
|
|
374
|
+
""",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _errors_py() -> str:
|
|
379
|
+
return _py(
|
|
380
|
+
"errors.py",
|
|
381
|
+
"""
|
|
382
|
+
from __future__ import annotations
|
|
383
|
+
|
|
384
|
+
import logging
|
|
385
|
+
|
|
386
|
+
from api_builder_runtime import FunctionError
|
|
387
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
388
|
+
from fastapi.exceptions import RequestValidationError
|
|
389
|
+
from fastapi.responses import JSONResponse
|
|
390
|
+
|
|
391
|
+
from .responses import ErrorEnvelope
|
|
392
|
+
|
|
393
|
+
logger = logging.getLogger(__name__)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class OutputValidationError(Exception):
|
|
397
|
+
pass
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class DevAuthenticationError(Exception):
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def install_exception_handlers(app: FastAPI) -> None:
|
|
405
|
+
@app.exception_handler(RequestValidationError)
|
|
406
|
+
async def request_validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
407
|
+
return _error(request, 422, "REQUEST_VALIDATION_FAILED", "Request validation failed.")
|
|
408
|
+
|
|
409
|
+
@app.exception_handler(HTTPException)
|
|
410
|
+
async def http_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
411
|
+
return _error(request, exc.status_code, "HTTP_ERROR", "Request failed.", headers=exc.headers)
|
|
412
|
+
|
|
413
|
+
@app.exception_handler(FunctionError)
|
|
414
|
+
async def function_error_handler(request: Request, exc: FunctionError) -> JSONResponse:
|
|
415
|
+
status = getattr(request.app.state, "error_statuses", {}).get(exc.code, 500)
|
|
416
|
+
return _error(request, status, exc.code, exc.message)
|
|
417
|
+
|
|
418
|
+
@app.exception_handler(OutputValidationError)
|
|
419
|
+
async def output_handler(request: Request, exc: OutputValidationError) -> JSONResponse:
|
|
420
|
+
logger.exception("generated API output validation failed")
|
|
421
|
+
return _error(request, 500, "OUTPUT_VALIDATION_FAILED", "Generated API output validation failed.")
|
|
422
|
+
|
|
423
|
+
@app.exception_handler(DevAuthenticationError)
|
|
424
|
+
async def dev_auth_handler(request: Request, exc: DevAuthenticationError) -> JSONResponse:
|
|
425
|
+
return _error(request, 401, "DEV_AUTHENTICATION_FAILED", "Development route authentication failed.")
|
|
426
|
+
|
|
427
|
+
@app.exception_handler(Exception)
|
|
428
|
+
async def unexpected_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
429
|
+
logger.exception("unexpected API failure")
|
|
430
|
+
return _error(request, 500, "UNEXPECTED_RUNTIME_FAILURE", "An unexpected internal error occurred.")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _error(
|
|
434
|
+
request: Request,
|
|
435
|
+
status_code: int,
|
|
436
|
+
code: str,
|
|
437
|
+
message: str,
|
|
438
|
+
*,
|
|
439
|
+
headers: dict[str, str] | None = None,
|
|
440
|
+
) -> JSONResponse:
|
|
441
|
+
request_id = getattr(request.state, "request_id", "req_unknown")
|
|
442
|
+
body = ErrorEnvelope(error_code=code, message=message, request_id=request_id).model_dump()
|
|
443
|
+
return JSONResponse(status_code=status_code, content=body, headers=headers)
|
|
444
|
+
""",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _responses_py() -> str:
|
|
449
|
+
return _py(
|
|
450
|
+
"responses.py",
|
|
451
|
+
"""
|
|
452
|
+
from __future__ import annotations
|
|
453
|
+
|
|
454
|
+
from typing import Generic, TypeVar
|
|
455
|
+
|
|
456
|
+
from pydantic import BaseModel, Field
|
|
457
|
+
|
|
458
|
+
T = TypeVar("T")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class SuccessEnvelope(BaseModel, Generic[T]):
|
|
462
|
+
ok: bool = True
|
|
463
|
+
data: T
|
|
464
|
+
request_id: str
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class ErrorItem(BaseModel):
|
|
468
|
+
field: str | None = None
|
|
469
|
+
reason: str
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class ErrorEnvelope(BaseModel):
|
|
473
|
+
ok: bool = False
|
|
474
|
+
error_code: str
|
|
475
|
+
message: str
|
|
476
|
+
request_id: str
|
|
477
|
+
details: list[ErrorItem] = Field(default_factory=list)
|
|
478
|
+
""",
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _middleware_py() -> str:
|
|
483
|
+
return _py(
|
|
484
|
+
"middleware.py",
|
|
485
|
+
"""
|
|
486
|
+
from __future__ import annotations
|
|
487
|
+
|
|
488
|
+
from uuid import uuid4
|
|
489
|
+
|
|
490
|
+
from fastapi import FastAPI, Request
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def install_middleware(app: FastAPI) -> None:
|
|
494
|
+
@app.middleware("http")
|
|
495
|
+
async def request_id_middleware(request: Request, call_next):
|
|
496
|
+
request.state.request_id = request.headers.get("X-Request-ID") or f"req_{uuid4().hex}"
|
|
497
|
+
response = await call_next(request)
|
|
498
|
+
response.headers["X-Request-ID"] = request.state.request_id
|
|
499
|
+
return response
|
|
500
|
+
""",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _health_py() -> str:
|
|
505
|
+
return _py(
|
|
506
|
+
"health.py",
|
|
507
|
+
"""
|
|
508
|
+
from __future__ import annotations
|
|
509
|
+
|
|
510
|
+
from fastapi import APIRouter
|
|
511
|
+
|
|
512
|
+
router = APIRouter()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@router.get("/healthz")
|
|
516
|
+
async def healthz() -> dict[str, bool]:
|
|
517
|
+
return {"ok": True}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@router.get("/readyz")
|
|
521
|
+
async def readyz() -> dict[str, bool]:
|
|
522
|
+
return {"ok": True}
|
|
523
|
+
""",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _main_py(dev_routes: bool) -> str:
|
|
528
|
+
include_dev = (
|
|
529
|
+
"settings.environment in {'local', 'development', 'test'}"
|
|
530
|
+
if dev_routes
|
|
531
|
+
else "False"
|
|
532
|
+
)
|
|
533
|
+
return _py(
|
|
534
|
+
"main.py",
|
|
535
|
+
f"""
|
|
536
|
+
from __future__ import annotations
|
|
537
|
+
|
|
538
|
+
from fastapi import FastAPI
|
|
539
|
+
|
|
540
|
+
from .config import Settings
|
|
541
|
+
from .errors import install_exception_handlers
|
|
542
|
+
from .generated.manifest import ERROR_STATUSES
|
|
543
|
+
from .health import router as health_router
|
|
544
|
+
from .middleware import install_middleware
|
|
545
|
+
from .routers.public import router as public_router
|
|
546
|
+
from .routers.dev import router as dev_router
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
550
|
+
settings = settings or Settings()
|
|
551
|
+
app = FastAPI(title="Generated API", version="{GENERATOR_VERSION}")
|
|
552
|
+
app.state.settings = settings
|
|
553
|
+
app.state.error_statuses = ERROR_STATUSES
|
|
554
|
+
install_middleware(app)
|
|
555
|
+
install_exception_handlers(app)
|
|
556
|
+
app.include_router(health_router)
|
|
557
|
+
app.include_router(public_router)
|
|
558
|
+
if {include_dev}:
|
|
559
|
+
app.include_router(dev_router)
|
|
560
|
+
return app
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
app = create_app()
|
|
564
|
+
""",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _public_router_py(services: dict[str, list[FunctionInfo]]) -> str:
|
|
569
|
+
lines = [
|
|
570
|
+
_header("routers/public.py"),
|
|
571
|
+
"from __future__ import annotations",
|
|
572
|
+
"",
|
|
573
|
+
"from fastapi import APIRouter, Depends",
|
|
574
|
+
"",
|
|
575
|
+
"from app.context import ExecutionContext, resolve_context",
|
|
576
|
+
"from app.dependencies import ServiceDependencies, get_dependencies",
|
|
577
|
+
"from app.responses import SuccessEnvelope",
|
|
578
|
+
"",
|
|
579
|
+
]
|
|
580
|
+
for service, functions in services.items():
|
|
581
|
+
first = functions[0]
|
|
582
|
+
last = functions[-1]
|
|
583
|
+
lines.extend(
|
|
584
|
+
[
|
|
585
|
+
f"from app.services.{service} import run_{service}_service",
|
|
586
|
+
f"from {first.module} import {first.input_model.__name__}",
|
|
587
|
+
f"from {last.module} import {last.output_model.__name__}",
|
|
588
|
+
]
|
|
589
|
+
)
|
|
590
|
+
lines.extend(["", "router = APIRouter()", ""])
|
|
591
|
+
for service, functions in services.items():
|
|
592
|
+
first = functions[0]
|
|
593
|
+
last = functions[-1]
|
|
594
|
+
lines.extend(
|
|
595
|
+
[
|
|
596
|
+
"@router.post(",
|
|
597
|
+
f' "/api/v1/{_kebab(service)}",',
|
|
598
|
+
f' operation_id="run_{service}_service",',
|
|
599
|
+
f" response_model=SuccessEnvelope[{last.output_model.__name__}],",
|
|
600
|
+
")",
|
|
601
|
+
f"async def run_{service}_route(",
|
|
602
|
+
f" payload: {first.input_model.__name__},",
|
|
603
|
+
" deps: ServiceDependencies = Depends(get_dependencies),",
|
|
604
|
+
" context: ExecutionContext = Depends(resolve_context),",
|
|
605
|
+
f") -> SuccessEnvelope[{last.output_model.__name__}]:",
|
|
606
|
+
f" result = await run_{service}_service(payload, deps, context)",
|
|
607
|
+
" return SuccessEnvelope(data=result, request_id=context.request_id)",
|
|
608
|
+
"",
|
|
609
|
+
]
|
|
610
|
+
)
|
|
611
|
+
return "\n".join(lines)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _dev_router_py(services: dict[str, list[FunctionInfo]], dev_routes: bool) -> str:
|
|
615
|
+
lines = [
|
|
616
|
+
_header("routers/dev.py"),
|
|
617
|
+
"from __future__ import annotations",
|
|
618
|
+
"",
|
|
619
|
+
"from fastapi import APIRouter, Depends, Header, Request",
|
|
620
|
+
"from pydantic import ValidationError",
|
|
621
|
+
"",
|
|
622
|
+
"from app.context import ExecutionContext, resolve_context",
|
|
623
|
+
"from app.dependencies import ServiceDependencies, get_dependencies",
|
|
624
|
+
"from app.errors import DevAuthenticationError, OutputValidationError",
|
|
625
|
+
"from app.responses import SuccessEnvelope",
|
|
626
|
+
"",
|
|
627
|
+
]
|
|
628
|
+
if not dev_routes:
|
|
629
|
+
lines.extend(["router = APIRouter()", ""])
|
|
630
|
+
return "\n".join(lines)
|
|
631
|
+
for functions in services.values():
|
|
632
|
+
for info in functions:
|
|
633
|
+
lines.extend(
|
|
634
|
+
[
|
|
635
|
+
f"from {info.module} import {info.name}, {info.input_model.__name__}, {info.output_model.__name__}",
|
|
636
|
+
]
|
|
637
|
+
)
|
|
638
|
+
lines.extend(
|
|
639
|
+
[
|
|
640
|
+
"",
|
|
641
|
+
"router = APIRouter()",
|
|
642
|
+
"",
|
|
643
|
+
'def require_dev_token(request: Request, x_internal_dev_token: str | None = Header(default=None, alias="X-Internal-Dev-Token")) -> None:',
|
|
644
|
+
" if x_internal_dev_token != request.app.state.settings.internal_dev_token:",
|
|
645
|
+
" raise DevAuthenticationError()",
|
|
646
|
+
"",
|
|
647
|
+
]
|
|
648
|
+
)
|
|
649
|
+
for service, functions in services.items():
|
|
650
|
+
for info in functions:
|
|
651
|
+
call = (
|
|
652
|
+
f"await {info.name}(payload, deps.for_function({info.name!r}), context)"
|
|
653
|
+
if info.is_async
|
|
654
|
+
else f"{info.name}(payload, deps.for_function({info.name!r}), context)"
|
|
655
|
+
)
|
|
656
|
+
lines.extend(
|
|
657
|
+
[
|
|
658
|
+
"@router.post(",
|
|
659
|
+
f' "/__dev/functions/{_kebab(service)}/{_kebab(info.name)}",',
|
|
660
|
+
f' operation_id="dev_{info.name}",',
|
|
661
|
+
f" response_model=SuccessEnvelope[{info.output_model.__name__}],",
|
|
662
|
+
")",
|
|
663
|
+
f"async def dev_{info.name}_route(",
|
|
664
|
+
f" payload: {info.input_model.__name__},",
|
|
665
|
+
" _auth: None = Depends(require_dev_token),",
|
|
666
|
+
" deps: ServiceDependencies = Depends(get_dependencies),",
|
|
667
|
+
" context: ExecutionContext = Depends(resolve_context),",
|
|
668
|
+
f") -> SuccessEnvelope[{info.output_model.__name__}]:",
|
|
669
|
+
f" raw = {call}",
|
|
670
|
+
" try:",
|
|
671
|
+
f" result = {info.output_model.__name__}.model_validate(raw)",
|
|
672
|
+
" except ValidationError as exc:",
|
|
673
|
+
" raise OutputValidationError() from exc",
|
|
674
|
+
" return SuccessEnvelope(data=result, request_id=context.request_id)",
|
|
675
|
+
"",
|
|
676
|
+
]
|
|
677
|
+
)
|
|
678
|
+
return "\n".join(lines)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _service_py(service: str, functions: list[FunctionInfo]) -> str:
|
|
682
|
+
lines = [
|
|
683
|
+
_header(f"services/{service}.py"),
|
|
684
|
+
"from __future__ import annotations",
|
|
685
|
+
"",
|
|
686
|
+
"from pydantic import ValidationError",
|
|
687
|
+
"",
|
|
688
|
+
"from app.context import ExecutionContext",
|
|
689
|
+
"from app.dependencies import ServiceDependencies",
|
|
690
|
+
"from app.errors import OutputValidationError",
|
|
691
|
+
"",
|
|
692
|
+
]
|
|
693
|
+
for info in functions:
|
|
694
|
+
lines.append(
|
|
695
|
+
f"from {info.module} import {info.name}, {info.input_model.__name__}, {info.output_model.__name__}"
|
|
696
|
+
)
|
|
697
|
+
first = functions[0]
|
|
698
|
+
last = functions[-1]
|
|
699
|
+
lines.extend(
|
|
700
|
+
[
|
|
701
|
+
"",
|
|
702
|
+
f"async def run_{service}_service(",
|
|
703
|
+
f" payload: {first.input_model.__name__},",
|
|
704
|
+
" deps: ServiceDependencies,",
|
|
705
|
+
" context: ExecutionContext,",
|
|
706
|
+
f") -> {last.output_model.__name__}:",
|
|
707
|
+
" current = payload",
|
|
708
|
+
]
|
|
709
|
+
)
|
|
710
|
+
for index, info in enumerate(functions, start=1):
|
|
711
|
+
if index > 1:
|
|
712
|
+
lines.extend(
|
|
713
|
+
[
|
|
714
|
+
" try:",
|
|
715
|
+
f" current = {info.input_model.__name__}.model_validate(current.model_dump())",
|
|
716
|
+
" except ValidationError as exc:",
|
|
717
|
+
" raise OutputValidationError() from exc",
|
|
718
|
+
]
|
|
719
|
+
)
|
|
720
|
+
call = (
|
|
721
|
+
f"await {info.name}(current, deps.for_function({info.name!r}), context)"
|
|
722
|
+
if info.is_async
|
|
723
|
+
else f"{info.name}(current, deps.for_function({info.name!r}), context)"
|
|
724
|
+
)
|
|
725
|
+
lines.extend(
|
|
726
|
+
[
|
|
727
|
+
f" raw_{index} = {call}",
|
|
728
|
+
" try:",
|
|
729
|
+
f" current = {info.output_model.__name__}.model_validate(raw_{index})",
|
|
730
|
+
" except ValidationError as exc:",
|
|
731
|
+
" raise OutputValidationError() from exc",
|
|
732
|
+
]
|
|
733
|
+
)
|
|
734
|
+
lines.extend([" return current", ""])
|
|
735
|
+
return "\n".join(lines)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _function_registry_py(services: dict[str, list[FunctionInfo]]) -> str:
|
|
739
|
+
lines = [_header("generated/function_registry.py"), "FUNCTIONS = ("]
|
|
740
|
+
for functions in services.values():
|
|
741
|
+
for info in functions:
|
|
742
|
+
lines.append(
|
|
743
|
+
f" {{'service': {info.service!r}, 'step': {info.step}, 'name': {info.name!r}, 'module': {info.module!r}}},"
|
|
744
|
+
)
|
|
745
|
+
lines.extend([")", ""])
|
|
746
|
+
return "\n".join(lines)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _service_registry_py(services: dict[str, list[FunctionInfo]]) -> str:
|
|
750
|
+
lines = [_header("generated/service_registry.py"), "SERVICES = {"]
|
|
751
|
+
for service, functions in services.items():
|
|
752
|
+
lines.append(f" {service!r}: {[info.name for info in functions]!r},")
|
|
753
|
+
lines.extend(["}", ""])
|
|
754
|
+
return "\n".join(lines)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _manifest(
|
|
758
|
+
services: dict[str, list[FunctionInfo]], dev_routes: bool
|
|
759
|
+
) -> dict[str, Any]:
|
|
760
|
+
return {
|
|
761
|
+
"generated_file": "DO NOT EDIT MANUALLY",
|
|
762
|
+
"generator_version": GENERATOR_VERSION,
|
|
763
|
+
"dev_routes": dev_routes,
|
|
764
|
+
"services": {
|
|
765
|
+
service: [
|
|
766
|
+
{
|
|
767
|
+
"name": info.name,
|
|
768
|
+
"module": info.module,
|
|
769
|
+
"step": info.step,
|
|
770
|
+
"dependencies": list(info.dependencies),
|
|
771
|
+
"errors": info.errors,
|
|
772
|
+
}
|
|
773
|
+
for info in functions
|
|
774
|
+
]
|
|
775
|
+
for service, functions in services.items()
|
|
776
|
+
},
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _manifest_py(services: dict[str, list[FunctionInfo]], dev_routes: bool) -> str:
|
|
781
|
+
error_statuses = {
|
|
782
|
+
code: status
|
|
783
|
+
for functions in services.values()
|
|
784
|
+
for info in functions
|
|
785
|
+
for code, status in info.errors.items()
|
|
786
|
+
}
|
|
787
|
+
return _py(
|
|
788
|
+
"generated/manifest.py",
|
|
789
|
+
f"MANIFEST = {_manifest(services, dev_routes)!r}\nERROR_STATUSES = {error_statuses!r}\n",
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _manifest_json(services: dict[str, list[FunctionInfo]], dev_routes: bool) -> str:
|
|
794
|
+
return json.dumps(_manifest(services, dev_routes), indent=2, sort_keys=True) + "\n"
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _test_health_py() -> str:
|
|
798
|
+
return _py(
|
|
799
|
+
"tests/test_health.py",
|
|
800
|
+
"""
|
|
801
|
+
from app.main import create_app
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def test_health_routes_exist():
|
|
805
|
+
app = create_app()
|
|
806
|
+
paths = set(app.openapi()["paths"])
|
|
807
|
+
assert "/healthz" in paths
|
|
808
|
+
assert "/readyz" in paths
|
|
809
|
+
""",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _test_openapi_py() -> str:
|
|
814
|
+
return _py(
|
|
815
|
+
"tests/test_openapi.py",
|
|
816
|
+
"""
|
|
817
|
+
from app.main import create_app
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def test_openapi_generates():
|
|
821
|
+
schema = create_app().openapi()
|
|
822
|
+
assert "paths" in schema
|
|
823
|
+
""",
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _validate_openapi(project_root: Path) -> None:
|
|
828
|
+
sys.path.insert(0, str(project_root))
|
|
829
|
+
importlib.invalidate_caches()
|
|
830
|
+
for name in list(sys.modules):
|
|
831
|
+
if name == "app" or name.startswith("app."):
|
|
832
|
+
del sys.modules[name]
|
|
833
|
+
try:
|
|
834
|
+
module = importlib.import_module("app.main")
|
|
835
|
+
module.create_app().openapi()
|
|
836
|
+
except Exception as exc:
|
|
837
|
+
raise BuilderError("GENERATED_OPENAPI_INVALID", str(exc)) from exc
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _write_generated(path: Path, text: str, force: bool) -> Path:
|
|
841
|
+
if path.exists() and not force:
|
|
842
|
+
existing = path.read_text()
|
|
843
|
+
if (
|
|
844
|
+
existing
|
|
845
|
+
and "GENERATED FILE" not in existing
|
|
846
|
+
and "generated_file" not in existing
|
|
847
|
+
):
|
|
848
|
+
raise BuilderError("GENERATED_FILE_CONFLICT", str(path))
|
|
849
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
850
|
+
tmp.write_text(text)
|
|
851
|
+
tmp.replace(path)
|
|
852
|
+
return path
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _py(name: str, body: str) -> str:
|
|
856
|
+
return (
|
|
857
|
+
_header(name)
|
|
858
|
+
+ "\n".join(line.rstrip() for line in body.strip().splitlines())
|
|
859
|
+
+ "\n"
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _header(name: str) -> str:
|
|
864
|
+
return f"# GENERATED FILE - DO NOT EDIT MANUALLY\n# Source: functions/**/*.py\n# Generated: {name}\n# Generator version: {GENERATOR_VERSION}\n\n"
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _kebab(value: str) -> str:
|
|
868
|
+
return value.replace("_", "-")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class ErrorDetail:
|
|
9
|
+
field: str | None
|
|
10
|
+
reason: str
|
|
11
|
+
metadata: dict[str, Any] | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FunctionError(Exception):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
code: str,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
details: list[ErrorDetail] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.details = details or []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def function(
|
|
29
|
+
*,
|
|
30
|
+
service: str,
|
|
31
|
+
step: int,
|
|
32
|
+
description: str = "",
|
|
33
|
+
dependencies: list[str] | tuple[str, ...] = (),
|
|
34
|
+
errors: dict[str, int] | None = None,
|
|
35
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
36
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
37
|
+
setattr(
|
|
38
|
+
fn,
|
|
39
|
+
"__api_builder_function__",
|
|
40
|
+
{
|
|
41
|
+
"service": service,
|
|
42
|
+
"step": step,
|
|
43
|
+
"description": description,
|
|
44
|
+
"dependencies": tuple(dependencies),
|
|
45
|
+
"errors": errors or {},
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
return fn
|
|
49
|
+
|
|
50
|
+
return decorate
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: function-api-builder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate complete FastAPI applications from decorated Python business functions.
|
|
5
|
+
Author: Sushruth Samson
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: api-builder,api-generator,backend,code-generation,fastapi,pydantic
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: fastapi>=0.115
|
|
21
|
+
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Requires-Dist: uvicorn>=0.30
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
29
|
+
Requires-Dist: twine>=6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# API Builder
|
|
33
|
+
|
|
34
|
+
Generate a FastAPI application from decorated Python business functions.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install function-api-builder
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Use
|
|
43
|
+
|
|
44
|
+
Create `functions/example.py` in your project:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from pydantic import BaseModel
|
|
48
|
+
from api_builder_runtime import function
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Input(BaseModel):
|
|
52
|
+
value: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Output(BaseModel):
|
|
56
|
+
result: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@function(service="example", step=1)
|
|
60
|
+
def run(payload: Input, deps, context) -> Output:
|
|
61
|
+
return Output(result=payload.value)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then run:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
api-builder
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The builder writes a complete `app/` FastAPI application with a public service route, optional local development routes, health checks, OpenAPI, and generated tests.
|
|
71
|
+
|
|
72
|
+
The function signature must be `(payload, deps, context)`, with Pydantic models for the payload and return value. See `examples/basic/functions/pdf_summary.py` for a multi-step service.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
api_builder/__init__.py,sha256=J2J8lNVjzFow0It-gNJj3drAO4q0VfHkQFo-UuntQU4,115
|
|
2
|
+
api_builder/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
api_builder/cli.py,sha256=4JKxdbKyiXyYclXE58m4iaAbdQZe45sak3_fwogG-O8,870
|
|
4
|
+
api_builder/generator.py,sha256=44m3HW77MHGm-GZGuHQfmZboUmf6ZWFCyM8OFVJcl0g,28069
|
|
5
|
+
api_builder_runtime/__init__.py,sha256=d7rCDFTjGATRfnm_wJl-Hhgu12wCunIJWQgzytkW4nM,1203
|
|
6
|
+
function_api_builder-0.1.0.dist-info/METADATA,sha256=DkSnrw39qfvphxFEcHJggt7T5XAftxrMOtTrs-1KWI0,2109
|
|
7
|
+
function_api_builder-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
function_api_builder-0.1.0.dist-info/entry_points.txt,sha256=r80gTGMlJgvD0x46XQRjM9Bd-Dn09OMge23CkzAA5QU,53
|
|
9
|
+
function_api_builder-0.1.0.dist-info/RECORD,,
|