@tatara/cli 0.2.0 → 0.3.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/dist/bin.mjs +142 -12
- package/package.json +1 -1
- package/templates/features/admin/files/src/apps/admin/server.ts +3 -2
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
- package/templates/features/auth/files/src/core/auth/middleware.ts +1 -1
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
- package/templates/features/authz/files/src/core/authz/middleware.ts +1 -1
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
- package/templates/features/authz/files/src/core/authz/services/grants.ts +1 -1
- package/templates/features/authz/files/src/core/authz/services/members.ts +1 -1
- package/templates/features/authz/files/src/core/authz/services/roles.ts +1 -1
- package/templates/features/base/files/src/apps/api/server.ts +3 -2
- package/templates/features/base/files/src/core/http/app.ts +28 -41
- package/templates/features/base/files/src/core/http/routes.ts +7 -11
- package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +1 -1
- package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +1 -1
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +2 -2
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +2 -2
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
- package/templates/versions.json +2 -1
- package/templates/features/base/files/src/core/http/errors.ts +0 -79
- package/templates/features/base/files/src/core/http/middleware/error-handler.ts +0 -36
- package/templates/features/base/files/src/core/http/middleware/request-context.ts +0 -24
- package/templates/features/base/files/src/core/http/middleware/validation.ts +0 -17
- package/templates/features/base/files/src/core/http/response.ts +0 -72
- package/templates/features/base/files/src/core/http/server.ts +0 -15
package/dist/bin.mjs
CHANGED
|
@@ -1051,12 +1051,12 @@ const i18n = {
|
|
|
1051
1051
|
content: "app.use(detectLanguage);"
|
|
1052
1052
|
},
|
|
1053
1053
|
{
|
|
1054
|
-
file: "src/core/http/
|
|
1054
|
+
file: "src/core/http/app.ts",
|
|
1055
1055
|
slot: "imports",
|
|
1056
1056
|
content: "import { t } from \"#core/i18n/index\";"
|
|
1057
1057
|
},
|
|
1058
1058
|
{
|
|
1059
|
-
file: "src/core/http/
|
|
1059
|
+
file: "src/core/http/app.ts",
|
|
1060
1060
|
slot: "error-message",
|
|
1061
1061
|
content: "if (err.translatable) message = t(c.get(\"language\"), `errors.${err.code}`, message);"
|
|
1062
1062
|
},
|
|
@@ -1168,6 +1168,11 @@ const betterAuth = {
|
|
|
1168
1168
|
slot: "imports",
|
|
1169
1169
|
content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
|
|
1170
1170
|
},
|
|
1171
|
+
{
|
|
1172
|
+
file: "src/core/http/app.ts",
|
|
1173
|
+
slot: "expose-headers",
|
|
1174
|
+
content: "\"set-auth-token\","
|
|
1175
|
+
},
|
|
1171
1176
|
{
|
|
1172
1177
|
file: "src/core/http/app.ts",
|
|
1173
1178
|
slot: "openapi",
|
|
@@ -1640,6 +1645,7 @@ const base = {
|
|
|
1640
1645
|
label: "Hono API",
|
|
1641
1646
|
dependencies: [
|
|
1642
1647
|
"@tatara/core",
|
|
1648
|
+
"@tatara/http",
|
|
1643
1649
|
"hono",
|
|
1644
1650
|
"@hono/node-server",
|
|
1645
1651
|
"@hono/zod-openapi",
|
|
@@ -3212,7 +3218,7 @@ function createBaseline(project, deps) {
|
|
|
3212
3218
|
//#region src/upgrade/codemods/core-package.ts
|
|
3213
3219
|
const CORE = "@tatara/core";
|
|
3214
3220
|
/** Files @tatara/core replaces, with the import specifier apps used for each. */
|
|
3215
|
-
const MOVED = {
|
|
3221
|
+
const MOVED$1 = {
|
|
3216
3222
|
"src/core/commands/define.ts": "#core/commands/define",
|
|
3217
3223
|
"src/core/commands/runner.ts": "#core/commands/runner",
|
|
3218
3224
|
"src/core/process/lifecycle.ts": "#core/process/lifecycle",
|
|
@@ -3247,8 +3253,8 @@ async function rewriteEnv(ctx) {
|
|
|
3247
3253
|
}
|
|
3248
3254
|
/** Only call sites of functions that now come from @tatara/core take the logger argument. */
|
|
3249
3255
|
async function passLogger(ctx, gone) {
|
|
3250
|
-
const runnerGone = gone.includes(MOVED["src/core/commands/runner.ts"]);
|
|
3251
|
-
const lifecycleGone = gone.includes(MOVED["src/core/process/lifecycle.ts"]);
|
|
3256
|
+
const runnerGone = gone.includes(MOVED$1["src/core/commands/runner.ts"]);
|
|
3257
|
+
const lifecycleGone = gone.includes(MOVED$1["src/core/process/lifecycle.ts"]);
|
|
3252
3258
|
const cli = await exists(ctx, CLI_ENTRY);
|
|
3253
3259
|
if (cli) {
|
|
3254
3260
|
let next = cli;
|
|
@@ -3294,17 +3300,14 @@ export const logger = pino(
|
|
|
3294
3300
|
export type Logger = typeof logger;
|
|
3295
3301
|
`);
|
|
3296
3302
|
}
|
|
3297
|
-
|
|
3298
|
-
//#region src/upgrade/codemods.ts
|
|
3299
|
-
/** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
|
|
3300
|
-
const CODEMODS = [{
|
|
3303
|
+
const corePackage = {
|
|
3301
3304
|
version: "0.2.0",
|
|
3302
3305
|
id: "core-package",
|
|
3303
3306
|
description: "Use @tatara/core for commands, flags, lifecycle, readiness, logger options and localDefault",
|
|
3304
3307
|
async run(ctx) {
|
|
3305
3308
|
await ctx.addDependency(CORE, versionOf(CORE));
|
|
3306
3309
|
const gone = [];
|
|
3307
|
-
for (const [path, specifier] of Object.entries(MOVED)) if (await ctx.removeIfUnchanged(path) === "kept") ctx.report.notes.push(`${path} is customised; its imports still point at it. Move the change into the app or eject.`);
|
|
3310
|
+
for (const [path, specifier] of Object.entries(MOVED$1)) if (await ctx.removeIfUnchanged(path) === "kept") ctx.report.notes.push(`${path} is customised; its imports still point at it. Move the change into the app or eject.`);
|
|
3308
3311
|
else gone.push(specifier);
|
|
3309
3312
|
for (const specifier of gone) await ctx.rewriteImports(specifier, CORE);
|
|
3310
3313
|
await rewriteEnv(ctx);
|
|
@@ -3312,6 +3315,114 @@ const CODEMODS = [{
|
|
|
3312
3315
|
await allowFreshReleases(ctx);
|
|
3313
3316
|
await useLoggerOptions(ctx);
|
|
3314
3317
|
}
|
|
3318
|
+
};
|
|
3319
|
+
//#endregion
|
|
3320
|
+
//#region src/upgrade/codemods/http-package.ts
|
|
3321
|
+
const HTTP = "@tatara/http";
|
|
3322
|
+
/** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
|
|
3323
|
+
const APP = "src/core/http/app.ts";
|
|
3324
|
+
const ROUTES = "src/core/http/routes.ts";
|
|
3325
|
+
const SERVERS = ["src/apps/api/server.ts", "src/apps/admin/server.ts"];
|
|
3326
|
+
/** Files @tatara/http replaces: import specifier, and the wiring files that import them. */
|
|
3327
|
+
const MOVED = [
|
|
3328
|
+
{
|
|
3329
|
+
path: "src/core/http/errors.ts",
|
|
3330
|
+
specifier: "#core/http/errors",
|
|
3331
|
+
usedBy: []
|
|
3332
|
+
},
|
|
3333
|
+
{
|
|
3334
|
+
path: "src/core/http/response.ts",
|
|
3335
|
+
specifier: "#core/http/response",
|
|
3336
|
+
usedBy: []
|
|
3337
|
+
},
|
|
3338
|
+
{
|
|
3339
|
+
path: "src/core/http/middleware/validation.ts",
|
|
3340
|
+
specifier: "#core/http/middleware/validation",
|
|
3341
|
+
usedBy: [APP, ROUTES]
|
|
3342
|
+
},
|
|
3343
|
+
{
|
|
3344
|
+
path: "src/core/http/middleware/error-handler.ts",
|
|
3345
|
+
specifier: "#core/http/middleware/error-handler",
|
|
3346
|
+
usedBy: [APP]
|
|
3347
|
+
},
|
|
3348
|
+
{
|
|
3349
|
+
path: "src/core/http/middleware/request-context.ts",
|
|
3350
|
+
specifier: "#core/http/middleware/request-context",
|
|
3351
|
+
usedBy: [APP]
|
|
3352
|
+
},
|
|
3353
|
+
{
|
|
3354
|
+
path: "src/core/http/server.ts",
|
|
3355
|
+
specifier: "#core/http/server",
|
|
3356
|
+
usedBy: SERVERS
|
|
3357
|
+
}
|
|
3358
|
+
];
|
|
3359
|
+
async function read(ctx, path) {
|
|
3360
|
+
return ctx.read(path).catch(() => void 0);
|
|
3361
|
+
}
|
|
3362
|
+
/** Files outside the group that import one of the moved files (they would break). */
|
|
3363
|
+
async function outsideImporters(ctx) {
|
|
3364
|
+
const group = /* @__PURE__ */ new Set([
|
|
3365
|
+
APP,
|
|
3366
|
+
ROUTES,
|
|
3367
|
+
...SERVERS,
|
|
3368
|
+
...MOVED.map((m) => m.path)
|
|
3369
|
+
]);
|
|
3370
|
+
const specifiers = MOVED.filter((m) => m.usedBy.length > 0).map((m) => m.specifier);
|
|
3371
|
+
const pattern = new RegExp(`["'](${specifiers.map((s) => s.replace(/[/.]/g, "\\$&")).join("|")})["']`);
|
|
3372
|
+
const out = [];
|
|
3373
|
+
for (const path of await glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3374
|
+
cwd: ctx.project.root,
|
|
3375
|
+
ignore: ["src/generated/**"]
|
|
3376
|
+
})) {
|
|
3377
|
+
if (group.has(path)) continue;
|
|
3378
|
+
if (pattern.test(await ctx.read(path))) out.push(path);
|
|
3379
|
+
}
|
|
3380
|
+
return out;
|
|
3381
|
+
}
|
|
3382
|
+
//#endregion
|
|
3383
|
+
//#region src/upgrade/codemods.ts
|
|
3384
|
+
/** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
|
|
3385
|
+
const CODEMODS = [corePackage, {
|
|
3386
|
+
version: "0.3.0",
|
|
3387
|
+
id: "http-package",
|
|
3388
|
+
description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
|
|
3389
|
+
async run(ctx) {
|
|
3390
|
+
await ctx.addDependency(HTTP, versionOf(HTTP));
|
|
3391
|
+
const blockers = [];
|
|
3392
|
+
const replace = [];
|
|
3393
|
+
for (const path of [
|
|
3394
|
+
APP,
|
|
3395
|
+
ROUTES,
|
|
3396
|
+
...SERVERS
|
|
3397
|
+
]) {
|
|
3398
|
+
const text = await read(ctx, path);
|
|
3399
|
+
if (text === void 0) continue;
|
|
3400
|
+
if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
|
|
3401
|
+
else blockers.push(`${path} is customised`);
|
|
3402
|
+
}
|
|
3403
|
+
const present = [];
|
|
3404
|
+
for (const entry of MOVED) {
|
|
3405
|
+
const text = await read(ctx, entry.path);
|
|
3406
|
+
if (text === void 0) continue;
|
|
3407
|
+
present.push(entry);
|
|
3408
|
+
if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
|
|
3409
|
+
}
|
|
3410
|
+
for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
|
|
3411
|
+
if (blockers.length > 0) {
|
|
3412
|
+
ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
for (const path of replace) {
|
|
3416
|
+
const next = await ctx.renderCurrent(path);
|
|
3417
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3418
|
+
}
|
|
3419
|
+
for (const entry of present) await ctx.removeIfUnchanged(entry.path);
|
|
3420
|
+
for (const specifier of [
|
|
3421
|
+
"#core/http/errors",
|
|
3422
|
+
"#core/http/response",
|
|
3423
|
+
"#core/http/middleware/validation"
|
|
3424
|
+
]) await ctx.rewriteImports(specifier, HTTP);
|
|
3425
|
+
}
|
|
3315
3426
|
}];
|
|
3316
3427
|
//#endregion
|
|
3317
3428
|
//#region src/upgrade/context.ts
|
|
@@ -3320,13 +3431,19 @@ function createContext(project, baseline, opts = {}) {
|
|
|
3320
3431
|
const report = {
|
|
3321
3432
|
ran: [],
|
|
3322
3433
|
written: [],
|
|
3434
|
+
regenerated: [],
|
|
3323
3435
|
removed: [],
|
|
3324
3436
|
kept: [],
|
|
3325
3437
|
notes: []
|
|
3326
3438
|
};
|
|
3327
3439
|
const abs = (path) => join(project.root, path);
|
|
3440
|
+
let rendered;
|
|
3328
3441
|
const write = async (path, text) => {
|
|
3329
|
-
if (!report.written.includes(path))
|
|
3442
|
+
if (!report.written.includes(path)) {
|
|
3443
|
+
report.written.push(path);
|
|
3444
|
+
const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
|
|
3445
|
+
if (before === void 0 || await baseline.isUnchanged(path, before)) report.regenerated.push(path);
|
|
3446
|
+
}
|
|
3330
3447
|
if (opts.dryRun) return;
|
|
3331
3448
|
await mkdir(dirname(abs(path)), { recursive: true });
|
|
3332
3449
|
await writeFile(abs(path), text);
|
|
@@ -3368,6 +3485,19 @@ function createContext(project, baseline, opts = {}) {
|
|
|
3368
3485
|
}
|
|
3369
3486
|
return changed;
|
|
3370
3487
|
},
|
|
3488
|
+
async renderCurrent(path) {
|
|
3489
|
+
const { name, orm, database, auth, authMethods, addons, packageManager } = project.config;
|
|
3490
|
+
rendered ??= renderApp({
|
|
3491
|
+
name,
|
|
3492
|
+
orm,
|
|
3493
|
+
database,
|
|
3494
|
+
auth,
|
|
3495
|
+
authMethods,
|
|
3496
|
+
addons,
|
|
3497
|
+
packageManager
|
|
3498
|
+
});
|
|
3499
|
+
return (await rendered).get(path);
|
|
3500
|
+
},
|
|
3371
3501
|
async addDependency(name, range, { dev = false } = {}) {
|
|
3372
3502
|
const pkg = JSON.parse(await readFile(abs("package.json"), "utf8"));
|
|
3373
3503
|
const key = dev ? "devDependencies" : "dependencies";
|
|
@@ -3401,7 +3531,7 @@ async function nextRecord(project, report) {
|
|
|
3401
3531
|
const record = { ...project.config.generated?.files ?? {} };
|
|
3402
3532
|
for (const path of report.removed) delete record[path];
|
|
3403
3533
|
const current = await hashTrackedFiles(project.root);
|
|
3404
|
-
for (const path of report.
|
|
3534
|
+
for (const path of report.regenerated) {
|
|
3405
3535
|
const hash = current[path];
|
|
3406
3536
|
if (hash) record[path] = hash;
|
|
3407
3537
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { startServer } from "@tatara/http";
|
|
1
2
|
import { app } from "#apps/admin/app";
|
|
2
3
|
import { env } from "#core/config/env";
|
|
3
|
-
import {
|
|
4
|
+
import { logger } from "#core/logging/logger";
|
|
4
5
|
|
|
5
|
-
startServer(app, env.ADMIN_PORT, "admin");
|
|
6
|
+
startServer(app, { port: env.ADMIN_PORT, name: "admin", logger });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
2
|
import { currentPrincipal } from "#core/auth/middleware";
|
|
3
|
-
import { errorResponses, jsonContent, success, successSchema } from "
|
|
3
|
+
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
4
4
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
5
5
|
import { greetingSchema } from "#modules/example/validators/greeting.validators";
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createMiddleware } from "hono/factory";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import type { AppEnv, Principal } from "#core/http/context";
|
|
4
|
-
import { ForbiddenError, UnauthorizedError } from "
|
|
4
|
+
import { ForbiddenError, UnauthorizedError } from "@tatara/http";
|
|
5
5
|
|
|
6
6
|
/** 401 unless the request carries a valid session or token. */
|
|
7
7
|
export function requireAuth() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
2
|
import { currentPrincipal, requireAuth } from "#core/auth/middleware";
|
|
3
|
-
import { errorResponses, jsonContent, success, successSchema } from "
|
|
3
|
+
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
4
4
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
5
5
|
|
|
6
6
|
const principalSchema = z
|
|
@@ -2,7 +2,7 @@ import { createMiddleware } from "hono/factory";
|
|
|
2
2
|
import type { Scope } from "#core/authz/constants";
|
|
3
3
|
import { checkPermission } from "#core/authz/resolver";
|
|
4
4
|
import type { AppEnv } from "#core/http/context";
|
|
5
|
-
import { ForbiddenError, UnauthorizedError } from "
|
|
5
|
+
import { ForbiddenError, UnauthorizedError } from "@tatara/http";
|
|
6
6
|
import type { PermissionKey } from "#generated/permissions";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
userParams,
|
|
20
20
|
userRoleParams,
|
|
21
21
|
} from "#core/authz/validators";
|
|
22
|
-
import { errorResponses, jsonBody, jsonContent, success, successSchema } from "
|
|
22
|
+
import { errorResponses, jsonBody, jsonContent, success, successSchema } from "@tatara/http";
|
|
23
23
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
24
24
|
import { catalog } from "#generated/permissions";
|
|
25
25
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assertGrantValid, InvalidGrantError, META_RESOURCES } from "#core/authz/catalog";
|
|
2
2
|
import type { Scope } from "#core/authz/constants";
|
|
3
3
|
import type { PermissionGrant } from "#core/authz/types";
|
|
4
|
-
import { BusinessRuleError } from "
|
|
4
|
+
import { BusinessRuleError } from "@tatara/http";
|
|
5
5
|
import { catalog } from "#generated/permissions";
|
|
6
6
|
|
|
7
7
|
/** Validates grants against the catalog; custom roles and overlays may not grant meta resources. */
|
|
@@ -3,7 +3,7 @@ import { type OverlayEffect, type Scope, SYSTEM_ACTOR } from "#core/authz/consta
|
|
|
3
3
|
import { store } from "#core/authz/db/store";
|
|
4
4
|
import { audit } from "#core/authz/services/audit";
|
|
5
5
|
import { validateGrants } from "#core/authz/services/grants";
|
|
6
|
-
import { NotFoundError } from "
|
|
6
|
+
import { NotFoundError } from "@tatara/http";
|
|
7
7
|
|
|
8
8
|
export async function getMember(orgId: string, userId: string) {
|
|
9
9
|
const [roles, overlays] = await Promise.all([
|
|
@@ -4,7 +4,7 @@ import { audit } from "#core/authz/services/audit";
|
|
|
4
4
|
import { validateGrants } from "#core/authz/services/grants";
|
|
5
5
|
import { SYSTEM_ROLE_KEYS } from "#core/authz/system-roles";
|
|
6
6
|
import type { RoleRow } from "#core/authz/types";
|
|
7
|
-
import { BusinessRuleError, ConflictError, NotFoundError } from "
|
|
7
|
+
import { BusinessRuleError, ConflictError, NotFoundError } from "@tatara/http";
|
|
8
8
|
|
|
9
9
|
export interface RoleInput {
|
|
10
10
|
key: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { startServer } from "@tatara/http";
|
|
1
2
|
import { app } from "#apps/api/app";
|
|
2
3
|
import { env } from "#core/config/env";
|
|
3
|
-
import {
|
|
4
|
+
import { logger } from "#core/logging/logger";
|
|
4
5
|
|
|
5
|
-
startServer(app, env.PORT, "api");
|
|
6
|
+
startServer(app, { port: env.PORT, name: "api", logger });
|
|
@@ -1,57 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Scalar } from "@scalar/hono-api-reference";
|
|
3
|
-
import { bodyLimit } from "hono/body-limit";
|
|
4
|
-
import { cors } from "hono/cors";
|
|
5
|
-
import { secureHeaders } from "hono/secure-headers";
|
|
1
|
+
import { createApp, handleErrors, mountRoutes, serveOpenApi } from "@tatara/http";
|
|
6
2
|
import { env } from "#core/config/env";
|
|
7
3
|
import type { AppEnv } from "#core/http/context";
|
|
8
|
-
import { runReadinessChecks } from "@tatara/core";
|
|
9
|
-
import { errorHandler, notFoundHandler } from "#core/http/middleware/error-handler";
|
|
10
|
-
import { requestContext } from "#core/http/middleware/request-context";
|
|
11
|
-
import { validationHook } from "#core/http/middleware/validation";
|
|
12
4
|
import type { AppName } from "#core/http/routes";
|
|
5
|
+
import { logger } from "#core/logging/logger";
|
|
13
6
|
import { routes } from "#generated/routes";
|
|
14
7
|
// tatara:slot imports
|
|
15
8
|
|
|
16
9
|
export const API_PREFIX = "/api";
|
|
17
10
|
|
|
11
|
+
/** The app's HTTP wiring: plain Hono, with tatara's defaults from @tatara/http. */
|
|
18
12
|
export function createHttpApp(name: AppName, options: { title: string }) {
|
|
19
|
-
const app =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
);
|
|
31
|
-
app.use(bodyLimit({ maxSize: env.BODY_LIMIT_BYTES }));
|
|
32
|
-
// tatara:slot middleware
|
|
33
|
-
|
|
34
|
-
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
35
|
-
app.get("/ready", async (c) => {
|
|
36
|
-
const result = await runReadinessChecks();
|
|
37
|
-
return c.json(result, result.ready ? 200 : 503);
|
|
13
|
+
const app = createApp<AppEnv>({
|
|
14
|
+
logger,
|
|
15
|
+
cors: {
|
|
16
|
+
origins: env.CORS_ORIGINS,
|
|
17
|
+
exposeHeaders: [
|
|
18
|
+
"X-Request-Id",
|
|
19
|
+
// tatara:slot expose-headers
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
bodyLimitBytes: env.BODY_LIMIT_BYTES,
|
|
23
|
+
defaults: { principal: null, language: "en" },
|
|
38
24
|
});
|
|
25
|
+
// tatara:slot middleware
|
|
39
26
|
|
|
40
27
|
// tatara:slot routes
|
|
41
28
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
29
|
+
mountRoutes(app, routes, { app: name, prefix: API_PREFIX });
|
|
30
|
+
serveOpenApi(app, {
|
|
31
|
+
title: options.title,
|
|
32
|
+
async extend(document) {
|
|
33
|
+
// tatara:slot openapi
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
handleErrors(app, {
|
|
37
|
+
message(err, c) {
|
|
38
|
+
let message = err.message;
|
|
39
|
+
// tatara:slot error-message
|
|
40
|
+
return message;
|
|
41
|
+
},
|
|
51
42
|
});
|
|
52
|
-
app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
|
|
53
|
-
|
|
54
|
-
app.onError(errorHandler);
|
|
55
|
-
app.notFound(notFoundHandler);
|
|
56
43
|
return app;
|
|
57
44
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createRouter as createTataraRouter,
|
|
3
|
+
type RouteDefinition as TataraRouteDefinition,
|
|
4
|
+
} from "@tatara/http";
|
|
2
5
|
import type { AppEnv } from "#core/http/context";
|
|
3
|
-
import { validationHook } from "#core/http/middleware/validation";
|
|
4
6
|
|
|
5
7
|
export const APP_NAMES = [
|
|
6
8
|
"api",
|
|
@@ -9,13 +11,7 @@ export const APP_NAMES = [
|
|
|
9
11
|
|
|
10
12
|
export type AppName = (typeof APP_NAMES)[number];
|
|
11
13
|
|
|
12
|
-
export
|
|
13
|
-
/** which HTTP app serves these routes */
|
|
14
|
-
app: AppName;
|
|
15
|
-
/** mounted under `/api` + basePath, e.g. "/notes" */
|
|
16
|
-
basePath: string;
|
|
17
|
-
router: OpenAPIHono<AppEnv>;
|
|
18
|
-
}
|
|
14
|
+
export type RouteDefinition = TataraRouteDefinition<AppName, AppEnv>;
|
|
19
15
|
|
|
20
16
|
/** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
|
|
21
17
|
export function defineRoutes(definition: RouteDefinition): RouteDefinition {
|
|
@@ -23,6 +19,6 @@ export function defineRoutes(definition: RouteDefinition): RouteDefinition {
|
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
/** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
|
|
26
|
-
export function createRouter()
|
|
27
|
-
return
|
|
22
|
+
export function createRouter() {
|
|
23
|
+
return createTataraRouter<AppEnv>();
|
|
28
24
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
-
import { errorResponses, jsonContent, success, successSchema } from "
|
|
2
|
+
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
3
3
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
4
4
|
import { greet } from "#modules/example/services/greeting.service";
|
|
5
5
|
import {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
-
import { jsonContent, success, successSchema } from "
|
|
2
|
+
import { jsonContent, success, successSchema } from "@tatara/http";
|
|
3
3
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
4
4
|
import { __moduleCamel__Status } from "#modules/__moduleKebab__/services/__moduleKebab__.service";
|
|
5
5
|
import { __moduleCamel__StatusSchema } from "#modules/__moduleKebab__/validators/__moduleKebab__.validators";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { count, desc, eq } from "drizzle-orm";
|
|
2
2
|
import { db, type Executor } from "#core/db/client";
|
|
3
3
|
import { newId } from "#core/db/id";
|
|
4
|
-
import { NotFoundError } from "
|
|
5
|
-
import type { PaginationQuery } from "
|
|
4
|
+
import { NotFoundError } from "@tatara/http";
|
|
5
|
+
import type { PaginationQuery } from "@tatara/http";
|
|
6
6
|
import { __namePluralCamel__ } from "#modules/__moduleKebab__/db/__nameKebab__.schema";
|
|
7
7
|
import type {
|
|
8
8
|
Create__namePascal__Input,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { db, type Executor } from "#core/db/client";
|
|
2
2
|
import { newId } from "#core/db/id";
|
|
3
|
-
import { NotFoundError } from "
|
|
4
|
-
import type { PaginationQuery } from "
|
|
3
|
+
import { NotFoundError } from "@tatara/http";
|
|
4
|
+
import type { PaginationQuery } from "@tatara/http";
|
|
5
5
|
import type {
|
|
6
6
|
Create__namePascal__Input,
|
|
7
7
|
__namePascal__,
|
package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
-
import { errorResponses, jsonContent, success, successSchema } from "
|
|
2
|
+
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
3
3
|
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
4
4
|
// tatara:slot imports
|
|
5
5
|
import {
|
package/templates/versions.json
CHANGED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
2
|
-
|
|
3
|
-
export class AppError extends Error {
|
|
4
|
-
readonly status: ContentfulStatusCode;
|
|
5
|
-
readonly code: string;
|
|
6
|
-
readonly details?: unknown;
|
|
7
|
-
/** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
|
|
8
|
-
readonly translatable: boolean;
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
status: ContentfulStatusCode,
|
|
12
|
-
code: string,
|
|
13
|
-
message: string,
|
|
14
|
-
details?: unknown,
|
|
15
|
-
translatable = false,
|
|
16
|
-
) {
|
|
17
|
-
super(message);
|
|
18
|
-
this.name = new.target.name;
|
|
19
|
-
this.status = status;
|
|
20
|
-
this.code = code;
|
|
21
|
-
this.details = details;
|
|
22
|
-
this.translatable = translatable;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class ValidationError extends AppError {
|
|
27
|
-
constructor(message?: string, details?: unknown) {
|
|
28
|
-
super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class BusinessRuleError extends AppError {
|
|
33
|
-
constructor(code: string, message: string, details?: unknown) {
|
|
34
|
-
// codes are specific to the rule, so a translation for the code is never less precise
|
|
35
|
-
super(400, code, message, details, true);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export class UnauthorizedError extends AppError {
|
|
40
|
-
constructor(message?: string) {
|
|
41
|
-
super(
|
|
42
|
-
401,
|
|
43
|
-
"UNAUTHORIZED",
|
|
44
|
-
message ?? "Authentication required",
|
|
45
|
-
undefined,
|
|
46
|
-
message === undefined,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export class ForbiddenError extends AppError {
|
|
52
|
-
constructor(message?: string) {
|
|
53
|
-
super(
|
|
54
|
-
403,
|
|
55
|
-
"FORBIDDEN",
|
|
56
|
-
message ?? "You do not have permission to do this",
|
|
57
|
-
undefined,
|
|
58
|
-
message === undefined,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export class NotFoundError extends AppError {
|
|
64
|
-
constructor(resource?: string) {
|
|
65
|
-
super(
|
|
66
|
-
404,
|
|
67
|
-
"NOT_FOUND",
|
|
68
|
-
`${resource ?? "Resource"} not found`,
|
|
69
|
-
undefined,
|
|
70
|
-
resource === undefined,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export class ConflictError extends AppError {
|
|
76
|
-
constructor(message?: string, details?: unknown) {
|
|
77
|
-
super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { Context } from "hono";
|
|
2
|
-
import { HTTPException } from "hono/http-exception";
|
|
3
|
-
import type { AppEnv } from "#core/http/context";
|
|
4
|
-
import { AppError } from "#core/http/errors";
|
|
5
|
-
// tatara:slot imports
|
|
6
|
-
|
|
7
|
-
export function errorHandler(err: Error, c: Context<AppEnv>) {
|
|
8
|
-
if (err instanceof AppError) {
|
|
9
|
-
let message = err.message;
|
|
10
|
-
// tatara:slot error-message
|
|
11
|
-
return c.json(
|
|
12
|
-
{
|
|
13
|
-
success: false as const,
|
|
14
|
-
error: message,
|
|
15
|
-
code: err.code,
|
|
16
|
-
...(err.details === undefined ? {} : { details: err.details }),
|
|
17
|
-
},
|
|
18
|
-
err.status,
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
if (err instanceof HTTPException) {
|
|
22
|
-
return c.json(
|
|
23
|
-
{ success: false as const, error: err.message || "Request failed", code: "HTTP_ERROR" },
|
|
24
|
-
err.status,
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
c.get("logger").error({ err }, "unhandled error");
|
|
28
|
-
return c.json(
|
|
29
|
-
{ success: false as const, error: "Something went wrong", code: "INTERNAL_ERROR" },
|
|
30
|
-
500,
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function notFoundHandler(c: Context<AppEnv>) {
|
|
35
|
-
return c.json({ success: false as const, error: "Route not found", code: "NOT_FOUND" }, 404);
|
|
36
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { createMiddleware } from "hono/factory";
|
|
2
|
-
import type { AppEnv } from "#core/http/context";
|
|
3
|
-
import { logger } from "#core/logging/logger";
|
|
4
|
-
|
|
5
|
-
const REQUEST_ID = /^[\w-]{8,128}$/;
|
|
6
|
-
|
|
7
|
-
/** Sets requestId, a request-scoped logger and defaults; logs one line per request. */
|
|
8
|
-
export const requestContext = createMiddleware<AppEnv>(async (c, next) => {
|
|
9
|
-
const incoming = c.req.header("x-request-id");
|
|
10
|
-
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
11
|
-
const log = logger.child({ requestId });
|
|
12
|
-
c.set("requestId", requestId);
|
|
13
|
-
c.set("logger", log);
|
|
14
|
-
c.set("principal", null);
|
|
15
|
-
c.set("language", "en");
|
|
16
|
-
c.header("x-request-id", requestId);
|
|
17
|
-
const start = performance.now();
|
|
18
|
-
await next();
|
|
19
|
-
const ms = Math.round(performance.now() - start);
|
|
20
|
-
const status = c.res.status;
|
|
21
|
-
const entry = { method: c.req.method, path: c.req.path, status, ms };
|
|
22
|
-
if (status >= 500) log.error(entry, "request failed");
|
|
23
|
-
else log.info(entry, "request");
|
|
24
|
-
});
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { Hook } from "@hono/zod-openapi";
|
|
2
|
-
import type { AppEnv } from "#core/http/context";
|
|
3
|
-
import { ValidationError } from "#core/http/errors";
|
|
4
|
-
|
|
5
|
-
// biome-ignore lint/suspicious/noExplicitAny: the hook is shared by every route schema
|
|
6
|
-
export const validationHook: Hook<any, AppEnv, any, any> = (result) => {
|
|
7
|
-
if (!result.success) {
|
|
8
|
-
throw new ValidationError(
|
|
9
|
-
undefined,
|
|
10
|
-
result.error.issues.map((issue) => ({
|
|
11
|
-
path: issue.path.join("."),
|
|
12
|
-
message: issue.message,
|
|
13
|
-
code: issue.code,
|
|
14
|
-
})),
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { z } from "@hono/zod-openapi";
|
|
2
|
-
|
|
3
|
-
export const errorSchema = z
|
|
4
|
-
.object({
|
|
5
|
-
success: z.literal(false),
|
|
6
|
-
error: z.string(),
|
|
7
|
-
code: z.string(),
|
|
8
|
-
details: z.unknown().optional(),
|
|
9
|
-
})
|
|
10
|
-
.openapi("Error");
|
|
11
|
-
|
|
12
|
-
export const paginationMetaSchema = z
|
|
13
|
-
.object({
|
|
14
|
-
page: z.number().int(),
|
|
15
|
-
pageSize: z.number().int(),
|
|
16
|
-
total: z.number().int(),
|
|
17
|
-
totalPages: z.number().int(),
|
|
18
|
-
})
|
|
19
|
-
.openapi("PaginationMeta");
|
|
20
|
-
|
|
21
|
-
export const paginationQuerySchema = z.object({
|
|
22
|
-
page: z.coerce.number().int().min(1).default(1),
|
|
23
|
-
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
|
27
|
-
|
|
28
|
-
export function successSchema<T extends z.ZodType>(data: T) {
|
|
29
|
-
return z.object({ success: z.literal(true), data });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function paginatedSchema<T extends z.ZodType>(item: T) {
|
|
33
|
-
return z.object({ success: z.literal(true), data: z.array(item), meta: paginationMetaSchema });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function success<T>(data: T) {
|
|
37
|
-
return { success: true as const, data };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function paginated<T>(data: T[], page: number, pageSize: number, total: number) {
|
|
41
|
-
return {
|
|
42
|
-
success: true as const,
|
|
43
|
-
data,
|
|
44
|
-
meta: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function jsonContent<T extends z.ZodType>(schema: T, description: string) {
|
|
49
|
-
return { content: { "application/json": { schema } }, description };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** A required JSON request body. Without `required`, a request with no body skips validation. */
|
|
53
|
-
export function jsonBody<T extends z.ZodType>(schema: T, description: string) {
|
|
54
|
-
return { ...jsonContent(schema, description), required: true };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const ERROR_DESCRIPTIONS = {
|
|
58
|
-
400: "Invalid request",
|
|
59
|
-
401: "Authentication required",
|
|
60
|
-
403: "Forbidden",
|
|
61
|
-
404: "Not found",
|
|
62
|
-
409: "Conflict",
|
|
63
|
-
} as const;
|
|
64
|
-
|
|
65
|
-
type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
|
|
66
|
-
|
|
67
|
-
/** OpenAPI error responses for the given statuses, all using the shared error envelope. */
|
|
68
|
-
export function errorResponses<const S extends ErrorStatus[]>(...statuses: S) {
|
|
69
|
-
return Object.fromEntries(
|
|
70
|
-
statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]),
|
|
71
|
-
) as { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>> };
|
|
72
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { serve } from "@hono/node-server";
|
|
2
|
-
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
3
|
-
import type { AppEnv } from "#core/http/context";
|
|
4
|
-
import { logger } from "#core/logging/logger";
|
|
5
|
-
import { handleSignals, onShutdown } from "@tatara/core";
|
|
6
|
-
|
|
7
|
-
/** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
|
|
8
|
-
export function startServer(app: OpenAPIHono<AppEnv>, port: number, name: string) {
|
|
9
|
-
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
10
|
-
logger.info({ port: info.port, app: name }, `${name} listening on http://localhost:${info.port}`);
|
|
11
|
-
});
|
|
12
|
-
onShutdown(`${name}-http`, () => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
13
|
-
handleSignals(logger);
|
|
14
|
-
return server;
|
|
15
|
-
}
|