@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,310 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import bodyParser from 'body-parser'
|
|
3
|
+
import { operations, paths, operationPaths } from '../spec/openapi.js'
|
|
4
|
+
import { RequestContext } from '../../../context.js'
|
|
5
|
+
|
|
6
|
+
export type OpenApiRouteResponseBodyMethod<
|
|
7
|
+
T,
|
|
8
|
+
TMethod extends string = 'get',
|
|
9
|
+
TDefault = unknown
|
|
10
|
+
> = T extends {
|
|
11
|
+
[key in TMethod]: {
|
|
12
|
+
responses: { 200: { content: { 'application/json': infer U } } }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
? U
|
|
16
|
+
: TDefault
|
|
17
|
+
|
|
18
|
+
export type OpenApiRouteResponseBody<
|
|
19
|
+
T,
|
|
20
|
+
TMethod extends string = 'get'
|
|
21
|
+
> = OpenApiRouteResponseBodyMethod<
|
|
22
|
+
T,
|
|
23
|
+
TMethod,
|
|
24
|
+
OpenApiRouteResponseBodyMethod<
|
|
25
|
+
T,
|
|
26
|
+
'post',
|
|
27
|
+
OpenApiRouteResponseBodyMethod<
|
|
28
|
+
T,
|
|
29
|
+
'put',
|
|
30
|
+
OpenApiRouteResponseBodyMethod<T, 'delete'>
|
|
31
|
+
>
|
|
32
|
+
>
|
|
33
|
+
>
|
|
34
|
+
|
|
35
|
+
type LowercaseKeys<T extends Record<keyof any, any>> = {
|
|
36
|
+
[key in keyof T as key extends string ? Lowercase<key> : key]: T[key]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type OpenApiRoutePathParam<T> = T extends {
|
|
40
|
+
get: { parameters: { path: infer U } }
|
|
41
|
+
}
|
|
42
|
+
? U
|
|
43
|
+
: T extends { post: { parameters: { path: infer U } } }
|
|
44
|
+
? U
|
|
45
|
+
: T extends { put: { parameters: { path: infer U } } }
|
|
46
|
+
? U
|
|
47
|
+
: unknown
|
|
48
|
+
|
|
49
|
+
export type OpenApiRouteQueryParam<T> = T extends {
|
|
50
|
+
get: { parameters: { query: infer U } }
|
|
51
|
+
}
|
|
52
|
+
? U
|
|
53
|
+
: T extends { post: { parameters: { query: infer U } } }
|
|
54
|
+
? U
|
|
55
|
+
: unknown
|
|
56
|
+
|
|
57
|
+
export type OpenApiRouteHeaderParam<T> = T extends {
|
|
58
|
+
get: {
|
|
59
|
+
parameters: {
|
|
60
|
+
header: infer U extends Record<string | number | symbol, any>
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
? LowercaseKeys<U>
|
|
65
|
+
: T extends {
|
|
66
|
+
post: {
|
|
67
|
+
parameters: {
|
|
68
|
+
header: infer U extends Record<string | number | symbol, any>
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
? LowercaseKeys<U>
|
|
73
|
+
: unknown
|
|
74
|
+
|
|
75
|
+
export type OpenApiRouteParam<T> = OpenApiRoutePathParam<T> &
|
|
76
|
+
OpenApiRouteQueryParam<T> &
|
|
77
|
+
OpenApiRouteHeaderParam<T>
|
|
78
|
+
|
|
79
|
+
export type OpenApiRouteRequestBodyMethod<
|
|
80
|
+
T,
|
|
81
|
+
TMethod extends string = 'post',
|
|
82
|
+
TDefault = unknown
|
|
83
|
+
> = T extends {
|
|
84
|
+
[key in TMethod]: {
|
|
85
|
+
requestBody: {
|
|
86
|
+
content:
|
|
87
|
+
| { 'application/json': infer U }
|
|
88
|
+
| { 'multipart/form-data': infer U }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
? U
|
|
93
|
+
: TDefault
|
|
94
|
+
|
|
95
|
+
export type OpenApiRouteRequestBody<
|
|
96
|
+
T,
|
|
97
|
+
TMethod extends string = 'post'
|
|
98
|
+
> = OpenApiRouteRequestBodyMethod<
|
|
99
|
+
T,
|
|
100
|
+
TMethod,
|
|
101
|
+
OpenApiRouteRequestBodyMethod<
|
|
102
|
+
T,
|
|
103
|
+
'put',
|
|
104
|
+
OpenApiRouteRequestBodyMethod<
|
|
105
|
+
T,
|
|
106
|
+
'delete',
|
|
107
|
+
OpenApiRouteRequestBodyMethod<T, 'patch'>
|
|
108
|
+
>
|
|
109
|
+
>
|
|
110
|
+
>
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* pipeMiddleware takes multiple middlewares and creates and merges them into
|
|
114
|
+
* one using express Router.
|
|
115
|
+
*/
|
|
116
|
+
export const pipeMiddleware = (...middlewares: express.RequestHandler[]) => {
|
|
117
|
+
const router = express.Router({ mergeParams: true })
|
|
118
|
+
middlewares.forEach(m => router.use(m))
|
|
119
|
+
return router
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ApiHandler<TRes> = (
|
|
123
|
+
ctx: Readonly<RequestContext>,
|
|
124
|
+
req: express.Request,
|
|
125
|
+
res: express.Response
|
|
126
|
+
) => TRes | Promise<TRes>
|
|
127
|
+
|
|
128
|
+
const asyncHandler =
|
|
129
|
+
<TRes>(fn: ApiHandler<TRes>): express.Handler =>
|
|
130
|
+
async (req, res, next) => {
|
|
131
|
+
try {
|
|
132
|
+
const result = await fn(req.context, req, res)
|
|
133
|
+
res.json(result)
|
|
134
|
+
} catch (error: unknown) {
|
|
135
|
+
next(error)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type OperationIds = keyof operations
|
|
140
|
+
|
|
141
|
+
type ApiMimeTypes = string
|
|
142
|
+
|
|
143
|
+
type Content = {
|
|
144
|
+
content: any
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type MimeContent<MimeType extends string> = {
|
|
148
|
+
content: { [key in MimeType]?: any }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type MimeContentValue<
|
|
152
|
+
MimeType extends ApiMimeTypes,
|
|
153
|
+
T extends MimeContent<MimeType>
|
|
154
|
+
> = {
|
|
155
|
+
[K in keyof T['content']]: T['content'][K]
|
|
156
|
+
}[keyof T['content']]
|
|
157
|
+
|
|
158
|
+
type OpenApiContentTypes<OpenApiContent extends Record<number, any>> = {
|
|
159
|
+
[K in keyof OpenApiContent]: OpenApiContent[K] extends Content
|
|
160
|
+
? MimeContentValue<ApiMimeTypes, OpenApiContent[K]>
|
|
161
|
+
: never
|
|
162
|
+
}[keyof OpenApiContent]
|
|
163
|
+
|
|
164
|
+
export type OperationParams<OperationId extends OperationIds> =
|
|
165
|
+
operations[OperationId]['parameters']['path']
|
|
166
|
+
|
|
167
|
+
export type OperationQuery<OperationId extends OperationIds> =
|
|
168
|
+
operations[OperationId]['parameters']['query']
|
|
169
|
+
|
|
170
|
+
export type OperationResponse<OperationId extends OperationIds> =
|
|
171
|
+
OpenApiContentTypes<operations[OperationId]['responses']>
|
|
172
|
+
|
|
173
|
+
export type OperationBody<OperationId extends OperationIds> =
|
|
174
|
+
operations[OperationId] extends never
|
|
175
|
+
? never
|
|
176
|
+
: MimeContentValue<ApiMimeTypes, operations[OperationId]['requestBody']>
|
|
177
|
+
|
|
178
|
+
export type OpenApiHandler<OperationId extends OperationIds> =
|
|
179
|
+
express.RequestHandler<
|
|
180
|
+
OperationParams<OperationId>,
|
|
181
|
+
OperationResponse<OperationId>,
|
|
182
|
+
OperationBody<OperationId>,
|
|
183
|
+
OperationQuery<OperationId>
|
|
184
|
+
>
|
|
185
|
+
|
|
186
|
+
export type OperationHandler<OperationId extends keyof operations> = (
|
|
187
|
+
ctx: RequestContext,
|
|
188
|
+
req: Parameters<OpenApiHandler<OperationId>>[0],
|
|
189
|
+
res: Parameters<OpenApiHandler<OperationId>>[1]
|
|
190
|
+
) => Promise<OperationResponse<OperationId>>
|
|
191
|
+
|
|
192
|
+
type RouteHandlers<SubsetOperationIds extends OperationIds> = {
|
|
193
|
+
[Key in SubsetOperationIds]: OperationHandler<Key>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type RestApiController<
|
|
197
|
+
SubsetOperationIds extends OperationIds = OperationIds
|
|
198
|
+
> = {
|
|
199
|
+
[Key in SubsetOperationIds]: OpenApiHandler<Key>
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const handleOperationAsync = <OperationId extends OperationIds>(
|
|
203
|
+
fn: OperationHandler<OperationId>
|
|
204
|
+
): OpenApiHandler<OperationId> => asyncHandler(fn as any) as any
|
|
205
|
+
|
|
206
|
+
const createRestController = <SubsetOperationIds extends OperationIds>(
|
|
207
|
+
def: RouteHandlers<SubsetOperationIds>
|
|
208
|
+
): RestApiController<SubsetOperationIds> => {
|
|
209
|
+
return Object.entries(def).reduce((ctrl, [operationId, handler]) => {
|
|
210
|
+
if (!handler) {
|
|
211
|
+
return ctrl
|
|
212
|
+
}
|
|
213
|
+
ctrl[operationId as SubsetOperationIds] = handleOperationAsync(
|
|
214
|
+
handler as any
|
|
215
|
+
) as any
|
|
216
|
+
return ctrl
|
|
217
|
+
}, {} as RestApiController<SubsetOperationIds>)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const openApiRouter = (
|
|
221
|
+
router: express.Router,
|
|
222
|
+
{ removePrefix }: { removePrefix: string } = { removePrefix: '' }
|
|
223
|
+
) => ({
|
|
224
|
+
express: router,
|
|
225
|
+
route: <
|
|
226
|
+
Method extends keyof paths[Path],
|
|
227
|
+
Path extends keyof paths,
|
|
228
|
+
OperationId extends OperationIds
|
|
229
|
+
>(
|
|
230
|
+
method: Method,
|
|
231
|
+
path: Path,
|
|
232
|
+
handler: OpenApiHandler<OperationId>
|
|
233
|
+
) => {
|
|
234
|
+
const route = path
|
|
235
|
+
.toString()
|
|
236
|
+
.replaceAll('}', '')
|
|
237
|
+
.replaceAll('{', ':')
|
|
238
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
239
|
+
.replace(new RegExp(`^${removePrefix}`), '')
|
|
240
|
+
|
|
241
|
+
switch (method) {
|
|
242
|
+
case 'get':
|
|
243
|
+
router.get(route, handler)
|
|
244
|
+
break
|
|
245
|
+
case 'post':
|
|
246
|
+
router.post(route, handler)
|
|
247
|
+
break
|
|
248
|
+
case 'patch':
|
|
249
|
+
router.patch(route, handler)
|
|
250
|
+
break
|
|
251
|
+
case 'delete':
|
|
252
|
+
router.delete(route, handler)
|
|
253
|
+
break
|
|
254
|
+
case 'head':
|
|
255
|
+
router.head(route, handler)
|
|
256
|
+
break
|
|
257
|
+
case 'trace':
|
|
258
|
+
router.trace(route, handler)
|
|
259
|
+
break
|
|
260
|
+
case 'options':
|
|
261
|
+
router.options(route, handler)
|
|
262
|
+
break
|
|
263
|
+
default:
|
|
264
|
+
throw new Error(
|
|
265
|
+
`The OpenApi router received invalid HTTP method to be registered: ${method.toString()}`
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
export const registerOpenApiRoutes = <SomeOperationIds extends OperationIds>(
|
|
272
|
+
router: ReturnType<typeof openApiRouter>,
|
|
273
|
+
controller: Partial<RestApiController<SomeOperationIds>>
|
|
274
|
+
) => {
|
|
275
|
+
const operations = Object.keys(controller) as SomeOperationIds[]
|
|
276
|
+
operations.forEach(operation => {
|
|
277
|
+
if (!controller[operation]) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
router.route(
|
|
282
|
+
operationPaths[operation].method,
|
|
283
|
+
operationPaths[operation].path,
|
|
284
|
+
controller[operation]
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* ctrl is a scoped object for controller functions
|
|
291
|
+
*/
|
|
292
|
+
export const ctrl = {
|
|
293
|
+
json: pipeMiddleware(
|
|
294
|
+
bodyParser.json(),
|
|
295
|
+
// Monkeypatch res.json to assign the body to res.out first in order
|
|
296
|
+
// to log it by pino
|
|
297
|
+
(_req, res: any, next) => {
|
|
298
|
+
const resJson = res.json.bind(res)
|
|
299
|
+
res.json = (body?: any) => {
|
|
300
|
+
res.out = body
|
|
301
|
+
return resJson(body)
|
|
302
|
+
}
|
|
303
|
+
next()
|
|
304
|
+
}
|
|
305
|
+
),
|
|
306
|
+
asyncHandler,
|
|
307
|
+
createRestController,
|
|
308
|
+
openApiRouter,
|
|
309
|
+
registerOpenApiRoutes,
|
|
310
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import { createRequestLogger } from './rest/middleware/request-logger.js'
|
|
3
|
+
import { Container } from '../container.js'
|
|
4
|
+
import { config } from '../config.js'
|
|
5
|
+
import { createErrorHandler } from './rest/middleware/error-handler.js'
|
|
6
|
+
import { routes } from './rest/routes.js'
|
|
7
|
+
import { createContextMiddleware } from './rest/middleware/context-middleware.js'
|
|
8
|
+
|
|
9
|
+
export const createServer = (appContainer: Container) => {
|
|
10
|
+
const { logger } = appContainer
|
|
11
|
+
|
|
12
|
+
const server = express()
|
|
13
|
+
const errorHandler = createErrorHandler(config.server)
|
|
14
|
+
const requestLogger = createRequestLogger(logger)
|
|
15
|
+
const contextMiddleware = createContextMiddleware(appContainer)
|
|
16
|
+
|
|
17
|
+
server.disable('x-powered-by')
|
|
18
|
+
|
|
19
|
+
server.use(requestLogger)
|
|
20
|
+
server.use(contextMiddleware)
|
|
21
|
+
server.use('/api/v1/', routes.api)
|
|
22
|
+
server.use(errorHandler)
|
|
23
|
+
|
|
24
|
+
return server
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Server will run on this port
|
|
3
|
+
"SERVER_PORT": 3000,
|
|
4
|
+
// Logging level, see https://github.com/pinojs/pino/blob/master/docs/api.md#logger-level
|
|
5
|
+
"LOGGER_DEFAULT_LEVEL": "debug",
|
|
6
|
+
// Enable/disable logging object multiline formatted logging https://github.com/pinojs/pino/blob/master/docs/api.md#prettyprint-boolean--object
|
|
7
|
+
"LOGGER_PRETTY": false,
|
|
8
|
+
// Response development errors for debugging
|
|
9
|
+
"SERVER_ALLOW_RESPONSE_ERRORS": false,
|
|
10
|
+
// Enable GraphQL introspection
|
|
11
|
+
"SERVER_ENABLE_INTROSPECTION": true
|
|
12
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { omit } = require('lodash')
|
|
2
|
+
const defaultConfig = {
|
|
3
|
+
...require('@ackee/styleguide-backend-config/eslint'),
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
root: true,
|
|
8
|
+
ignorePatterns: ['dist', 'docs', 'src/generated'],
|
|
9
|
+
overrides: [
|
|
10
|
+
{
|
|
11
|
+
...omit(defaultConfig, ['ignorePatterns']),
|
|
12
|
+
files: ['*.ts', '*.js'],
|
|
13
|
+
parserOptions: {
|
|
14
|
+
project: '.eslint.tsconfig.json',
|
|
15
|
+
},
|
|
16
|
+
rules: {
|
|
17
|
+
...defaultConfig['rules'],
|
|
18
|
+
'@typescript-eslint/strict-boolean-expressions': 0,
|
|
19
|
+
'@typescript-eslint/no-misused-promises': 'warn',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
files: ['**/*.graphql'],
|
|
24
|
+
extends: 'plugin:@graphql-eslint/schema-recommended',
|
|
25
|
+
parserOptions: {
|
|
26
|
+
graphQLConfig: {
|
|
27
|
+
schema: './src/view/graphql/schema/*.graphql',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
rules: {
|
|
31
|
+
'@graphql-eslint/strict-id-in-types': 0,
|
|
32
|
+
'@graphql-eslint/require-description': [
|
|
33
|
+
'warn',
|
|
34
|
+
{
|
|
35
|
+
types: true,
|
|
36
|
+
DirectiveDefinition: true,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
'@graphql-eslint/no-unreachable-types': 'warn',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# GraphQL Cloudrun Starter
|
|
2
|
+
|
|
3
|
+
Node.js project scaffolded with Cloudrun and GraphQL
|
|
4
|
+
|
|
5
|
+
## 🎉 Initialize project
|
|
6
|
+
|
|
7
|
+
Run `create-node-app cloudrun-graphql` to init your project. By default project is created in `../node-app` folder.
|
|
8
|
+
|
|
9
|
+
You can pass `destination` argument into the command as well.
|
|
10
|
+
Example:
|
|
11
|
+
|
|
12
|
+
- `create-node-app cloudrun-graphql /Users/foo/Documents/bar `
|
|
13
|
+
|
|
14
|
+
## 👷 Continuous Integration
|
|
15
|
+
|
|
16
|
+
### Environment variables
|
|
17
|
+
|
|
18
|
+
Make sure you replace all the variable values containing `REPLACEME` in `ci-branch-config` files.
|
|
19
|
+
|
|
20
|
+
The following variables must be set for each branch in `ci-branch-config` directory.
|
|
21
|
+
|
|
22
|
+
- `GCP_PROJECT_ID` - GCP project identifier
|
|
23
|
+
- `ENVIRONMENT` - e.g. `development`
|
|
24
|
+
|
|
25
|
+
Optional variables:
|
|
26
|
+
|
|
27
|
+
- `ALLOCATED_MEMORY` - Memory allocated at Cloudrun, default is 384Mi
|
|
28
|
+
- `CLOUD_RUN_SERVICE_ACCOUNT` - CloudRun service account
|
|
29
|
+
- `ENV_SECRETS` - Secret variables of the deployment, format is: `KEY=[NAME OF SECRET IN SECRET MANAGER:VERSION],...`
|
|
30
|
+
- `ENV_VARS` - Environment variables of the deployment, format is: `KEY=VALUE,...`
|
|
31
|
+
- `GCP_SA_KEY` - We use different service accounts for different environments. So we have to overwrite `GCP_SA_KEY` variable e.g.`GCP_SA_KEY=$SECRET_GCP_SA_KEY_DEVELOPMENT` Variable `SECRET_GCP_SA_KEY_<environment>` should be set in `GitLab CI secret variables`
|
|
32
|
+
- `GCP_SECRET_NAME` - Name of secret in Google Secret Manager
|
|
33
|
+
- `MAX_INSTANCES` - Maximum instance count in Cloudrun, default is 8
|
|
34
|
+
- `MIN_INSTANCES` - Minimum instance count in Cloudrun, default is 0
|
|
35
|
+
- `SECRET_PATH` - CloudRun volume where secrets will be injected e.g. `/config/secrets.json` can't be the same path ass app work dir e.g. `/usr/src/app` deploy will fail cause secret protection. But have to be identical with ENV `CFG_JSON_PATH` in Dockerfile
|
|
36
|
+
- `SKIP_AUDIT` - Skip NPM Audit, defaults to `false`
|
|
37
|
+
- `SKIP_LINT` - Skip lint, defaults to `false`
|
|
38
|
+
- `SKIP_TESTS` - Skip tests, defaults to `false`
|
|
39
|
+
- `SQL_INSTANCE_NAME` - SQL instance name
|
|
40
|
+
- `VPC_CONNECTOR_NAME` - serverless connector name, has to be in the same region
|
|
41
|
+
- `DOCKER_REGISTRY_TYPE` - whenever to push Docker image into Container Registry or Artifacts Registry, default is
|
|
42
|
+
`container`
|
|
43
|
+
- `DOCKER_REGISTRY_URL` - hostname of Docker Registry, defaults to `eu.gcr.io`
|
|
44
|
+
- `ANY_ADDITIONAL_CLOUDRUN_ARGS` - any argument required by Cloud Run, eg `--concurrency=1000 --clear-labels ...`
|
|
45
|
+
|
|
46
|
+
Common variables:
|
|
47
|
+
|
|
48
|
+
- `GCP_TEST_SECRET_PROJECT_ID` - GCP project identifier for test secrets
|
|
49
|
+
- `GCP_SECRET_TEST_NAME` - Name of test secret in Google Secret Manager
|
|
50
|
+
|
|
51
|
+
## 📄 Additional Resources
|
|
52
|
+
|
|
53
|
+
- [Google Cloudrun docs](https://cloud.google.com/run/docs)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { pino as pinoLogger } from 'pino'
|
|
2
|
+
import { LoggerFactoryPort } from '../domain/ports/logger.d.js'
|
|
3
|
+
|
|
4
|
+
// https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity
|
|
5
|
+
const PinoLevelToSeverityLookup: Record<string, string> = {
|
|
6
|
+
trace: 'DEBUG',
|
|
7
|
+
debug: 'DEBUG',
|
|
8
|
+
info: 'INFO',
|
|
9
|
+
warn: 'WARNING',
|
|
10
|
+
error: 'ERROR',
|
|
11
|
+
fatal: 'CRITICAL',
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
const defaultPinoConf = (defaultLevel: string) => ({
|
|
15
|
+
messageKey: 'message',
|
|
16
|
+
formatters: {
|
|
17
|
+
messageKey: 'message',
|
|
18
|
+
level: (label: string, num: number) => {
|
|
19
|
+
return {
|
|
20
|
+
severity:
|
|
21
|
+
PinoLevelToSeverityLookup[label] ??
|
|
22
|
+
PinoLevelToSeverityLookup[defaultLevel],
|
|
23
|
+
level: num,
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const pinoLoggerFactory: LoggerFactoryPort = {
|
|
30
|
+
create: config => {
|
|
31
|
+
return pinoLogger({
|
|
32
|
+
...defaultPinoConf(config.defaultLevel),
|
|
33
|
+
transport: config.enablePrettyPrint
|
|
34
|
+
? {
|
|
35
|
+
target: 'pino-pretty',
|
|
36
|
+
options: {
|
|
37
|
+
colorize: true,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
: undefined,
|
|
41
|
+
level: config.defaultLevel,
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createLoader, maskedValues, values } from 'configuru'
|
|
2
|
+
import { Level } from 'pino'
|
|
3
|
+
|
|
4
|
+
const loader = createLoader({
|
|
5
|
+
defaultConfigPath: '.env.jsonc',
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
const configSchema = {
|
|
9
|
+
server: {
|
|
10
|
+
port: loader.number('SERVER_PORT'),
|
|
11
|
+
allowResponseErrors: loader.bool('SERVER_ALLOW_RESPONSE_ERRORS'),
|
|
12
|
+
enableIntrospection: loader.bool('SERVER_ENABLE_INTROSPECTION'),
|
|
13
|
+
},
|
|
14
|
+
logger: {
|
|
15
|
+
defaultLevel: loader.custom(x => x as any as Level)('LOGGER_DEFAULT_LEVEL'),
|
|
16
|
+
pretty: loader.bool('LOGGER_PRETTY'),
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const config = values(configSchema)
|
|
21
|
+
export const safeConfig = maskedValues(configSchema)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { config } from './config.js'
|
|
2
|
+
import { pinoLoggerFactory } from './adapters/pino.logger.js'
|
|
3
|
+
import { LoggerPort } from './domain/ports/logger.d.js'
|
|
4
|
+
|
|
5
|
+
export interface Container {
|
|
6
|
+
logger: LoggerPort
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const createContainer = (): Container => {
|
|
10
|
+
const logger = pinoLoggerFactory.create(config.logger)
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
logger,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Container } from './container.js'
|
|
2
|
+
|
|
3
|
+
interface BaseContext {
|
|
4
|
+
container: Container
|
|
5
|
+
note?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ApiUserRequestContext extends BaseContext {
|
|
9
|
+
type: 'api-user'
|
|
10
|
+
user: null // Add UserContext if Auth is implemented
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ServerRequestContext extends BaseContext {
|
|
14
|
+
type: 'server'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Defines context in which current application runs.
|
|
19
|
+
* Context should be created with every external call (user request, cli input, ...)
|
|
20
|
+
*/
|
|
21
|
+
export type RequestContext = ApiUserRequestContext | ServerRequestContext
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creator of the context for the App. Every API layer is responsible for
|
|
25
|
+
* creating the context based on the Api parameters (http headers, protocol settings etc...)
|
|
26
|
+
*/
|
|
27
|
+
export type RequestContextFactory<Params extends any[]> = (
|
|
28
|
+
container: Readonly<Container>,
|
|
29
|
+
...params: Params
|
|
30
|
+
) => Promise<RequestContext>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creator of the server context that should be used only in executions that are invoked
|
|
34
|
+
* on server, f.e. CLI, server start up etc.
|
|
35
|
+
*/
|
|
36
|
+
export type ServerRequestContextFactory<Params extends any[]> = (
|
|
37
|
+
container: Readonly<Container>,
|
|
38
|
+
...params: Params
|
|
39
|
+
) => Promise<ServerRequestContext>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ErrorCode, errorMessages } from './codes.js'
|
|
2
|
+
|
|
3
|
+
export class DomainError<AdditionalData = any> extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly code: ErrorCode,
|
|
6
|
+
public readonly message: string = errorMessages[code],
|
|
7
|
+
public readonly data?: AdditionalData
|
|
8
|
+
) {
|
|
9
|
+
super(message)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ValidationErrorData =
|
|
14
|
+
| Array<{ field: string; message: string }>
|
|
15
|
+
| { field: string; message: string }
|
|
16
|
+
|
|
17
|
+
export class ValidationError extends DomainError<ValidationErrorData> {
|
|
18
|
+
constructor(errors: ValidationErrorData) {
|
|
19
|
+
super(
|
|
20
|
+
ErrorCode.VALIDATION,
|
|
21
|
+
errorMessages[ErrorCode.VALIDATION],
|
|
22
|
+
Array.isArray(errors) ? errors : [errors]
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface LoggerConfig {
|
|
2
|
+
defaultLevel: string
|
|
3
|
+
enablePrettyPrint?: boolean
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type BaseLoggerFn = (object: any, message?: string) => void
|
|
7
|
+
|
|
8
|
+
export interface LoggerPort {
|
|
9
|
+
level: string
|
|
10
|
+
debug: BaseLoggerFn
|
|
11
|
+
info: BaseLoggerFn
|
|
12
|
+
warn: BaseLoggerFn
|
|
13
|
+
error: BaseLoggerFn
|
|
14
|
+
fatal: BaseLoggerFn
|
|
15
|
+
trace: BaseLoggerFn
|
|
16
|
+
silent: BaseLoggerFn
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoggerFactoryPort {
|
|
20
|
+
create: (config: LoggerConfig) => LoggerPort
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { safeConfig } from './config.js'
|
|
2
|
+
import { createAppServer, startServer } from './view/server.js'
|
|
3
|
+
import { createContainer } from './container.js'
|
|
4
|
+
|
|
5
|
+
const appContainer = createContainer()
|
|
6
|
+
const { logger } = appContainer
|
|
7
|
+
|
|
8
|
+
logger.info({ config: safeConfig }, 'Loaded config')
|
|
9
|
+
|
|
10
|
+
const appServer = createAppServer()
|
|
11
|
+
void startServer({ ...appServer, container: appContainer })
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { describe, it } from 'mocha'
|
|
3
|
+
import { createAppServer } from '../view/server.js'
|
|
4
|
+
import { gql } from 'graphql-tag'
|
|
5
|
+
|
|
6
|
+
describe('Hello world', () => {
|
|
7
|
+
it('should return greeting', async () => {
|
|
8
|
+
const query = gql`
|
|
9
|
+
query Hello {
|
|
10
|
+
greeting
|
|
11
|
+
}
|
|
12
|
+
`
|
|
13
|
+
const { server } = createAppServer()
|
|
14
|
+
const res = await server.executeOperation({ query })
|
|
15
|
+
|
|
16
|
+
assert(res.body.kind === 'single')
|
|
17
|
+
assert.deepStrictEqual(res.body.singleResult.errors, undefined)
|
|
18
|
+
assert.deepStrictEqual(
|
|
19
|
+
res.body.singleResult.data?.greeting,
|
|
20
|
+
'Hello, world! 🎉'
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
})
|