nextlove 0.2.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.json +3 -0
- package/README.md +6 -0
- package/bin.js +28 -0
- package/next-env.d.ts +5 -0
- package/next.config.js +6 -0
- package/package.json +50 -0
- package/src/generate-openapi/index.ts +224 -0
- package/src/index.ts +4 -0
- package/src/types/index.ts +107 -0
- package/src/with-route-spec/index.ts +96 -0
- package/src/with-route-spec/middlewares/with-methods.ts +22 -0
- package/src/with-route-spec/middlewares/with-validation.ts +101 -0
- package/tests/route-spec-types.ts +154 -0
- package/tsconfig.json +21 -0
package/.eslintrc.json
ADDED
package/README.md
ADDED
package/bin.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#! /usr/bin/node
|
|
2
|
+
|
|
3
|
+
const argv = require("minimist")(process.argv.slice(2))
|
|
4
|
+
const { register } = require("esbuild-register/dist/node")
|
|
5
|
+
|
|
6
|
+
const { unregister } = register({
|
|
7
|
+
target: `node${process.version.slice(1)}`,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
if (argv._[0] === "generate-openapi") {
|
|
11
|
+
if (argv._.length === 2) {
|
|
12
|
+
argv.packageDir = argv._[1]
|
|
13
|
+
}
|
|
14
|
+
if (argv["package-dir"]) {
|
|
15
|
+
argv.packageDir = argv["package-dir"]
|
|
16
|
+
}
|
|
17
|
+
if (!argv["packageDir"]) throw new Error("Missing --packageDir")
|
|
18
|
+
|
|
19
|
+
require("./dist/generate-openapi")
|
|
20
|
+
.generateOpenAPI({
|
|
21
|
+
packageDir: argv["packageDir"],
|
|
22
|
+
})
|
|
23
|
+
.then((result) => {
|
|
24
|
+
if (!argv.outputFile) {
|
|
25
|
+
console.log(result)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
package/next-env.d.ts
ADDED
package/next.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nextlove",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"repository": "https://github.com/seamapi/nextlove",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public",
|
|
8
|
+
"registry": "https://registry.npmjs.org/"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"nsm": "node_modules/nextjs-server-modules/bin.js",
|
|
12
|
+
"nextlove": "bin.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "tsc --noEmit",
|
|
17
|
+
"build": "tsup --dts --sourcemap inline src"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"esbuild": "^0.14.7",
|
|
21
|
+
"esbuild-runner": "^2.2.1",
|
|
22
|
+
"lodash": "^4.17.21",
|
|
23
|
+
"next": "12.2.0",
|
|
24
|
+
"nextjs-exception-middleware": "^1.1.2",
|
|
25
|
+
"nextjs-middleware-wrappers": "^1.3.0",
|
|
26
|
+
"nextjs-server-modules": "^1.7.0",
|
|
27
|
+
"react": "18.2.0",
|
|
28
|
+
"react-dom": "18.2.0",
|
|
29
|
+
"zod": "^3.17.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@anatine/zod-openapi": "^1.10.0",
|
|
33
|
+
"@types/lodash": "^4.14.182",
|
|
34
|
+
"@types/node": "18.0.0",
|
|
35
|
+
"@types/react": "18.0.14",
|
|
36
|
+
"@types/react-dom": "18.0.5",
|
|
37
|
+
"chalk": "^5.1.2",
|
|
38
|
+
"esbuild-register": "^3.3.3",
|
|
39
|
+
"eslint": "8.18.0",
|
|
40
|
+
"eslint-config-next": "12.2.0",
|
|
41
|
+
"expect-type": "^0.15.0",
|
|
42
|
+
"minimist": "^1.2.7",
|
|
43
|
+
"nextjs-middleware-wrappers": "^1.3.0",
|
|
44
|
+
"openapi3-ts": "^3.1.1",
|
|
45
|
+
"ts-toolbelt": "^9.6.0",
|
|
46
|
+
"turbo": "^1.3.1",
|
|
47
|
+
"type-fest": "^3.1.0",
|
|
48
|
+
"typescript": "4.7.4"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import globby from "globby"
|
|
4
|
+
import { generateSchema } from "@anatine/zod-openapi"
|
|
5
|
+
import {
|
|
6
|
+
OpenApiBuilder,
|
|
7
|
+
OperationObject,
|
|
8
|
+
ParameterObject,
|
|
9
|
+
SecurityRequirementObject,
|
|
10
|
+
} from "openapi3-ts"
|
|
11
|
+
import { RouteSpec, SetupParams } from "../types"
|
|
12
|
+
import { Entries } from "type-fest"
|
|
13
|
+
import chalk from "chalk"
|
|
14
|
+
|
|
15
|
+
export const defaultMapFilePathToHTTPRoute = (file_path: string) => {
|
|
16
|
+
const route = file_path.replace(/^\.\/pages\/api\//, "")
|
|
17
|
+
return route.replace(/\.ts$/, "").replace("public", "")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const getSecurityObject = (
|
|
21
|
+
auth_type: RouteSpec["auth"]
|
|
22
|
+
): SecurityRequirementObject[] => {
|
|
23
|
+
switch (auth_type) {
|
|
24
|
+
case "key_or_session":
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
apiKeyAuth: [],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
sessionAuth: [],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
case "key":
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
apiKeyAuth: [],
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
case "session":
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
sessionAuth: [],
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
case "none":
|
|
46
|
+
return []
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`Unknown auth type: ${auth_type}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface GenerateOpenAPIOpts {
|
|
53
|
+
packageDir: string
|
|
54
|
+
outputFile?: string
|
|
55
|
+
pathGlob?: string
|
|
56
|
+
mapFilePathToHTTPRoute?: (file_path: string) => string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* This function generates an OpenAPI spec from the Next.js API routes.
|
|
61
|
+
*
|
|
62
|
+
* You normally invoke this with `nextapi generate-openapi` in a
|
|
63
|
+
* "build:openapi" package.json script.
|
|
64
|
+
*/
|
|
65
|
+
export async function generateOpenAPI(opts: GenerateOpenAPIOpts) {
|
|
66
|
+
const {
|
|
67
|
+
packageDir,
|
|
68
|
+
outputFile,
|
|
69
|
+
pathGlob = "/pages/api/**/*.ts",
|
|
70
|
+
mapFilePathToHTTPRoute = defaultMapFilePathToHTTPRoute,
|
|
71
|
+
} = opts
|
|
72
|
+
|
|
73
|
+
// Load all route specs
|
|
74
|
+
console.log(`searching "${packageDir}${pathGlob}"...`)
|
|
75
|
+
const filepaths = await globby(`${packageDir}${pathGlob}`)
|
|
76
|
+
console.log(`found ${filepaths.length} files`)
|
|
77
|
+
const filepathToRouteFn = new Map<
|
|
78
|
+
string,
|
|
79
|
+
{
|
|
80
|
+
setupParams: SetupParams
|
|
81
|
+
routeSpec: RouteSpec
|
|
82
|
+
routeFn: Function
|
|
83
|
+
}
|
|
84
|
+
>()
|
|
85
|
+
|
|
86
|
+
await Promise.all(
|
|
87
|
+
filepaths.map(async (p) => {
|
|
88
|
+
const { default: routeFn } = await require(path.resolve(p))
|
|
89
|
+
|
|
90
|
+
if (routeFn) {
|
|
91
|
+
if (!routeFn._setupParams) {
|
|
92
|
+
console.warn(
|
|
93
|
+
chalk.yellow(
|
|
94
|
+
`Ignoring "${p}" because it wasn't created with withRouteSpec`
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
filepathToRouteFn.set(p, {
|
|
100
|
+
setupParams: routeFn._setupParams,
|
|
101
|
+
routeSpec: routeFn._routeSpec,
|
|
102
|
+
routeFn,
|
|
103
|
+
})
|
|
104
|
+
} else {
|
|
105
|
+
console.warn(chalk.yellow(`Couldn't find route ${p}`))
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// TODO detect if there are multiple setups and output different APIs
|
|
111
|
+
const { setupParams: globalSetupParams } = filepathToRouteFn.values().next()
|
|
112
|
+
.value as { setupParams: SetupParams }
|
|
113
|
+
|
|
114
|
+
const securitySchemes = {}
|
|
115
|
+
const securityObjectsForAuthType = {}
|
|
116
|
+
for (const authName of Object.keys(globalSetupParams.authMiddlewareMap)) {
|
|
117
|
+
const mw = globalSetupParams.authMiddlewareMap[authName]
|
|
118
|
+
if (mw.securitySchema) {
|
|
119
|
+
securitySchemes[authName] = (mw as any).securitySchema
|
|
120
|
+
} else {
|
|
121
|
+
console.warn(
|
|
122
|
+
chalk.yellow(
|
|
123
|
+
`Authentication middleware "${authName}" has no securitySchema. You can define this on the function (e.g. after the export do... \n\nmyMiddleware.securitySchema = {\n type: "http"\n scheme: "bearer"\n bearerFormat: "JWT"\n // or API Token etc.\n}\n\nYou can also define "securityObjects" this way, if you want to make the endpoint support multiple modes of authentication.\n\n`
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
securityObjectsForAuthType[authName] = (mw as any).securityObjects || [
|
|
129
|
+
{
|
|
130
|
+
[authName]: [],
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Build OpenAPI spec
|
|
136
|
+
const builder = OpenApiBuilder.create({
|
|
137
|
+
openapi: "3.0.0",
|
|
138
|
+
info: {
|
|
139
|
+
title: globalSetupParams.apiName,
|
|
140
|
+
version: "1.0.0",
|
|
141
|
+
},
|
|
142
|
+
servers: [
|
|
143
|
+
{
|
|
144
|
+
url: globalSetupParams.productionServerUrl || "https://example.com",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
paths: {},
|
|
148
|
+
components: {
|
|
149
|
+
securitySchemes,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
for (const [file_path, { setupParams, routeSpec }] of filepathToRouteFn) {
|
|
154
|
+
const route: OperationObject = {
|
|
155
|
+
summary: mapFilePathToHTTPRoute(file_path),
|
|
156
|
+
responses: {
|
|
157
|
+
200: {
|
|
158
|
+
description: "OK",
|
|
159
|
+
},
|
|
160
|
+
400: {
|
|
161
|
+
description: "Bad Request",
|
|
162
|
+
},
|
|
163
|
+
401: {
|
|
164
|
+
description: "Unauthorized",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
security: securityObjectsForAuthType[routeSpec.auth],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (routeSpec.jsonBody || routeSpec.commonParams) {
|
|
171
|
+
route.requestBody = {
|
|
172
|
+
content: {
|
|
173
|
+
"application/json": {
|
|
174
|
+
schema: generateSchema(
|
|
175
|
+
(routeSpec.jsonBody as any) || routeSpec.commonParams
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (routeSpec.queryParams) {
|
|
183
|
+
const schema = generateSchema(routeSpec.queryParams)
|
|
184
|
+
|
|
185
|
+
if (schema.properties) {
|
|
186
|
+
const parameters: ParameterObject[] = Object.keys(
|
|
187
|
+
schema.properties as any
|
|
188
|
+
).map((name) => {
|
|
189
|
+
return {
|
|
190
|
+
name,
|
|
191
|
+
in: "query",
|
|
192
|
+
schema: schema.properties![name],
|
|
193
|
+
required: schema.required?.includes(name),
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
route.parameters = parameters
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (routeSpec.jsonResponse) {
|
|
202
|
+
route.responses[200].content = {
|
|
203
|
+
"application/json": {
|
|
204
|
+
schema: generateSchema(routeSpec.jsonResponse),
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Some routes accept multiple methods
|
|
210
|
+
builder.addPath(mapFilePathToHTTPRoute(file_path), {
|
|
211
|
+
...routeSpec.methods
|
|
212
|
+
.map((method) => ({
|
|
213
|
+
[method.toLowerCase()]: route,
|
|
214
|
+
}))
|
|
215
|
+
.reduceRight((acc, cur) => ({ ...acc, ...cur }), {}),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (outputFile) {
|
|
220
|
+
await fs.writeFile(outputFile, builder.getSpecAsJson())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return builder.getSpecAsJson()
|
|
224
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { NextApiResponse, NextApiRequest } from "next"
|
|
2
|
+
import { Middleware as WrapperMiddleware } from "nextjs-middleware-wrappers"
|
|
3
|
+
import { z } from "zod"
|
|
4
|
+
import { HTTPMethods } from "../with-route-spec/middlewares/with-methods"
|
|
5
|
+
import { SecuritySchemeObject, SecurityRequirementObject } from "openapi3-ts"
|
|
6
|
+
|
|
7
|
+
export type Middleware<T, Dep = {}> = WrapperMiddleware<T, Dep> & {
|
|
8
|
+
securitySchema?: SecuritySchemeObject
|
|
9
|
+
securityObjects?: SecurityRequirementObject[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ParamDef = z.ZodTypeAny | z.ZodEffects<z.ZodTypeAny>
|
|
13
|
+
|
|
14
|
+
export interface RouteSpec<
|
|
15
|
+
Auth extends string = string,
|
|
16
|
+
Methods extends HTTPMethods[] = any,
|
|
17
|
+
JsonBody extends ParamDef = z.ZodTypeAny,
|
|
18
|
+
QueryParams extends ParamDef = z.ZodTypeAny,
|
|
19
|
+
CommonParams extends ParamDef = z.ZodTypeAny,
|
|
20
|
+
Middlewares extends readonly Middleware<any, any>[] = any[],
|
|
21
|
+
JsonResponse extends ParamDef = z.ZodTypeAny
|
|
22
|
+
> {
|
|
23
|
+
methods: Methods
|
|
24
|
+
auth: Auth
|
|
25
|
+
jsonBody?: JsonBody
|
|
26
|
+
queryParams?: QueryParams
|
|
27
|
+
commonParams?: CommonParams
|
|
28
|
+
middlewares?: Middlewares
|
|
29
|
+
jsonResponse?: JsonResponse
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MiddlewareChainOutput<
|
|
33
|
+
MWChain extends readonly Middleware<any, any>[]
|
|
34
|
+
> = MWChain extends readonly []
|
|
35
|
+
? {}
|
|
36
|
+
: MWChain extends readonly [infer First, ...infer Rest]
|
|
37
|
+
? First extends Middleware<infer T, any>
|
|
38
|
+
? T &
|
|
39
|
+
(Rest extends readonly Middleware<any, any>[]
|
|
40
|
+
? MiddlewareChainOutput<Rest>
|
|
41
|
+
: never)
|
|
42
|
+
: never
|
|
43
|
+
: never
|
|
44
|
+
|
|
45
|
+
export type AuthMiddlewares = {
|
|
46
|
+
[auth_type: string]: Middleware<any, any>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SetupParams<
|
|
50
|
+
AuthMW extends AuthMiddlewares = AuthMiddlewares,
|
|
51
|
+
GlobalMW extends Middleware<any, any>[] = any[]
|
|
52
|
+
> {
|
|
53
|
+
authMiddlewareMap: AuthMW
|
|
54
|
+
globalMiddlewares: GlobalMW
|
|
55
|
+
exceptionHandlingMiddleware?: ((next: Function) => Function) | null
|
|
56
|
+
|
|
57
|
+
// These improve OpenAPI generation
|
|
58
|
+
apiName: string
|
|
59
|
+
productionServerUrl: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const defaultMiddlewareMap = {
|
|
63
|
+
none: (next) => next,
|
|
64
|
+
} as const
|
|
65
|
+
|
|
66
|
+
export type RouteFunction<
|
|
67
|
+
SP extends SetupParams<AuthMiddlewares>,
|
|
68
|
+
RS extends RouteSpec
|
|
69
|
+
> = (
|
|
70
|
+
req: (SP["authMiddlewareMap"] &
|
|
71
|
+
typeof defaultMiddlewareMap)[RS["auth"]] extends Middleware<
|
|
72
|
+
infer AuthMWOut,
|
|
73
|
+
any
|
|
74
|
+
>
|
|
75
|
+
? Omit<NextApiRequest, "query" | "body"> &
|
|
76
|
+
AuthMWOut &
|
|
77
|
+
MiddlewareChainOutput<
|
|
78
|
+
RS["middlewares"] extends readonly Middleware<any, any>[]
|
|
79
|
+
? [...SP["globalMiddlewares"], ...RS["middlewares"]]
|
|
80
|
+
: SP["globalMiddlewares"]
|
|
81
|
+
> & {
|
|
82
|
+
body: (RS["jsonBody"] extends z.ZodTypeAny
|
|
83
|
+
? z.infer<RS["jsonBody"]>
|
|
84
|
+
: {}) &
|
|
85
|
+
(RS["commonParams"] extends z.ZodTypeAny
|
|
86
|
+
? z.infer<RS["commonParams"]>
|
|
87
|
+
: {})
|
|
88
|
+
query: (RS["queryParams"] extends z.ZodTypeAny
|
|
89
|
+
? z.infer<RS["queryParams"]>
|
|
90
|
+
: {}) &
|
|
91
|
+
(RS["commonParams"] extends z.ZodTypeAny
|
|
92
|
+
? z.infer<RS["commonParams"]>
|
|
93
|
+
: {})
|
|
94
|
+
}
|
|
95
|
+
: `unknown auth type: ${RS["auth"]}. You should configure this auth type in your auth_middlewares w/ createWithRouteSpec, or maybe you need to add "as const" to your route spec definition.`,
|
|
96
|
+
res: NextApiResponse<
|
|
97
|
+
RS["jsonResponse"] extends z.ZodTypeAny ? z.infer<RS["jsonResponse"]> : any
|
|
98
|
+
>
|
|
99
|
+
) => Promise<void>
|
|
100
|
+
|
|
101
|
+
export type CreateWithRouteSpecFunction = <
|
|
102
|
+
SP extends SetupParams<AuthMiddlewares, any>
|
|
103
|
+
>(
|
|
104
|
+
setupParams: SP
|
|
105
|
+
) => <RS extends RouteSpec<any, any, any, any, any, any, any>>(
|
|
106
|
+
route_spec: RS
|
|
107
|
+
) => (next: RouteFunction<SP, RS>) => any
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { NextApiResponse, NextApiRequest } from "next"
|
|
2
|
+
import { withExceptionHandling } from "nextjs-exception-middleware"
|
|
3
|
+
import wrappers, { Middleware } from "nextjs-middleware-wrappers"
|
|
4
|
+
import { CreateWithRouteSpecFunction, RouteSpec } from "../types"
|
|
5
|
+
import withMethods, { HTTPMethods } from "./middlewares/with-methods"
|
|
6
|
+
import withValidation from "./middlewares/with-validation"
|
|
7
|
+
import { z } from "zod"
|
|
8
|
+
|
|
9
|
+
type ParamDef = z.ZodTypeAny | z.ZodEffects<z.ZodTypeAny>
|
|
10
|
+
|
|
11
|
+
export const checkRouteSpec = <
|
|
12
|
+
AuthType extends string = string,
|
|
13
|
+
Methods extends HTTPMethods[] = HTTPMethods[],
|
|
14
|
+
JsonBody extends ParamDef = z.ZodTypeAny,
|
|
15
|
+
QueryParams extends ParamDef = z.ZodTypeAny,
|
|
16
|
+
CommonParams extends ParamDef = z.ZodTypeAny,
|
|
17
|
+
Middlewares extends readonly Middleware<any, any>[] = readonly Middleware<
|
|
18
|
+
any,
|
|
19
|
+
any
|
|
20
|
+
>[],
|
|
21
|
+
Spec extends RouteSpec<
|
|
22
|
+
AuthType,
|
|
23
|
+
Methods,
|
|
24
|
+
JsonBody,
|
|
25
|
+
QueryParams,
|
|
26
|
+
CommonParams,
|
|
27
|
+
Middlewares
|
|
28
|
+
> = RouteSpec<
|
|
29
|
+
AuthType,
|
|
30
|
+
Methods,
|
|
31
|
+
JsonBody,
|
|
32
|
+
QueryParams,
|
|
33
|
+
CommonParams,
|
|
34
|
+
Middlewares
|
|
35
|
+
>
|
|
36
|
+
>(
|
|
37
|
+
spec: Spec
|
|
38
|
+
): string extends Spec["auth"]
|
|
39
|
+
? `your route spec is underspecified, add "as const"`
|
|
40
|
+
: Spec => spec as any
|
|
41
|
+
|
|
42
|
+
export const createWithRouteSpec: CreateWithRouteSpecFunction = ((
|
|
43
|
+
setupParams
|
|
44
|
+
) => {
|
|
45
|
+
const {
|
|
46
|
+
authMiddlewareMap = {},
|
|
47
|
+
globalMiddlewares = [],
|
|
48
|
+
exceptionHandlingMiddleware = withExceptionHandling({
|
|
49
|
+
addOkStatus: true,
|
|
50
|
+
}) as any,
|
|
51
|
+
} = setupParams
|
|
52
|
+
|
|
53
|
+
const withRouteSpec = (spec: RouteSpec) => {
|
|
54
|
+
const createRouteExport = (userDefinedRouteFn) => {
|
|
55
|
+
const rootRequestHandler = async (
|
|
56
|
+
req: NextApiRequest,
|
|
57
|
+
res: NextApiResponse
|
|
58
|
+
) => {
|
|
59
|
+
authMiddlewareMap["none"] = (next) => next
|
|
60
|
+
|
|
61
|
+
const auth_middleware = authMiddlewareMap[spec.auth]
|
|
62
|
+
if (!auth_middleware) throw new Error(`Unknown auth type: ${spec.auth}`)
|
|
63
|
+
|
|
64
|
+
return wrappers(
|
|
65
|
+
...((exceptionHandlingMiddleware
|
|
66
|
+
? [exceptionHandlingMiddleware]
|
|
67
|
+
: []) as [any]),
|
|
68
|
+
...((globalMiddlewares || []) as []),
|
|
69
|
+
auth_middleware,
|
|
70
|
+
...((spec.middlewares || []) as []),
|
|
71
|
+
withMethods(spec.methods),
|
|
72
|
+
withValidation({
|
|
73
|
+
jsonBody: spec.jsonBody,
|
|
74
|
+
queryParams: spec.queryParams,
|
|
75
|
+
commonParams: spec.commonParams,
|
|
76
|
+
}),
|
|
77
|
+
userDefinedRouteFn
|
|
78
|
+
)(req as any, res)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
rootRequestHandler._setupParams = setupParams
|
|
82
|
+
rootRequestHandler._routeSpec = spec
|
|
83
|
+
|
|
84
|
+
return rootRequestHandler
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
createRouteExport._setupParams = setupParams
|
|
88
|
+
createRouteExport._routeSpec = spec
|
|
89
|
+
|
|
90
|
+
return createRouteExport
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
withRouteSpec._setupParams = setupParams
|
|
94
|
+
|
|
95
|
+
return withRouteSpec
|
|
96
|
+
}) as any
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MethodNotAllowedException } from "nextjs-exception-middleware"
|
|
2
|
+
|
|
3
|
+
export type HTTPMethods =
|
|
4
|
+
| "GET"
|
|
5
|
+
| "POST"
|
|
6
|
+
| "DELETE"
|
|
7
|
+
| "PUT"
|
|
8
|
+
| "PATCH"
|
|
9
|
+
| "HEAD"
|
|
10
|
+
| "OPTIONS"
|
|
11
|
+
|
|
12
|
+
export const withMethods = (methods: HTTPMethods[]) => (next) => (req, res) => {
|
|
13
|
+
if (!methods.includes(req.method)) {
|
|
14
|
+
throw new MethodNotAllowedException({
|
|
15
|
+
type: "method_not_allowed",
|
|
16
|
+
message: `only ${methods.join(",")} accepted`,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
return next(req, res)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default withMethods
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from "next"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { BadRequestException } from "nextjs-exception-middleware"
|
|
4
|
+
import { isEmpty } from "lodash"
|
|
5
|
+
|
|
6
|
+
const parseCommaSeparateArrays = (
|
|
7
|
+
schema: z.ZodTypeAny,
|
|
8
|
+
input: Record<string, unknown>
|
|
9
|
+
) => {
|
|
10
|
+
const parsed_input = Object.assign({}, input)
|
|
11
|
+
|
|
12
|
+
// todo: iterate over Zod top level keys, if there's an array, parse it
|
|
13
|
+
|
|
14
|
+
return schema.parse(parsed_input)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RequestInput<
|
|
18
|
+
JsonBody extends z.ZodTypeAny,
|
|
19
|
+
QueryParams extends z.ZodTypeAny,
|
|
20
|
+
CommonParams extends z.ZodTypeAny
|
|
21
|
+
> {
|
|
22
|
+
jsonBody?: JsonBody
|
|
23
|
+
queryParams?: QueryParams
|
|
24
|
+
commonParams?: CommonParams
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const zodIssueToString = (issue: z.ZodIssue) => {
|
|
28
|
+
if (issue.path.join(".") === "") {
|
|
29
|
+
return issue.message
|
|
30
|
+
}
|
|
31
|
+
if (issue.message === "Required") {
|
|
32
|
+
return `${issue.path.join(".")} is required`
|
|
33
|
+
}
|
|
34
|
+
return `${issue.message} for "${issue.path.join(".")}"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const withValidation =
|
|
38
|
+
<
|
|
39
|
+
JsonBody extends z.ZodTypeAny,
|
|
40
|
+
QueryParams extends z.ZodTypeAny,
|
|
41
|
+
CommonParams extends z.ZodTypeAny
|
|
42
|
+
>(
|
|
43
|
+
input: RequestInput<JsonBody, QueryParams, CommonParams>
|
|
44
|
+
) =>
|
|
45
|
+
(next) =>
|
|
46
|
+
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
47
|
+
if (
|
|
48
|
+
(req.method === "POST" || req.method === "PATCH") &&
|
|
49
|
+
!req.headers["content-type"]?.includes("application/json") &&
|
|
50
|
+
!isEmpty(req.body)
|
|
51
|
+
) {
|
|
52
|
+
throw new BadRequestException({
|
|
53
|
+
type: "invalid_content_type",
|
|
54
|
+
message: `POST requests must have Content-Type header with "application/json"`,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const original_combined_params = { ...req.query, ...req.body }
|
|
60
|
+
req.body = input.jsonBody?.parse(req.body)
|
|
61
|
+
req.query = input.queryParams?.parse(req.query)
|
|
62
|
+
|
|
63
|
+
if (input.commonParams) {
|
|
64
|
+
;(req as any).commonParams = parseCommaSeparateArrays(
|
|
65
|
+
input.commonParams,
|
|
66
|
+
original_combined_params
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
if (error.name === "ZodError") {
|
|
71
|
+
let message
|
|
72
|
+
if (error.issues.length === 1) {
|
|
73
|
+
const issue = error.issues[0]
|
|
74
|
+
message = zodIssueToString(issue)
|
|
75
|
+
} else {
|
|
76
|
+
const message_components: string[] = []
|
|
77
|
+
for (const issue of error.issues) {
|
|
78
|
+
message_components.push(zodIssueToString(issue))
|
|
79
|
+
}
|
|
80
|
+
message =
|
|
81
|
+
`${error.issues.length} Input Errors: ` +
|
|
82
|
+
message_components.join(", ")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new BadRequestException({
|
|
86
|
+
type: "invalid_input",
|
|
87
|
+
message,
|
|
88
|
+
validation_errors: error.format(),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new BadRequestException({
|
|
93
|
+
type: "invalid_input",
|
|
94
|
+
message: "Error while parsing input",
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return next(req, res)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default withValidation
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { MiddlewareChainOutput } from "../src/types"
|
|
2
|
+
import {
|
|
3
|
+
checkRouteSpec,
|
|
4
|
+
createWithRouteSpec,
|
|
5
|
+
Middleware,
|
|
6
|
+
RouteSpec,
|
|
7
|
+
} from "../src"
|
|
8
|
+
import { expectTypeOf } from "expect-type"
|
|
9
|
+
import { z } from "zod"
|
|
10
|
+
|
|
11
|
+
const authTokenMiddleware: Middleware<{
|
|
12
|
+
auth: {
|
|
13
|
+
authorized_by: "auth_token"
|
|
14
|
+
}
|
|
15
|
+
}> = (next) => (req, res) => {
|
|
16
|
+
req.auth = {
|
|
17
|
+
authorized_by: "auth_token",
|
|
18
|
+
}
|
|
19
|
+
return next(req, res)
|
|
20
|
+
}
|
|
21
|
+
const bearerMiddleware: Middleware<{
|
|
22
|
+
auth: {
|
|
23
|
+
authorized_by: "bearer"
|
|
24
|
+
}
|
|
25
|
+
}> = (next) => (req, res) => {
|
|
26
|
+
req.auth = {
|
|
27
|
+
authorized_by: "bearer",
|
|
28
|
+
}
|
|
29
|
+
return next(req, res)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dbMiddleware: Middleware<{
|
|
33
|
+
db: {
|
|
34
|
+
client: any
|
|
35
|
+
}
|
|
36
|
+
}> = (next) => (req, res) => {
|
|
37
|
+
req.db = { client: "..." }
|
|
38
|
+
return next(req, res)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const userMiddleware: Middleware<
|
|
42
|
+
{
|
|
43
|
+
user: {
|
|
44
|
+
user_id: string
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{}
|
|
48
|
+
> = (next) => (req, res) => {
|
|
49
|
+
req.user = { user_id: "..." }
|
|
50
|
+
return next(req, res)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const chain = [dbMiddleware, bearerMiddleware] as const
|
|
54
|
+
|
|
55
|
+
const chainOutput: MiddlewareChainOutput<typeof chain> = null as any
|
|
56
|
+
|
|
57
|
+
expectTypeOf(chainOutput).toEqualTypeOf<{
|
|
58
|
+
db: {
|
|
59
|
+
client: any
|
|
60
|
+
}
|
|
61
|
+
auth: {
|
|
62
|
+
authorized_by: "bearer"
|
|
63
|
+
}
|
|
64
|
+
}>
|
|
65
|
+
|
|
66
|
+
const projSetup = {
|
|
67
|
+
authMiddlewareMap: {
|
|
68
|
+
auth_token: authTokenMiddleware,
|
|
69
|
+
bearer: bearerMiddleware,
|
|
70
|
+
},
|
|
71
|
+
globalMiddlewares: [dbMiddleware],
|
|
72
|
+
|
|
73
|
+
apiName: "test",
|
|
74
|
+
productionServerUrl: "https://example.com",
|
|
75
|
+
} as const
|
|
76
|
+
|
|
77
|
+
const withRouteSpec = createWithRouteSpec(projSetup)
|
|
78
|
+
|
|
79
|
+
export const myRoute1Spec = checkRouteSpec({
|
|
80
|
+
auth: "none",
|
|
81
|
+
methods: ["POST"],
|
|
82
|
+
jsonBody: z.object({
|
|
83
|
+
count: z.number(),
|
|
84
|
+
}),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
export const myRoute1 = withRouteSpec(myRoute1Spec)(async (req, res) => {
|
|
88
|
+
expectTypeOf(req.db).toMatchTypeOf<{ client: any }>()
|
|
89
|
+
expectTypeOf(req.body).toMatchTypeOf<{ count: number }>()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export const myRoute2Spec = checkRouteSpec({
|
|
93
|
+
auth: "auth_token",
|
|
94
|
+
methods: ["GET"],
|
|
95
|
+
queryParams: z.object({
|
|
96
|
+
id: z.string(),
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
export const myRoute2 = withRouteSpec(myRoute2Spec)(async (req, res) => {
|
|
101
|
+
expectTypeOf(req.auth).toMatchTypeOf<{ authorized_by: "auth_token" }>()
|
|
102
|
+
expectTypeOf(req.query).toMatchTypeOf<{ id: string }>()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export const myRoute3Spec = checkRouteSpec({
|
|
106
|
+
auth: "none",
|
|
107
|
+
methods: ["POST"],
|
|
108
|
+
jsonBody: z.object({
|
|
109
|
+
A: z.string(),
|
|
110
|
+
}),
|
|
111
|
+
commonParams: z.object({
|
|
112
|
+
B: z.string(),
|
|
113
|
+
}),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
export const myRoute3 = withRouteSpec(myRoute3Spec)(async (req, res) => {
|
|
117
|
+
expectTypeOf(req.body).toMatchTypeOf<{ A: string; B: string }>()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const withImproperMWRouteSpec = createWithRouteSpec({
|
|
121
|
+
authMiddlewareMap: {
|
|
122
|
+
// @ts-expect-error - improperly defined middleware
|
|
123
|
+
asd: () => {},
|
|
124
|
+
},
|
|
125
|
+
globalMiddlewares: [],
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const middlewares = [userMiddleware] as const
|
|
129
|
+
|
|
130
|
+
export const myRoute4Spec = checkRouteSpec({
|
|
131
|
+
auth: "none",
|
|
132
|
+
methods: ["POST"],
|
|
133
|
+
middlewares,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export const myRoute4 = withRouteSpec(myRoute4Spec)(async (req, res) => {
|
|
137
|
+
expectTypeOf(req.db).toMatchTypeOf<{ client: any }>()
|
|
138
|
+
expectTypeOf(req.user).toMatchTypeOf<{ user_id: string }>()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
export const myRoute5Spec = checkRouteSpec({
|
|
142
|
+
auth: "none",
|
|
143
|
+
methods: ["POST"],
|
|
144
|
+
jsonResponse: z.object({
|
|
145
|
+
id: z.string(),
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
export const myRoute5 = withRouteSpec(myRoute5Spec)(async (req, res) => {
|
|
150
|
+
// @ts-expect-error - should be a string
|
|
151
|
+
res.status(200).json({ id: 123 })
|
|
152
|
+
|
|
153
|
+
res.status(200).json({ id: "123" })
|
|
154
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2020",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noImplicitAny": false,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"module": "esnext",
|
|
13
|
+
"moduleResolution": "node",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"jsx": "preserve",
|
|
17
|
+
"incremental": false
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|