@ackee/create-node-app 1.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/.eslintrc.cjs +10 -0
- package/AUTHORS +3 -0
- package/LICENSE.txt +22 -0
- package/README.md +39 -0
- package/bin/create-node-app.js +6 -0
- package/lib/Bootstrap.js +79 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/Logger.js +12 -0
- package/lib/Logger.js.map +1 -0
- package/lib/Npm.js +33 -0
- package/lib/Npm.js.map +1 -0
- package/lib/PackageJson.js +39 -0
- package/lib/PackageJson.js.map +1 -0
- package/lib/Starter.js +2 -0
- package/lib/Starter.js.map +1 -0
- package/lib/Toolbelt.js +102 -0
- package/lib/Toolbelt.js.map +1 -0
- package/lib/cloudrun/CloudRunStarter.js +126 -0
- package/lib/cloudrun/CloudRunStarter.js.map +1 -0
- package/lib/cloudrun-graphql/GraphQLStarter.js +118 -0
- package/lib/cloudrun-graphql/GraphQLStarter.js.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/logo.png +0 -0
- package/package.json +42 -0
- package/prettier.config.cjs +1 -0
- package/src/Bootstrap.ts +99 -0
- package/src/Logger.ts +11 -0
- package/src/Npm.ts +38 -0
- package/src/PackageJson.ts +47 -0
- package/src/Starter.ts +7 -0
- package/src/Toolbelt.ts +132 -0
- package/src/cloudrun/CloudRunStarter.ts +181 -0
- package/src/cloudrun-graphql/GraphQLStarter.ts +182 -0
- package/src/types.ts +1 -0
- package/starter/cloudrun/.env.jsonc +10 -0
- package/starter/cloudrun/.eslint.tsconfig.json +4 -0
- package/starter/cloudrun/.eslintrc.cjs +8 -0
- package/starter/cloudrun/README.md +69 -0
- package/starter/cloudrun/src/adapters/pino.logger.ts +44 -0
- package/starter/cloudrun/src/config.ts +22 -0
- package/starter/cloudrun/src/container.ts +18 -0
- package/starter/cloudrun/src/context.ts +39 -0
- package/starter/cloudrun/src/domain/errors/codes.ts +9 -0
- package/starter/cloudrun/src/domain/errors/errors.ts +25 -0
- package/starter/cloudrun/src/domain/health-check.service.ts +15 -0
- package/starter/cloudrun/src/domain/ports/logger.d.ts +21 -0
- package/starter/cloudrun/src/index.ts +17 -0
- package/starter/cloudrun/src/test/health-check.test.ts +25 -0
- package/starter/cloudrun/src/test/util/openapi-test.util.ts +71 -0
- package/starter/cloudrun/src/view/cli/README.md +17 -0
- package/starter/cloudrun/src/view/cli/cli.ts +94 -0
- package/starter/cloudrun/src/view/cli/openapi/generate.ts +64 -0
- package/starter/cloudrun/src/view/rest/controller/health-check.controller.ts +33 -0
- package/starter/cloudrun/src/view/rest/middleware/context-middleware.ts +28 -0
- package/starter/cloudrun/src/view/rest/middleware/error-handler.ts +60 -0
- package/starter/cloudrun/src/view/rest/middleware/request-logger.ts +37 -0
- package/starter/cloudrun/src/view/rest/request.d.ts +9 -0
- package/starter/cloudrun/src/view/rest/routes.ts +15 -0
- package/starter/cloudrun/src/view/rest/spec/openapi.yml +65 -0
- package/starter/cloudrun/src/view/rest/util/openapi.util.ts +310 -0
- package/starter/cloudrun/src/view/server.ts +25 -0
- package/starter/cloudrun-graphql/.env.jsonc +12 -0
- package/starter/cloudrun-graphql/.eslint.tsconfig.json +4 -0
- package/starter/cloudrun-graphql/.eslintrc.cjs +43 -0
- package/starter/cloudrun-graphql/README.md +53 -0
- package/starter/cloudrun-graphql/codegen.yml +11 -0
- package/starter/cloudrun-graphql/src/adapters/pino.logger.ts +44 -0
- package/starter/cloudrun-graphql/src/config.ts +21 -0
- package/starter/cloudrun-graphql/src/container.ts +15 -0
- package/starter/cloudrun-graphql/src/context.ts +39 -0
- package/starter/cloudrun-graphql/src/domain/errors/codes.ts +9 -0
- package/starter/cloudrun-graphql/src/domain/errors/errors.ts +25 -0
- package/starter/cloudrun-graphql/src/domain/ports/logger.d.ts +21 -0
- package/starter/cloudrun-graphql/src/index.ts +11 -0
- package/starter/cloudrun-graphql/src/test/helloWorld.test.ts +23 -0
- package/starter/cloudrun-graphql/src/view/controller.ts +42 -0
- package/starter/cloudrun-graphql/src/view/graphql/resolvers/greeting.resolver.ts +5 -0
- package/starter/cloudrun-graphql/src/view/graphql/resolvers.ts +6 -0
- package/starter/cloudrun-graphql/src/view/graphql/schema/schema.graphql +6 -0
- package/starter/cloudrun-graphql/src/view/graphql/schema.ts +7 -0
- package/starter/cloudrun-graphql/src/view/server.ts +45 -0
- package/starter/shared/.dockerignore +19 -0
- package/starter/shared/.gitignore_ +5 -0
- package/starter/shared/.gitlab-ci.yml +199 -0
- package/starter/shared/.mocha-junit-config.json +6 -0
- package/starter/shared/.mocharc.json +8 -0
- package/starter/shared/.nvmrc +1 -0
- package/starter/shared/Dockerfile +40 -0
- package/starter/shared/ci-branch-config/common.env +7 -0
- package/starter/shared/ci-branch-config/development.env +7 -0
- package/starter/shared/ci-branch-config/master.env +7 -0
- package/starter/shared/ci-branch-config/stage.env +7 -0
- package/starter/shared/docker-compose/docker-compose-entrypoint.sh +7 -0
- package/starter/shared/docker-compose/docker-compose.ci.yml +19 -0
- package/starter/shared/docker-compose/docker-compose.local.yml +5 -0
- package/starter/shared/docker-compose/docker-compose.override.yml +5 -0
- package/starter/shared/docker-compose/docker-compose.yml +8 -0
- package/starter/shared/jest.config.js +12 -0
- package/starter/shared/prettier.config.cjs +1 -0
- package/starter/shared/src/test/setup.ts +1 -0
- package/starter/shared/tsconfig.json +22 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { config } from './config.js'
|
|
2
|
+
import { createContainer } from './container.js'
|
|
3
|
+
import { createServer } from './view/server.js'
|
|
4
|
+
|
|
5
|
+
const appContainer = createContainer()
|
|
6
|
+
const { logger } = appContainer
|
|
7
|
+
|
|
8
|
+
const server = createServer(appContainer)
|
|
9
|
+
|
|
10
|
+
server.listen(config.server.port, () => {
|
|
11
|
+
logger.info(
|
|
12
|
+
{ port: config.server.port },
|
|
13
|
+
`🚀 Server is running on port ${config.server.port}`
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default server
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { describe, it, before } from 'mocha'
|
|
3
|
+
import { createServer } from '../view/server.js'
|
|
4
|
+
import { createContainer } from '../container.js'
|
|
5
|
+
import { requestTyped } from './util/openapi-test.util.js'
|
|
6
|
+
|
|
7
|
+
describe('Health Check API', () => {
|
|
8
|
+
let server: ReturnType<typeof createServer>
|
|
9
|
+
let container: ReturnType<typeof createContainer>
|
|
10
|
+
|
|
11
|
+
before(() => {
|
|
12
|
+
container = createContainer()
|
|
13
|
+
server = createServer(container)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('GET /api/v1/healthz', () => {
|
|
17
|
+
it('should return successful health check response', async () => {
|
|
18
|
+
const response = await requestTyped(server, '/api/v1/healthz', 'get')
|
|
19
|
+
.sendTyped()
|
|
20
|
+
.expect(200)
|
|
21
|
+
.responseTyped()
|
|
22
|
+
assert.equal(response.body.status, 0)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import supertest from 'supertest'
|
|
2
|
+
import {
|
|
3
|
+
OpenApiRoutePathParam,
|
|
4
|
+
OpenApiRouteQueryParam,
|
|
5
|
+
OpenApiRouteRequestBody,
|
|
6
|
+
OpenApiRouteResponseBody,
|
|
7
|
+
} from '../../view/rest/util/openapi.util.js'
|
|
8
|
+
import * as openapi from '../../view/rest/spec/openapi.js'
|
|
9
|
+
import type { Express } from 'express'
|
|
10
|
+
|
|
11
|
+
export const request = (server: Express) => supertest(server)
|
|
12
|
+
|
|
13
|
+
type KeysOfUnion<T> = T extends T ? keyof T : never
|
|
14
|
+
|
|
15
|
+
export type Response<TBody> = Omit<
|
|
16
|
+
Awaited<ReturnType<ReturnType<typeof request>['get']>>,
|
|
17
|
+
'body'
|
|
18
|
+
> & { body: TBody }
|
|
19
|
+
|
|
20
|
+
export const requestTyped = <
|
|
21
|
+
Resource extends KeysOfUnion<openapi.paths>,
|
|
22
|
+
Method extends Exclude<KeysOfUnion<openapi.paths[Resource]>, 'parameters'>,
|
|
23
|
+
Resp extends OpenApiRouteResponseBody<Openapi[Resource], Method>,
|
|
24
|
+
Openapi extends openapi.paths = openapi.paths
|
|
25
|
+
>(
|
|
26
|
+
server: Express,
|
|
27
|
+
resource: Resource,
|
|
28
|
+
method: Method,
|
|
29
|
+
routeParams?: OpenApiRoutePathParam<Openapi[Resource]>,
|
|
30
|
+
queryParams?: OpenApiRouteQueryParam<Openapi[Resource]>
|
|
31
|
+
) => {
|
|
32
|
+
const replacedResource = Object.keys(
|
|
33
|
+
(routeParams ?? {}) as Record<string, string>
|
|
34
|
+
).reduce((resource, param) => {
|
|
35
|
+
return resource.replace(
|
|
36
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
37
|
+
new RegExp(`{${param}}`, 'g'),
|
|
38
|
+
(routeParams as Record<string, string>)[param]
|
|
39
|
+
)
|
|
40
|
+
}, resource)
|
|
41
|
+
|
|
42
|
+
const url = new URL('https://ackee.cz')
|
|
43
|
+
Object.entries(queryParams ?? {}).forEach(([key, val]) => {
|
|
44
|
+
if (val !== undefined && val !== null) {
|
|
45
|
+
url.searchParams.set(key, val.toString())
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const req = request(server)[method](
|
|
50
|
+
replacedResource + '?' + url.searchParams.toString()
|
|
51
|
+
)
|
|
52
|
+
const originalSend = req.send.bind(req)
|
|
53
|
+
const enhanced = Object.assign(req, {
|
|
54
|
+
sendTyped(
|
|
55
|
+
body?: OpenApiRouteRequestBody<Openapi[Resource], Method>,
|
|
56
|
+
headers?: Record<string, string>
|
|
57
|
+
) {
|
|
58
|
+
if (headers) {
|
|
59
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
60
|
+
void req.set(key, value)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
void originalSend(body as any)
|
|
64
|
+
return enhanced
|
|
65
|
+
},
|
|
66
|
+
responseTyped: () => {
|
|
67
|
+
return req as Promise<Response<Resp>>
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
return enhanced
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# CLI
|
|
2
|
+
|
|
3
|
+
Cli tool to operate scripts that can use internal services or to add development toolkit with the same interface and usage. Currently cli tool is operated using tsx tool.
|
|
4
|
+
|
|
5
|
+
## 👷 Development
|
|
6
|
+
|
|
7
|
+
The CLI tool is build around `yargs` library. Each command needs its own `ts` file in current folder or in subfolder. The entrypoint `Cli.ts` scans the sub folders for `ts` files and intrerprets them as a command under the module called same as the folder.
|
|
8
|
+
|
|
9
|
+
For example, current [openapi folder](./openapi) creates a new module `openapi` and assigns a new command called `generate` based on contents in [generate.ts](./openapi/generate.ts) file.
|
|
10
|
+
|
|
11
|
+
Each command should export only functions and variables according to `CommandDefinition` in [cli.ts file](./cli.ts).
|
|
12
|
+
|
|
13
|
+
Take a look at the [generate.ts](./openapi/generate.ts) for example how to implement own command.
|
|
14
|
+
|
|
15
|
+
## ⚠️ Usage in production scripts
|
|
16
|
+
|
|
17
|
+
If the cli tool is used to run production scripts (CRON), make sure all dependencies are also listed in production list and build the code instead of using `tsx` (e.g. `yargs`).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import yargs, { Argv } from 'yargs'
|
|
2
|
+
import { hideBin } from 'yargs/helpers'
|
|
3
|
+
import { readdir, stat } from 'fs/promises'
|
|
4
|
+
import { join, extname } from 'path'
|
|
5
|
+
|
|
6
|
+
interface CommandDefinition {
|
|
7
|
+
// Description of the command used for help
|
|
8
|
+
description: string
|
|
9
|
+
// Defines positional description of the command (e.g. '<filepath>' will mean it takes one positional required argument)
|
|
10
|
+
positional?: string
|
|
11
|
+
// Function that will be called when the command is executed
|
|
12
|
+
run: (argv: any) => Promise<void> | void
|
|
13
|
+
// Function that will be called to add options to the command
|
|
14
|
+
options?: (yargs: Argv) => Argv
|
|
15
|
+
// Any other properties that will be added to the command
|
|
16
|
+
[key: string]: any
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const loadCommand = async (
|
|
20
|
+
commandName: string,
|
|
21
|
+
commandPath: string,
|
|
22
|
+
yargsInstance: Argv
|
|
23
|
+
) => {
|
|
24
|
+
try {
|
|
25
|
+
const modulePath = `file://${commandPath}`
|
|
26
|
+
const commandModule = (await import(modulePath)) as CommandDefinition
|
|
27
|
+
|
|
28
|
+
if (typeof commandModule.run === 'function') {
|
|
29
|
+
yargsInstance.command(
|
|
30
|
+
commandModule.positional
|
|
31
|
+
? `${commandName} ${commandModule.positional}`
|
|
32
|
+
: commandName,
|
|
33
|
+
commandModule.description ?? `${commandName} command`,
|
|
34
|
+
(yargs: Argv) => {
|
|
35
|
+
if (typeof commandModule.options === 'function') {
|
|
36
|
+
return commandModule.options(yargs)
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
async (argv: any) => {
|
|
40
|
+
try {
|
|
41
|
+
await commandModule.run(argv)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`Error running ${commandName}:`, error)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
} else {
|
|
49
|
+
console.warn(`Warning: ${commandPath} does not export a 'run' function`)
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`Error loading command from ${commandPath}:`, error)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const loadCommandsFromDirectory = async (
|
|
57
|
+
dirPath: string,
|
|
58
|
+
yargsInstance: Argv
|
|
59
|
+
): Promise<void> => {
|
|
60
|
+
const items = await readdir(dirPath)
|
|
61
|
+
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const fullPath = join(dirPath, item)
|
|
64
|
+
const stats = await stat(fullPath)
|
|
65
|
+
|
|
66
|
+
if (stats.isDirectory()) {
|
|
67
|
+
yargsInstance.command(item, `${item} module`, (yargs: Argv) => {
|
|
68
|
+
yargs.demandCommand(1, 'You need to specify a command.')
|
|
69
|
+
yargs.help()
|
|
70
|
+
return loadCommandsFromDirectory(fullPath, yargs)
|
|
71
|
+
})
|
|
72
|
+
} else if (stats.isFile() && extname(item) === '.ts' && item !== 'cli.ts') {
|
|
73
|
+
const commandName = item.replace('.ts', '')
|
|
74
|
+
await loadCommand(commandName, fullPath, yargsInstance)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const cli = async () => {
|
|
80
|
+
const yargsInstance = yargs(hideBin(process.argv))
|
|
81
|
+
.scriptName('cli')
|
|
82
|
+
.usage('Usage: $0 <command> [options]')
|
|
83
|
+
.demandCommand(1, 'You need to specify a command or module.')
|
|
84
|
+
.help()
|
|
85
|
+
.version()
|
|
86
|
+
|
|
87
|
+
await loadCommandsFromDirectory(import.meta.dirname, yargsInstance)
|
|
88
|
+
|
|
89
|
+
await yargsInstance.parse()
|
|
90
|
+
|
|
91
|
+
process.exit(0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
void cli()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import yaml from 'yaml'
|
|
5
|
+
|
|
6
|
+
export const description = 'Generate OpenAPI types and validate the spec'
|
|
7
|
+
export const positional = '<filepath>'
|
|
8
|
+
|
|
9
|
+
export const run = async (argv: any): Promise<void> => {
|
|
10
|
+
console.log('Generating OpenAPI types and validate the spec...')
|
|
11
|
+
const yamlFilePath = argv.filepath
|
|
12
|
+
const tsFilePath = path.join(
|
|
13
|
+
path.dirname(argv.filepath),
|
|
14
|
+
`${path.basename(argv.filepath, path.extname(argv.filepath))}.ts`
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
await new Promise((resolve, reject) => {
|
|
18
|
+
const process = spawn(
|
|
19
|
+
'npx',
|
|
20
|
+
['openapi-typescript', yamlFilePath, '--output', tsFilePath],
|
|
21
|
+
{ stdio: 'inherit' }
|
|
22
|
+
)
|
|
23
|
+
process.on('exit', resolve)
|
|
24
|
+
process.on('error', reject)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
console.log('Generating path mappings...')
|
|
28
|
+
|
|
29
|
+
const types = readFileSync(tsFilePath)
|
|
30
|
+
const spec = yaml.parse(readFileSync(yamlFilePath).toString())
|
|
31
|
+
|
|
32
|
+
const pathsWithOperationIds = Object.keys(spec.paths).reduce((acc, path) => {
|
|
33
|
+
const pathSpec = spec.paths[path]
|
|
34
|
+
Object.keys(pathSpec).forEach(method => {
|
|
35
|
+
if (pathSpec[method]?.operationId) {
|
|
36
|
+
acc[pathSpec[method].operationId] = {
|
|
37
|
+
method,
|
|
38
|
+
path,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return acc
|
|
43
|
+
}, {} as any)
|
|
44
|
+
|
|
45
|
+
const finalSpec =
|
|
46
|
+
`/* eslint-disable sonarjs/no-duplicate-string */\n` +
|
|
47
|
+
`/* eslint-disable sonarjs/use-type-alias */\n` +
|
|
48
|
+
types.toString('utf8').replaceAll('requestBody?:', 'requestBody:') +
|
|
49
|
+
`export const operationPaths = ${JSON.stringify(
|
|
50
|
+
pathsWithOperationIds
|
|
51
|
+
)} as const\n`
|
|
52
|
+
|
|
53
|
+
writeFileSync(tsFilePath, finalSpec)
|
|
54
|
+
|
|
55
|
+
console.log('Done 🎉')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const options = (yargs: any) => {
|
|
59
|
+
return yargs.positional('filepath', {
|
|
60
|
+
required: true,
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Input OpenAPI specification file',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Result } from 'node-healthz'
|
|
2
|
+
import { components } from '../spec/openapi.js'
|
|
3
|
+
import { ctrl } from '../util/openapi.util.js'
|
|
4
|
+
|
|
5
|
+
export enum Status {
|
|
6
|
+
Healthy,
|
|
7
|
+
Unhealthy,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const mapHealthCheckToApi = (
|
|
11
|
+
healthCheck: Result
|
|
12
|
+
): components['schemas']['HealthCheckResponse'] => {
|
|
13
|
+
return {
|
|
14
|
+
status: healthCheck.status,
|
|
15
|
+
checks: healthCheck.checks.map(check => ({
|
|
16
|
+
id: check.id,
|
|
17
|
+
status: check.status,
|
|
18
|
+
output: String(check.output),
|
|
19
|
+
required: check.required,
|
|
20
|
+
latency: check.latency,
|
|
21
|
+
latencyStatus: check.latencyStatus,
|
|
22
|
+
})),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const healthCheckController = ctrl.createRestController({
|
|
27
|
+
healthz: async ctx => {
|
|
28
|
+
const { healthCheckService } = ctx.container
|
|
29
|
+
|
|
30
|
+
const healthCheck = await healthCheckService.check()
|
|
31
|
+
return mapHealthCheckToApi(healthCheck)
|
|
32
|
+
},
|
|
33
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextFunction, Request, Response } from 'express'
|
|
2
|
+
import { RequestContextFactory } from '../../../context.js'
|
|
3
|
+
import { Container } from '../../../container.js'
|
|
4
|
+
|
|
5
|
+
const createContextFromHttpRequestFactory =
|
|
6
|
+
(): RequestContextFactory<[Request, Response]> =>
|
|
7
|
+
async (container, _req, _res) => {
|
|
8
|
+
return {
|
|
9
|
+
type: 'api-user',
|
|
10
|
+
container,
|
|
11
|
+
user: null, // Implement Authentication if needed (Auth header, cookie, ...)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const createContextMiddleware =
|
|
16
|
+
(container: Container) =>
|
|
17
|
+
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
18
|
+
try {
|
|
19
|
+
req.context = await createContextFromHttpRequestFactory()(
|
|
20
|
+
container,
|
|
21
|
+
req,
|
|
22
|
+
res
|
|
23
|
+
)
|
|
24
|
+
next()
|
|
25
|
+
} catch (err) {
|
|
26
|
+
next(err)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ErrorCode } from '../../../domain/errors/codes.js'
|
|
2
|
+
import { DomainError } from '../../../domain/errors/errors.js'
|
|
3
|
+
import express from 'express'
|
|
4
|
+
|
|
5
|
+
export interface ErrorHandlerConfig {
|
|
6
|
+
enableProductionHttpErrorResponses: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ERROR_DOMAIN_CODE_TO_HTTP_STATUS: Record<ErrorCode, number> = {
|
|
10
|
+
[ErrorCode.UNKNOWN]: 500,
|
|
11
|
+
[ErrorCode.VALIDATION]: 422,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const errorToProductionObject = <T extends Error>(error: T) => {
|
|
15
|
+
if (error instanceof DomainError) {
|
|
16
|
+
return {
|
|
17
|
+
...error.data,
|
|
18
|
+
code: error.code,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const errorToDevObject = <T extends Error>(error: T) => {
|
|
25
|
+
if (error instanceof DomainError) {
|
|
26
|
+
return {
|
|
27
|
+
...error.data,
|
|
28
|
+
code: error.code,
|
|
29
|
+
message: error.message,
|
|
30
|
+
stack: error.stack,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
message: error.message,
|
|
35
|
+
stack: error.stack,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createErrorHandler(
|
|
40
|
+
config: ErrorHandlerConfig
|
|
41
|
+
): express.ErrorRequestHandler {
|
|
42
|
+
return (error, _req, res, _next) => {
|
|
43
|
+
const statusCode =
|
|
44
|
+
error instanceof DomainError
|
|
45
|
+
? ERROR_DOMAIN_CODE_TO_HTTP_STATUS[error.code] ?? 500
|
|
46
|
+
: 500
|
|
47
|
+
|
|
48
|
+
;(res as any).error = errorToDevObject(error)
|
|
49
|
+
|
|
50
|
+
const mappedError = config.enableProductionHttpErrorResponses
|
|
51
|
+
? errorToProductionObject(error)
|
|
52
|
+
: errorToDevObject(error)
|
|
53
|
+
|
|
54
|
+
res.status(statusCode)
|
|
55
|
+
if (mappedError) {
|
|
56
|
+
;(res as any).out = mappedError
|
|
57
|
+
res.json(mappedError)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { pinoHttp, Options } from 'pino-http'
|
|
2
|
+
import { LoggerPort } from '../../../domain/ports/logger.d.js'
|
|
3
|
+
|
|
4
|
+
export function createRequestLogger(innerLogger: LoggerPort) {
|
|
5
|
+
const options: Options = {
|
|
6
|
+
logger: innerLogger as any,
|
|
7
|
+
serializers: {
|
|
8
|
+
res: res => {
|
|
9
|
+
res.error = res.raw.error
|
|
10
|
+
res.out = res.raw.out
|
|
11
|
+
return res
|
|
12
|
+
},
|
|
13
|
+
req: req => {
|
|
14
|
+
req.body = req.raw.body
|
|
15
|
+
return req
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
customReceivedMessage: (req, _res) =>
|
|
19
|
+
`--- ${String(req.method)} ${String(req.url)} - Request accepted`,
|
|
20
|
+
customSuccessMessage: (req, res) =>
|
|
21
|
+
`${res.statusCode} ${String(req.method)} ${String(
|
|
22
|
+
req.url
|
|
23
|
+
)} - Standard output`,
|
|
24
|
+
customErrorMessage: (req, res, _error) =>
|
|
25
|
+
`${res.statusCode} ${String(req.method)} ${String(req.url)} - ${
|
|
26
|
+
res.statusMessage
|
|
27
|
+
}`,
|
|
28
|
+
customErrorObject: (_req, res, _error, val) => ({
|
|
29
|
+
...val,
|
|
30
|
+
error: (res as any).error,
|
|
31
|
+
}),
|
|
32
|
+
customAttributeKeys: {
|
|
33
|
+
err: 'error',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
return pinoHttp(options)
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import { openApiRouter, registerOpenApiRoutes } from './util/openapi.util.js'
|
|
3
|
+
import { healthCheckController } from './controller/health-check.controller.js'
|
|
4
|
+
|
|
5
|
+
const apiRouter = openApiRouter(express.Router(), {
|
|
6
|
+
removePrefix: '/api/v1',
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
registerOpenApiRoutes(apiRouter, {
|
|
10
|
+
...healthCheckController,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const routes = {
|
|
14
|
+
api: apiRouter.express,
|
|
15
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
openapi: '3.0.3'
|
|
2
|
+
info:
|
|
3
|
+
title: Node App API
|
|
4
|
+
version: '0.0.1'
|
|
5
|
+
|
|
6
|
+
paths:
|
|
7
|
+
/api/v1/healthz:
|
|
8
|
+
get:
|
|
9
|
+
operationId: healthz
|
|
10
|
+
summary: Health check
|
|
11
|
+
tags:
|
|
12
|
+
- healthz
|
|
13
|
+
responses:
|
|
14
|
+
'200':
|
|
15
|
+
description: Health check response
|
|
16
|
+
content:
|
|
17
|
+
application/json:
|
|
18
|
+
schema:
|
|
19
|
+
$ref: '#/components/schemas/HealthCheckResponse'
|
|
20
|
+
|
|
21
|
+
components:
|
|
22
|
+
schemas:
|
|
23
|
+
HealthCheckResponse:
|
|
24
|
+
type: object
|
|
25
|
+
properties:
|
|
26
|
+
checks:
|
|
27
|
+
type: array
|
|
28
|
+
description: List of component health checks
|
|
29
|
+
items:
|
|
30
|
+
$ref: '#/components/schemas/HealthCheck'
|
|
31
|
+
status:
|
|
32
|
+
type: integer
|
|
33
|
+
description: Overall status (0=healthy, 1=error)
|
|
34
|
+
enum: [0, 1]
|
|
35
|
+
required:
|
|
36
|
+
- checks
|
|
37
|
+
- status
|
|
38
|
+
HealthCheck:
|
|
39
|
+
type: object
|
|
40
|
+
properties:
|
|
41
|
+
id:
|
|
42
|
+
type: string
|
|
43
|
+
description: Unique identifier of the checked component
|
|
44
|
+
example: 'postgres'
|
|
45
|
+
latency:
|
|
46
|
+
type: integer
|
|
47
|
+
description: Response time in milliseconds
|
|
48
|
+
example: 1
|
|
49
|
+
latencyStatus:
|
|
50
|
+
type: integer
|
|
51
|
+
description: Status of the latency check (0=good, 1=error, 2=timeout)
|
|
52
|
+
enum: [0, 1, 2]
|
|
53
|
+
example: 0
|
|
54
|
+
output:
|
|
55
|
+
type: string
|
|
56
|
+
description: Additional output information
|
|
57
|
+
example: '<masked>'
|
|
58
|
+
required:
|
|
59
|
+
type: boolean
|
|
60
|
+
description: Whether this component is required for the system to function
|
|
61
|
+
example: true
|
|
62
|
+
status:
|
|
63
|
+
type: integer
|
|
64
|
+
description: Status of the component (0=Low, 1=Medium, 2=High)
|
|
65
|
+
enum: [0, 1, 2]
|