diff --git a/biome.json b/biome.json index b6b85a9..5984454 100644 --- a/biome.json +++ b/biome.json @@ -17,8 +17,34 @@ "rules": { "recommended": true, "nursery": { + "noEmptyTypeParameters": "error", + "noInvalidUseBeforeDeclaration": "error", + "noUnusedImports": "error", + "noUnusedPrivateClassMembers": "error", + "noUselessLoneBlockStatements": "error", + "noUselessTernary": "error", + "useExportType": "error", "useImportType": "error", - "noUnusedImports": "error" + "useForOf": "error", + "useGroupedTypeImport": "error" + }, + "complexity": { + "noExcessiveCognitiveComplexity": "warn", + "useSimplifiedLogicExpression": "error" + }, + "correctness": { + "noNewSymbol": "error" + }, + "style": { + "useBlockStatements": "error", + "useCollapsedElseIf": "error", + "useShorthandArrayType": "error", + "useShorthandAssign": "error", + "useSingleCaseStatement": "error" + }, + "suspicious": { + "noApproximativeNumericConstant": "warn", + "noConsoleLog": "error" } } }, diff --git a/skeleton/base/biome.json b/skeleton/base/biome.json index 1ddeaa0..c5efafb 100644 --- a/skeleton/base/biome.json +++ b/skeleton/base/biome.json @@ -18,8 +18,34 @@ "rules": { "recommended": true, "nursery": { + "noEmptyTypeParameters": "error", + "noInvalidUseBeforeDeclaration": "error", + "noUnusedImports": "error", + "noUnusedPrivateClassMembers": "error", + "noUselessLoneBlockStatements": "error", + "noUselessTernary": "error", + "useExportType": "error", "useImportType": "error", - "noUnusedImports": "error" + "useForOf": "error", + "useGroupedTypeImport": "error" + }, + "complexity": { + "noExcessiveCognitiveComplexity": "warn", + "useSimplifiedLogicExpression": "error" + }, + "correctness": { + "noNewSymbol": "error" + }, + "style": { + "useBlockStatements": "error", + "useCollapsedElseIf": "error", + "useShorthandArrayType": "error", + "useShorthandAssign": "error", + "useSingleCaseStatement": "error" + }, + "suspicious": { + "noApproximativeNumericConstant": "warn", + "noConsoleLog": "error" } } }, diff --git a/skeleton/base/src/route/hello-world/foo.ts b/skeleton/base/src/route/hello-world/foo.ts deleted file mode 100644 index 72eb98d..0000000 --- a/skeleton/base/src/route/hello-world/foo.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Context } from "koa"; -import type Router from "koa-tree-router"; -import { z } from "zod"; -import { parseBody, parseQuery } from "../../util/zod.js"; - -const getHelloWorld = (context: Context) => { - context.body = { - data: { - message: "hello-world", - }, - }; -}; - -const listQuerySchema = z.object({ - foo: z.string().optional(), -}); - -const listHelloWorld = (context: Context) => { - const query = parseQuery(listQuerySchema, context); - - context.body = { data: { foo: query.foo } }; -}; - -const postSchema = z.object({ - foo: z.string(), -}); - -const postHelloWorld = (context: Context) => { - const input = parseBody(postSchema, context); - - context.status = 201; - context.body = { data: { foo: input.foo } }; -}; - -export const registerFooRoutes = (router: Router): void => { - const group = router.newGroup("/foo"); - group.get("/", getHelloWorld); - group.get("/list", listHelloWorld); - group.post("/list", postHelloWorld); -}; diff --git a/skeleton/base/src/route/hello-world/index.ts b/skeleton/base/src/route/hello-world/index.ts deleted file mode 100644 index 2e853e9..0000000 --- a/skeleton/base/src/route/hello-world/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type Router from "koa-tree-router"; -import { registerFooRoutes } from "./foo.js"; - -export const registerHelloWorldRoutes = (router: Router): void => { - const group = router.newGroup("/hello-world"); - registerFooRoutes(group); -}; diff --git a/skeleton/base/src/route/index.ts b/skeleton/base/src/route/index.ts index 1e98824..1439b57 100644 --- a/skeleton/base/src/route/index.ts +++ b/skeleton/base/src/route/index.ts @@ -1,6 +1,3 @@ import type Router from "koa-tree-router"; -import { registerHelloWorldRoutes } from "./hello-world/index.js"; -export const registerRoutes = (router: Router): void => { - registerHelloWorldRoutes(router); -}; +export const registerRoutes = (router: Router): void => {}; diff --git a/skeleton/base/src/util/json-api.ts b/skeleton/base/src/util/json-api.ts deleted file mode 100644 index 39354dc..0000000 --- a/skeleton/base/src/util/json-api.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type JsonApiError = { - id?: string; - links?: { - about?: string; - type?: string; - }; - status: string; - code?: string; - title?: string; - detail?: string; - source?: { - pointer?: string; - parameter?: string; - header?: string; - }; - meta?: Record; -}; - -export type JsonApiErrorResponse = { - errors: JsonApiError[]; - meta?: Record; -}; diff --git a/skeleton/base/src/util/zod.ts b/skeleton/base/src/util/zod.ts deleted file mode 100644 index 01c0b41..0000000 --- a/skeleton/base/src/util/zod.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Context } from "koa"; -import { z } from "zod"; -import type { JsonApiError } from "./json-api.js"; - -export class ZodValidationError extends Error { - public readonly status: number; - public readonly errors: z.ZodIssue[]; - - public constructor(message: string, status: number, errors: z.ZodIssue[]) { - super(message); - this.status = status; - this.errors = errors; - } - - public toJsonApiErrors(): JsonApiError[] { - return this.errors.map((error): JsonApiError => { - let source: JsonApiError["source"]; - const { code, message, path, fatal, ...rest } = error; - - if (this.status === 400) { - if (path.length !== 1 || typeof path[0] !== "string") { - throw new Error("Query parameters paths must be a single string"); - } - - source = { - parameter: path[0], - }; - } else { - source = { - pointer: `/${path.join("/")}`, - }; - } - - return { - status: this.status.toString(), - code, - title: message, - source, - meta: Object.keys(rest).length > 0 ? rest : undefined, - }; - }); - } -} - -export const parseBody = >( - schema: T, - context: Context, -): z.infer => { - const result = z.object({ data: schema }).safeParse(context.request.body); - - if (!result.success) { - throw new ZodValidationError("Validation of body failed", 422, result.error.errors); - } - - return result.data; -}; - -export const parseQuery = >( - schema: T, - context: Context, -): z.infer => { - const result = schema.safeParse(context.request.query); - - if (!result.success) { - throw new ZodValidationError("Validation of query failed", 400, result.error.errors); - } - - return result.data; -}; diff --git a/skeleton/features/appconfig/dev-app-config.toml.dist b/skeleton/features/appconfig/base/dev-app-config.toml similarity index 100% rename from skeleton/features/appconfig/dev-app-config.toml.dist rename to skeleton/features/appconfig/base/dev-app-config.toml diff --git a/skeleton/features/postgres/base/src/entity/World.ts b/skeleton/features/postgres/base/src/entity/World.ts deleted file mode 100644 index 61df50f..0000000 --- a/skeleton/features/postgres/base/src/entity/World.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { randomUUID } from "crypto"; -import { Entity, PrimaryKey, t } from "@mikro-orm/core"; - -@Entity() -export class World { - @PrimaryKey({ type: t.uuid }) - public readonly id: string = randomUUID(); - - @PrimaryKey({ type: t.text }) - public name: string; - - public constructor(name: string) { - this.name = name; - } -} diff --git a/skeleton/features/postgres/base/src/mikro-orm.config.ts b/skeleton/features/postgres/base/src/mikro-orm.config.ts index b64b80e..0f534a7 100644 --- a/skeleton/features/postgres/base/src/mikro-orm.config.ts +++ b/skeleton/features/postgres/base/src/mikro-orm.config.ts @@ -111,6 +111,9 @@ export default (async (): Promise => { pathTs: "./src/migration", generator, }, + discovery: { + warnWhenNoEntities: false, + }, loadStrategy: LoadStrategy.JOINED, ...postgresConfig, }); diff --git a/skeleton/features/postgres/base/src/util/mikro-orm.ts b/skeleton/features/postgres/base/src/util/mikro-orm.ts index a1f3d44..620b995 100644 --- a/skeleton/features/postgres/base/src/util/mikro-orm.ts +++ b/skeleton/features/postgres/base/src/util/mikro-orm.ts @@ -1,7 +1,9 @@ -import { RequestContext } from "@mikro-orm/core"; +import { type QueryOrderMap, RequestContext } from "@mikro-orm/core"; import { MikroORM } from "@mikro-orm/postgresql"; import fnv1a from "@sindresorhus/fnv1a"; +import { unflatten } from "flat"; import type { Next, ParameterizedContext } from "koa"; +import type { Sort } from "koa-jsonapi-zod"; export const orm = await MikroORM.init(); export const em = orm.em; @@ -22,3 +24,17 @@ await em.transactional(async (em) => { export const requestContextMiddleware = async (_context: ParameterizedContext, next: Next) => RequestContext.create(em, next); + +export const orderByFromJsonApiSort = ( + sort: Sort | undefined, +): QueryOrderMap[] | undefined => { + if (!sort) { + return undefined; + } + + return sort.map((field) => + unflatten({ + [field.field]: field.order, + }), + ); +}; diff --git a/skeleton/features/postgres/package.json b/skeleton/features/postgres/package.json index bdab48e..3eb3bf0 100644 --- a/skeleton/features/postgres/package.json +++ b/skeleton/features/postgres/package.json @@ -8,7 +8,8 @@ "@mikro-orm/core": "^6.0.0", "@mikro-orm/postgresql": "^6.0.0", "@mikro-orm/migrations": "^6.0.0", - "@sindresorhus/fnv1a": "^3.1.0" + "@sindresorhus/fnv1a": "^3.1.0", + "flat": "^6.0.1" }, "scripts": { "mikro-orm": "tsx ./node_modules/@mikro-orm/cli/esm" diff --git a/skeleton/package.json b/skeleton/package.json index 922d670..4af4c85 100644 --- a/skeleton/package.json +++ b/skeleton/package.json @@ -31,6 +31,7 @@ "koa-bodyparser": "^4.3.0", "koa-cache-control": "^2.0.0", "koa-compress": "^5.0.1", + "koa-jsonapi-zod": "^1.1.0", "koa-tree-router": "^0.12.1", "source-map-support": "^0.5.21", "winston": "^3.6.0", diff --git a/skeleton/templates/README.md.mustache b/skeleton/templates/README.md.mustache index 795b60c..4794b32 100644 --- a/skeleton/templates/README.md.mustache +++ b/skeleton/templates/README.md.mustache @@ -2,6 +2,8 @@ Koa API following [JSON:API](https://jsonapi.org/) 1.1 specification. +See [koa-jsonapi-zod](https://github.com/DASPRiD/koa-jsonapi-zod) examples for integration. + ## Setup - `pnpm install` diff --git a/skeleton/templates/cdk/src/api-stack.ts.mustache b/skeleton/templates/cdk/src/api-stack.ts.mustache index eeb6e86..c7870ea 100644 --- a/skeleton/templates/cdk/src/api-stack.ts.mustache +++ b/skeleton/templates/cdk/src/api-stack.ts.mustache @@ -8,6 +8,7 @@ import { InstanceType, IpAddresses, IpProtocol, + Port, SubnetType, Vpc, } from "aws-cdk-lib/aws-ec2"; @@ -75,12 +76,6 @@ export class ApiStack extends Stack { if (!databaseCluster.secret) { throw new Error("Database cluster has nio secret attached"); } - - const databaseProxy = databaseCluster.addProxy("DatabaseProxy", { - vpc, - secrets: [databaseCluster.secret], - clientPasswordAuthType: ClientPasswordAuthType.POSTGRES_SCRAM_SHA_256, - }); {{/if}} {{#if (has features "appconfig")}} @@ -138,8 +133,8 @@ export class ApiStack extends Stack { environment: { PORT: "80", {{#if (has features "postgres")}} - POSTGRES_HOSTNAME: databaseProxy.endpoint, - POSTGRES_PORT: "5432", + POSTGRES_HOSTNAME: databaseCluster.clusterEndpoint.hostname, + POSTGRES_PORT: databaseCluster.clusterEndpoint.port.toString(), POSTGRES_SECRET: databaseCluster.secret.secretArn, PGSSLMODE: "require", {{/if}} @@ -161,7 +156,7 @@ export class ApiStack extends Stack { }); {{#if (has features "postgres")}} - albService.service.connections.allowTo(databaseProxy.connections, Port.tcp(5432)); + albService.service.connections.allowToDefaultPort(databaseCluster.connections); databaseCluster.secret.grantRead(albService.taskDefinition.taskRole); {{/if}} {{#if (has features "appconfig")}} diff --git a/skeleton/templates/src/index.ts.mustache b/skeleton/templates/src/index.ts.mustache index ef40d89..b3471f3 100644 --- a/skeleton/templates/src/index.ts.mustache +++ b/skeleton/templates/src/index.ts.mustache @@ -6,6 +6,11 @@ import Koa from "koa"; import bodyParser from "koa-bodyparser"; import cacheControl from "koa-cache-control"; import compress from "koa-compress"; +import { + JsonApiErrorBody, + jsonApiErrorMiddleware, + jsonApiRequestMiddleware, +} from "koa-jsonapi-zod"; import Router from "koa-tree-router"; import "source-map-support/register.js"; import { appConfigHandler } from "./app-config/handler.js"; @@ -25,53 +30,20 @@ if (result.error) { const app = new Koa(); app.use(cacheControl({ noStore: true })); - -app.use(async (context, next) => { - try { - await next(); - } catch (error) { - if (isHttpError(error) && error.expose) { - context.status = error.status; - context.body = { - errors: [ - { - status: error.status.toString(), - code: error.name - .replace(/Error$/, "") - .replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), - title: error.message, - }, - ], - }; - return; - } - - if (error instanceof ZodValidationError) { - context.status = error.status; - context.body = { - errors: error.toJsonApiErrors(), - }; - return; - } - - context.status = 500; - context.body = { - errors: [ - { - status: "500", - code: "internal_server_error", - title: "Internal Server Error", - }, - ], - }; - logger.error(error instanceof Error ? error.stack : error); - } finally { - if (context.response.type === "application/json") { - context.set("Content-Type", "application/vnd.api+json"); - } - } -}); - +app.use( + jsonApiRequestMiddleware({ + excludedPaths: ["/health"], + }), +); +app.use( + jsonApiErrorMiddleware({ + logError: (error, exposed) => { + if (!exposed) { + logger.error(error instanceof Error ? error.stack : error); + } + }, + }), +); app.use(bodyParser()); app.use(cors({ maxAge: 86400 })); app.use( @@ -101,31 +73,22 @@ const router = new Router({ onMethodNotAllowed: (context) => { if (context.response.headers.allow === "") { context.remove("allow"); - context.response.headers.allow = undefined; context.status = 404; - context.body = { - errors: [ - { - status: "404", - code: "not_found", - title: "Resource not found", - }, - ], - }; + context.body = new JsonApiErrorBody({ + status: "404", + code: "not_found", + title: "Resource not found", + }); return; } context.status = 405; - context.body = { - errors: [ - { - status: "405", - code: "method_not_allowed", - title: "Method not allowed", - detail: `Allowed methods: ${context.response.headers.allow}`, - }, - ], - }; + context.body = new JsonApiErrorBody({ + status: "405", + code: "method_not_allowed", + title: "Method not allowed", + detail: `Allowed methods: ${context.response.headers.allow}`, + }); }, }); diff --git a/src/synth.ts b/src/synth.ts index 8ead645..1f66534 100644 --- a/src/synth.ts +++ b/src/synth.ts @@ -98,6 +98,7 @@ export const synthProject = async ( projectPath: string, config: ProjectConfig, stdout: NodeJS.WritableStream, + synthCdk = true, ): Promise => { const context: ProjectContext = { projectPath, @@ -149,7 +150,15 @@ export const synthProject = async ( }); await execute(context.stdout, "pnpm", ["install"], { cwd: path.join(projectPath, "cdk") }); await execute(context.stdout, "pnpm", ["run", "build"], { cwd: path.join(projectPath, "cdk") }); - await execute(context.stdout, "pnpm", ["exec", "cdk", "synth", "--app", "dist/env-uat.js"], { - cwd: path.join(projectPath, "cdk"), - }); + + if (synthCdk) { + await execute( + context.stdout, + "pnpm", + ["exec", "cdk", "synth", "--app", "dist/env-uat.js"], + { + cwd: path.join(projectPath, "cdk"), + }, + ); + } }; diff --git a/src/test-cli.ts b/src/test-cli.ts index 26cad0f..91ff263 100644 --- a/src/test-cli.ts +++ b/src/test-cli.ts @@ -37,8 +37,9 @@ await synthProject( region: "us-east-1", deployRoleArn: "arn://unknown", apiName: "koa-api-test", - features: [], + features: ["postgres", "appconfig"], uatCertificateArn: "arn://unknown", }, process.stdout, + false, );