Skip to content

Commit 199d4bf

Browse files
committed
Initial commit
0 parents  commit 199d4bf

File tree

10 files changed

+566
-0
lines changed

10 files changed

+566
-0
lines changed

.gitignore

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
8+
# TypeScript cache
9+
*.tsbuildinfo
10+
11+
# Optional eslint cache
12+
.eslintcache
13+
14+
# archives
15+
*.tgz
16+
17+
# dotenv environment variables file
18+
.env
19+
.env.test
20+
.env.production
21+
22+
# typescript output folder
23+
build/
24+
dist/
25+
26+
# private key
27+
id_rsa_userhost
28+
29+
# yarn
30+
.yarn/*
31+
!.yarn/cache
32+
!.yarn/patches
33+
!.yarn/plugins
34+
!.yarn/releases
35+
!.yarn/sdks
36+
!.yarn/versions
37+
# yarn v1 node_modules
38+
node_modules/

.npmignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
src

.npmrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@parallelworks:registry=https://npm.pkg.github.com

package.json

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "fsrouter",
3+
"version": "1.0.0",
4+
"description": "Filesystem based router for Express.js",
5+
"author": "Michael McQuade",
6+
"main": "build/index.js",
7+
"types": "build/index.d.ts",
8+
"repository": {
9+
"type": "git",
10+
"url": "github.com/parallelworks/fsrouter"
11+
},
12+
"scripts": {
13+
"compile": "tsc"
14+
},
15+
"license": "UNLICENSED",
16+
"private": true,
17+
"devDependencies": {
18+
"@types/express": "^4.17.13",
19+
"typescript": "^4.6.3"
20+
},
21+
"peerDependencies": {
22+
"express": "^4.17.1"
23+
},
24+
"dependencies": {
25+
"ajv": "^8.11.0"
26+
}
27+
}

src/index.ts

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { asyncErrorHandler } from './middleware/errors'
2+
import { createBodyValidator, createQueryValidator } from './validation'
3+
import {
4+
AllowedMethod,
5+
AllowedMethods,
6+
Endpoint,
7+
ExpressMethod,
8+
ExpressMethods,
9+
} from './types'
10+
import { RequestHandler, Router } from 'express'
11+
import globCb from 'glob'
12+
import { promisify } from 'util'
13+
14+
const router = Router()
15+
const glob = promisify(globCb)
16+
17+
interface EndpointModule extends Partial<Record<AllowedMethod, Endpoint>> {
18+
validation?: Partial<Record<AllowedMethod, Record<'body' | 'query', any>>>
19+
guestAccess?: boolean
20+
ensureAdmin?: boolean
21+
}
22+
23+
interface IInitFsRoutingParams {
24+
ensureAdmin: RequestHandler
25+
ensureAuthenticated: RequestHandler
26+
routesPath: string
27+
}
28+
29+
export const initFsRouting = async ({
30+
ensureAdmin,
31+
ensureAuthenticated,
32+
routesPath,
33+
}: IInitFsRoutingParams) => {
34+
// Apply middleware first
35+
console.log('Mounting routes')
36+
let numberOfRoutes = 0
37+
let numberOfFiles = 0
38+
let numberOfRoutesWithoutValidation = 0
39+
// Get all of the files under the routes directory
40+
const files = await getFiles(routesPath)
41+
numberOfFiles = files.length + 1
42+
// Sort by reverse alphabetical order, so items with colons are below items without colons. This makes it so we can override param routes.
43+
const promises = files
44+
.sort()
45+
.reverse()
46+
.map(async path => {
47+
// get the endpoint path for express by removing the base filesystem path
48+
let routePath = path.replace(routesPath, '')
49+
// remove the js portion
50+
routePath = routePath.replace(/.js$/, '')
51+
// replace index at beggining with /
52+
routePath = routePath.replace(/^\/index/, '/')
53+
// remove index at end
54+
routePath = routePath.replace(/\/index$/, '')
55+
// do not handle routes that begin with _
56+
const lastSlash = routePath.lastIndexOf('/')
57+
const endpointName = routePath.substr(lastSlash)
58+
59+
if (endpointName.startsWith('/_')) {
60+
console.log('Skipping mounting:', routePath)
61+
return
62+
}
63+
64+
// import route
65+
const module: EndpointModule = await import(path)
66+
// here we have the chance to alias routes to different locations, by storing multiple paths in routePaths
67+
const routePaths = [routePath]
68+
69+
// we treat authentication differently on u routes and API routes, can get rid of this when client is separted from API server
70+
71+
console.log(`Mounting route:`, routePaths[0])
72+
const [routesMounted, routesWithoutValidation] = mountEndpoints({
73+
paths: routePaths,
74+
endpoints: module,
75+
ensureAdmin,
76+
ensureAuthenticated,
77+
})
78+
numberOfRoutes += routesMounted
79+
numberOfRoutesWithoutValidation += routesWithoutValidation
80+
if (routesMounted === 0) console.log('\t | No exported HTTP methods')
81+
})
82+
await Promise.all(promises).then(() =>
83+
console.log(
84+
`${numberOfFiles} route files processed, ${numberOfRoutes} routes mounted, ${numberOfRoutesWithoutValidation} routes do not have validation.`
85+
)
86+
)
87+
return router
88+
}
89+
90+
// Returns the number of routes mounted, and the number of routes that had validation
91+
interface IMountEndpointsParams {
92+
paths: string[]
93+
endpoints: EndpointModule
94+
ensureAdmin: RequestHandler
95+
ensureAuthenticated: RequestHandler
96+
}
97+
98+
const mountEndpoints = ({
99+
paths,
100+
endpoints,
101+
ensureAdmin,
102+
ensureAuthenticated,
103+
}: IMountEndpointsParams): [number, number] => {
104+
let mounted = 0
105+
let numberWithValidation = 0
106+
107+
const validation = endpoints.validation
108+
const guestAccess = endpoints.guestAccess
109+
const adminOnly = endpoints.ensureAdmin
110+
111+
Object.entries(endpoints).map(([method, endpoint]) => {
112+
// skip exports that are not allowed Express methods
113+
const expressMethodName = method.toLowerCase()
114+
if (!isExpressMethod(expressMethodName)) {
115+
return
116+
}
117+
let handlers: RequestHandler[] = []
118+
if (!guestAccess) {
119+
handlers.push(ensureAuthenticated)
120+
121+
if (adminOnly) {
122+
handlers.push(ensureAdmin)
123+
}
124+
}
125+
let validationMsg = ''
126+
// check if it should have validation
127+
if (validation?.hasOwnProperty(method)) {
128+
if (isHttpMethod(method)) {
129+
const hasBody = validation[method]?.body
130+
const hasQuery = validation[method]?.query
131+
console.log(`\t | Mounting ${method} with validation`)
132+
// verify that the validation object has the correct keys (query and body)
133+
if (hasQuery) {
134+
validationMsg = `\t | ${method} has query validation\n`
135+
// add optional key property to all validators
136+
const query = {
137+
...validation[method]?.query,
138+
properties: {
139+
...validation[method]?.query?.properties,
140+
key: { type: 'string', description: 'API Key' },
141+
},
142+
}
143+
// Add query validation handler
144+
handlers.push(
145+
createQueryValidator({
146+
query: query,
147+
// body: validation[method]?.body,
148+
})
149+
)
150+
}
151+
if (hasBody) {
152+
// Add body validation handler
153+
validationMsg += `\t | ${method} has body validation`
154+
handlers.push(
155+
createBodyValidator({
156+
body: validation[method]?.body,
157+
})
158+
)
159+
}
160+
}
161+
} else {
162+
// this endpoint doesn't have validation yet
163+
validationMsg += '- no validation'
164+
numberWithValidation++
165+
}
166+
167+
const hasMiddleware = Array.isArray(endpoint)
168+
handlers = hasMiddleware
169+
? [...handlers, ...endpoint]
170+
: [...handlers, endpoint]
171+
// add async handling to all handlers
172+
handlers = handlers.map(handler => asyncErrorHandler(handler))
173+
// mount the route
174+
router[expressMethodName](paths, handlers)
175+
console.log(`\t | ${method} ${validationMsg}`)
176+
mounted++
177+
})
178+
return [mounted, numberWithValidation]
179+
}
180+
181+
const isExpressMethod = (method: string): method is ExpressMethod => {
182+
return ExpressMethods.includes(method as ExpressMethod)
183+
}
184+
185+
const isHttpMethod = (exportKey: string): exportKey is AllowedMethod => {
186+
return AllowedMethods.includes(exportKey as AllowedMethod)
187+
}
188+
189+
const getFiles = async (src: string) => {
190+
return glob(src + '/**/*.js', { nodir: true })
191+
}
192+
193+
export default router

src/middleware/errors.ts

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
ErrorRequestHandler,
3+
NextFunction,
4+
Request,
5+
RequestHandler,
6+
Response,
7+
} from 'express'
8+
9+
export class UserFacingError extends Error {
10+
timestamp: Date
11+
constructor(public message: string, public statusCode?: number) {
12+
super(message)
13+
this.name = 'UserFacingError'
14+
this.timestamp = new Date()
15+
}
16+
}
17+
18+
// TODO: Make this a class like UserFacingError
19+
interface IError {
20+
message: string
21+
error: boolean
22+
path: string
23+
time: Date
24+
stack?: string
25+
}
26+
const development = process.env.NODE_ENV !== 'production'
27+
28+
export const userFacingErrorHandler: ErrorRequestHandler = (
29+
err,
30+
req,
31+
res,
32+
next
33+
) => {
34+
if (err instanceof UserFacingError) {
35+
console.error(err.toString())
36+
// TODO: Get status code from error if it exists
37+
// return the user facing error message
38+
return res.status(err.statusCode || 500).json({
39+
error: true,
40+
message: err.message.toString(),
41+
timestamp: err.timestamp,
42+
path: req.originalUrl,
43+
})
44+
} else if (development) {
45+
// go to the default error handler, which shows more details
46+
return next(err)
47+
}
48+
// We're not in development, and this is not a user facing error, return a default "Unknown error"
49+
return res.status(500).json({
50+
error: true,
51+
message: 'Unknown Error',
52+
timstamp: new Date(),
53+
path: req.originalUrl,
54+
})
55+
}
56+
57+
export const defaultErrorHandler: ErrorRequestHandler = (
58+
err,
59+
req,
60+
res,
61+
next
62+
) => {
63+
const message = err.message || 'Unknown error'
64+
console.error(
65+
'Default error handler shown in development. Following error triggered it: ',
66+
err
67+
)
68+
const body: IError = {
69+
error: true,
70+
time: new Date(),
71+
message,
72+
path: req.originalUrl,
73+
stack: err.stack,
74+
}
75+
return res.status(500).json(body)
76+
}
77+
78+
// This is used to wrap async functions
79+
export const asyncErrorHandler = function wrap(fn: RequestHandler) {
80+
return async function (req: Request, res: Response, next: NextFunction) {
81+
// catch both synchronous exceptions and asynchronous rejections
82+
try {
83+
// await the function, if it throws, then go to an error handler
84+
85+
await fn(req, res, next)
86+
} catch (e) {
87+
console.error('Error in asyncErrorHandler')
88+
next(e)
89+
}
90+
}
91+
}

src/types.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { RequestHandler } from 'express'
2+
// TODO: Change these to {} instead of any, which will make it more strict
3+
export type Endpoint<
4+
RequestParams extends Record<string, string> = any,
5+
RequestQuery = any,
6+
RequestBody = any,
7+
ResponseBody = any
8+
> =
9+
| RequestHandler<RequestParams, RequestQuery, RequestBody, ResponseBody>
10+
| RequestHandler[]
11+
12+
export const AllowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
13+
export type AllowedMethod = typeof AllowedMethods[number]
14+
export const ExpressMethods = ['get', 'post', 'put', 'patch', 'delete'] as const
15+
export type ExpressMethod = typeof ExpressMethods[number]
16+
17+
export interface ISession {
18+
id: string
19+
}

0 commit comments

Comments
 (0)