@tatara/cli 0.2.0 → 0.4.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 +221 -19
- 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/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +1 -1
- package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +1 -1
- package/templates/features/authz/files/src/core/authz/db/seed.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/authz/files+drizzle/src/core/authz/db/store.ts +1 -1
- package/templates/features/authz/files+prisma/src/core/authz/db/store.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 +22 -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/features/base/files/tests/helpers/api.ts +3 -47
- package/templates/features/better-auth/files/src/core/auth/auth.ts +1 -1
- package/templates/features/better-auth/files/src/core/auth/db/seed.ts +1 -1
- package/templates/features/better-auth/files/tests/helpers/auth.ts +1 -1
- package/templates/features/db/files/tests/global-setup.ts +4 -16
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +2 -53
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +7 -78
- package/templates/features/db/files+postgres/src/core/db/admin.ts +2 -50
- package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +1 -13
- package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +1 -13
- package/templates/features/prisma/files/src/core/db/migrate.ts +1 -13
- 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 +3 -3
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +3 -3
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
- package/templates/versions.json +4 -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/templates/features/db/files/src/core/db/id.ts +0 -13
- package/templates/features/db/files/src/core/db/seed.ts +0 -14
- package/templates/features/db/files/tests/helpers/database-url.ts +0 -9
|
@@ -1,53 +1,9 @@
|
|
|
1
|
+
import { client } from "@tatara/testing";
|
|
1
2
|
import { app as apiApp } from "#apps/api/app";
|
|
2
3
|
// tatara:slot imports
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
export interface ApiResponse<T = unknown> {
|
|
7
|
-
status: number;
|
|
8
|
-
headers: Headers;
|
|
9
|
-
body: T;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface RequestOptions {
|
|
13
|
-
headers?: Record<string, string>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function send<T>(
|
|
17
|
-
target: HttpApp,
|
|
18
|
-
method: string,
|
|
19
|
-
path: string,
|
|
20
|
-
body: unknown,
|
|
21
|
-
options: RequestOptions = {},
|
|
22
|
-
): Promise<ApiResponse<T>> {
|
|
23
|
-
const headers: Record<string, string> = { ...options.headers };
|
|
24
|
-
if (body !== undefined) headers["content-type"] = "application/json";
|
|
25
|
-
const res = await target.request(path, {
|
|
26
|
-
method,
|
|
27
|
-
headers,
|
|
28
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
-
});
|
|
30
|
-
const text = await res.text();
|
|
31
|
-
let parsed: unknown = text;
|
|
32
|
-
try {
|
|
33
|
-
parsed = text ? JSON.parse(text) : null;
|
|
34
|
-
} catch {}
|
|
35
|
-
return { status: res.status, headers: res.headers, body: parsed as T };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function client(target: HttpApp) {
|
|
39
|
-
return {
|
|
40
|
-
get: <T = unknown>(path: string, o?: RequestOptions) => send<T>(target, "GET", path, undefined, o),
|
|
41
|
-
post: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
42
|
-
send<T>(target, "POST", path, body, o),
|
|
43
|
-
put: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
44
|
-
send<T>(target, "PUT", path, body, o),
|
|
45
|
-
patch: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
46
|
-
send<T>(target, "PATCH", path, body, o),
|
|
47
|
-
delete: <T = unknown>(path: string, o?: RequestOptions) =>
|
|
48
|
-
send<T>(target, "DELETE", path, undefined, o),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
5
|
+
export type { ApiResponse, RequestOptions } from "@tatara/testing";
|
|
6
|
+
export { client };
|
|
51
7
|
|
|
52
8
|
/** Calls the api app in-process (no network). */
|
|
53
9
|
export const api = client(apiApp);
|
|
@@ -2,7 +2,7 @@ import { betterAuth } from "better-auth";
|
|
|
2
2
|
import { bearer, openAPI } from "better-auth/plugins";
|
|
3
3
|
import { database } from "#core/auth/adapter";
|
|
4
4
|
import { env } from "#core/config/env";
|
|
5
|
-
import { newId } from "
|
|
5
|
+
import { newId } from "@tatara/db";
|
|
6
6
|
// tatara:slot imports
|
|
7
7
|
|
|
8
8
|
export const auth = betterAuth({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { auth } from "#core/auth/auth";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
|
-
import { defineSeed } from "
|
|
3
|
+
import { defineSeed } from "@tatara/db";
|
|
4
4
|
|
|
5
5
|
/** Development admin account (never created in production). */
|
|
6
6
|
export default defineSeed({
|
|
@@ -1,27 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { parseEnv } from "node:util";
|
|
3
|
+
import { prepareTestDatabases } from "@tatara/testing";
|
|
3
4
|
import { ensureDatabase } from "#core/db/admin";
|
|
4
5
|
import { migrateDatabase } from "#core/db/migrate";
|
|
5
|
-
import { TEST_WORKERS, workerDatabaseUrl } from "#tests/helpers/database-url";
|
|
6
6
|
|
|
7
7
|
/** Creates and migrates one database per test worker, once per run. */
|
|
8
8
|
export default async function setup() {
|
|
9
9
|
const fileEnv = existsSync(".env.test")
|
|
10
10
|
? (parseEnv(readFileSync(".env.test", "utf8")) as Record<string, string>)
|
|
11
11
|
: {};
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
|
|
15
|
-
await Promise.all(
|
|
16
|
-
Array.from({ length: TEST_WORKERS }, async (_, i) => {
|
|
17
|
-
const url = workerDatabaseUrl(base, i + 1);
|
|
18
|
-
await ensureDatabase(url);
|
|
19
|
-
await migrateDatabase(url);
|
|
20
|
-
}),
|
|
21
|
-
);
|
|
22
|
-
} catch (error) {
|
|
23
|
-
throw new Error(
|
|
24
|
-
`Could not prepare the test databases. Is the database running? (\`docker compose up -d --wait\`)\n${String(error)}`,
|
|
25
|
-
);
|
|
26
|
-
}
|
|
12
|
+
const baseUrl = process.env.DATABASE_URL ?? fileEnv.DATABASE_URL;
|
|
13
|
+
if (!baseUrl) throw new Error("DATABASE_URL is not set for tests (.env.test)");
|
|
14
|
+
await prepareTestDatabases({ baseUrl, ensureDatabase, migrateDatabase });
|
|
27
15
|
}
|
|
@@ -1,53 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const MIGRATION_TABLES = ["__drizzle_migrations", "_prisma_migrations"];
|
|
4
|
-
|
|
5
|
-
function databaseName(url: string): string {
|
|
6
|
-
return decodeURIComponent(new URL(url).pathname.slice(1));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
async function withConnection<T>(url: string, fn: (c: mysql.Connection) => Promise<T>): Promise<T> {
|
|
10
|
-
const connection = await mysql.createConnection({ uri: url, multipleStatements: true });
|
|
11
|
-
try {
|
|
12
|
-
return await fn(connection);
|
|
13
|
-
} finally {
|
|
14
|
-
await connection.end();
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Creates the database named in `url` if it does not exist. */
|
|
19
|
-
export async function ensureDatabase(url: string): Promise<void> {
|
|
20
|
-
const name = databaseName(url);
|
|
21
|
-
const server = new URL(url);
|
|
22
|
-
server.pathname = "/";
|
|
23
|
-
await withConnection(server.toString(), async (c) => {
|
|
24
|
-
await c.query(`create database if not exists \`${name.replaceAll("`", "``")}\``);
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function tables(c: mysql.Connection): Promise<string[]> {
|
|
29
|
-
const [rows] = await c.query<mysql.RowDataPacket[]>(
|
|
30
|
-
"select table_name as name from information_schema.tables where table_schema = database() and table_type = 'BASE TABLE'",
|
|
31
|
-
);
|
|
32
|
-
return rows.map((r) => String(r.name));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function dropAllTables(url: string): Promise<void> {
|
|
36
|
-
await withConnection(url, async (c) => {
|
|
37
|
-
const names = await tables(c);
|
|
38
|
-
if (names.length === 0) return;
|
|
39
|
-
await c.query(
|
|
40
|
-
`set foreign_key_checks = 0; drop table ${names.map((t) => `\`${t}\``).join(", ")}; set foreign_key_checks = 1;`,
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function truncateAllTables(url: string): Promise<void> {
|
|
46
|
-
await withConnection(url, async (c) => {
|
|
47
|
-
const names = (await tables(c)).filter((t) => !MIGRATION_TABLES.includes(t));
|
|
48
|
-
if (names.length === 0) return;
|
|
49
|
-
await c.query(
|
|
50
|
-
`set foreign_key_checks = 0; ${names.map((t) => `truncate table \`${t}\`;`).join(" ")} set foreign_key_checks = 1;`,
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
1
|
+
// This app uses MySQL through mysql2.
|
|
2
|
+
export { dropAllTables, ensureDatabase, truncateAllTables } from "@tatara/db/mysql2";
|
|
@@ -1,78 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* mariadb connection options from a mysql:// URL. Public-key retrieval (needed by MySQL's
|
|
9
|
-
* caching_sha2_password without TLS) is only enabled for local hosts or when the URL asks
|
|
10
|
-
* for it; use `?ssl=true` for remote databases.
|
|
11
|
-
*/
|
|
12
|
-
export function connectionConfig(url: string): mariadb.ConnectionConfig {
|
|
13
|
-
const u = new URL(url);
|
|
14
|
-
const param = (name: string) => u.searchParams.get(name);
|
|
15
|
-
return {
|
|
16
|
-
host: u.hostname,
|
|
17
|
-
port: Number(u.port || 3306),
|
|
18
|
-
user: decodeURIComponent(u.username),
|
|
19
|
-
password: decodeURIComponent(u.password),
|
|
20
|
-
database: decodeURIComponent(u.pathname.slice(1)) || undefined,
|
|
21
|
-
allowPublicKeyRetrieval:
|
|
22
|
-
param("allowPublicKeyRetrieval") === "true" || LOCAL_HOSTS.has(u.hostname),
|
|
23
|
-
...(param("ssl") === "true" ? { ssl: { rejectUnauthorized: true } } : {}),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const adminConfig = (url: string): mariadb.ConnectionConfig => ({
|
|
28
|
-
...connectionConfig(url),
|
|
29
|
-
multipleStatements: true,
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
async function withConnection<T>(
|
|
33
|
-
config: mariadb.ConnectionConfig,
|
|
34
|
-
fn: (c: mariadb.Connection) => Promise<T>,
|
|
35
|
-
): Promise<T> {
|
|
36
|
-
const connection = await mariadb.createConnection(config);
|
|
37
|
-
try {
|
|
38
|
-
return await fn(connection);
|
|
39
|
-
} finally {
|
|
40
|
-
await connection.end();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Creates the database named in `url` if it does not exist. */
|
|
45
|
-
export async function ensureDatabase(url: string): Promise<void> {
|
|
46
|
-
const config = adminConfig(url);
|
|
47
|
-
const name = String(config.database);
|
|
48
|
-
await withConnection({ ...config, database: undefined }, async (c) => {
|
|
49
|
-
await c.query(`create database if not exists \`${name.replaceAll("`", "``")}\``);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function tables(c: mariadb.Connection): Promise<string[]> {
|
|
54
|
-
const rows: { name: string }[] = await c.query(
|
|
55
|
-
"select table_name as name from information_schema.tables where table_schema = database() and table_type = 'BASE TABLE'",
|
|
56
|
-
);
|
|
57
|
-
return rows.map((r) => String(r.name));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function dropAllTables(url: string): Promise<void> {
|
|
61
|
-
await withConnection(adminConfig(url), async (c) => {
|
|
62
|
-
const names = await tables(c);
|
|
63
|
-
if (names.length === 0) return;
|
|
64
|
-
await c.query(
|
|
65
|
-
`set foreign_key_checks = 0; drop table ${names.map((t) => `\`${t}\``).join(", ")}; set foreign_key_checks = 1;`,
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function truncateAllTables(url: string): Promise<void> {
|
|
71
|
-
await withConnection(adminConfig(url), async (c) => {
|
|
72
|
-
const names = (await tables(c)).filter((t) => !MIGRATION_TABLES.includes(t));
|
|
73
|
-
if (names.length === 0) return;
|
|
74
|
-
await c.query(
|
|
75
|
-
`set foreign_key_checks = 0; ${names.map((t) => `truncate table \`${t}\`;`).join(" ")} set foreign_key_checks = 1;`,
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
1
|
+
// This app uses MySQL through the mariadb driver (Prisma adapter).
|
|
2
|
+
export {
|
|
3
|
+
connectionConfig,
|
|
4
|
+
dropAllTables,
|
|
5
|
+
ensureDatabase,
|
|
6
|
+
truncateAllTables,
|
|
7
|
+
} from "@tatara/db/mariadb";
|
|
@@ -1,50 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const MIGRATION_TABLES = ["__drizzle_migrations", "_prisma_migrations"];
|
|
4
|
-
|
|
5
|
-
function databaseName(url: string): string {
|
|
6
|
-
return decodeURIComponent(new URL(url).pathname.slice(1));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
async function withClient<T>(url: string, fn: (client: pg.Client) => Promise<T>): Promise<T> {
|
|
10
|
-
const client = new pg.Client({ connectionString: url });
|
|
11
|
-
await client.connect();
|
|
12
|
-
try {
|
|
13
|
-
return await fn(client);
|
|
14
|
-
} finally {
|
|
15
|
-
await client.end();
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Creates the database named in `url` if it does not exist. */
|
|
20
|
-
export async function ensureDatabase(url: string): Promise<void> {
|
|
21
|
-
const name = databaseName(url);
|
|
22
|
-
const admin = new URL(url);
|
|
23
|
-
admin.pathname = "/postgres";
|
|
24
|
-
await withClient(admin.toString(), async (client) => {
|
|
25
|
-
const found = await client.query("select 1 from pg_database where datname = $1", [name]);
|
|
26
|
-
if (found.rowCount === 0) await client.query(`create database "${name.replaceAll('"', '""')}"`);
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function dropAllTables(url: string): Promise<void> {
|
|
31
|
-
await withClient(url, async (client) => {
|
|
32
|
-
await client.query("drop schema if exists drizzle cascade");
|
|
33
|
-
await client.query("drop schema public cascade");
|
|
34
|
-
await client.query("create schema public");
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function truncateAllTables(url: string): Promise<void> {
|
|
39
|
-
await withClient(url, async (client) => {
|
|
40
|
-
const { rows } = await client.query<{ tablename: string }>(
|
|
41
|
-
"select tablename from pg_tables where schemaname = 'public'",
|
|
42
|
-
);
|
|
43
|
-
const tables = rows.map((r) => r.tablename).filter((t) => !MIGRATION_TABLES.includes(t));
|
|
44
|
-
if (tables.length > 0) {
|
|
45
|
-
await client.query(
|
|
46
|
-
`truncate table ${tables.map((t) => `"${t}"`).join(", ")} restart identity cascade`,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
}
|
|
1
|
+
// This app uses PostgreSQL through node-postgres.
|
|
2
|
+
export { dropAllTables, ensureDatabase, truncateAllTables } from "@tatara/db/postgres";
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { migrate } from "drizzle-orm/mysql2/migrator";
|
|
3
|
-
import mysql from "mysql2/promise";
|
|
4
|
-
|
|
5
|
-
/** Applies ./drizzle migrations to the database at `url`. */
|
|
6
|
-
export async function migrateDatabase(url: string): Promise<void> {
|
|
7
|
-
const connection = await mysql.createConnection({ uri: url });
|
|
8
|
-
try {
|
|
9
|
-
await migrate(drizzle({ client: connection }), { migrationsFolder: "drizzle" });
|
|
10
|
-
} finally {
|
|
11
|
-
await connection.end();
|
|
12
|
-
}
|
|
13
|
-
}
|
|
1
|
+
export { migrateDatabase } from "@tatara/db/drizzle-mysql";
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
-
import pg from "pg";
|
|
4
|
-
|
|
5
|
-
/** Applies ./drizzle migrations to the database at `url`. */
|
|
6
|
-
export async function migrateDatabase(url: string): Promise<void> {
|
|
7
|
-
const pool = new pg.Pool({ connectionString: url, max: 1 });
|
|
8
|
-
try {
|
|
9
|
-
await migrate(drizzle({ client: pool }), { migrationsFolder: "drizzle" });
|
|
10
|
-
} finally {
|
|
11
|
-
await pool.end();
|
|
12
|
-
}
|
|
13
|
-
}
|
|
1
|
+
export { migrateDatabase } from "@tatara/db/drizzle-postgres";
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import { promisify } from "node:util";
|
|
4
|
-
|
|
5
|
-
const run = promisify(execFile);
|
|
6
|
-
|
|
7
|
-
/** Applies prisma/migrations to the database at `url` (`prisma migrate deploy`). */
|
|
8
|
-
export async function migrateDatabase(url: string): Promise<void> {
|
|
9
|
-
await run(resolve("node_modules/.bin/prisma"), ["migrate", "deploy"], {
|
|
10
|
-
// tatara-check-ignore env-access: the Prisma CLI needs the full environment
|
|
11
|
-
env: { ...process.env, DATABASE_URL: url },
|
|
12
|
-
});
|
|
13
|
-
}
|
|
1
|
+
export { migrateDatabase } from "@tatara/db/prisma";
|
|
@@ -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
|
-
import { newId } from "
|
|
4
|
-
import { NotFoundError } from "
|
|
5
|
-
import type { PaginationQuery } from "
|
|
3
|
+
import { newId } from "@tatara/db";
|
|
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
|
-
import { newId } from "
|
|
3
|
-
import { NotFoundError } from "
|
|
4
|
-
import type { PaginationQuery } from "
|
|
2
|
+
import { newId } from "@tatara/db";
|
|
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
|
-
}
|