@buildvision/nest-sdk-generator 1.2.2 → 2.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.
@@ -2,31 +2,89 @@
2
2
  * @file Analyzer for the source API's controllers' methods' parameters
3
3
  */
4
4
 
5
- import { ParameterDeclaration } from 'ts-morph'
6
- import { debug, format, warn } from '../logging'
7
- import { expectSingleStrLitDecorator } from './decorator'
5
+ import { ClassDeclaration, Decorator, MethodDeclaration, Node, ParameterDeclaration, Type } from 'ts-morph'
6
+ import { debug, panic } from '../logging'
8
7
  import { SdkHttpMethod } from './methods'
9
- import { paramsOfRoute, Route } from './route'
8
+ import { Route, paramsFromRoute } from './route'
10
9
  import { ResolvedTypeDeps, resolveTypeDependencies } from './typedeps'
11
10
 
12
11
  /**
13
- * SDK interface for a controller's method's parameters
12
+ * Type of a parameter that must be passed to an SDK method: Body, Query or Route Param
13
+ * Supports accessing parameters individually by key or as a whole object
14
+ *
15
+ * EXAMPLE
16
+ *
17
+ * Controller Method:
18
+ *
19
+ * getStuff(@Query('email') email: string, @Query('companyId'): CompanyId)
20
+ *
21
+ * SDK Method Param:
22
+ * { email: string, companyId: CompanyId }
23
+ *
24
+ * OR
25
+ *
26
+ * Controller Method:
27
+ * type EmailAndCompanyId = { email: string, companyId: CompanyId }
28
+ * getStuff(@Query(): EmailAndCompanyId)
29
+ *
30
+ * SDK Method Param:
31
+ * EmailAndCompanyId
14
32
  */
15
- export interface SdkMethodParams {
16
- /** Route parameters */
17
- parameters: Map<string, ResolvedTypeDeps> | null
18
33
 
19
- /** Query parameters */
20
- query: Map<string, ResolvedTypeDeps> | null
21
34
 
22
- /** Body parameters */
23
- body: SdkMethodBodyParam | null
35
+ export type SdkMethodParam = SdkMethodParamMap | ResolvedTypeDeps | null;
36
+ export type SdkMethodParamMap = { [key: string]: ResolvedTypeDeps };
37
+
38
+ export const isParamResolvedType = (param: SdkMethodParam): param is ResolvedTypeDeps => {
39
+ if (param === null) {
40
+ return false
41
+ }
42
+ return 'rawType' in param && 'resolvedType' in param && 'relativeFilePath' in param && 'dependencies' in param && 'localTypes' in param
43
+ }
44
+
45
+ export const getParamResolvedTypes = (param: SdkMethodParam): ResolvedTypeDeps[] => {
46
+ if (param === null) {
47
+ return []
48
+ }
49
+ if (isParamResolvedType(param)) {
50
+ return [param]
51
+ }
52
+ return Object.values(param)
24
53
  }
25
54
 
26
55
  /**
27
- * Single body parameter in a SDK's method
56
+ * SDK interface for a controller's method's parameters
28
57
  */
29
- export type SdkMethodBodyParam = { full: true; type: ResolvedTypeDeps } | { full: false; fields: Map<string, ResolvedTypeDeps> }
58
+ export interface SdkMethodParams {
59
+ routeParams: SdkMethodParam,
60
+ queryParams: SdkMethodParam,
61
+ bodyParams: SdkMethodParam,
62
+ context: MethodContext,
63
+ }
64
+
65
+ export type MethodContext = {
66
+ controllerClass: ClassDeclaration,
67
+ method: MethodDeclaration,
68
+ httpMethod: SdkHttpMethod,
69
+ route: Route,
70
+ filePath: string,
71
+ absoluteSrcPath: string,
72
+ }
73
+
74
+ export enum ArgDecorator {
75
+ Param = 'Param',
76
+ Query = 'Query',
77
+ Body = 'Body',
78
+ }
79
+
80
+ export type DecoratedArg = {
81
+ arg: ParameterDeclaration,
82
+ argParamKey: string | null,
83
+ decorator: Decorator,
84
+ decoratorType: ArgDecorator,
85
+ context: MethodContext,
86
+ }
87
+
30
88
 
31
89
  /**
32
90
  * Generate a SDK interface for a controller's method's parameters
@@ -37,205 +95,163 @@ export type SdkMethodBodyParam = { full: true; type: ResolvedTypeDeps } | { full
37
95
  * @param absoluteSrcPath Absolute path to the source directory
38
96
  * @returns A SDK interface for the method's parameters
39
97
  */
40
- export function analyzeParams(
41
- httpMethod: SdkHttpMethod,
42
- route: Route,
43
- args: ParameterDeclaration[],
44
- filePath: string,
45
- absoluteSrcPath: string
46
- ): SdkMethodParams | Error {
47
- // The collected informations we will return
48
- const collected: SdkMethodParams = {
49
- parameters: null,
50
- query: null,
51
- body: null,
98
+ export function extractParams(
99
+ context: MethodContext
100
+ ): SdkMethodParams {
101
+ const { route, filePath } = context
102
+ const { Param, Query, Body } = extractDecoratedArgs(context)
103
+ const resolvedParams: SdkMethodParams = {
104
+ routeParams: mergeDecoratedArgs(Param),
105
+ queryParams: mergeDecoratedArgs(Query),
106
+ bodyParams: mergeDecoratedArgs(Body),
107
+ context,
52
108
  }
53
109
 
54
- // Get the named parameters of the route
55
- const routeParams = paramsOfRoute(route)
110
+ /**
111
+ * Validate Route Params exist in route URL
112
+ */
113
+ const { routeParams } = resolvedParams
114
+ if (routeParams) {
115
+ const allowedRouteParams = new Set(paramsFromRoute(route))
116
+ const usedRouteParams = routeParams instanceof Type ?
117
+ // If routeParams was resolved to a single type, assume there's only one decorated argument
118
+ Param[0].arg.getType().getProperties().map((prop) => prop.getName()) :
119
+ // If routeParams was resolved to a map, assume the keys are the route params
120
+ Object.keys(routeParams)
121
+
122
+ for (const usedParam of usedRouteParams) {
123
+ if (!allowedRouteParams.has(usedParam)) {
124
+ panicWithContext(`Route param ${usedParam} does not appear in route URL`, context)
125
+ }
126
+ }
127
+ }
128
+
129
+
130
+ return resolvedParams
131
+ }
132
+
133
+ export function extractDecoratedArgs(context: MethodContext): Record<ArgDecorator, DecoratedArg[]> {
134
+ const decoratedArgs = {
135
+ [ArgDecorator.Param]: [] as DecoratedArg[],
136
+ [ArgDecorator.Query]: [] as DecoratedArg[],
137
+ [ArgDecorator.Body]: [] as DecoratedArg[],
138
+ }
139
+
140
+ const { method, filePath, controllerClass } = context
141
+ const args = method.getParameters()
56
142
 
57
- // Treat all arguments (to not confuse with the route's parameters)
58
143
  for (const arg of args) {
59
- const name = arg.getName()
144
+ const argName = arg.getName()
60
145
 
61
- debug('├───── Detected argument: {yellow}', name)
146
+ debug('├───── Detected argument: {yellow}', argName)
62
147
 
63
148
  // Arguments are collected as soon as they have a decorator like @Query() or @Body()
64
- const decs = arg.getDecorators()
149
+ const argDecorators = arg.getDecorators().filter((dec) => (Object.values(ArgDecorator) as string[]).includes(dec.getName()))
65
150
 
66
- if (decs.length === 0) {
151
+ if (argDecorators.length === 0) {
67
152
  // If we have no argument, this is not an argument we are interested in, so we just skip it
68
153
  debug('├───── Skipping this argument as it does not have a decorator')
69
154
  continue
70
- } else if (decs.length > 1) {
71
- // If we have more than one decorator, this could mean we have for instance an @NotEmpty() @Query() or something like this,
72
- // which is currently not supported.
73
- return new Error('Skipping this argument as it has multiple decorators, which is currently not supported')
155
+ } else if (argDecorators.length > 1) {
156
+ panic(
157
+ `${controllerClass.getName()} ${method.getName()} has multiple decorators on argument ${argName} ${argDecorators.map((dec) => dec.getName()).join(', ')}`
158
+ )
74
159
  }
75
-
76
160
  // Get the only decrator
77
- const dec = decs[0]
78
- const decName = dec.getName()
79
-
80
- // Treat the @Param() decorator
81
- if (decName === 'Param') {
82
- debug('├───── Detected decorator {blue}', '@Param')
83
-
84
- // We expect a single string argument for this decorator,
85
- // which is the route parameter's name
86
- const paramName = expectSingleStrLitDecorator(dec)
87
-
88
- if (paramName instanceof Error) return paramName
89
-
90
- // If there is no argument, this argument is a global receiver which maps the full set of parameters
91
- // We theorically *could* extract the type informations from this object type, but this would be insanely complex
92
- // So, we just skip it as it's a lot more simple, and is not commonly used anyway as it has a set of downsides
93
- if (paramName === null) {
94
- warn('├───── Skipping this argument as it is a generic parameters receiver, which is currently not supported')
95
- continue
96
- }
161
+ const decorator = argDecorators[0]
162
+ const decoratorType = decorator.getName() as ArgDecorator
163
+ const argParamKey = extractArgParamKey({ ...context, arg, decorator })
164
+ const argType = arg.getType()
165
+
166
+ if (!argParamKey && !argType.isObject()) {
167
+ panic(
168
+ `${controllerClass.getName()} ${method.getName()} generic controller argument ${decoratorType}() ${argName} must be an object type`
169
+ )
170
+ }
97
171
 
98
- // Ensure the specified parameter appears in the method's route
99
- if (!routeParams.includes(paramName)) return new Error(format('├───── Cannot map unknown parameter {yellow}', paramName))
172
+ decoratedArgs[decoratorType].push({
173
+ arg,
174
+ argParamKey,
175
+ decorator,
176
+ decoratorType,
177
+ context,
178
+ })
179
+ }
100
180
 
101
- debug('├───── Mapping argument to parameter: {yellow}', paramName)
181
+ return decoratedArgs
182
+ }
102
183
 
103
- // Get the route parameter's type
104
- const typ = resolveTypeDependencies(arg.getType(), filePath, absoluteSrcPath)
184
+ export function extractArgParamKey(context: MethodContext & {
185
+ arg: ParameterDeclaration,
186
+ decorator: Decorator,
187
+ }): string | null {
188
+ const { decorator, controllerClass, method, arg } = context
189
+ const decoratorArgs = decorator.getArguments()
105
190
 
106
- debug('├───── Detected parameter type: {yellow} ({magentaBright} dependencies)', typ.resolvedType, typ.dependencies.size)
191
+ if (decoratorArgs.length > 1) {
192
+ panic(
193
+ `${controllerClass.getName()} ${method.getName()} ${decorator.getName()}() ${arg.getName()} argument decorator has multiple parameters`
194
+ )
195
+ } else if (decoratorArgs.length === 0) {
196
+ return null
197
+ }
107
198
 
108
- // Update the method's route parameters
199
+ const decoratorArg = decoratorArgs[0]
109
200
 
110
- if (paramName in {}) {
111
- return new Error(
112
- format(`Detected @Param() field whose name {yellow} collides with a JavaScript's native object property`, paramName)
113
- )
114
- }
201
+ if (!Node.isStringLiteral(decoratorArg)) {
202
+ panic('The argument provided to the decorator is not a string literal:\n>>> {cyan}', arg.getText())
203
+ }
115
204
 
116
- collected.parameters ??= new Map()
117
- collected.parameters.set(paramName, typ)
118
- }
205
+ const paramKey = decoratorArg.getLiteralText()
206
+ if (paramKey in {}) {
207
+ panicWithContext(
208
+ `${decorator.getName()}('${paramKey}') param name collides with JavaScript native object property`,
209
+ context
210
+ )
211
+ }
119
212
 
120
- // Treat the @Query() decorator
121
- else if (decName === 'Query') {
122
- debug('├───── Detected decorator {blue}', '@Query')
213
+ return paramKey
214
+ }
123
215
 
124
- // We expect a single string argument for this decorator,
125
- // which is the query parameter's name
126
- const queryName = expectSingleStrLitDecorator(dec)
216
+ export function mergeDecoratedArgs(decoratedArgs: DecoratedArg[]): SdkMethodParam {
217
+ let genericParam: SdkMethodParam | null = null
218
+ let paramMap: SdkMethodParamMap = {}
127
219
 
128
- if (queryName instanceof Error) return queryName
220
+ for (const decoratedArg of decoratedArgs) {
221
+ const { argParamKey, decoratorType, arg, context } = decoratedArg
222
+ const resolvedType = resolveTypeDependencies(arg.getType(), context.filePath, context.absoluteSrcPath)
129
223
 
130
- // If there is no argument, this argument is a global receiver which maps the full set of parameters
131
- // We theorically *could* extract the type informations from this object type, but this would be insanely complex
132
- // So, we just skip it as it's a lot more simple, and is not commonly used anyway as it has a set of downsides
133
- if (queryName === null) {
134
- warn('├───── Skipping this argument as it is a generic query receiver')
135
- continue
224
+ if (argParamKey) {
225
+ if (paramMap[argParamKey]) {
226
+ panicWithContext(`${decoratorType}('${argParamKey}') used twice in controller method`, decoratedArg.context)
136
227
  }
137
228
 
138
- debug('├───── Mapping argument to query: {yellow}', queryName)
139
-
140
- // Get the parameter's type
141
- const typ = resolveTypeDependencies(arg.getType(), filePath, absoluteSrcPath)
142
-
143
- debug(`├───── Detected query type: {yellow} ({magentaBright} dependencies)`, typ.resolvedType, typ.dependencies.size)
144
-
145
- // Update the method's query parameter
146
-
147
- if (queryName in {}) {
148
- return new Error(
149
- format(`Detected @Query() field whose name {yellow} collides with a JavaScript's native object property`, queryName)
150
- )
229
+ paramMap[argParamKey] = resolvedType
230
+ } else {
231
+ if (genericParam) {
232
+ panicWithContext(`${decoratorType}() used twice in controller method`, decoratedArg.context)
151
233
  }
152
234
 
153
- collected.query ??= new Map()
154
- collected.query.set(queryName, typ)
235
+ genericParam = resolvedType
155
236
  }
237
+ }
156
238
 
157
- // Treat the @Body() decorator
158
- else if (decName === 'Body') {
159
- debug('├───── Detected decorator {blue}', '@Body')
160
-
161
- // GET requests cannot have a BODY
162
- if (httpMethod === SdkHttpMethod.Get) {
163
- return new Error('GET requests cannot have a BODY!')
164
- }
165
-
166
- // We expect a single string argument for this decorator,
167
- // which is the body field's name
168
- const fieldName = expectSingleStrLitDecorator(dec)
169
-
170
- if (fieldName instanceof Error) return fieldName
171
-
172
- // Get the field's type
173
- const typ = resolveTypeDependencies(arg.getType(), filePath, absoluteSrcPath)
174
-
175
- const depsCount = typ.dependencies.size
176
-
177
- debug(
178
- `├───── Detected BODY type: {cyan} ({magentaBright} ${
179
- depsCount === 0 ? 'no dependency' : depsCount > 1 ? 'dependencies' : 'dependency'
180
- })`,
181
- typ.resolvedType,
182
- depsCount
183
- )
184
-
185
- // If there no name was provided to the decorator, then the decorator is a generic receiver which means it maps to the full body type
186
- // This also means we can map the BODY type to this argument's type
187
- if (fieldName === null) {
188
- const body = collected.body
189
-
190
- // If we previously had an @Body(<name>) decorator on another argument, we have an important risk of mistyping
191
- // => e.g. `@Body("a") a: string, @Body() body: { a: number }` is invalid as the type for the `a` field mismatches
192
- // => It's easy to make an error as the @Body() type is often hidden behind a DTO
193
- // But that's extremely complex to check automatically, so we just display a warning instead
194
- // Also, that's not the kind of thing we make on purpose very often, so it's more likely it's an error, which makes it even more important
195
- // to display a warning here.
196
- if (body?.full === false)
197
- warn('├───── Detected full @Body() decorator after a single parameter. This is considered a bad practice, avoid it if you can!')
198
- // Having two generic @Body() decorators is meaningless and will likey lead to errors, so we return a precise error here
199
- else if (body?.full) {
200
- return new Error(
201
- format(
202
- `Detected two @Body() decorators: found {yellow} previously, while method argument {yellow} indicates type {yellow}`,
203
- body.type.resolvedType,
204
- name,
205
- typ.resolvedType
206
- )
207
- )
208
- }
209
-
210
- debug("├───── Mapping argument to full request's body")
211
-
212
- // Update the whole BODY type
213
- collected.body = { full: true, type: typ }
214
- } else {
215
- // Here we have an @Body(<string>) decorator
216
-
217
- // If we previously had an @Body() decorator, this can lead to several types of errors (see the big comment above for more informations)
218
- if (collected.body?.full) {
219
- warn('├───── Detected single @Body() decorator after a full parameter. This is considered a bad practice, avoid it if you can!')
220
- } else {
221
- debug('├───── Mapping argument to BODY field: {yellow}', fieldName)
222
-
223
- // Update the BODY type by adding the current field to it
239
+ const hasArgsByKey = Object.keys(paramMap).length > 0
224
240
 
225
- if (fieldName in {}) {
226
- return new Error(
227
- format(`Detected @Body() field whose name {yellow} collides with a JavaScript's native object property`, fieldName)
228
- )
229
- }
241
+ if (genericParam && hasArgsByKey) {
242
+ panicWithContext(`Cannot mix generic and specific ${decoratedArgs[0].decoratorType}()`, decoratedArgs[0].context)
243
+ }
230
244
 
231
- collected.body ??= { full: false, fields: new Map() }
245
+ return hasArgsByKey ? paramMap : genericParam
246
+ }
232
247
 
233
- collected.body.fields.set(fieldName, typ)
234
- }
235
- }
236
- }
248
+ const panicWithContext = (message: string, context: MethodContext) => {
249
+ const { controllerClass, httpMethod, method, filePath } = context
250
+ const ctx = {
251
+ controller: controllerClass.getName(),
252
+ method: method.getName(),
253
+ httpMethod,
254
+ filePath,
237
255
  }
238
-
239
- // Success!
240
- return collected
256
+ panic(`${message}\n${JSON.stringify(ctx, null, 2)}`)
241
257
  }
@@ -90,8 +90,12 @@ export function analyzeUri(uriPath: string): Route | Error {
90
90
  * Get the named parameters of a parsed route
91
91
  * @param route
92
92
  */
93
- export function paramsOfRoute(route: Route): string[] {
94
- return route.parts.map((part) => ('param' in part ? part.param : null)).filter((e) => e !== null) as string[]
93
+ export function routePartIsParam(part: RoutePart): part is { readonly param: string } {
94
+ return "param" in part
95
+ }
96
+
97
+ export function paramsFromRoute(route: Route): string[] {
98
+ return route.parts.filter(routePartIsParam).map((part) => part.param)
95
99
  }
96
100
 
97
101
  /**