@zaaxch/tailframe 1.0.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/package.json +1 -1
- package/src/architecture.mjs +4 -0
- package/src/conventions.mjs +84 -0
- package/src/generate.mjs +118 -57
- package/src/new.mjs +170 -118
- package/src/service-templates.mjs +483 -0
- package/src/ui-templates.mjs +141 -0
- package/src/validate.mjs +1 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
const pascal = (kebab) => kebab.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
2
|
+
const camel = (kebab) => {
|
|
3
|
+
const value = pascal(kebab);
|
|
4
|
+
return value[0].toLowerCase() + value.slice(1);
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function serviceOperationName(moduleName, verbNoun) {
|
|
8
|
+
const moduleSuffix = pascal(moduleName);
|
|
9
|
+
if (verbNoun.endsWith(moduleSuffix) && verbNoun !== moduleSuffix) {
|
|
10
|
+
const prefix = verbNoun.slice(0, -moduleSuffix.length);
|
|
11
|
+
return prefix[0].toLowerCase() + prefix.slice(1);
|
|
12
|
+
}
|
|
13
|
+
return verbNoun[0].toLowerCase() + verbNoun.slice(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const requestContextSource = `export interface AuthenticatedPrincipal {
|
|
17
|
+
uid: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RequestContext {
|
|
22
|
+
requestId: string;
|
|
23
|
+
principal?: AuthenticatedPrincipal;
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export const useCaseSource = `import type { RequestContext } from "@/core/RequestContext";
|
|
28
|
+
|
|
29
|
+
export interface UseCase<Input, Output> {
|
|
30
|
+
execute(context: RequestContext, input: Input): Promise<Output>;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export const appErrorSource = `export type FailureKind =
|
|
35
|
+
| "invalid"
|
|
36
|
+
| "unauthenticated"
|
|
37
|
+
| "forbidden"
|
|
38
|
+
| "not_found"
|
|
39
|
+
| "conflict"
|
|
40
|
+
| "unprocessable"
|
|
41
|
+
| "rate_limited"
|
|
42
|
+
| "unavailable"
|
|
43
|
+
| "internal";
|
|
44
|
+
|
|
45
|
+
export class AppError extends Error {
|
|
46
|
+
readonly name = "AppError";
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
readonly code: string,
|
|
50
|
+
message: string,
|
|
51
|
+
readonly kind: FailureKind,
|
|
52
|
+
readonly details?: unknown
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
export function createRequestContextSource(auth) {
|
|
60
|
+
if (auth === "firebase") {
|
|
61
|
+
return `import { randomUUID } from "node:crypto";
|
|
62
|
+
import type { Request } from "express";
|
|
63
|
+
import type { RequestContext } from "@/core/RequestContext";
|
|
64
|
+
import { AppError } from "@/core/errors";
|
|
65
|
+
import { verifyFirebaseToken } from "@/platform/auth/firebase";
|
|
66
|
+
|
|
67
|
+
export async function createRequestContext(req: Request): Promise<RequestContext> {
|
|
68
|
+
const requestId = String(req.headers["x-request-id"] ?? randomUUID());
|
|
69
|
+
const header = req.headers.authorization;
|
|
70
|
+
if (!header?.startsWith("Bearer ")) return { requestId };
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const decoded = await verifyFirebaseToken(header.slice(7));
|
|
74
|
+
return { requestId, principal: { uid: decoded.uid, email: decoded.email } };
|
|
75
|
+
} catch {
|
|
76
|
+
throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function systemRequestContext(requestId = "system"): RequestContext {
|
|
81
|
+
return { requestId };
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
return `import { randomUUID } from "node:crypto";
|
|
86
|
+
import type { Request } from "express";
|
|
87
|
+
import type { RequestContext } from "@/core/RequestContext";
|
|
88
|
+
|
|
89
|
+
export async function createRequestContext(req: Request): Promise<RequestContext> {
|
|
90
|
+
return { requestId: String(req.headers["x-request-id"] ?? randomUUID()) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function systemRequestContext(requestId = "system"): RequestContext {
|
|
94
|
+
return { requestId };
|
|
95
|
+
}
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const firebaseSource = `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
|
|
100
|
+
import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
|
|
101
|
+
import { env } from "@/platform/config/env";
|
|
102
|
+
|
|
103
|
+
export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
|
|
104
|
+
if (!getApps().length) {
|
|
105
|
+
initializeApp({ credential: applicationDefault(), projectId: env.firebaseProjectId || undefined });
|
|
106
|
+
}
|
|
107
|
+
return getAuth().verifyIdToken(token);
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
export const rpcSource = `import type { Response } from "express";
|
|
112
|
+
|
|
113
|
+
export function rpcResult(res: Response, result: unknown) {
|
|
114
|
+
return res.json({ result });
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
export const rpcHandlerSource = `import type { NextFunction, Request, RequestHandler, Response } from "express";
|
|
119
|
+
import type Joi from "joi";
|
|
120
|
+
import type { RequestContext } from "@/core/RequestContext";
|
|
121
|
+
import { createRequestContext } from "@/platform/http/createRequestContext";
|
|
122
|
+
import { rpcResult } from "@/platform/http/rpc";
|
|
123
|
+
|
|
124
|
+
interface RpcOperation<Input, Output> {
|
|
125
|
+
execute(context: RequestContext, input: Input): Output | Promise<Output>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface RpcHandlerOptions<Input, WireInput> {
|
|
129
|
+
mapInput?: (input: WireInput) => Input;
|
|
130
|
+
status?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Translates one validated RPC request into a use-case call and the shared result envelope. */
|
|
134
|
+
export function rpcHandler<Input, Output, WireInput = Input>(
|
|
135
|
+
schema: Joi.Schema<WireInput>,
|
|
136
|
+
operation: RpcOperation<Input, Output>,
|
|
137
|
+
options: RpcHandlerOptions<Input, WireInput> = {}
|
|
138
|
+
): RequestHandler {
|
|
139
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
140
|
+
try {
|
|
141
|
+
const validated = await schema.validateAsync(req.body ?? {}, { abortEarly: false });
|
|
142
|
+
const input = options.mapInput ? options.mapInput(validated) : (validated as unknown as Input);
|
|
143
|
+
if (options.status !== undefined) res.status(options.status);
|
|
144
|
+
rpcResult(res, await operation.execute(await createRequestContext(req), input));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
next(error);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
export const httpErrorsSource = `import type { ErrorRequestHandler } from "express";
|
|
153
|
+
import Joi from "joi";
|
|
154
|
+
import { AppError } from "@/core/errors";
|
|
155
|
+
|
|
156
|
+
const statusByKind: Readonly<Record<AppError["kind"], number>> = {
|
|
157
|
+
invalid: 400,
|
|
158
|
+
unauthenticated: 401,
|
|
159
|
+
forbidden: 403,
|
|
160
|
+
not_found: 404,
|
|
161
|
+
conflict: 409,
|
|
162
|
+
unprocessable: 422,
|
|
163
|
+
rate_limited: 429,
|
|
164
|
+
unavailable: 503,
|
|
165
|
+
internal: 500
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
169
|
+
if (error instanceof AppError) {
|
|
170
|
+
res.status(statusByKind[error.kind]).json({
|
|
171
|
+
error: {
|
|
172
|
+
code: error.code,
|
|
173
|
+
message: error.message,
|
|
174
|
+
...(error.details === undefined ? {} : { details: error.details })
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (Joi.isError(error)) {
|
|
180
|
+
res.status(400).json({
|
|
181
|
+
error: {
|
|
182
|
+
code: "VALIDATION_FAILED",
|
|
183
|
+
message: error.message,
|
|
184
|
+
...(error.details === undefined ? {} : { details: error.details })
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.error(error);
|
|
190
|
+
res.status(500).json({ error: { code: "INTERNAL", message: "Internal server error" } });
|
|
191
|
+
};
|
|
192
|
+
`;
|
|
193
|
+
|
|
194
|
+
export const csrfSource = `import type { RequestHandler } from "express";
|
|
195
|
+
import { AppError } from "@/core/errors";
|
|
196
|
+
|
|
197
|
+
const MUTATING = ["POST", "PUT", "PATCH", "DELETE"];
|
|
198
|
+
|
|
199
|
+
export const requireCsrfHeader: RequestHandler = (req, _res, next) => {
|
|
200
|
+
if (MUTATING.includes(req.method) && req.headers["x-requested-with"] !== "XMLHttpRequest") {
|
|
201
|
+
next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
next();
|
|
205
|
+
};
|
|
206
|
+
`;
|
|
207
|
+
|
|
208
|
+
export const getHealthSource = `import type { RequestContext } from "@/core/RequestContext";
|
|
209
|
+
import type { UseCase } from "@/core/UseCase";
|
|
210
|
+
|
|
211
|
+
export type HealthResult = { status: "ok" };
|
|
212
|
+
|
|
213
|
+
export class GetHealth implements UseCase<Record<string, never>, HealthResult> {
|
|
214
|
+
async execute(_context: RequestContext, _input: Record<string, never>): Promise<HealthResult> {
|
|
215
|
+
return { status: "ok" };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
export const readinessProbeSource = `export interface ReadinessProbe {
|
|
221
|
+
check(): Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
`;
|
|
224
|
+
|
|
225
|
+
export const getReadinessSource = `import type { RequestContext } from "@/core/RequestContext";
|
|
226
|
+
import type { UseCase } from "@/core/UseCase";
|
|
227
|
+
import { AppError } from "@/core/errors";
|
|
228
|
+
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
229
|
+
|
|
230
|
+
export type ReadinessResult = { status: "ready" };
|
|
231
|
+
|
|
232
|
+
export class GetReadiness implements UseCase<Record<string, never>, ReadinessResult> {
|
|
233
|
+
constructor(private readonly probes: readonly ReadinessProbe[]) {}
|
|
234
|
+
|
|
235
|
+
async execute(_context: RequestContext, _input: Record<string, never>): Promise<ReadinessResult> {
|
|
236
|
+
try {
|
|
237
|
+
await Promise.all(this.probes.map((probe) => probe.check()));
|
|
238
|
+
return { status: "ready" };
|
|
239
|
+
} catch {
|
|
240
|
+
throw new AppError("NOT_READY", "Service is not ready", "unavailable");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
export const healthSchemasSource = `import Joi from "joi";
|
|
247
|
+
|
|
248
|
+
export const GetHealthSchema = Joi.object({}).unknown(false);
|
|
249
|
+
export const GetReadinessSchema = Joi.object({}).unknown(false);
|
|
250
|
+
`;
|
|
251
|
+
|
|
252
|
+
export const healthRoutesSource = `import { Router } from "express";
|
|
253
|
+
import type { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
254
|
+
import type { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
255
|
+
import { GetHealthSchema, GetReadinessSchema } from "@/modules/health/http/health.schemas";
|
|
256
|
+
import { rpcHandler } from "@/platform/http/rpcHandler";
|
|
257
|
+
|
|
258
|
+
interface HealthUseCases {
|
|
259
|
+
get: GetHealth;
|
|
260
|
+
readiness: GetReadiness;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function healthRoutes(useCases: HealthUseCases) {
|
|
264
|
+
const router = Router();
|
|
265
|
+
router.post("/health.get", rpcHandler(GetHealthSchema, useCases.get));
|
|
266
|
+
router.post("/health.readiness", rpcHandler(GetReadinessSchema, useCases.readiness));
|
|
267
|
+
return router;
|
|
268
|
+
}
|
|
269
|
+
`;
|
|
270
|
+
|
|
271
|
+
export const mongoReadinessProbeSource = `import type { Db } from "mongodb";
|
|
272
|
+
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
273
|
+
|
|
274
|
+
export class MongoReadinessProbe implements ReadinessProbe {
|
|
275
|
+
constructor(private readonly db: Db) {}
|
|
276
|
+
|
|
277
|
+
async check(): Promise<void> {
|
|
278
|
+
await this.db.command({ ping: 1 });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
`;
|
|
282
|
+
|
|
283
|
+
export const redisReadinessProbeSource = `import type { RedisClientType } from "redis";
|
|
284
|
+
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
285
|
+
|
|
286
|
+
export class RedisReadinessProbe implements ReadinessProbe {
|
|
287
|
+
constructor(private readonly redis: RedisClientType) {}
|
|
288
|
+
|
|
289
|
+
async check(): Promise<void> {
|
|
290
|
+
await this.redis.ping();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
`;
|
|
294
|
+
|
|
295
|
+
export const redisSource = `import { createClient, type RedisClientType } from "redis";
|
|
296
|
+
import { env } from "@/platform/config/env";
|
|
297
|
+
|
|
298
|
+
export const redis: RedisClientType = createClient({ url: env.redisUrl });
|
|
299
|
+
redis.on("error", (error) => console.error("Redis client error", error));
|
|
300
|
+
|
|
301
|
+
export async function connectRedis() {
|
|
302
|
+
if (!redis.isOpen) await redis.connect();
|
|
303
|
+
return redis;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function closeRedis() {
|
|
307
|
+
if (redis.isOpen) await redis.quit();
|
|
308
|
+
}
|
|
309
|
+
`;
|
|
310
|
+
|
|
311
|
+
export function containerSource({ redis }) {
|
|
312
|
+
return `import { container, type InjectionToken } from "tsyringe";
|
|
313
|
+
import type { Db } from "mongodb";
|
|
314
|
+
${redis ? 'import type { RedisClientType } from "redis";\n' : ""}import { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
315
|
+
import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
316
|
+
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
317
|
+
import { MongoReadinessProbe } from "@/platform/integrations/mongodb/MongoReadinessProbe";
|
|
318
|
+
${redis ? 'import { RedisReadinessProbe } from "@/platform/integrations/redis/RedisReadinessProbe";\n' : ""}
|
|
319
|
+
const register = <T>(token: InjectionToken<T>, value: T) => container.registerInstance(token, value);
|
|
320
|
+
|
|
321
|
+
export interface ApplicationDependencies {
|
|
322
|
+
db: Db;
|
|
323
|
+
${redis ? "\tredis: RedisClientType;\n" : ""}}
|
|
324
|
+
|
|
325
|
+
/** App-owned composition root. Domain and use-case classes remain dependency-injection-framework free. */
|
|
326
|
+
export function registerDependencies(dependencies: ApplicationDependencies) {
|
|
327
|
+
container.reset();
|
|
328
|
+
|
|
329
|
+
const readinessProbes: ReadinessProbe[] = [new MongoReadinessProbe(dependencies.db)];
|
|
330
|
+
${redis ? "\treadinessProbes.push(new RedisReadinessProbe(dependencies.redis));\n" : ""}
|
|
331
|
+
register(GetHealth, new GetHealth());
|
|
332
|
+
register(GetReadiness, new GetReadiness(readinessProbes));
|
|
333
|
+
|
|
334
|
+
return container;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export { container };
|
|
338
|
+
`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const applicationRoutesSource = `import { Router } from "express";
|
|
342
|
+
import { container } from "@/app/container";
|
|
343
|
+
import { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
344
|
+
import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
345
|
+
import { healthRoutes } from "@/modules/health/http/health.routes";
|
|
346
|
+
|
|
347
|
+
export function applicationRoutes() {
|
|
348
|
+
const router = Router();
|
|
349
|
+
router.use(
|
|
350
|
+
healthRoutes({
|
|
351
|
+
get: container.resolve(GetHealth),
|
|
352
|
+
readiness: container.resolve(GetReadiness)
|
|
353
|
+
})
|
|
354
|
+
);
|
|
355
|
+
return router;
|
|
356
|
+
}
|
|
357
|
+
`;
|
|
358
|
+
|
|
359
|
+
export function serverSource({ ui, redis, csrf }) {
|
|
360
|
+
return `${ui ? 'import path from "node:path";\n' : ""}import express from "express";
|
|
361
|
+
import cors from "cors";
|
|
362
|
+
import { registerDependencies } from "@/app/container";
|
|
363
|
+
import { applicationRoutes } from "@/app/routes";
|
|
364
|
+
import { env } from "@/platform/config/env";
|
|
365
|
+
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
366
|
+
${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { requireCsrfHeader } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
|
|
367
|
+
|
|
368
|
+
export interface ApplicationOptions {
|
|
369
|
+
${ui ? "\tpublicPath?: string;\n\tproduction?: boolean;\n" : ""}}
|
|
370
|
+
|
|
371
|
+
export function createApplication(options: ApplicationOptions = {}) {
|
|
372
|
+
const app = express();
|
|
373
|
+
${ui ? '\tconst publicPath = options.publicPath ?? path.join(__dirname, "..", "public");\n\tconst production = options.production ?? env.nodeEnv === "production";\n' : ""} app.set("trust proxy", 1);
|
|
374
|
+
${ui ? "\tif (production) {\n\t\tapp.use(express.static(publicPath, { index: false, maxAge: \"1y\", immutable: true }));\n\t}\n" : ""} app.use(cors({ origin: env.corsOrigin }));
|
|
375
|
+
app.use(express.json());
|
|
376
|
+
app.use("/api/v1", ${csrf ? "requireCsrfHeader, " : ""}applicationRoutes());
|
|
377
|
+
${ui ? '\tif (production) {\n\t\tapp.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));\n\t}\n' : ""} app.use(errorHandler);
|
|
378
|
+
return app;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function startServer() {
|
|
382
|
+
const db = await connectDatabase();
|
|
383
|
+
${redis ? "\tconst redis = await connectRedis();\n" : ""} registerDependencies({ db${redis ? ", redis" : ""} });
|
|
384
|
+
const app = createApplication();
|
|
385
|
+
const server = app.listen(env.port);
|
|
386
|
+
const shutdown = async () => {
|
|
387
|
+
await new Promise<void>((resolve, reject) => {
|
|
388
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
389
|
+
});
|
|
390
|
+
${redis ? "\t\tawait closeRedis();\n" : ""} await closeDatabase();
|
|
391
|
+
};
|
|
392
|
+
process.once("SIGINT", () => void shutdown());
|
|
393
|
+
process.once("SIGTERM", () => void shutdown());
|
|
394
|
+
return { app, server, shutdown };
|
|
395
|
+
}
|
|
396
|
+
`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function workerSource() {
|
|
400
|
+
return `import { container, registerDependencies } from "@/app/container";
|
|
401
|
+
import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
402
|
+
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
403
|
+
import { systemRequestContext } from "@/platform/http/createRequestContext";
|
|
404
|
+
import { closeRedis, connectRedis } from "@/platform/redis";
|
|
405
|
+
|
|
406
|
+
export async function startWorker() {
|
|
407
|
+
const db = await connectDatabase();
|
|
408
|
+
const redis = await connectRedis();
|
|
409
|
+
registerDependencies({ db, redis });
|
|
410
|
+
await container.resolve(GetReadiness).execute(systemRequestContext(), {});
|
|
411
|
+
|
|
412
|
+
const shutdown = async () => {
|
|
413
|
+
await closeRedis();
|
|
414
|
+
await closeDatabase();
|
|
415
|
+
};
|
|
416
|
+
process.once("SIGINT", () => void shutdown());
|
|
417
|
+
process.once("SIGTERM", () => void shutdown());
|
|
418
|
+
return { shutdown };
|
|
419
|
+
}
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export const getHealthTestSource = `import { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
424
|
+
|
|
425
|
+
describe("GetHealth", () => {
|
|
426
|
+
it("returns health through the use-case contract", async () => {
|
|
427
|
+
await expect(new GetHealth().execute({ requestId: "test" }, {})).resolves.toEqual({ status: "ok" });
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
`;
|
|
431
|
+
|
|
432
|
+
export const getReadinessTestSource = `import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
433
|
+
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
434
|
+
|
|
435
|
+
const probe = (check: () => Promise<void>): ReadinessProbe => ({ check });
|
|
436
|
+
|
|
437
|
+
describe("GetReadiness", () => {
|
|
438
|
+
it("returns ready after every probe succeeds", async () => {
|
|
439
|
+
const first = jest.fn().mockResolvedValue(undefined);
|
|
440
|
+
const second = jest.fn().mockResolvedValue(undefined);
|
|
441
|
+
const useCase = new GetReadiness([probe(first), probe(second)]);
|
|
442
|
+
|
|
443
|
+
await expect(useCase.execute({ requestId: "test" }, {})).resolves.toEqual({ status: "ready" });
|
|
444
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
445
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("maps a failed probe to the shared unavailable error", async () => {
|
|
449
|
+
const useCase = new GetReadiness([probe(jest.fn().mockRejectedValue(new Error("offline")))]);
|
|
450
|
+
|
|
451
|
+
await expect(useCase.execute({ requestId: "test" }, {})).rejects.toMatchObject({
|
|
452
|
+
code: "NOT_READY",
|
|
453
|
+
kind: "unavailable"
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
`;
|
|
458
|
+
|
|
459
|
+
export function serviceHttpFiles(moduleName, verbNoun) {
|
|
460
|
+
const operation = serviceOperationName(moduleName, verbNoun);
|
|
461
|
+
const dependency = operation;
|
|
462
|
+
return {
|
|
463
|
+
[`src/modules/${moduleName}/http/${moduleName}.schemas.ts`]: `import Joi from "joi";
|
|
464
|
+
|
|
465
|
+
export const ${verbNoun}Schema = Joi.object({}).unknown(false);
|
|
466
|
+
`,
|
|
467
|
+
[`src/modules/${moduleName}/http/${moduleName}.routes.ts`]: `import { Router } from "express";
|
|
468
|
+
import type { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";
|
|
469
|
+
import { ${verbNoun}Schema } from "@/modules/${moduleName}/http/${moduleName}.schemas";
|
|
470
|
+
import { rpcHandler } from "@/platform/http/rpcHandler";
|
|
471
|
+
|
|
472
|
+
interface ${pascal(moduleName)}UseCases {
|
|
473
|
+
${dependency}: ${verbNoun};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function ${camel(moduleName)}Routes(useCases: ${pascal(moduleName)}UseCases) {
|
|
477
|
+
const router = Router();
|
|
478
|
+
router.post("/${moduleName}.${operation}", rpcHandler(${verbNoun}Schema, useCases.${dependency}));
|
|
479
|
+
return router;
|
|
480
|
+
}
|
|
481
|
+
`
|
|
482
|
+
};
|
|
483
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export const uiErrorsSource = `export type FailureKind =
|
|
2
|
+
| "invalid"
|
|
3
|
+
| "unauthenticated"
|
|
4
|
+
| "forbidden"
|
|
5
|
+
| "not_found"
|
|
6
|
+
| "conflict"
|
|
7
|
+
| "unprocessable"
|
|
8
|
+
| "rate_limited"
|
|
9
|
+
| "unavailable"
|
|
10
|
+
| "internal"
|
|
11
|
+
| "network";
|
|
12
|
+
|
|
13
|
+
export interface ServiceError {
|
|
14
|
+
code: string;
|
|
15
|
+
message: string;
|
|
16
|
+
kind: FailureKind;
|
|
17
|
+
details?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const isServiceError = (value: unknown): value is ServiceError =>
|
|
21
|
+
typeof value === "object" &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
typeof (value as ServiceError).code === "string" &&
|
|
24
|
+
typeof (value as ServiceError).message === "string" &&
|
|
25
|
+
typeof (value as ServiceError).kind === "string";
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export const uiRpcSource = `export interface RpcResponse<T> {
|
|
29
|
+
result: T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RpcErrorEnvelope {
|
|
33
|
+
error: { code: string; message: string; details?: unknown };
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const uiHttpSource = `import axios, { type AxiosError } from "axios";
|
|
38
|
+
import type { ServiceError } from "@/core/errors";
|
|
39
|
+
import type { RpcErrorEnvelope, RpcResponse } from "@/core/rpc";
|
|
40
|
+
|
|
41
|
+
const failureKind = (status?: number): ServiceError["kind"] => {
|
|
42
|
+
if (!status) return "network";
|
|
43
|
+
if (status === 401) return "unauthenticated";
|
|
44
|
+
if (status === 403) return "forbidden";
|
|
45
|
+
if (status === 404) return "not_found";
|
|
46
|
+
if (status === 409) return "conflict";
|
|
47
|
+
if (status === 422) return "unprocessable";
|
|
48
|
+
if (status === 429) return "rate_limited";
|
|
49
|
+
if (status === 503) return "unavailable";
|
|
50
|
+
if (status >= 500) return "internal";
|
|
51
|
+
return "invalid";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const http = axios.create({
|
|
55
|
+
baseURL: \`\${window.location.origin}/api/v1\`,
|
|
56
|
+
headers: {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
Accept: "application/json",
|
|
59
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export interface HttpConfiguration {
|
|
64
|
+
getIdToken?: () => Promise<string | undefined>;
|
|
65
|
+
handleUnauthorized?: () => Promise<void> | void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let configuration: HttpConfiguration = {};
|
|
69
|
+
|
|
70
|
+
export function configureHttpClient(next: HttpConfiguration) {
|
|
71
|
+
configuration = next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
http.interceptors.request.use(async (config) => {
|
|
75
|
+
const token = await configuration.getIdToken?.();
|
|
76
|
+
if (token) config.headers.Authorization = \`Bearer \${token}\`;
|
|
77
|
+
return config;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
http.interceptors.response.use(
|
|
81
|
+
(response) => response,
|
|
82
|
+
async (error: AxiosError<RpcErrorEnvelope>) => {
|
|
83
|
+
const status = error.response?.status;
|
|
84
|
+
if (status === 401) await configuration.handleUnauthorized?.();
|
|
85
|
+
const envelope = error.response?.data?.error;
|
|
86
|
+
const serviceError: ServiceError = {
|
|
87
|
+
code: envelope?.code ?? (status ? \`HTTP_\${status}\` : "NETWORK"),
|
|
88
|
+
message: envelope?.message ?? error.message,
|
|
89
|
+
kind: failureKind(status),
|
|
90
|
+
...(envelope?.details === undefined ? {} : { details: envelope.details })
|
|
91
|
+
};
|
|
92
|
+
return Promise.reject(serviceError);
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export async function rpc<T>(operation: string, payload: unknown = {}): Promise<T> {
|
|
97
|
+
return (await http.post<RpcResponse<T>>(operation, payload)).data.result;
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
export const configureHttpSource = `import type { Pinia } from "pinia";
|
|
102
|
+
import router from "@/app/router";
|
|
103
|
+
import { useAuthStore } from "@/app/stores/auth.store";
|
|
104
|
+
import { RouteNames } from "@/core/RouteNames";
|
|
105
|
+
import { configureHttpClient } from "@/platform/http";
|
|
106
|
+
|
|
107
|
+
export function configureHttp(pinia: Pinia) {
|
|
108
|
+
const auth = useAuthStore(pinia);
|
|
109
|
+
|
|
110
|
+
configureHttpClient({
|
|
111
|
+
getIdToken: async () => auth.getIdToken(),
|
|
112
|
+
handleUnauthorized: async () => {
|
|
113
|
+
await auth.logout();
|
|
114
|
+
await router.push({ name: RouteNames.HOME });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
export const healthApiSource = `import { rpc } from "@/platform/http";
|
|
121
|
+
|
|
122
|
+
export interface Health {
|
|
123
|
+
status: "ok";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface Readiness {
|
|
127
|
+
status: "ready";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const getHealth = () => rpc<Health>("health.get");
|
|
131
|
+
export const getReadiness = () => rpc<Readiness>("health.readiness");
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
export function moduleApiSource(moduleName, typeName) {
|
|
135
|
+
return `import { rpc } from "@/platform/http";
|
|
136
|
+
|
|
137
|
+
export async function get${typeName}() {
|
|
138
|
+
return rpc<unknown>("${moduleName}.get");
|
|
139
|
+
}
|
|
140
|
+
`;
|
|
141
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { validateArchitecture } from "./architecture.mjs";
|
|
|
2
2
|
import { validateConventions } from "./conventions.mjs";
|
|
3
3
|
import { isExcepted, loadExceptions } from "./exceptions.mjs";
|
|
4
4
|
|
|
5
|
-
const NON_EXCEPTABLE_RULES = new Set(["U5", "U6", "U7"]);
|
|
5
|
+
const NON_EXCEPTABLE_RULES = new Set(["S8", "S9", "U5", "U6", "U7"]);
|
|
6
6
|
|
|
7
7
|
export function runValidate(root, kind) {
|
|
8
8
|
const structural = validateArchitecture(root, kind);
|