create-win-project 1.3.0 → 2.0.0
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.
- package/LICENSE +1 -1
- package/README.md +58 -160
- package/checks/check-compatibility.js +95 -0
- package/{scripts/verify-generated.mjs → checks/check-generated-project.js} +28 -4
- package/{scripts/validate-content.mjs → checks/check-library.js} +17 -7
- package/checks/check-package.js +15 -0
- package/checks/classify-changes.js +29 -0
- package/checks/run-compatibility-shard.js +21 -0
- package/docs/README.md +20 -0
- package/docs/capabilities.md +13 -0
- package/docs/compatibility.md +13 -0
- package/docs/generated-project.md +12 -0
- package/docs/getting-started.md +26 -0
- package/docs/maintainers/architecture.md +161 -0
- package/docs/maintainers/ci-strategy.md +46 -0
- package/docs/{CONTENT_MODEL.md → maintainers/content-model.md} +3 -3
- package/docs/maintainers/contributing.md +154 -0
- package/docs/{DEPENDENCY_MAINTENANCE.md → maintainers/dependencies.md} +1 -1
- package/docs/migration-v2.md +13 -0
- package/docs/production-contract.md +24 -0
- package/index.js +2 -429
- package/{playbooks → library}/INDEX.md +5 -5
- package/library/compatibility-impact.json +14 -0
- package/{playbooks/capabilities/ci/github-actions.manifest.json → library/development-tools/ci/definition.json} +1 -0
- package/library/development-tools/devops/makefile/commands.md +11 -0
- package/library/development-tools/devops/makefile/definition.json +11 -0
- package/library/development-tools/devops/makefile/docker.md +9 -0
- package/library/development-tools/devops/makefile/validation.md +5 -0
- package/{playbooks/devops/pr-template.manifest.json → library/development-tools/devops/pr-template/definition.json} +1 -0
- package/library/features/sqlalchemy-alembic.md +17 -0
- package/library/optional-features/concerns/zod/errors.md +5 -0
- package/library/optional-features/concerns/zod/testing.md +5 -0
- package/library/optional-features/concerns/zod/validation.md +21 -0
- package/library/optional-features/styling/css-modules/definition.json +12 -0
- package/library/optional-features/styling/css-modules/responsive.md +5 -0
- package/library/optional-features/styling/css-modules/theme.md +7 -0
- package/{playbooks/platform/mobile.manifest.json → library/platforms/mobile/definition.json} +1 -0
- package/{playbooks/platform/web.manifest.json → library/platforms/web/definition.json} +1 -0
- package/{playbooks/stack/react-native.manifest.json → library/stacks/expo/definition.json} +2 -2
- package/library/stacks/fastapi/architecture.md +40 -0
- package/library/stacks/fastapi/definition.json +39 -0
- package/library/stacks/fastapi/runtime.md +25 -0
- package/library/stacks/fastapi/security.md +26 -0
- package/library/stacks/fastapi/structure.md +27 -0
- package/library/stacks/fastapi/testing.md +23 -0
- package/{playbooks/stack/nextjs.manifest.json → library/stacks/nextjs/definition.json} +2 -2
- package/{playbooks/stack/no-frontend.manifest.json → library/stacks/no-frontend/definition.json} +1 -1
- package/{playbooks/stack/react-vite.manifest.json → library/stacks/react-vite/definition.json} +2 -2
- package/{compatibility/profiles.json → library/tested-versions.json} +46 -2
- package/{playbooks/universal/accessibility.manifest.json → library/universal/accessibility/definition.json} +1 -1
- package/library/universal/coding-rules/definition.json +13 -0
- package/library/universal/coding-rules/hygiene.md +9 -0
- package/library/universal/coding-rules/naming.md +17 -0
- package/{playbooks/universal/error-handling.manifest.json → library/universal/error-handling/definition.json} +1 -0
- package/library/universal/git-conventions/branches.md +5 -0
- package/library/universal/git-conventions/commits.md +7 -0
- package/library/universal/git-conventions/definition.json +13 -0
- package/library/universal/git-conventions/workflow.md +5 -0
- package/{playbooks/universal/observability.manifest.json → library/universal/observability/definition.json} +1 -0
- package/{playbooks/universal/security.manifest.json → library/universal/security/definition.json} +1 -0
- package/library/universal/typescript/boundaries.md +13 -0
- package/library/universal/typescript/definition.json +12 -0
- package/library/universal/typescript/errors.md +5 -0
- package/library/universal/typescript/patterns.md +7 -0
- package/package.json +14 -15
- package/public/logo.svg +19 -0
- package/src/cli/arguments.js +63 -0
- package/src/cli/display.js +220 -0
- package/src/cli/main.js +203 -0
- package/src/cli/navigation.js +156 -0
- package/src/cli/questions.js +188 -0
- package/src/cli/system-check.js +158 -0
- package/src/engine/create-project.js +10 -0
- package/src/engine/install-dependencies.js +13 -0
- package/{lib/catalog.js → src/engine/load-library.js} +8 -8
- package/{lib/files.js → src/engine/project-files.js} +10 -1
- package/{lib/playbooks.js → src/engine/project-guidance.js} +26 -3
- package/{lib/application-shapes.js → src/engine/project-shapes.js} +4 -4
- package/{lib/template.js → src/engine/render-templates.js} +11 -0
- package/{lib/compatibility.js → src/engine/tested-versions.js} +21 -2
- package/src/engine/upgrade-report.js +20 -0
- package/src/engine/write-files.js +34 -0
- package/{lib/stacks/registry.js → src/stacks/available-stacks.js} +25 -1
- package/src/stacks/backends/README.md +5 -0
- package/src/stacks/backends/fastapi/ci.js +3 -0
- package/src/stacks/backends/fastapi/create-files.js +874 -0
- package/src/stacks/backends/fastapi/docker.js +75 -0
- package/src/stacks/backends/fastapi/environment.js +3 -0
- package/src/stacks/backends/fastapi/index.js +32 -0
- package/{lib/stacks → src/stacks/backends}/laravel/architecture.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/auth/oidc.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/auth/public.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/auth/sanctum.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/auth/session.js +1 -0
- package/src/stacks/backends/laravel/ci.js +3 -0
- package/{lib/stacks → src/stacks/backends}/laravel/composer.js +1 -1
- package/{lib/stacks/laravel/generate.js → src/stacks/backends/laravel/create-files.js} +1 -0
- package/src/stacks/backends/laravel/docker.js +14 -0
- package/src/stacks/backends/laravel/environment.js +5 -0
- package/{lib/stacks → src/stacks/backends}/laravel/index.js +11 -3
- package/{lib/stacks → src/stacks/backends}/laravel/ui/blade.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/ui/index.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/ui/inertia-react.js +4 -2
- package/{lib/stacks → src/stacks/backends}/laravel/ui/livewire.js +1 -0
- package/{lib/stacks → src/stacks/backends}/laravel/ui/shared.js +1 -0
- package/src/stacks/backends/none/index.js +23 -0
- package/src/stacks/backends/postgres/ci.js +3 -0
- package/src/stacks/backends/postgres/create-files.js +21 -0
- package/src/stacks/backends/postgres/docker.js +3 -0
- package/src/stacks/backends/postgres/environment.js +3 -0
- package/src/stacks/backends/postgres/index.js +30 -0
- package/src/stacks/backends/springboot/ci.js +3 -0
- package/src/stacks/backends/springboot/create-files.js +91 -0
- package/src/stacks/backends/springboot/docker.js +3 -0
- package/src/stacks/backends/springboot/environment.js +3 -0
- package/src/stacks/backends/springboot/index.js +31 -0
- package/src/stacks/backends/supabase/ci.js +3 -0
- package/src/stacks/backends/supabase/create-files.js +52 -0
- package/src/stacks/backends/supabase/docker.js +3 -0
- package/src/stacks/backends/supabase/environment.js +3 -0
- package/src/stacks/backends/supabase/index.js +31 -0
- package/src/stacks/backends/supabase/native.js +14 -0
- package/src/stacks/compose-files.js +259 -0
- package/{lib/generator.js → src/stacks/create-project.js} +206 -57
- package/src/stacks/frontends/README.md +4 -0
- package/src/stacks/frontends/nextjs/ci.js +3 -0
- package/src/stacks/frontends/nextjs/create-files.js +46 -0
- package/src/stacks/frontends/nextjs/dependencies.js +5 -0
- package/src/stacks/frontends/nextjs/docker.js +3 -0
- package/src/stacks/frontends/nextjs/environment.js +7 -0
- package/src/stacks/frontends/nextjs/index.js +35 -0
- package/src/stacks/frontends/react-native/ci.js +3 -0
- package/src/stacks/frontends/react-native/create-files.js +28 -0
- package/src/stacks/frontends/react-native/dependencies.js +5 -0
- package/src/stacks/frontends/react-native/docker.js +5 -0
- package/src/stacks/frontends/react-native/environment.js +7 -0
- package/src/stacks/frontends/react-native/index.js +30 -0
- package/src/stacks/frontends/react-vite/ci.js +3 -0
- package/src/stacks/frontends/react-vite/create-files.js +27 -0
- package/src/stacks/frontends/react-vite/dependencies.js +5 -0
- package/src/stacks/frontends/react-vite/docker.js +3 -0
- package/src/stacks/frontends/react-vite/environment.js +8 -0
- package/src/stacks/frontends/react-vite/index.js +32 -0
- package/src/stacks/shared/capability-packs.js +31 -0
- package/{lib → src}/stacks/shared/contributions.js +1 -1
- package/src/stacks/shared/environment.js +38 -0
- package/src/stacks/shared/javascript-package.js +117 -0
- package/src/stacks/shared/testing-files.js +16 -0
- package/templates/ci/fastapi.yml +62 -0
- package/templates/docker/compose/postgres.yml +1 -1
- package/templates/docker/compose/springboot.yml +3 -3
- package/templates/docker/compose/supabase.yml +1 -1
- package/templates/docker/compose-prod/fastapi.yml +52 -0
- package/templates/docker/compose-prod/springboot.yml +20 -2
- package/templates/docker/dockerfile/fastapi.dev.dockerfile +9 -0
- package/templates/docker/dockerfile/fastapi.prod.dockerfile +11 -0
- package/templates/docker/dockerfile/nextjs.prod.dockerfile +1 -0
- package/templates/docker/dockerfile/springboot.prod.dockerfile +4 -1
- package/templates/docker/dockerfile/vite.prod.dockerfile +2 -1
- package/templates/makefile/fastapi.mk +97 -0
- package/docs/ARCHITECTURE.md +0 -118
- package/lib/banner.js +0 -45
- package/lib/constants.js +0 -3
- package/lib/doctor.js +0 -45
- package/lib/interview.js +0 -77
- package/lib/laravel-scaffold.js +0 -3
- package/lib/scaffold.js +0 -467
- package/lib/stacks/index.js +0 -8
- package/playbooks/concerns/zod.md +0 -174
- package/playbooks/devops/makefile.manifest.json +0 -10
- package/playbooks/devops/makefile.md +0 -556
- package/playbooks/styling/css-modules-extensions.md +0 -267
- package/playbooks/styling/css-modules.manifest.json +0 -11
- package/playbooks/universal/coding-rules.manifest.json +0 -12
- package/playbooks/universal/coding-rules.md +0 -281
- package/playbooks/universal/git-conventions.manifest.json +0 -10
- package/playbooks/universal/git-conventions.md +0 -186
- package/playbooks/universal/typescript.manifest.json +0 -11
- package/playbooks/universal/typescript.md +0 -272
- package/scripts/compatibility-matrix.mjs +0 -48
- /package/{playbooks/capabilities → library/development-tools}/ci/github-actions.md +0 -0
- /package/{playbooks/devops → library/development-tools/devops/pr-template}/pr-template.md +0 -0
- /package/{playbooks/capabilities/docker/docker.manifest.json → library/development-tools/docker/definition.json} +0 -0
- /package/{playbooks/capabilities → library/development-tools}/docker/overview.md +0 -0
- /package/{playbooks/capabilities → library/features}/auth/oidc-resource-server.md +0 -0
- /package/{playbooks/capabilities → library/features}/auth/spring-session.md +0 -0
- /package/{playbooks/capabilities/flyway/flyway.manifest.json → library/features/flyway/definition.json} +0 -0
- /package/{playbooks/capabilities → library/features}/flyway/environments.md +0 -0
- /package/{playbooks/capabilities → library/features}/flyway/migrations.md +0 -0
- /package/{playbooks/capabilities → library/features}/flyway/testing.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/database.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/migrations.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/observability.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/oidc-resource-server.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/queues.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/sanctum-spa.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/scheduler.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/session-auth.md +0 -0
- /package/{playbooks/capabilities → library/features}/laravel/storage-uploads.md +0 -0
- /package/{playbooks/capabilities → library/features}/postgresql/architecture.md +0 -0
- /package/{playbooks/capabilities/postgresql/postgresql.manifest.json → library/features/postgresql/definition.json} +0 -0
- /package/{playbooks/capabilities → library/features}/postgresql/migrations.md +0 -0
- /package/{playbooks/capabilities → library/features}/postgresql/schema-design.md +0 -0
- /package/{playbooks/capabilities → library/features}/postgresql/security.md +0 -0
- /package/{playbooks/capabilities → library/features}/postgresql/testing.md +0 -0
- /package/{playbooks/capabilities → library/features}/prisma/architecture.md +0 -0
- /package/{playbooks/capabilities/prisma/prisma.manifest.json → library/features/prisma/definition.json} +0 -0
- /package/{playbooks/capabilities → library/features}/prisma/migrations.md +0 -0
- /package/{playbooks/capabilities → library/features}/prisma/runtime.md +0 -0
- /package/{playbooks/capabilities → library/features}/prisma/schema.md +0 -0
- /package/{playbooks/capabilities → library/features}/prisma/testing.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/architecture.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/authentication.md +0 -0
- /package/{playbooks/capabilities/supabase/supabase.manifest.json → library/features/supabase/definition.json} +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/expo.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/migrations.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/nextjs.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/rls.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/testing.md +0 -0
- /package/{playbooks/capabilities → library/features}/supabase/vite.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/axios.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/next-safe-action.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/next-themes.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/nuqs.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/t3-env.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/tanstack-query.md +0 -0
- /package/{playbooks → library/optional-features}/concerns/zustand.md +0 -0
- /package/{playbooks/styling/native-styles.manifest.json → library/optional-features/styling/native-styles/definition.json} +0 -0
- /package/{playbooks/styling → library/optional-features/styling/native-styles}/native-styles.md +0 -0
- /package/{playbooks/styling/tailwind.manifest.json → library/optional-features/styling/tailwind/definition.json} +0 -0
- /package/{playbooks/styling → library/optional-features/styling/tailwind}/tailwind-extensions.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/blade/architecture.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/blade/runtime.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/blade/security.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/blade/structure.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/blade/testing.md +0 -0
- /package/{playbooks/stack/laravel-ui.manifest.json → library/platforms/laravel-ui/definition.json} +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/inertia-react/architecture.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/inertia-react/runtime.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/inertia-react/security.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/inertia-react/structure.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/inertia-react/testing.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/livewire/architecture.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/livewire/runtime.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/livewire/security.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/livewire/structure.md +0 -0
- /package/{playbooks/platform → library/platforms}/laravel-ui/livewire/testing.md +0 -0
- /package/{playbooks/platform → library/platforms/mobile}/mobile.md +0 -0
- /package/{playbooks/platform → library/platforms/web}/web.md +0 -0
- /package/{playbooks/stack → library/stacks}/expo/architecture.md +0 -0
- /package/{playbooks/stack → library/stacks}/expo/runtime.md +0 -0
- /package/{playbooks/stack → library/stacks}/expo/security.md +0 -0
- /package/{playbooks/stack → library/stacks}/expo/structure.md +0 -0
- /package/{playbooks/stack → library/stacks}/expo/testing.md +0 -0
- /package/{playbooks/stack → library/stacks}/laravel/architecture.md +0 -0
- /package/{playbooks/stack/laravel.manifest.json → library/stacks/laravel/definition.json} +0 -0
- /package/{playbooks/stack → library/stacks}/laravel/runtime.md +0 -0
- /package/{playbooks/stack → library/stacks}/laravel/security.md +0 -0
- /package/{playbooks/stack → library/stacks}/laravel/structure.md +0 -0
- /package/{playbooks/stack → library/stacks}/laravel/testing.md +0 -0
- /package/{playbooks/stack → library/stacks}/nextjs/architecture.md +0 -0
- /package/{playbooks/stack → library/stacks}/nextjs/runtime.md +0 -0
- /package/{playbooks/stack → library/stacks}/nextjs/security.md +0 -0
- /package/{playbooks/stack → library/stacks}/nextjs/structure.md +0 -0
- /package/{playbooks/stack → library/stacks}/nextjs/testing.md +0 -0
- /package/{playbooks/stack/none.manifest.json → library/stacks/none/definition.json} +0 -0
- /package/{playbooks/stack → library/stacks}/react-vite/architecture.md +0 -0
- /package/{playbooks/stack → library/stacks}/react-vite/runtime.md +0 -0
- /package/{playbooks/stack → library/stacks}/react-vite/security.md +0 -0
- /package/{playbooks/stack → library/stacks}/react-vite/structure.md +0 -0
- /package/{playbooks/stack → library/stacks}/react-vite/testing.md +0 -0
- /package/{playbooks/stack → library/stacks}/springboot/architecture.md +0 -0
- /package/{playbooks/stack/springboot.manifest.json → library/stacks/springboot/definition.json} +0 -0
- /package/{playbooks/stack → library/stacks}/springboot/runtime.md +0 -0
- /package/{playbooks/stack → library/stacks}/springboot/security.md +0 -0
- /package/{playbooks/stack → library/stacks}/springboot/structure.md +0 -0
- /package/{playbooks/stack → library/stacks}/springboot/testing.md +0 -0
- /package/{playbooks/universal → library/universal/accessibility}/accessibility.md +0 -0
- /package/{playbooks/universal → library/universal/error-handling}/error-handling.md +0 -0
- /package/{playbooks/universal → library/universal/observability}/observability.md +0 -0
- /package/{playbooks/universal → library/universal/security}/security.md +0 -0
- /package/{lib → src/engine}/project-location.js +0 -0
- /package/{lib → src}/stacks/context.js +0 -0
- /package/{lib/stacks/contract.js → src/stacks/rules.js} +0 -0
- /package/{ci → templates/ci}/expo.yml +0 -0
- /package/{ci → templates/ci}/laravel.yml +0 -0
- /package/{ci → templates/ci}/nextjs.yml +0 -0
- /package/{ci → templates/ci}/springboot.yml +0 -0
- /package/{ci → templates/ci}/vite.yml +0 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import { pythonPackageVersion } from '../../shared/javascript-package.js'
|
|
2
|
+
|
|
3
|
+
function versions(stack) {
|
|
4
|
+
const version = (name) => pythonPackageVersion(stack.profile, name, 'fastapi scaffold')
|
|
5
|
+
return {
|
|
6
|
+
fastapi: version('fastapi'),
|
|
7
|
+
uvicorn: version('uvicorn'),
|
|
8
|
+
pydanticSettings: version('pydantic-settings'),
|
|
9
|
+
sqlalchemy: version('sqlalchemy'),
|
|
10
|
+
asyncpg: version('asyncpg'),
|
|
11
|
+
alembic: version('alembic'),
|
|
12
|
+
pyjwt: version('pyjwt'),
|
|
13
|
+
httpx: version('httpx'),
|
|
14
|
+
pytest: version('pytest'),
|
|
15
|
+
pytestAsyncio: version('pytest-asyncio'),
|
|
16
|
+
ruff: version('ruff'),
|
|
17
|
+
mypy: version('mypy'),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pyproject(answers, stack, v) {
|
|
22
|
+
return `[project]
|
|
23
|
+
name = "${answers.projectName}"
|
|
24
|
+
version = "0.1.0"
|
|
25
|
+
description = "${answers.projectDescription}"
|
|
26
|
+
readme = "README.md"
|
|
27
|
+
requires-python = ">=3.13"
|
|
28
|
+
dependencies = [
|
|
29
|
+
"fastapi==${v.fastapi}",
|
|
30
|
+
"uvicorn==${v.uvicorn}",
|
|
31
|
+
"pydantic-settings==${v.pydanticSettings}",
|
|
32
|
+
"sqlalchemy==${v.sqlalchemy}",
|
|
33
|
+
"asyncpg==${v.asyncpg}",
|
|
34
|
+
"alembic==${v.alembic}",
|
|
35
|
+
"pyjwt==${v.pyjwt}",
|
|
36
|
+
"httpx==${v.httpx}",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest==${v.pytest}",
|
|
42
|
+
"pytest-asyncio==${v.pytestAsyncio}",
|
|
43
|
+
"ruff==${v.ruff}",
|
|
44
|
+
"mypy==${v.mypy}",
|
|
45
|
+
"httpx==${v.httpx}",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["app"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
target-version = "py313"
|
|
57
|
+
line-length = 100
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
python_version = "3.13"
|
|
64
|
+
strict = true
|
|
65
|
+
ignore_missing_imports = true
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
testpaths = ["tests"]
|
|
70
|
+
`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function backendReadme(answers) {
|
|
74
|
+
return `# ${answers.projectName} backend
|
|
75
|
+
|
|
76
|
+
FastAPI service for ${answers.projectDescription}.
|
|
77
|
+
|
|
78
|
+
Run \`uv sync\` to install the exact tested dependencies, then use the commands in the
|
|
79
|
+
repository documentation to develop, test, and operate the service.
|
|
80
|
+
`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mainPy(stack, core) {
|
|
84
|
+
return `"""Application entrypoint for ${stack.architecture} profile."""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import logging
|
|
89
|
+
import uuid
|
|
90
|
+
from collections.abc import AsyncIterator
|
|
91
|
+
from contextlib import asynccontextmanager
|
|
92
|
+
|
|
93
|
+
from fastapi import FastAPI, Request
|
|
94
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
95
|
+
from fastapi.responses import JSONResponse
|
|
96
|
+
|
|
97
|
+
from ${core}.config import settings
|
|
98
|
+
from ${core}.db import close_engine
|
|
99
|
+
|
|
100
|
+
from .routes_health import router as health_router
|
|
101
|
+
from .status_router import router as status_router
|
|
102
|
+
|
|
103
|
+
logger = logging.getLogger(__name__)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AppError(Exception):
|
|
107
|
+
"""Typed application error with a stable code."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, code: str, message: str, status: int = 400) -> None:
|
|
110
|
+
super().__init__(message)
|
|
111
|
+
self.code = code
|
|
112
|
+
self.status = status
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@asynccontextmanager
|
|
116
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
117
|
+
yield
|
|
118
|
+
await close_engine()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_app() -> FastAPI:
|
|
122
|
+
app = FastAPI(title="API", version="0.1.0", lifespan=lifespan)
|
|
123
|
+
|
|
124
|
+
origins = [o.strip() for o in settings.cors_allowed_origins.split(",") if o.strip()]
|
|
125
|
+
app.add_middleware(
|
|
126
|
+
CORSMiddleware,
|
|
127
|
+
allow_origins=origins,
|
|
128
|
+
allow_credentials=False,
|
|
129
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
130
|
+
allow_headers=["Authorization", "Content-Type"],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@app.middleware("http")
|
|
134
|
+
async def request_id(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
135
|
+
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
|
136
|
+
response = await call_next(request)
|
|
137
|
+
response.headers["X-Request-ID"] = request_id
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
@app.exception_handler(AppError)
|
|
141
|
+
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
|
|
142
|
+
request_id = request.headers.get("X-Request-ID", "")
|
|
143
|
+
return JSONResponse(
|
|
144
|
+
status_code=exc.status,
|
|
145
|
+
content={"error": {"code": exc.code, "message": str(exc), "requestId": request_id}},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
app.include_router(health_router)
|
|
149
|
+
app.include_router(status_router)
|
|
150
|
+
logger.info("application startup complete")
|
|
151
|
+
return app
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
app = create_app()
|
|
155
|
+
`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function configPy() {
|
|
159
|
+
return `"""Validated application configuration."""
|
|
160
|
+
|
|
161
|
+
from __future__ import annotations
|
|
162
|
+
|
|
163
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Settings(BaseSettings):
|
|
167
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
168
|
+
|
|
169
|
+
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/app"
|
|
170
|
+
oidc_issuer: str = ""
|
|
171
|
+
oidc_audience: str = "api"
|
|
172
|
+
oidc_algorithms: str = "RS256"
|
|
173
|
+
oidc_jwks_url: str = ""
|
|
174
|
+
cors_allowed_origins: str = "http://localhost:3000"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
settings = Settings()
|
|
178
|
+
`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function dbPy(configModule) {
|
|
182
|
+
return `"""Async SQLAlchemy engine and session management."""
|
|
183
|
+
|
|
184
|
+
from __future__ import annotations
|
|
185
|
+
|
|
186
|
+
from collections.abc import AsyncIterator
|
|
187
|
+
from datetime import datetime
|
|
188
|
+
from uuid import UUID
|
|
189
|
+
|
|
190
|
+
from sqlalchemy import DateTime, func
|
|
191
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
192
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
193
|
+
|
|
194
|
+
from ${configModule} import settings
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Base(DeclarativeBase):
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Example(Base):
|
|
202
|
+
__tablename__ = "examples"
|
|
203
|
+
|
|
204
|
+
id: Mapped[UUID] = mapped_column(primary_key=True)
|
|
205
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
206
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_engine = create_async_engine(settings.database_url, pool_size=5, max_overflow=10)
|
|
210
|
+
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
214
|
+
async with _session_factory() as session:
|
|
215
|
+
yield session
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def close_engine() -> None:
|
|
219
|
+
await _engine.dispose()
|
|
220
|
+
`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function securityPy(authentication, configModule) {
|
|
224
|
+
if (authentication === 'public') {
|
|
225
|
+
return `"""Public authentication model: no user accounts, endpoints intentionally open."""
|
|
226
|
+
|
|
227
|
+
from __future__ import annotations
|
|
228
|
+
|
|
229
|
+
from typing import Any
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def require_auth() -> Any:
|
|
233
|
+
"""Public applications do not invent authentication; returns no principal."""
|
|
234
|
+
return None
|
|
235
|
+
`
|
|
236
|
+
}
|
|
237
|
+
if (authentication === 'undecided') {
|
|
238
|
+
return `"""Undecided authentication model: deny protected routes until auth is designed."""
|
|
239
|
+
|
|
240
|
+
from __future__ import annotations
|
|
241
|
+
|
|
242
|
+
from fastapi import HTTPException
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def require_auth() -> None:
|
|
246
|
+
"""Fail closed; health stays public, every other endpoint is denied."""
|
|
247
|
+
raise HTTPException(status_code=403, detail="Authentication is not configured.")
|
|
248
|
+
`
|
|
249
|
+
}
|
|
250
|
+
return `"""OIDC bearer validation for protected routes."""
|
|
251
|
+
|
|
252
|
+
from __future__ import annotations
|
|
253
|
+
|
|
254
|
+
from typing import Annotated
|
|
255
|
+
|
|
256
|
+
import jwt
|
|
257
|
+
from fastapi import Depends, HTTPException
|
|
258
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
259
|
+
from jwt import PyJWKClient
|
|
260
|
+
|
|
261
|
+
from ${configModule} import settings
|
|
262
|
+
|
|
263
|
+
_bearer = HTTPBearer(auto_error=False)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _jwks_url() -> str:
|
|
267
|
+
if settings.oidc_jwks_url:
|
|
268
|
+
return settings.oidc_jwks_url
|
|
269
|
+
return f"{settings.oidc_issuer.rstrip('/')}/.well-known/jwks.json"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def require_auth(
|
|
273
|
+
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
|
274
|
+
) -> dict[str, object]:
|
|
275
|
+
"""Validate the bearer access token; deny by default."""
|
|
276
|
+
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
277
|
+
raise HTTPException(status_code=401, detail="Missing bearer token.")
|
|
278
|
+
algorithms = [a.strip() for a in settings.oidc_algorithms.split(",") if a.strip()]
|
|
279
|
+
try:
|
|
280
|
+
signing_key = PyJWKClient(_jwks_url()).get_signing_key_from_jwt(credentials.credentials)
|
|
281
|
+
claims: dict[str, object] = jwt.decode(
|
|
282
|
+
credentials.credentials,
|
|
283
|
+
signing_key.key,
|
|
284
|
+
algorithms=algorithms,
|
|
285
|
+
issuer=settings.oidc_issuer,
|
|
286
|
+
audience=settings.oidc_audience,
|
|
287
|
+
options={"require": ["exp", "iss", "aud"]},
|
|
288
|
+
)
|
|
289
|
+
except jwt.PyJWTError as exc:
|
|
290
|
+
raise HTTPException(status_code=401, detail="Invalid bearer token.") from exc
|
|
291
|
+
return claims
|
|
292
|
+
`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function healthRouter() {
|
|
296
|
+
return `"""Public health and readiness routes."""
|
|
297
|
+
|
|
298
|
+
from __future__ import annotations
|
|
299
|
+
|
|
300
|
+
from fastapi import APIRouter
|
|
301
|
+
from sqlalchemy import text
|
|
302
|
+
|
|
303
|
+
router = APIRouter(tags=["health"])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@router.get("/health")
|
|
307
|
+
async def health() -> dict[str, str]:
|
|
308
|
+
return {"status": "ok"}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@router.get("/ready")
|
|
312
|
+
async def ready() -> dict[str, str]:
|
|
313
|
+
from .db import _engine # noqa: PLC0415
|
|
314
|
+
|
|
315
|
+
async with _engine.connect() as connection:
|
|
316
|
+
await connection.execute(text("SELECT 1"))
|
|
317
|
+
return {"status": "ready"}
|
|
318
|
+
`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function healthRouterCore() {
|
|
322
|
+
return `"""Public health and readiness routes."""
|
|
323
|
+
|
|
324
|
+
from __future__ import annotations
|
|
325
|
+
|
|
326
|
+
from fastapi import APIRouter
|
|
327
|
+
from sqlalchemy import text
|
|
328
|
+
|
|
329
|
+
from app.core.db import _engine
|
|
330
|
+
|
|
331
|
+
router = APIRouter(tags=["health"])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.get("/health")
|
|
335
|
+
async def health() -> dict[str, str]:
|
|
336
|
+
return {"status": "ok"}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@router.get("/ready")
|
|
340
|
+
async def ready() -> dict[str, str]:
|
|
341
|
+
async with _engine.connect() as connection:
|
|
342
|
+
await connection.execute(text("SELECT 1"))
|
|
343
|
+
return {"status": "ready"}
|
|
344
|
+
`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function smallApiRouter(authentication) {
|
|
348
|
+
const guard = authentication === 'public'
|
|
349
|
+
? ''
|
|
350
|
+
: ', Depends\n\nfrom .security import require_auth'
|
|
351
|
+
const dep = authentication === 'public' ? '' : ', dependencies=[Depends(require_auth)]'
|
|
352
|
+
return `"""Application routes for the small profile."""
|
|
353
|
+
|
|
354
|
+
from __future__ import annotations
|
|
355
|
+
|
|
356
|
+
from fastapi import APIRouter${guard}
|
|
357
|
+
|
|
358
|
+
router = APIRouter(prefix="/api", tags=["status"]${dep})
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@router.get("/status")
|
|
362
|
+
async def status() -> dict[str, str]:
|
|
363
|
+
return {"status": "ok", "architecture": "small"}
|
|
364
|
+
`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function statusFeature(prefix, architecture) {
|
|
368
|
+
return {
|
|
369
|
+
[`${prefix}/schemas.py`]: `"""Transport schemas for the status feature."""
|
|
370
|
+
|
|
371
|
+
from __future__ import annotations
|
|
372
|
+
|
|
373
|
+
from pydantic import BaseModel
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class StatusResponse(BaseModel):
|
|
377
|
+
status: str
|
|
378
|
+
architecture: str
|
|
379
|
+
`,
|
|
380
|
+
[`${prefix}/repository.py`]: `"""Persistence boundary for the status feature."""
|
|
381
|
+
|
|
382
|
+
from __future__ import annotations
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class StatusRepository:
|
|
386
|
+
def current(self) -> dict[str, str]:
|
|
387
|
+
return {"status": "ok", "architecture": "${architecture}"}
|
|
388
|
+
`,
|
|
389
|
+
[`${prefix}/service.py`]: `"""Application operations for the status feature."""
|
|
390
|
+
|
|
391
|
+
from __future__ import annotations
|
|
392
|
+
|
|
393
|
+
from .repository import StatusRepository
|
|
394
|
+
from .schemas import StatusResponse
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class StatusService:
|
|
398
|
+
def __init__(self, repository: StatusRepository | None = None) -> None:
|
|
399
|
+
self._repository = repository or StatusRepository()
|
|
400
|
+
|
|
401
|
+
def current(self) -> StatusResponse:
|
|
402
|
+
return StatusResponse(**self._repository.current())
|
|
403
|
+
`,
|
|
404
|
+
[`${prefix}/router.py`]: `"""HTTP adapter for the status feature."""
|
|
405
|
+
|
|
406
|
+
from __future__ import annotations
|
|
407
|
+
|
|
408
|
+
from fastapi import APIRouter, Depends
|
|
409
|
+
|
|
410
|
+
from app.core.security import require_auth
|
|
411
|
+
|
|
412
|
+
from .service import StatusService
|
|
413
|
+
|
|
414
|
+
router = APIRouter(prefix="/api", tags=["status"])
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@router.get("/status", dependencies=[Depends(require_auth)])
|
|
418
|
+
async def status() -> dict[str, object]:
|
|
419
|
+
return StatusService().current().model_dump()
|
|
420
|
+
`,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function largeInit() {
|
|
425
|
+
return `"""Public module interface for the status feature."""
|
|
426
|
+
|
|
427
|
+
from __future__ import annotations
|
|
428
|
+
|
|
429
|
+
from .router import router
|
|
430
|
+
from .schemas import StatusResponse
|
|
431
|
+
from .service import StatusService
|
|
432
|
+
|
|
433
|
+
__all__ = ["StatusService", "StatusResponse", "router"]
|
|
434
|
+
`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function boundaryTest() {
|
|
438
|
+
return `"""Architecture boundary: feature modules expose only their public interface."""
|
|
439
|
+
|
|
440
|
+
from __future__ import annotations
|
|
441
|
+
|
|
442
|
+
import pathlib
|
|
443
|
+
|
|
444
|
+
MODULE = pathlib.Path(__file__).resolve().parents[1] / "app" / "modules" / "status"
|
|
445
|
+
PUBLIC = {"__init__.py", "router.py", "service.py", "schemas.py"}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_module_keeps_implementation_internal() -> None:
|
|
449
|
+
assert MODULE.is_dir()
|
|
450
|
+
internals = {"repository.py"}
|
|
451
|
+
assert internals <= {p.name for p in MODULE.iterdir()}
|
|
452
|
+
init = (MODULE / "__init__.py").read_text()
|
|
453
|
+
assert "StatusService" in init and "router" in init
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_only_public_modules_are_imported() -> None:
|
|
457
|
+
tree = (MODULE / "router.py").read_text()
|
|
458
|
+
assert "repository" not in tree
|
|
459
|
+
`
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function alembicIni(projectName) {
|
|
463
|
+
return `[alembic]
|
|
464
|
+
script_location = alembic
|
|
465
|
+
prepend_sys_path = .
|
|
466
|
+
version_path_separator = os
|
|
467
|
+
|
|
468
|
+
[loggers]
|
|
469
|
+
keys = root,sqlalchemy,alembic
|
|
470
|
+
|
|
471
|
+
[handlers]
|
|
472
|
+
keys = console
|
|
473
|
+
|
|
474
|
+
[formatters]
|
|
475
|
+
keys = generic
|
|
476
|
+
|
|
477
|
+
[logger_root]
|
|
478
|
+
level = WARN
|
|
479
|
+
handlers = console
|
|
480
|
+
|
|
481
|
+
[logger_sqlalchemy]
|
|
482
|
+
level = WARN
|
|
483
|
+
handlers =
|
|
484
|
+
qualname = sqlalchemy.engine
|
|
485
|
+
|
|
486
|
+
[logger_alembic]
|
|
487
|
+
level = INFO
|
|
488
|
+
handlers =
|
|
489
|
+
qualname = alembic
|
|
490
|
+
|
|
491
|
+
[handler_console]
|
|
492
|
+
class = StreamHandler
|
|
493
|
+
args = (sys.stderr,)
|
|
494
|
+
level = NOTSET
|
|
495
|
+
formatter = generic
|
|
496
|
+
|
|
497
|
+
[formatter_generic]
|
|
498
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
499
|
+
datefmt = %H:%M:%S
|
|
500
|
+
`
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function alembicEnv(configModule, dbModule) {
|
|
504
|
+
return `"""Alembic async environment; run migrations explicitly with alembic upgrade head."""
|
|
505
|
+
|
|
506
|
+
from __future__ import annotations
|
|
507
|
+
|
|
508
|
+
import asyncio
|
|
509
|
+
from logging.config import fileConfig
|
|
510
|
+
|
|
511
|
+
from sqlalchemy import pool
|
|
512
|
+
from sqlalchemy.engine import Connection
|
|
513
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
514
|
+
|
|
515
|
+
from alembic import context
|
|
516
|
+
from ${configModule} import settings
|
|
517
|
+
from ${dbModule} import Base
|
|
518
|
+
|
|
519
|
+
config = context.config
|
|
520
|
+
if config.config_file_name is not None:
|
|
521
|
+
fileConfig(config.config_file_name)
|
|
522
|
+
config.set_main_option("sqlalchemy.url", settings.database_url)
|
|
523
|
+
|
|
524
|
+
target_metadata = Base.metadata
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def run_migrations_offline() -> None:
|
|
528
|
+
context.configure(
|
|
529
|
+
url=settings.database_url,
|
|
530
|
+
target_metadata=target_metadata,
|
|
531
|
+
literal_binds=True,
|
|
532
|
+
dialect_opts={"paramstyle": "named"},
|
|
533
|
+
)
|
|
534
|
+
with context.begin_transaction():
|
|
535
|
+
context.run_migrations()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def do_run_migrations(connection: Connection) -> None:
|
|
539
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
540
|
+
with context.begin_transaction():
|
|
541
|
+
context.run_migrations()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def run_migrations_online() -> None:
|
|
545
|
+
connectable = async_engine_from_config(
|
|
546
|
+
config.get_section(config.config_ini_section, {}),
|
|
547
|
+
prefix="sqlalchemy.",
|
|
548
|
+
poolclass=pool.NullPool,
|
|
549
|
+
)
|
|
550
|
+
async with connectable.connect() as connection:
|
|
551
|
+
await connection.run_sync(do_run_migrations)
|
|
552
|
+
await connectable.dispose()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
if context.is_offline_mode():
|
|
556
|
+
run_migrations_offline()
|
|
557
|
+
else:
|
|
558
|
+
asyncio.run(run_migrations_online())
|
|
559
|
+
`
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function baselineMigration() {
|
|
563
|
+
return `"""Baseline schema: examples table only; add product tables per feature."""
|
|
564
|
+
|
|
565
|
+
from __future__ import annotations
|
|
566
|
+
|
|
567
|
+
import sqlalchemy as sa
|
|
568
|
+
|
|
569
|
+
from alembic import op
|
|
570
|
+
|
|
571
|
+
revision = "0001_baseline"
|
|
572
|
+
down_revision = None
|
|
573
|
+
branch_labels = None
|
|
574
|
+
depends_on = None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def upgrade() -> None:
|
|
578
|
+
op.create_table(
|
|
579
|
+
"examples",
|
|
580
|
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
|
581
|
+
sa.Column(
|
|
582
|
+
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
|
583
|
+
),
|
|
584
|
+
sa.Column(
|
|
585
|
+
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def downgrade() -> None:
|
|
591
|
+
op.drop_table("examples")
|
|
592
|
+
`
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function conftestPy() {
|
|
596
|
+
return `"""Shared pytest fixtures."""
|
|
597
|
+
|
|
598
|
+
from __future__ import annotations
|
|
599
|
+
|
|
600
|
+
from collections.abc import AsyncIterator
|
|
601
|
+
|
|
602
|
+
import pytest_asyncio
|
|
603
|
+
from httpx import ASGITransport, AsyncClient
|
|
604
|
+
|
|
605
|
+
from app.main import create_app
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@pytest_asyncio.fixture
|
|
609
|
+
async def client() -> AsyncIterator[AsyncClient]:
|
|
610
|
+
app = create_app()
|
|
611
|
+
transport = ASGITransport(app=app)
|
|
612
|
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
613
|
+
yield client
|
|
614
|
+
`
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function healthTest() {
|
|
618
|
+
return `"""Health contract tests (no database)."""
|
|
619
|
+
|
|
620
|
+
from __future__ import annotations
|
|
621
|
+
|
|
622
|
+
import pytest
|
|
623
|
+
from httpx import AsyncClient
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@pytest.mark.asyncio
|
|
627
|
+
async def test_health_is_public(client: AsyncClient) -> None:
|
|
628
|
+
response = await client.get("/health")
|
|
629
|
+
assert response.status_code == 200
|
|
630
|
+
assert response.json() == {"status": "ok"}
|
|
631
|
+
`
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function securityTest(authentication) {
|
|
635
|
+
if (authentication === 'public') {
|
|
636
|
+
return `"""Public model: application endpoints stay open without invented auth."""
|
|
637
|
+
|
|
638
|
+
from __future__ import annotations
|
|
639
|
+
|
|
640
|
+
import pytest
|
|
641
|
+
from httpx import AsyncClient
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@pytest.mark.asyncio
|
|
645
|
+
async def test_status_is_public(client: AsyncClient) -> None:
|
|
646
|
+
response = await client.get("/api/status")
|
|
647
|
+
assert response.status_code == 200
|
|
648
|
+
assert response.json()["status"] == "ok"
|
|
649
|
+
`
|
|
650
|
+
}
|
|
651
|
+
if (authentication === 'undecided') {
|
|
652
|
+
return `"""Undecided model: protected routes fail closed until auth is designed."""
|
|
653
|
+
|
|
654
|
+
from __future__ import annotations
|
|
655
|
+
|
|
656
|
+
import pytest
|
|
657
|
+
from httpx import AsyncClient
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@pytest.mark.asyncio
|
|
661
|
+
async def test_status_fails_closed(client: AsyncClient) -> None:
|
|
662
|
+
response = await client.get("/api/status")
|
|
663
|
+
assert response.status_code == 403
|
|
664
|
+
`
|
|
665
|
+
}
|
|
666
|
+
return `"""OIDC model: missing and invalid bearer tokens are rejected."""
|
|
667
|
+
|
|
668
|
+
from __future__ import annotations
|
|
669
|
+
|
|
670
|
+
import pytest
|
|
671
|
+
from httpx import AsyncClient
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@pytest.mark.asyncio
|
|
675
|
+
async def test_status_rejects_missing_bearer_token(client: AsyncClient) -> None:
|
|
676
|
+
response = await client.get("/api/status")
|
|
677
|
+
assert response.status_code == 401
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@pytest.mark.asyncio
|
|
681
|
+
async def test_status_rejects_invalid_bearer_token(client: AsyncClient) -> None:
|
|
682
|
+
response = await client.get("/api/status", headers={"Authorization": "Bearer invalid"})
|
|
683
|
+
assert response.status_code == 401
|
|
684
|
+
`
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function buildFastApiFiles(answers, vars, stack) {
|
|
688
|
+
const v = versions(stack)
|
|
689
|
+
const root = stack.frontendKey === 'no-frontend' ? '' : 'backend/'
|
|
690
|
+
const auth = stack.authentication
|
|
691
|
+
const files = {
|
|
692
|
+
[`${root}pyproject.toml`]: pyproject(answers, stack, v),
|
|
693
|
+
[`${root}.python-version`]: `${stack.profile.runtimes.python}\n`,
|
|
694
|
+
[`${root}alembic.ini`]: alembicIni(answers.projectName),
|
|
695
|
+
[`${root}alembic/versions/.gitkeep`]: '',
|
|
696
|
+
[`${root}tests/__init__.py`]: '',
|
|
697
|
+
[`${root}tests/conftest.py`]: conftestPy(),
|
|
698
|
+
[`${root}tests/test_health.py`]: healthTest(),
|
|
699
|
+
[`${root}tests/test_security.py`]: securityTest(auth),
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (root) {
|
|
703
|
+
files[`${root}README.md`] = backendReadme(answers)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (stack.architecture === 'small') {
|
|
707
|
+
files[`${root}app/__init__.py`] = ''
|
|
708
|
+
files[`${root}app/main.py`] = smallMain(stack)
|
|
709
|
+
files[`${root}app/config.py`] = configPy()
|
|
710
|
+
files[`${root}app/db.py`] = dbPy('.config')
|
|
711
|
+
files[`${root}app/security.py`] = securityPy(auth, '.config')
|
|
712
|
+
files[`${root}app/routes_health.py`] = healthRouter()
|
|
713
|
+
files[`${root}app/routes_api.py`] = smallApiRouter(auth)
|
|
714
|
+
files[`${root}alembic/env.py`] = alembicEnv('app.config', 'app.db')
|
|
715
|
+
files[`${root}alembic/versions/0001_baseline.py`] = baselineMigration()
|
|
716
|
+
} else if (stack.architecture === 'medium') {
|
|
717
|
+
files[`${root}app/__init__.py`] = ''
|
|
718
|
+
files[`${root}app/main.py`] = mainPy(stack, 'app.core')
|
|
719
|
+
files[`${root}app/routes_health.py`] = healthRouterCore()
|
|
720
|
+
files[`${root}app/status_router.py`] = `"""Status router alias for the medium profile."""
|
|
721
|
+
|
|
722
|
+
from __future__ import annotations
|
|
723
|
+
|
|
724
|
+
from app.features.status.router import router as router
|
|
725
|
+
`
|
|
726
|
+
files[`${root}app/core/__init__.py`] = ''
|
|
727
|
+
files[`${root}app/core/config.py`] = configPy()
|
|
728
|
+
files[`${root}app/core/db.py`] = dbPy('app.core.config')
|
|
729
|
+
files[`${root}app/core/security.py`] = securityPy(auth, 'app.core.config')
|
|
730
|
+
files[`${root}app/core/logging.py`] = `"""Structured PII-safe logging configuration."""
|
|
731
|
+
|
|
732
|
+
from __future__ import annotations
|
|
733
|
+
|
|
734
|
+
import logging
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def configure_logging() -> None:
|
|
738
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
|
|
739
|
+
`
|
|
740
|
+
files[`${root}app/features/__init__.py`] = ''
|
|
741
|
+
files[`${root}app/features/status/__init__.py`] = ''
|
|
742
|
+
Object.assign(files, Object.fromEntries(
|
|
743
|
+
Object.entries(statusFeature(`${root}app/features/status`, 'medium')).map(([k, val]) => [k, val]),
|
|
744
|
+
))
|
|
745
|
+
files[`${root}alembic/env.py`] = alembicEnv('app.core.config', 'app.core.db')
|
|
746
|
+
files[`${root}alembic/versions/0001_baseline.py`] = baselineMigration()
|
|
747
|
+
} else {
|
|
748
|
+
files[`${root}app/__init__.py`] = ''
|
|
749
|
+
files[`${root}app/main.py`] = mainPy(stack, 'app.core')
|
|
750
|
+
files[`${root}app/routes_health.py`] = healthRouterCore()
|
|
751
|
+
files[`${root}app/status_router.py`] = `"""Status router alias for the large profile."""
|
|
752
|
+
|
|
753
|
+
from __future__ import annotations
|
|
754
|
+
|
|
755
|
+
from app.modules.status import router as router
|
|
756
|
+
`
|
|
757
|
+
files[`${root}app/core/__init__.py`] = ''
|
|
758
|
+
files[`${root}app/core/config.py`] = configPy()
|
|
759
|
+
files[`${root}app/core/db.py`] = dbPy('app.core.config')
|
|
760
|
+
files[`${root}app/core/security.py`] = securityPy(auth, 'app.core.config')
|
|
761
|
+
files[`${root}app/core/logging.py`] = `"""Structured PII-safe logging configuration."""
|
|
762
|
+
|
|
763
|
+
from __future__ import annotations
|
|
764
|
+
|
|
765
|
+
import logging
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def configure_logging() -> None:
|
|
769
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
|
|
770
|
+
`
|
|
771
|
+
files[`${root}app/modules/__init__.py`] = ''
|
|
772
|
+
files[`${root}app/modules/status/__init__.py`] = largeInit()
|
|
773
|
+
Object.assign(files, Object.fromEntries(
|
|
774
|
+
Object.entries(statusFeature(`${root}app/modules/status`, 'large'))
|
|
775
|
+
.filter(([k]) => !k.endsWith('/router.py'))
|
|
776
|
+
.map(([k, val]) => [k, val]),
|
|
777
|
+
))
|
|
778
|
+
files[`${root}app/modules/status/router.py`] = `"""HTTP adapter for the status feature (public interface re-exported)."""
|
|
779
|
+
|
|
780
|
+
from __future__ import annotations
|
|
781
|
+
|
|
782
|
+
from fastapi import APIRouter, Depends
|
|
783
|
+
|
|
784
|
+
from app.core.security import require_auth
|
|
785
|
+
|
|
786
|
+
from .service import StatusService
|
|
787
|
+
|
|
788
|
+
router = APIRouter(prefix="/api", tags=["status"])
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@router.get("/status", dependencies=[Depends(require_auth)])
|
|
792
|
+
async def status() -> dict[str, object]:
|
|
793
|
+
return StatusService().current().model_dump()
|
|
794
|
+
`
|
|
795
|
+
files[`${root}tests/test_boundaries.py`] = boundaryTest()
|
|
796
|
+
files[`${root}alembic/env.py`] = alembicEnv('app.core.config', 'app.core.db')
|
|
797
|
+
files[`${root}alembic/versions/0001_baseline.py`] = baselineMigration()
|
|
798
|
+
}
|
|
799
|
+
return files
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function smallMain(stack) {
|
|
803
|
+
return `"""Application entrypoint for the small profile."""
|
|
804
|
+
|
|
805
|
+
from __future__ import annotations
|
|
806
|
+
|
|
807
|
+
import logging
|
|
808
|
+
import uuid
|
|
809
|
+
from collections.abc import AsyncIterator
|
|
810
|
+
from contextlib import asynccontextmanager
|
|
811
|
+
|
|
812
|
+
from fastapi import FastAPI, Request
|
|
813
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
814
|
+
from fastapi.responses import JSONResponse
|
|
815
|
+
|
|
816
|
+
from .config import settings
|
|
817
|
+
from .db import close_engine
|
|
818
|
+
from .routes_api import router as api_router
|
|
819
|
+
from .routes_health import router as health_router
|
|
820
|
+
|
|
821
|
+
logger = logging.getLogger(__name__)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
class AppError(Exception):
|
|
825
|
+
"""Typed application error with a stable code."""
|
|
826
|
+
|
|
827
|
+
def __init__(self, code: str, message: str, status: int = 400) -> None:
|
|
828
|
+
super().__init__(message)
|
|
829
|
+
self.code = code
|
|
830
|
+
self.status = status
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@asynccontextmanager
|
|
834
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
835
|
+
yield
|
|
836
|
+
await close_engine()
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def create_app() -> FastAPI:
|
|
840
|
+
app = FastAPI(title="API", version="0.1.0", lifespan=lifespan)
|
|
841
|
+
|
|
842
|
+
origins = [o.strip() for o in settings.cors_allowed_origins.split(",") if o.strip()]
|
|
843
|
+
app.add_middleware(
|
|
844
|
+
CORSMiddleware,
|
|
845
|
+
allow_origins=origins,
|
|
846
|
+
allow_credentials=False,
|
|
847
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
848
|
+
allow_headers=["Authorization", "Content-Type"],
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
@app.middleware("http")
|
|
852
|
+
async def request_id(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
853
|
+
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
|
854
|
+
response = await call_next(request)
|
|
855
|
+
response.headers["X-Request-ID"] = request_id
|
|
856
|
+
return response
|
|
857
|
+
|
|
858
|
+
@app.exception_handler(AppError)
|
|
859
|
+
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
|
|
860
|
+
request_id = request.headers.get("X-Request-ID", "")
|
|
861
|
+
return JSONResponse(
|
|
862
|
+
status_code=exc.status,
|
|
863
|
+
content={"error": {"code": exc.code, "message": str(exc), "requestId": request_id}},
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
app.include_router(health_router)
|
|
867
|
+
app.include_router(api_router)
|
|
868
|
+
logger.info("application startup complete")
|
|
869
|
+
return app
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
app = create_app()
|
|
873
|
+
`
|
|
874
|
+
}
|