@horka/app-forge 0.1.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 +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
import Logging
|
|
3
|
+
import Monitoring
|
|
4
|
+
|
|
5
|
+
@main
|
|
6
|
+
enum Entrypoint {
|
|
7
|
+
static func main() async throws {
|
|
8
|
+
var env = try Environment.detect()
|
|
9
|
+
|
|
10
|
+
// JSON structured logs in release (one object per line — Loki/Grafana-ready),
|
|
11
|
+
// human-readable text locally. Level comes from --log / LOG_LEVEL: NEVER hardcoded.
|
|
12
|
+
if env.isRelease {
|
|
13
|
+
let level = try Logger.Level.detect(from: &env)
|
|
14
|
+
LoggingSystem.bootstrap { label in
|
|
15
|
+
JSONLogHandler(label: label, level: level)
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
try LoggingSystem.bootstrap(from: &env)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let app = try await Application.make(env)
|
|
22
|
+
|
|
23
|
+
do {
|
|
24
|
+
try await configure(app)
|
|
25
|
+
} catch {
|
|
26
|
+
app.logger.report(error: error)
|
|
27
|
+
try? await app.asyncShutdown()
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
try await app.execute()
|
|
31
|
+
try await app.asyncShutdown()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private extension Logger.Level {
|
|
36
|
+
/// Precedence: --log CLI flag > LOG_LEVEL env var > environment default.
|
|
37
|
+
static func detect(from environment: inout Environment) throws -> Logger.Level {
|
|
38
|
+
struct LogSignature: CommandSignature {
|
|
39
|
+
@Option(name: "log", help: "Change log level")
|
|
40
|
+
var level: Logger.Level?
|
|
41
|
+
init() { }
|
|
42
|
+
}
|
|
43
|
+
return try LogSignature(from: &environment.commandInput).level
|
|
44
|
+
?? Environment.process.LOG_LEVEL
|
|
45
|
+
?? (environment == .production ? .notice : .info)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import Vapor
|
|
3
|
+
import Monitoring
|
|
4
|
+
|
|
5
|
+
func routes(_ app: Application) throws {
|
|
6
|
+
// Feature modules — exactly ONE register line per module (auto-registration:
|
|
7
|
+
// the module's Controllers enum conforms to ControllersRegister).
|
|
8
|
+
try App.Item.Controllers.register(app: app)
|
|
9
|
+
|
|
10
|
+
// Liveness probe for load balancers / uptime monitors.
|
|
11
|
+
// Unauthenticated by design; excluded from request logs (HTTPLoggingMiddleware).
|
|
12
|
+
app.get("health") { _ in
|
|
13
|
+
["status": "ok"]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Prometheus scrape endpoint — bearer-token gated with a constant-time compare.
|
|
17
|
+
// Handler lives in the Monitoring target (L1); see docs-architecture/OPS.md.
|
|
18
|
+
app.get("metrics") { req in
|
|
19
|
+
try handleMetricsRequest(req)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
|
|
3
|
+
extension App {
|
|
4
|
+
/// Typed error contract. Every error this API intentionally returns is a case of one
|
|
5
|
+
/// of the enums below; the middleware serializes it as `{code, name, description}`.
|
|
6
|
+
///
|
|
7
|
+
/// Rules (see docs-architecture/CONVENTIONS.md):
|
|
8
|
+
/// - Feature code throws `App.Failed.*` — never raw `Abort`, never string errors.
|
|
9
|
+
/// - One enum per HTTP status; the case name IS the public error identifier.
|
|
10
|
+
/// - Keep `convert()` on ONE line — `scripts/generate-error-codes.sh` parses this file
|
|
11
|
+
/// to generate `docs/ERROR_CODES.md`. That doc is GENERATED, never hand-written.
|
|
12
|
+
enum Failed {
|
|
13
|
+
enum Middlewares {}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
extension App.Failed {
|
|
18
|
+
|
|
19
|
+
protocol CustomError: Error {
|
|
20
|
+
func convert() -> HTTPStatus
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
enum BadRequest: CustomError {
|
|
24
|
+
case jsonNotDecodable
|
|
25
|
+
case queryNotFound
|
|
26
|
+
case pathNotFound
|
|
27
|
+
case idNotFound
|
|
28
|
+
case dataNotValid
|
|
29
|
+
|
|
30
|
+
func convert() -> HTTPStatus { .badRequest }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
enum Unauthorized: CustomError {
|
|
34
|
+
case unauthorized
|
|
35
|
+
|
|
36
|
+
func convert() -> HTTPStatus { .unauthorized }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
enum Forbidden: CustomError {
|
|
40
|
+
case accessDenied
|
|
41
|
+
|
|
42
|
+
func convert() -> HTTPStatus { .forbidden }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
enum NotFound: CustomError {
|
|
46
|
+
case dataNotFound
|
|
47
|
+
|
|
48
|
+
func convert() -> HTTPStatus { .notFound }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
enum Conflict: CustomError {
|
|
52
|
+
case dataAlreadyExist
|
|
53
|
+
|
|
54
|
+
func convert() -> HTTPStatus { .conflict }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
enum InternalServer: CustomError {
|
|
58
|
+
case jsonNotEncodable
|
|
59
|
+
case databaseError
|
|
60
|
+
case unknown
|
|
61
|
+
|
|
62
|
+
func convert() -> HTTPStatus { .internalServerError }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// MARK: - Middlewares
|
|
67
|
+
extension App.Failed.Middlewares: MiddlewaresRegister {
|
|
68
|
+
static func allCases() -> [any Middleware] {
|
|
69
|
+
[
|
|
70
|
+
Handler()
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
import Monitoring
|
|
3
|
+
|
|
4
|
+
extension App.Failed.Middlewares {
|
|
5
|
+
/// Converts thrown `App.Failed.CustomError` values into the public error contract:
|
|
6
|
+
/// HTTP status from `convert()`, JSON body `{code, name, description}`.
|
|
7
|
+
///
|
|
8
|
+
/// `name` is the enum case (stable, machine-matchable by clients); `description` is
|
|
9
|
+
/// the human-readable status reason. Untyped errors are logged with full context,
|
|
10
|
+
/// counted, and rethrown so Vapor's ErrorMiddleware formats them — a typed API must
|
|
11
|
+
/// make untyped errors LOUD, not pretty.
|
|
12
|
+
struct Handler: AsyncMiddleware {
|
|
13
|
+
struct ErrorDescription: Codable {
|
|
14
|
+
let code: UInt
|
|
15
|
+
let name: String
|
|
16
|
+
let description: String
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
|
20
|
+
do {
|
|
21
|
+
return try await next.respond(to: request)
|
|
22
|
+
} catch let error as any App.Failed.CustomError {
|
|
23
|
+
let status = error.convert()
|
|
24
|
+
|
|
25
|
+
recordHTTPError(status: status.code, errorName: "\(error)")
|
|
26
|
+
|
|
27
|
+
let payload = ErrorDescription(
|
|
28
|
+
code: status.code,
|
|
29
|
+
name: "\(error)",
|
|
30
|
+
description: status.reasonPhrase
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
do {
|
|
34
|
+
let body = try Response.Body(data: JSONEncoder().encode(payload))
|
|
35
|
+
var headers = HTTPHeaders()
|
|
36
|
+
headers.add(name: .contentType, value: "application/json; charset=utf-8")
|
|
37
|
+
return Response(status: status, headers: headers, body: body)
|
|
38
|
+
} catch {
|
|
39
|
+
return Response(status: .internalServerError,
|
|
40
|
+
body: .init(string: "Error encoding error response"))
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
request.logger.error("Unhandled error", metadata: [
|
|
44
|
+
"error_type": "\(type(of: error))",
|
|
45
|
+
"error_description": "\(String(reflecting: error))",
|
|
46
|
+
"path": "\(request.url.path)",
|
|
47
|
+
"method": "\(request.method.rawValue)",
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
recordHTTPError(status: 500, errorName: "untyped")
|
|
51
|
+
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import Vapor
|
|
3
|
+
|
|
4
|
+
extension App {
|
|
5
|
+
/// REFERENCE FEATURE MODULE — copy this shape for every new feature.
|
|
6
|
+
///
|
|
7
|
+
/// A feature module is one folder under `Features/` holding everything the feature
|
|
8
|
+
/// needs, split by role: Controllers (L5), DTO (L5), Services (L3), Entities (L3),
|
|
9
|
+
/// Repositories (L2), Migrations (L2), Jobs (L2, when needed).
|
|
10
|
+
///
|
|
11
|
+
/// This file is the module's front door: the namespace + its registration surfaces.
|
|
12
|
+
/// Wiring a feature into the app takes exactly two lines elsewhere:
|
|
13
|
+
/// - `try App.Item.Controllers.register(app: app)` in Configure/routes.swift
|
|
14
|
+
/// - `App.Item.Migrations.register(app: app)` in Configure/configure.swift (ORDER MATTERS)
|
|
15
|
+
enum Item {
|
|
16
|
+
enum Controllers {}
|
|
17
|
+
enum Migrations {}
|
|
18
|
+
enum DTO {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// MARK: - Controllers
|
|
23
|
+
extension App.Item.Controllers: ControllersRegister {
|
|
24
|
+
static func allCases() -> [any RouteCollection] {
|
|
25
|
+
[
|
|
26
|
+
Crud()
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// MARK: - Migrations
|
|
32
|
+
extension App.Item.Migrations: MigrationsRegister {
|
|
33
|
+
static func allCases() -> [any Migration] {
|
|
34
|
+
[
|
|
35
|
+
Create()
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import Vapor
|
|
3
|
+
|
|
4
|
+
extension App.Item.Controllers {
|
|
5
|
+
/// L5 — HTTP boundary only: decode DTO.Input, call the Service, encode DTO.Output.
|
|
6
|
+
/// No Fluent queries here, no business rules — if a guard encodes a domain decision,
|
|
7
|
+
/// it belongs in the Service.
|
|
8
|
+
struct Crud: RouteCollection {
|
|
9
|
+
func boot(routes: any RoutesBuilder) throws {
|
|
10
|
+
let items = routes.grouped("api", "items")
|
|
11
|
+
items.get(use: index)
|
|
12
|
+
items.post(use: create)
|
|
13
|
+
items.get(":itemID", use: get)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Sendable
|
|
17
|
+
func index(req: Request) async throws -> [App.Item.DTO.Output] {
|
|
18
|
+
try await App.Item.Service(db: req.db)
|
|
19
|
+
.list()
|
|
20
|
+
.map { try App.Item.DTO.Output(entity: $0) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Sendable
|
|
24
|
+
func get(req: Request) async throws -> App.Item.DTO.Output {
|
|
25
|
+
guard let id = req.parameters.get("itemID", as: UUID.self) else {
|
|
26
|
+
throw App.Failed.BadRequest.idNotFound
|
|
27
|
+
}
|
|
28
|
+
let entity = try await App.Item.Service(db: req.db).get(id)
|
|
29
|
+
return try App.Item.DTO.Output(entity: entity)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Sendable
|
|
33
|
+
func create(req: Request) async throws -> App.Item.DTO.Output {
|
|
34
|
+
guard let input = try? req.content.decode(App.Item.DTO.Input.self) else {
|
|
35
|
+
throw App.Failed.BadRequest.jsonNotDecodable
|
|
36
|
+
}
|
|
37
|
+
let entity = try await App.Item.Service(db: req.db).create(name: input.name)
|
|
38
|
+
return try App.Item.DTO.Output(entity: entity)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
|
|
3
|
+
extension App.Item.DTO {
|
|
4
|
+
/// Wire types. Entities NEVER serialize directly — the DTO is the public contract,
|
|
5
|
+
/// the entity is a private persistence detail (renaming a column must not be an
|
|
6
|
+
/// API-breaking change).
|
|
7
|
+
struct Input: Content {
|
|
8
|
+
let name: String
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
struct Output: Content {
|
|
12
|
+
let id: UUID
|
|
13
|
+
let name: String
|
|
14
|
+
let createdAt: Date?
|
|
15
|
+
|
|
16
|
+
init(entity: App.Item.Entity) throws {
|
|
17
|
+
self.id = try entity.requireID()
|
|
18
|
+
self.name = entity.name
|
|
19
|
+
self.createdAt = entity.createdAt
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import Vapor
|
|
3
|
+
|
|
4
|
+
extension App.Item {
|
|
5
|
+
typealias Entities = [Entity]
|
|
6
|
+
|
|
7
|
+
/// Fluent model. `@unchecked Sendable` is the Fluent-imposed exception to the
|
|
8
|
+
/// no-unchecked rule — property wrappers make models mutable reference types.
|
|
9
|
+
/// CONTAINMENT RULE: an Entity never crosses a concurrency boundary and never leaves
|
|
10
|
+
/// the request scope — map to DTO.Output at the controller edge
|
|
11
|
+
/// (docs-architecture/GOTCHAS_LINUX_SWIFT.md).
|
|
12
|
+
final class Entity: Model, @unchecked Sendable {
|
|
13
|
+
static let schema = "items"
|
|
14
|
+
|
|
15
|
+
@ID(key: .id)
|
|
16
|
+
var id: UUID?
|
|
17
|
+
|
|
18
|
+
@Field(key: "name")
|
|
19
|
+
var name: String
|
|
20
|
+
|
|
21
|
+
@Timestamp(key: "created_at", on: .create)
|
|
22
|
+
var createdAt: Date?
|
|
23
|
+
|
|
24
|
+
init() { }
|
|
25
|
+
|
|
26
|
+
init(name: String) {
|
|
27
|
+
self.name = name
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
|
|
3
|
+
extension App.Item.Migrations {
|
|
4
|
+
/// Schema changes are append-only: never edit a shipped migration — add a new one
|
|
5
|
+
/// (`AddXxx`, `FixXxx`) and list it after this one in AppItem.swift.
|
|
6
|
+
struct Create: AsyncMigration {
|
|
7
|
+
typealias Entity = App.Item.Entity
|
|
8
|
+
|
|
9
|
+
func prepare(on database: any Database) async throws {
|
|
10
|
+
try await database.schema(Entity.schema)
|
|
11
|
+
.id()
|
|
12
|
+
.field("name", .string, .required)
|
|
13
|
+
.field("created_at", .datetime)
|
|
14
|
+
// Name uniqueness is enforced by the DATABASE, not by a check-then-insert in
|
|
15
|
+
// the service (that read+write is a TOCTOU race under concurrency). The Service
|
|
16
|
+
// still pre-checks for a clean 409, but this constraint is the source of truth.
|
|
17
|
+
.unique(on: "name")
|
|
18
|
+
.create()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func revert(on database: any Database) async throws {
|
|
22
|
+
try await database.schema(Entity.schema).delete()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
extension App.Item {
|
|
5
|
+
/// L2 — DATA ACCESS ONLY. A repository answers "what does the database say" and
|
|
6
|
+
/// nothing else: no validation, no normalization, no typed business errors, no
|
|
7
|
+
/// orchestration. All of that lives in Service (L3). If a method here starts making
|
|
8
|
+
/// decisions, it is in the wrong layer.
|
|
9
|
+
struct Repository: Sendable {
|
|
10
|
+
let db: any Database
|
|
11
|
+
|
|
12
|
+
func all() async throws -> Entities {
|
|
13
|
+
try await Entity.query(on: db)
|
|
14
|
+
.sort(\.$name)
|
|
15
|
+
.all()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func find(_ id: UUID) async throws -> Entity? {
|
|
19
|
+
try await Entity.find(id, on: db)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func exists(name: String) async throws -> Bool {
|
|
23
|
+
try await Entity.query(on: db)
|
|
24
|
+
.filter(\.$name == name)
|
|
25
|
+
.count() > 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func save(_ entity: Entity) async throws {
|
|
29
|
+
try await entity.save(on: db)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import FluentKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import {{PROJECT_NAME}}Foundation
|
|
5
|
+
|
|
6
|
+
extension App.Item {
|
|
7
|
+
/// L3 — BUSINESS LOGIC for the Item feature. The only layer that makes decisions:
|
|
8
|
+
/// validation, normalization, typed errors, multi-step orchestration.
|
|
9
|
+
/// Controllers translate HTTP ↔ DTO and call the service; the repository only
|
|
10
|
+
/// touches the database. Services never see `Request` — they take plain values,
|
|
11
|
+
/// which keeps them testable without HTTP.
|
|
12
|
+
struct Service: Sendable {
|
|
13
|
+
let repository: Repository
|
|
14
|
+
|
|
15
|
+
init(db: any Database) {
|
|
16
|
+
self.repository = Repository(db: db)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func list() async throws -> Entities {
|
|
20
|
+
try await repository.all()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func get(_ id: UUID) async throws -> Entity {
|
|
24
|
+
guard let entity = try await repository.find(id) else {
|
|
25
|
+
throw App.Failed.NotFound.dataNotFound
|
|
26
|
+
}
|
|
27
|
+
return entity
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Business rules: names are stored trimmed, must not be blank, must be unique.
|
|
31
|
+
///
|
|
32
|
+
/// Uniqueness is enforced by the DB's `.unique(on: "name")` constraint, NOT by the
|
|
33
|
+
/// pre-check below: `exists` + `save` is a TOCTOU race — two concurrent creates can both
|
|
34
|
+
/// pass the check and both insert. The pre-check stays only to return a clean 409 on the
|
|
35
|
+
/// common (uncontended) path; the constraint-failure catch is what makes it correct under
|
|
36
|
+
/// concurrency, mapping the DB violation to the SAME typed 409.
|
|
37
|
+
func create(name: String) async throws -> Entity {
|
|
38
|
+
let cleanName = name.trimmed
|
|
39
|
+
|
|
40
|
+
guard !cleanName.isEmpty else {
|
|
41
|
+
throw App.Failed.BadRequest.dataNotValid
|
|
42
|
+
}
|
|
43
|
+
guard try await repository.exists(name: cleanName) == false else {
|
|
44
|
+
throw App.Failed.Conflict.dataAlreadyExist
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let entity = Entity(name: cleanName)
|
|
48
|
+
do {
|
|
49
|
+
try await repository.save(entity)
|
|
50
|
+
} catch let error as any DatabaseError where error.isConstraintFailure {
|
|
51
|
+
// A concurrent insert won the race after our pre-check — same outcome, typed 409.
|
|
52
|
+
throw App.Failed.Conflict.dataAlreadyExist
|
|
53
|
+
}
|
|
54
|
+
return entity
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
|
|
3
|
+
/// Auto-registration surface for a feature module's controllers.
|
|
4
|
+
///
|
|
5
|
+
/// A feature conforms its `Controllers` namespace enum and lists its RouteCollections in
|
|
6
|
+
/// `allCases()`; `routes.swift` then needs exactly ONE line per feature:
|
|
7
|
+
/// `try App.<Feature>.Controllers.register(app: app)`.
|
|
8
|
+
protocol ControllersRegister {
|
|
9
|
+
static func allCases() -> [any RouteCollection]
|
|
10
|
+
static func register(app: Application) throws
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
extension ControllersRegister {
|
|
14
|
+
static func register(app: Application) throws {
|
|
15
|
+
try allCases().forEach { try app.register(collection: $0) }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
|
|
3
|
+
/// Auto-registration surface for a feature module's global middlewares.
|
|
4
|
+
/// Used sparingly — most features need none. The typed-error handler (App.Failed) is the
|
|
5
|
+
/// canonical conformer.
|
|
6
|
+
protocol MiddlewaresRegister {
|
|
7
|
+
static func allCases() -> [any Middleware]
|
|
8
|
+
static func register(app: Application)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
extension MiddlewaresRegister {
|
|
12
|
+
static func register(app: Application) {
|
|
13
|
+
allCases().forEach { app.middleware.use($0) }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Fluent
|
|
2
|
+
import Vapor
|
|
3
|
+
|
|
4
|
+
/// Auto-registration surface for a feature module's migrations.
|
|
5
|
+
///
|
|
6
|
+
/// `allCases()` order = execution order WITHIN the feature. Order ACROSS features is
|
|
7
|
+
/// decided by the explicit `register` call sequence in `configure.swift` — that ordering
|
|
8
|
+
/// is load-bearing (foreign keys), see docs-architecture/GOTCHAS_LINUX_SWIFT.md.
|
|
9
|
+
protocol MigrationsRegister {
|
|
10
|
+
static func allCases() -> [any Migration]
|
|
11
|
+
static func register(app: Application)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
extension MigrationsRegister {
|
|
15
|
+
static func register(app: Application) {
|
|
16
|
+
allCases().forEach { app.migrations.add($0) }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Logging
|
|
3
|
+
|
|
4
|
+
/// A LogHandler that writes one JSON object per line to stdout — directly parseable by
|
|
5
|
+
/// Loki/Grafana/CloudWatch. Bootstrapped in `entrypoint.swift` for release builds only;
|
|
6
|
+
/// development keeps Vapor's human-readable text handler.
|
|
7
|
+
public struct JSONLogHandler: LogHandler {
|
|
8
|
+
public var logLevel: Logger.Level
|
|
9
|
+
public var metadata: Logger.Metadata = [:]
|
|
10
|
+
|
|
11
|
+
private let label: String
|
|
12
|
+
|
|
13
|
+
public subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
|
14
|
+
get { metadata[key] }
|
|
15
|
+
set { metadata[key] = newValue }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public init(label: String, level: Logger.Level = .info) {
|
|
19
|
+
self.label = label
|
|
20
|
+
self.logLevel = level
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public func log(
|
|
24
|
+
level: Logger.Level,
|
|
25
|
+
message: Logger.Message,
|
|
26
|
+
metadata: Logger.Metadata?,
|
|
27
|
+
source: String,
|
|
28
|
+
file: String,
|
|
29
|
+
function: String,
|
|
30
|
+
line: UInt
|
|
31
|
+
) {
|
|
32
|
+
var merged = self.metadata
|
|
33
|
+
if let extra = metadata {
|
|
34
|
+
merged.merge(extra) { _, new in new }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let timestamp = Date().formatted(
|
|
38
|
+
.iso8601.year().month().day().time(includingFractionalSeconds: true)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
var dict: [String: String] = [
|
|
42
|
+
"timestamp": timestamp,
|
|
43
|
+
"level": level.rawValue,
|
|
44
|
+
"message": "\(message)",
|
|
45
|
+
"source": label,
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
// Metadata becomes flat top-level keys — queryable without JSON-path gymnastics.
|
|
49
|
+
for (key, value) in merged {
|
|
50
|
+
dict[key] = "\(value)"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]),
|
|
54
|
+
let jsonString = String(data: jsonData, encoding: .utf8) {
|
|
55
|
+
// Single fputs call per line: lines never interleave across threads.
|
|
56
|
+
fputs(jsonString + "\n", stdout)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Vapor
|
|
2
|
+
|
|
3
|
+
/// Logs every HTTP request with its duration, skipping configured paths.
|
|
4
|
+
/// Replaces Vapor's default RouteLoggingMiddleware to:
|
|
5
|
+
/// - keep noisy probe paths (/metrics, /health) out of the logs
|
|
6
|
+
/// - include response duration in the log metadata
|
|
7
|
+
public struct HTTPLoggingMiddleware: AsyncMiddleware {
|
|
8
|
+
private let excludedPaths: Set<String>
|
|
9
|
+
|
|
10
|
+
public init(excludedPaths: [String] = ["/metrics", "/health"]) {
|
|
11
|
+
self.excludedPaths = Set(excludedPaths)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
|
15
|
+
let path = request.url.path
|
|
16
|
+
|
|
17
|
+
guard !excludedPaths.contains(path) else {
|
|
18
|
+
return try await next.respond(to: request)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DispatchTime, not Date: monotonic — wall-clock jumps (NTP) can't produce
|
|
22
|
+
// negative or absurd durations.
|
|
23
|
+
let start = DispatchTime.now()
|
|
24
|
+
|
|
25
|
+
do {
|
|
26
|
+
let response = try await next.respond(to: request)
|
|
27
|
+
let durationMs = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
|
|
28
|
+
|
|
29
|
+
request.logger.info("\(request.method) \(path) \(response.status.code)", metadata: [
|
|
30
|
+
"method": "\(request.method)",
|
|
31
|
+
"path": "\(path)",
|
|
32
|
+
"status": "\(response.status.code)",
|
|
33
|
+
"duration_ms": "\(String(format: "%.1f", durationMs))",
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
return response
|
|
37
|
+
} catch {
|
|
38
|
+
let durationMs = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
|
|
39
|
+
|
|
40
|
+
request.logger.error("\(request.method) \(path) ERROR", metadata: [
|
|
41
|
+
"method": "\(request.method)",
|
|
42
|
+
"path": "\(path)",
|
|
43
|
+
"duration_ms": "\(String(format: "%.1f", durationMs))",
|
|
44
|
+
"error": "\(error)",
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|