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.
@@ -0,0 +1,5 @@
1
+ """Generate a FastAPI app from decorated business functions."""
2
+
3
+ __all__ = ["build"]
4
+
5
+ from .generator import build
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ api-builder = api_builder.cli:main