diff --git a/examples/basic-server/src/index.ts b/examples/basic-server/src/index.ts index ac8a1f1..590005f 100644 --- a/examples/basic-server/src/index.ts +++ b/examples/basic-server/src/index.ts @@ -1,26 +1,26 @@ -import { prismy, res, router } from 'prismy' +import { prismy, Result, router } from 'prismy' export const rootHandler = prismy([], () => { - return res( + return Result( [ '', '', '

Root Page

', 'Go to /test', '', - ].join('') + ].join(''), ) }) const testHandler = prismy([], () => { - return res( + return Result( [ '', '', '

Test Page

', 'Go to Root', '', - ].join('') + ].join(''), ) }) diff --git a/examples/file-upload/specs/file.spec.ts b/examples/file-upload/specs/file.spec.ts new file mode 100644 index 0000000..3c5a0ac --- /dev/null +++ b/examples/file-upload/specs/file.spec.ts @@ -0,0 +1,226 @@ +import { testServerManager } from '../../../specs/helpers' +import { + ErrorResult, + getPrismyContext, + Middleware, + prismy, + Result, +} from '../../../src' +import { MultipartBodySelector } from './file' +import path from 'path' +import { File } from 'buffer' +import fs from 'fs' +import { nanoid } from 'nanoid' +import Formidable from 'formidable' + +const testBoundary = `------test-boundary-${nanoid()}` +const testUploadRoot = path.join(process.cwd(), 'file-test-dest') + +beforeAll(async () => { + await testServerManager.start() +}) + +afterAll(async () => { + await testServerManager.close() + fs.rmSync(testUploadRoot, { recursive: true }) + fs.mkdirSync(testUploadRoot) +}) + +describe('MultipartBodySelector', () => { + it('parse a file and a field (FormData)', async () => { + const handler = MultipartTestHandler() + + const formData = new FormData() + const fileBuffer = fs.readFileSync( + path.join(process.cwd(), 'specs/dummyFiles/smallFile.txt'), + ) + const fileBlob = new Blob([fileBuffer]) + const file = new File([fileBlob], 'smallFile.txt') + formData.append('testFile', file) + formData.append('testField', 'testValue') + + const response = await testServerManager.loadRequestListenerAndCall( + handler, + '/', + { + method: 'post', + body: formData, + }, + ) + + expect(response).toMatchObject({ + statusCode: 200, + }) + expect(JSON.parse(response.body)).toMatchObject({ + fields: { + testField: 'testValue', + }, + files: { + testFile: [ + { + filepath: expect.stringMatching( + new RegExp(testUploadRoot + '/[A-z0-9]+'), + ), + mimetype: 'application/octet-stream', + newFilename: expect.stringMatching('[A-z0-9]+'), + originalFilename: 'smallFile.txt', + size: file.size, + }, + ], + }, + }) + }) + + it('parse a file and a field (Raw)', async () => { + const handler = MultipartTestHandler() + + const response = await testServerManager.loadRequestListenerAndCall( + handler, + '/', + { + method: 'post', + headers: { + 'content-type': `multipart/form-data; boundary=${testBoundary}`, + }, + body: [ + '--' + testBoundary, + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '--' + testBoundary, + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '--' + testBoundary + '--', + ].join('\r\n'), + }, + ) + + expect(JSON.parse(response.body)).toMatchObject({ + fields: [ + { + name: 'file_name_0', + value: 'super alpha file', + info: { + encoding: '7bit', + mimeType: 'text/plain', + nameTruncated: false, + valueTruncated: false, + }, + }, + ], + files: [ + { + filePath: expect.stringMatching( + new RegExp( + `${testUploadRoot}/prismy-temp-dir-[A-z0-9_-]+/prismy-upload-[A-z0-9_-]+`, + ), + ), + info: { + encoding: '7bit', + filename: '1k_a.dat', + mimeType: 'application/octet-stream', + }, + name: 'upload_file_0', + }, + ], + }) + expect(response).toMatchObject({ + statusCode: 200, + }) + }) + + it('throws when body is empty', async () => { + const handler = MultipartTestHandler() + + const response = await testServerManager.loadRequestListenerAndCall( + handler, + '/', + { + method: 'post', + headers: { + 'content-type': `multipart/form-data; boundary=${testBoundary}`, + }, + body: '', + }, + ) + + expect(JSON.parse(response.body)).toMatchObject({ + error: expect.stringContaining('Error: Unexpected end of form'), + }) + expect(response).toMatchObject({ + statusCode: 400, + }) + }) + + it('throws if field size hits limit', async () => { + const handler = MultipartTestHandler({ + maxFileSize: 13, + maxFieldsSize: 3, + }) + + const response = await testServerManager.loadRequestListenerAndCall( + handler, + '/', + { + method: 'post', + headers: { + 'content-type': `multipart/form-data; boundary=${testBoundary}`, + }, + body: [ + '--' + testBoundary, + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '--' + testBoundary, + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '--' + testBoundary + '--', + ].join('\r\n'), + }, + ) + + const jsonBody = JSON.parse(response.body) + expect(jsonBody).toMatchObject({ + error: expect.stringContaining('Error: options.maxFieldsSize'), + }) + expect(response).toMatchObject({ + statusCode: 413, + }) + }) +}) + +const errorDataMap = new WeakMap() +function MultipartTestHandler(options: Formidable.Options = {}) { + const multipartBodySelector = MultipartBodySelector({ + uploadDir: testUploadRoot, + ...options, + }) + return prismy( + [multipartBodySelector], + (body) => { + return Result(body) + }, + [ + Middleware([], (next) => async () => { + try { + return await next() + } catch (error: any) { + const context = getPrismyContext() + return ErrorResult( + error.statusCode != null ? error.statusCode : 500, + { + error: error.stack, + data: errorDataMap.get(context), + }, + ) + } + }), + ], + ) +} diff --git a/examples/file-upload/specs/file.ts b/examples/file-upload/specs/file.ts new file mode 100644 index 0000000..5311fe7 --- /dev/null +++ b/examples/file-upload/specs/file.ts @@ -0,0 +1,93 @@ +import { getPrismyContext } from '../prismy' +import { createPrismySelector, PrismySelector } from './createSelector' +import { createError } from '../error' +import Formidable, { errors as formidableErrors, File } from 'formidable' + +function createMultipartBodyReader( + options: Formidable.Options & { multivaluedFields?: MVF[] } = {}, +): () => Promise<{ + fields: Omit<{ [key: string]: undefined | string }, MVF> & { + [key in MVF]: string[] | undefined + } + files: { [key: string]: File[] | undefined } +}> { + return async () => { + const context = getPrismyContext() + const contentType = context.req.headers['content-type'] + if (!isContentTypeMultipart(contentType)) { + throw createError( + 400, + `Content type must be multipart/form-data. (Current: ${contentType})`, + ) + } + const { multivaluedFields = [], ...otherOptions } = options + const form = Formidable(otherOptions) + try { + const [fields, files] = await form.parse(context.req) + return { + fields: Object.entries(fields).reduce( + (obj, [key, value]) => { + if (value == null) { + return obj + } + if (multivaluedFields.indexOf(key as any) > -1) { + obj[key] = value + } else { + obj[key] = value[0] + } + + return obj + }, + {} as Omit<{ [key: string]: undefined | string }, MVF> & { + [key in MVF]: string[] | undefined + }, + ), + files, + } + } catch (error: any) { + switch (error.code) { + case formidableErrors.biggerThanMaxFileSize: + throw createError(413, error.message, error) + } + console.log(error) + throw error + } + } +} + +export function MultipartBodySelector( + options: Formidable.Options & { multivaluedFields?: MVF[] } = {}, +): PrismySelector<{ + fields: Omit<{ [key: string]: undefined | string }, MVF> & { + [key in MVF]: string[] | undefined + } + files: { [key: string]: File[] | undefined } +}> { + return createPrismySelector(async () => { + return createMultipartBodyReader(options)() + }) +} +export function MultipartBodyReaderSelector( + options: Formidable.Options & { multivaluedFields?: MVF[] } = {}, +): PrismySelector< + () => Promise<{ + fields: Omit<{ [key: string]: undefined | string }, MVF> & { + [key in MVF]: string[] | undefined + } + files: { [key: string]: File[] | undefined } + }> +> { + return createPrismySelector(async () => { + return createMultipartBodyReader(options) + }) +} + +function isContentTypeMultipart(contentType: string | undefined) { + if (contentType == null) { + return false + } + if (contentType.startsWith('multipart/form-data')) { + return true + } + return false +} diff --git a/examples/file-upload/specs/files.ts b/examples/file-upload/specs/files.ts new file mode 100644 index 0000000..1443a6f --- /dev/null +++ b/examples/file-upload/specs/files.ts @@ -0,0 +1,7 @@ +import { MultipartBodySelector } from '../../../src/selectors/file' +import { expectType } from '../../../specs/helpers' + +const selector = MultipartBodySelector({ multivaluedFields: ['hello'] }) +const body = await selector.resolve() +expectType(body.fields.hello) +expectType(body.fields.noHello) diff --git a/examples/with-session/src/index.ts b/examples/with-session/src/index.ts index 3829142..ab33ec4 100644 --- a/examples/with-session/src/index.ts +++ b/examples/with-session/src/index.ts @@ -1,4 +1,4 @@ -import { prismy, createUrlEncodedBodySelector, redirect, res } from 'prismy' +import { prismy, createUrlEncodedBodySelector, redirect, Result } from 'prismy' import { methodRouter } from 'prismy-method-router' import createSession from 'prismy-session' import JWTCookieStrategy from 'prismy-session-strategy-jwt-cookie' @@ -9,17 +9,17 @@ interface SessionData { const { sessionSelector, sessionMiddleware } = createSession( new JWTCookieStrategy({ - secret: 'RANDOM_HASH' - }) + secret: 'RANDOM_HASH', + }), ) const urlEncodedBodySelector = createUrlEncodedBodySelector() export default methodRouter( { - get: prismy([sessionSelector], session => { + get: prismy([sessionSelector], (session) => { const { data } = session - return res( + return Result( [ '', '', @@ -28,15 +28,15 @@ export default methodRouter( '', '', '', - '' - ].join('') + '', + ].join(''), ) }), post: prismy([sessionSelector, urlEncodedBodySelector], (session, body) => { session.data = typeof body.message === 'string' ? { message: body.message } : null return redirect('/') - }) + }), }, - [sessionMiddleware] + [sessionMiddleware], ) diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..be69ae0 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,18 @@ +import type { JestConfigWithTsJest } from 'ts-jest' + +const jestConfig: JestConfigWithTsJest = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist/', '/examples/', '/src'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + isolatedModules: true, + }, + ], + }, + coverageReporters: ['text', 'text-summary'], +} + +export default jestConfig diff --git a/package.json b/package.json index b14a14f..9a4a06c 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,21 @@ { "name": "prismy", - "version": "3.0.0", - "description": ":rainbow: Simple and fast type safe server library based on micro for now.sh v2.", + "version": "4.0.0-16", + "description": ":rainbow: Simple and fast type safe server library.", "keywords": [ - "micro", - "service", - "microservice", - "serverless", - "API", - "now" + "http", + "server", + "web", + "type", + "type-safe", + "lightweight", + "fast" ], "author": "Junyoung Choi ", "homepage": "https://github.com/prismyland/prismy", "license": "MIT", "main": "dist/index.js", + "module": "dist/es/index.js", "types": "dist/index.d.ts", "files": [ "dist/index.d.ts", @@ -23,8 +25,21 @@ "type": "git", "url": "git+https://github.com/prismyland/prismy.git" }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/es/index.js" + }, + "./test": { + "types": "./dist/test.d.ts", + "require": "./dist/test.js", + "import": "./dist/es/index.js" + } + }, "scripts": { - "build": "rimraf dist && tsc -P tsconfig.build.json", + "build": "rimraf dist && tsc -P tsconfig.build.json && npm run build-es", + "build-es": "tsc -P tsconfig.es.json", "lint": "prettier --check src/**/*.ts specs/**/*.ts examples/*/src/**/*.ts", "format": "prettier --write src/**/*.ts specs/**/*.ts examples/*/src/**/*.ts", "test": "npm run lint && npm run test-type && npm run test-coverage", @@ -40,33 +55,25 @@ }, "devDependencies": { "@types/content-type": "^1.1.3", - "@types/jest": "^24.0.13", - "@types/node": "^12.19.1", - "@types/test-listen": "^1.1.0", + "@types/cookie": "^0.6.0", + "@types/jest": "^29.5.11", + "@types/node": "^18.19.5", "codecov": "^3.8.0", - "jest": "^26.6.1", - "prettier": "^1.17.1", + "jest": "^29.7.0", + "prettier": "^3.1.1", "rimraf": "^3.0.0", - "test-listen": "^1.1.0", - "ts-jest": "^26.4.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", "typedoc": "^0.15.0", - "got": "^11.8.0", - "typescript": "^4.0.3" - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node", - "testPathIgnorePatterns": [ - "/node_modules/", - "/dist/", - "/examples/" - ] + "typescript": "^4.9.5" }, "dependencies": { + "async-listen": "^3.0.1", "content-type": "^1.0.4", + "cookie": "^0.6.0", "is-stream": "^2.0.0", "path-to-regexp": "^6.2.0", "raw-body": "^2.4.1", - "tslib": "^2.0.3" + "tslib": "^2.6.2" } } diff --git a/readme.md b/readme.md index a1fcd8a..e4ceba7 100644 --- a/readme.md +++ b/readme.md @@ -36,7 +36,7 @@ ## Features -- Very small (No Expressjs, the only deps are micro and tslib) +- Very small (using node.js's http module directly) - Takes advantage of the asynchronous nature of Javascript with full support for async / await - Simple and easy argument injection for handlers (Inspired by ReselectJS) - Completely **TYPE-SAFE** @@ -80,12 +80,12 @@ Make sure typescript strict setting is on if using typescript. `handler.ts` ```ts -import { prismy, res, Selector } from 'prismy' +import { prismy, Result, Selector } from 'prismy' const worldSelector: Selector = () => 'world'! export default prismy([worldSelector], async world => { - return res(`Hello ${world}`) // Hello world! + return Result(`Hello ${world}`) // Hello world! }) ``` @@ -163,7 +163,7 @@ Selectors are simple functions used to generate the arguments for the handler. A single `context` argument or type `Context`. ```ts -import { prismy, res, Selector } from 'prismy' +import { prismy, Result, Selector } from 'prismy' // This selector picks the current url off the request object const urlSelector: Selector = context => { @@ -178,7 +178,7 @@ export default prismy( // making it type safe without having to worry about verbose typings. url => { await doSomethingWithUrl(url) - return res('Done!') + return Result('Done!') } ) ``` @@ -187,7 +187,7 @@ Async selectors are also fully supported out of the box! It will resolve all selectors right before executing handler. ```ts -import { prismy, res, Selector } from 'prismy' +import { prismy, Result, Selector } from 'prismy' const asyncSelector: Selector = async context => { const value = await readValueFromFileSystem() @@ -196,7 +196,7 @@ const asyncSelector: Selector = async context => { export default prismy([asyncSelector], async value => { await doSomething(value) - return res('Done!') + return Result('Done!') }) ``` @@ -223,7 +223,7 @@ const jsonBodySelector = createJsonBodySelector({ export default prismy([jsonBodySelector], async jsonBody => { await doSomething(jsonBody) - return res('Done!') + return Result('Done!') }) ``` @@ -248,7 +248,7 @@ const requestBodySelector: Selector = context => { } export default prismy([requestBodySelector], requestBody => { - return res(`You're query was ${requestBody.json}!`) + return Result(`You're query was ${requestBody.json}!`) }) ``` @@ -267,7 +267,7 @@ This pattern, much like Redux middleware, allows you to: - Do something other than executing handler (e.g Routing, Error handling) ```ts -import { middleware, prismy, res, Selector, updateHeaders } from 'prismy' +import { middleware, prismy, Result, Selector, updateHeaders } from 'prismy' const withCors = middleware([], next => async () => { const resObject = await next() @@ -283,7 +283,7 @@ const withErrorHandler = middleware([urlSelector], next => async url => { try { return await next() } catch (error) { - return res(`Error from ${url}: ${error.message}`) + return Result(`Error from ${url}: ${error.message}`) } }) @@ -316,7 +316,7 @@ selector and middleware to give to prismy. Official strategies include `prismy-session-strategy-jwt-cookie` and `prismy-session-strategy-signed-cookie`. Both available on npm. ```ts -import { prismy, res } from 'prismy' +import { prismy, Result } from 'prismy' import createSession from 'prismy-session' import JWTSessionStrategy from 'prismy-session-strategy' @@ -331,7 +331,7 @@ default export prismy( async session => { const { data } = session await doSomething(data) - return res('Done') + return Result('Done') }, [sessionMiddleware] ) @@ -343,7 +343,7 @@ default export prismy( Prismy also offers a selector for cookies in the `prismy-cookie` package. ```ts -import { prismy, res } from 'prismy' +import { prismy, Result } from 'prismy' import { appendCookie, createCookiesSelector } from 'prismy-cookie' const cookiesSelector = createCookiesSelector() @@ -353,7 +353,7 @@ export default prismy([cookiesSelector], async cookies => { * a string key, value tuple returning a new response object with the * cookie appended. */ - return appendCookie(res('Cookie added!'), ['key', 'value']) + return appendCookie(Result('Cookie added!'), ['key', 'value']) }) ``` @@ -362,7 +362,7 @@ export default prismy([cookiesSelector], async cookies => { From v3, `prismy` provides `router` method to create a routing handler. ```ts -import { prismy, res } from 'prismy' +import { prismy, Result } from 'prismy' import { router } from 'prismy-method-router' import http from 'http' @@ -371,7 +371,7 @@ const myRouter = router([ ['/posts', 'get'], prismy([], () => { const posts = fetchPostList() - return res({ posts }) + return Result({ posts }) }) ], [ @@ -387,7 +387,7 @@ const myRouter = router([ '/posts/:postId', prismy([createRouteParamSelector('postId')], (postId) => { const post = fetchOnePost(postId) - return res({ post }) + return Result({ post }) }) ] ]) @@ -410,7 +410,7 @@ import { prismy, querySelector, redirect, - res, + Result, Selector } from 'prismy' import { methodRouter } from 'prismy-method-router' @@ -462,15 +462,15 @@ export default methodRouter( { get: prismy([], async () => { const todos = await getTodos() - return res({ todos }) + return Result({ todos }) }), post: prismy([contentSelector], async content => { const todo = await createTodo(content) - return res({ todo }) + return Result({ todo }) }), delete: prismy([todoIdSelector], async id => { await deleteTodo(id) - return res('Deleted') + return Result('Deleted') }) }, [authMiddleware, sessionMiddleware] @@ -548,7 +548,7 @@ prismy(selectors, handler) // will give type error prismy([selector1, selector2], handler) // Ok! ``` -- This weird type error may also occur if the handler does not return a `ResponseObject`. Use `res(..)` to generate a `ResponseObject` easily. +- This weird type error may also occur if the handler does not return a `ResponseObject`. Use `Result(..)` to generate a `ResponseObject` easily. ```ts // Will show crazy error. @@ -558,7 +558,7 @@ prismy([selector1, selector2], (one, two) => { // Ok! prismy([selector1, selector2], (one, two) => { - return res('Is a ResponseObject') + return Result('Is a ResponseObject') }) ``` diff --git a/specs/bodyReaders.spec.ts b/specs/bodyReaders.spec.ts index 37657f7..d9f26b0 100644 --- a/specs/bodyReaders.spec.ts +++ b/specs/bodyReaders.spec.ts @@ -1,276 +1,192 @@ -import got from 'got' import getRawBody from 'raw-body' -import { Context, middleware, prismy, res } from '../src' +import { Result, getPrismyContext, Handler } from '../src' import { readBufferBody, readJsonBody, readTextBody } from '../src/bodyReaders' -import { testHandler } from './helpers' +import { TestServer } from '../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) describe('readBufferBody', () => { it('reads buffer body from a request', async () => { - expect.hasAssertions() + const targetBuffer = Buffer.from('Hello, world!') + const handler = Handler([], async () => { + const { req } = getPrismyContext() - const bufferBodySelector = async ({ req }: Context) => { const body = await readBufferBody(req) - return body - } - const handler = prismy([bufferBodySelector], (body) => { - return res(body) + + return Result(body) }) - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from('Hello, world!') - const responsePromise = got(url, { - method: 'POST', - body: targetBuffer, - }) - const bufferPromise = responsePromise.buffer() - const [response, buffer] = await Promise.all([ - responsePromise, - bufferPromise, - ]) - - expect(buffer.equals(targetBuffer)).toBe(true) - expect(response.headers['content-length']).toBe( - targetBuffer.length.toString() - ) + const res = await ts.load(handler).call('/', { + method: 'post', + body: targetBuffer, }) + const resBuffer = Buffer.from(await res.arrayBuffer()) + expect(resBuffer.equals(targetBuffer)).toBeTruthy() + expect(res.headers.get('content-length')).toBe( + targetBuffer.length.toString(), + ) }) - it('reads buffer body regardless delaying', async () => { - expect.hasAssertions() + it('returns cached buffer if it has been read already', async () => { + const targetBuffer = Buffer.from('Hello, world!') + const handler = Handler([], async () => { + const { req } = getPrismyContext() + const body1 = await readBufferBody(req) + const body2 = await readBufferBody(req) - const bufferBodySelector = async ({ req }: Context) => { - const body = await readBufferBody(req) - return body - } - const handler = prismy( - [ - () => { - return new Promise((resolve) => { - setImmediate(resolve) - }) - }, - bufferBodySelector, - ], - (_, body) => { - return res(body) - }, - [ - middleware([], (next) => async () => { - try { - return await next() - } catch (error) { - console.error(error) - throw error - } - }), - ] - ) - - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from('Hello, world!') - const responsePromise = got(url, { - method: 'POST', - body: targetBuffer, + return Result({ + isCached: body1 === body2, }) - const bufferPromise = responsePromise.buffer() - const [response, buffer] = await Promise.all([ - responsePromise, - bufferPromise, - ]) - - expect(buffer.equals(targetBuffer)).toBe(true) - expect(response.headers['content-length']).toBe( - targetBuffer.length.toString() - ) }) - }) - it('returns cached buffer if it is read already', async () => { - expect.hasAssertions() - - const bufferBodySelector = async ({ req }: Context) => { - await readBufferBody(req) - const body = await readBufferBody(req) - return body - } - const handler = prismy([bufferBodySelector], (body) => { - return res(body) + const res = await ts.load(handler).call('/', { + method: 'post', + body: targetBuffer, }) - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from('Hello, world!') - const responsePromise = got(url, { - method: 'POST', - body: targetBuffer, - }) - const bufferPromise = responsePromise.buffer() - const [response, buffer] = await Promise.all([ - responsePromise, - bufferPromise, - ]) - - expect(buffer.equals(targetBuffer)).toBe(true) - expect(response.headers['content-length']).toBe( - targetBuffer.length.toString() - ) + expect(await res.json()).toEqual({ + isCached: true, }) }) it('throws 413 error if the request body is bigger than limits', async () => { - expect.hasAssertions() + const targetBuffer = Buffer.from( + 'Peter Piper picked a peck of pickled peppers', + ) - const bufferBodySelector = async ({ req }: Context) => { + const handler = Handler([], async () => { + const { req } = getPrismyContext() const body = await readBufferBody(req, { limit: '1 byte' }) - return body - } - const handler = prismy([bufferBodySelector], (body) => { - return res(body) - }) - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from( - 'Peter Piper picked a peck of pickled peppers' - ) - const response = await got(url, { - throwHttpErrors: false, - method: 'POST', - responseType: 'json', - body: targetBuffer, - }) + return Result(body) + }) - expect(response.statusCode).toBe(413) - expect(response.body).toMatch('Body exceeded 1 byte limit') + const res = await ts.load(handler).call('/', { + method: 'post', + body: targetBuffer, }) + expect(await res.text()).toContain( + 'Error: Body is too large (limit: 1 byte)', + ) + expect(res.status).toBe(413) }) it('throws 400 error if encoding of request body is invalid', async () => { - expect.hasAssertions() - - const bufferBodySelector = async ({ req }: Context) => { + const targetBuffer = Buffer.from('Hello, world!') + const handler = Handler([], async () => { + const { req } = getPrismyContext() const body = await readBufferBody(req, { encoding: 'lol' }) - return body - } - const handler = prismy([bufferBodySelector], (body) => { - return res(body) - }) - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from('Hello, world!') - const response = await got(url, { - throwHttpErrors: false, - method: 'POST', - responseType: 'json', - body: targetBuffer, - }) + return Result(body) + }) - expect(response.statusCode).toBe(400) - expect(response.body).toMatch('Invalid body') + const res = await ts.load(handler).call('/', { + method: 'post', + headers: { + 'content-type': 'application/json', + }, + body: targetBuffer, }) + + expect(await res.text()).toContain('Invalid body') + expect(res.status).toBe(400) }) it('throws 500 error if the request is drained already', async () => { - expect.hasAssertions() - - const bufferBodySelector = async ({ req }: Context) => { + const targetBuffer = Buffer.from('Oops!') + const handler = Handler([], async () => { + const { req } = getPrismyContext() const length = req.headers['content-length'] await getRawBody(req, { limit: '1mb', length }) const body = await readBufferBody(req) - return body - } - const handler = prismy([bufferBodySelector], (body) => { - return res(body) - }) - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from('Oops!') - const response = await got(url, { - throwHttpErrors: false, - method: 'POST', - responseType: 'json', - body: targetBuffer, - }) + return Result(body) + }) - expect(response.statusCode).toBe(500) - expect(response.body).toMatch('The request has already been drained') + const res = await ts.load(handler).call('/', { + method: 'post', + headers: { + 'content-type': 'application/json', + }, + body: targetBuffer, }) + + expect(await res.text()).toContain('The request has already been drained') }) }) describe('readTextBody', () => { it('reads text from request body', async () => { - expect.hasAssertions() - - const textBodySelector = async ({ req }: Context) => { + const targetBuffer = Buffer.from('Hello, World!') + const handler = Handler([], async () => { + const { req } = getPrismyContext() const body = await readTextBody(req) - return body - } - const handler = prismy([textBodySelector], (body) => { - return res(body) + + return Result(body) }) - await testHandler(handler, async (url) => { - const targetBuffer = Buffer.from('Hello, world!') - const response = await got(url, { - method: 'POST', - body: targetBuffer, - }) - expect(response.body).toBe('Hello, world!') - expect(response.headers['content-length']).toBe( - targetBuffer.length.toString() - ) + const res = await ts.load(handler).call('/', { + method: 'post', + body: targetBuffer, }) + + expect(await res.text()).toBe('Hello, World!') + expect(res.headers.get('content-length')).toBe( + targetBuffer.length.toString(), + ) }) }) describe('readJsonBody', () => { it('reads and parse JSON from a request body', async () => { - expect.hasAssertions() - - const jsonBodySelector = async ({ req }: Context) => { - const body = await readJsonBody(req) - return body + const targetObject = { + foo: 'bar', } - const handler = prismy([jsonBodySelector], (body) => { - return res(body) + const stringifiedTargetObject = JSON.stringify(targetObject) + const handler = Handler([], async () => { + const { req } = getPrismyContext() + const body = await readJsonBody(req) + + return Result(body) }) - await testHandler(handler, async (url) => { - const target = { - foo: 'bar', - } - const response = await got(url, { - method: 'POST', - responseType: 'json', - json: target, - }) - expect(response.body).toMatchObject(target) - expect(response.headers['content-length']).toBe( - JSON.stringify(target).length.toString() - ) + const res = await ts.load(handler).call('/', { + method: 'post', + body: stringifiedTargetObject, }) + + expect(await res.text()).toEqual(stringifiedTargetObject) + expect(res.headers.get('content-length')).toBe( + stringifiedTargetObject.length.toString(), + ) }) it('throws 400 error if the JSON body is invalid', async () => { - expect.hasAssertions() - - const jsonBodySelector = async ({ req }: Context) => { + const target = 'Oopsie! This is definitely not a JSON body' + const handler = Handler([], async () => { + const { req } = getPrismyContext() const body = await readJsonBody(req) - return body - } - const handler = prismy([jsonBodySelector], (body) => { - return res(body) + + return Result(body) }) - await testHandler(handler, async (url) => { - const target = 'Oopsie' - const response = await got(url, { - throwHttpErrors: false, - method: 'POST', - responseType: 'json', - body: target, - }) - expect(response.statusCode).toBe(400) - expect(response.body).toMatch('Error: Invalid JSON') + const res = await ts.load(handler).call('/', { + method: 'post', + headers: { + 'content-type': 'application/json', + }, + body: target, }) + + expect(await res.text()).toContain('Invalid JSON') + expect(res.status).toBe(400) }) }) diff --git a/specs/error.spec.ts b/specs/error.spec.ts new file mode 100644 index 0000000..828b40c --- /dev/null +++ b/specs/error.spec.ts @@ -0,0 +1,54 @@ +import { + ErrorResult, + Result, + createError, + createErrorResultFromError, +} from '../src' + +describe('createErrorResultFromError', () => { + it('returns error as it is if the error is PrismyResult', () => { + // Given + const error = Result('test') + + // When + const result = createErrorResultFromError(error) + + // Then + expect(result).toBe(error) + }) + + it('returns error as it is if the error is PrismyErrorResult', () => { + // Given + const error = ErrorResult(403, 'Forbidden') + + // When + const result = createErrorResultFromError(error) + + // Then + expect(result).toBe(error) + }) + + it('creates 500 result if the error is unknown', () => { + // Given + const error = new Error('Unknown') + + // When + const result = createErrorResultFromError(error) + + // Then + expect(result.statusCode).toBe(500) + expect(result.body).toMatch(/^Error: Unknown/) + }) + + it('uses statusCode if provided', () => { + // Given + const error = createError(403, 'Forbidden') + + // When + const result = createErrorResultFromError(error) + + // Then + expect(result.statusCode).toBe(403) + expect(result.body).toMatch(/^Error: Forbidden/) + }) +}) diff --git a/specs/handler.spec.ts b/specs/handler.spec.ts new file mode 100644 index 0000000..f5f79e3 --- /dev/null +++ b/specs/handler.spec.ts @@ -0,0 +1,30 @@ +import { Handler, Result, createPrismySelector, getPrismyContext } from '../src' + +describe('Handler', () => { + it('exposes raw prismy handler for unit tests', () => { + const rawUrlSelector = createPrismySelector( + () => getPrismyContext().req.url!, + ) + const handler = Handler([rawUrlSelector], (url) => Result(url)) + + const result = handler.handle('Hello, World!') + + expect(result).toMatchObject({ + body: 'Hello, World!', + headers: {}, + statusCode: 200, + }) + }) + + it('handles without a selector', () => { + const handler = Handler(() => Result('Hello!')) + + const result = handler.handle() + + expect(result).toMatchObject({ + body: 'Hello!', + headers: {}, + statusCode: 200, + }) + }) +}) diff --git a/specs/helpers.ts b/specs/helpers.ts deleted file mode 100644 index 0f5bc1e..0000000 --- a/specs/helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import http from 'http' -import listen from 'test-listen' -import { RequestListener } from 'http' - -export type TestCallback = (url: string) => void - -/* istanbul ignore next */ -export async function testHandler( - handler: RequestListener, - testCallback: TestCallback -): Promise { - const server = new http.Server(handler) - - const url = await listen(server) - try { - await testCallback(url) - } catch (error) { - throw error - } finally { - server.close() - } -} - -/* istanbul ignore next */ -export function expectType(value: T): void {} diff --git a/specs/middleware.spec.ts b/specs/middleware.spec.ts index 21e176a..7074eab 100644 --- a/specs/middleware.spec.ts +++ b/specs/middleware.spec.ts @@ -1,75 +1,90 @@ -import got from 'got' -import { testHandler } from './helpers' -import { - prismy, - res, - Selector, - PrismyPureMiddleware, - middleware, - AsyncSelector, -} from '../src' +import { Result, Middleware, getPrismyContext, Handler } from '../src' +import { createPrismySelector } from '../src/selector' +import { TestServer } from '../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) describe('middleware', () => { it('creates Middleware via selectors and middleware handler', async () => { - const rawUrlSelector: Selector = (context) => context.req.url! - const errorMiddleware: PrismyPureMiddleware = middleware( - [rawUrlSelector], - (next) => async (url) => { - try { - return await next() - } catch (error) { - return res(`${url} : ${(error as any).message}`, 500) - } - } + const rawUrlSelector = createPrismySelector( + () => getPrismyContext().req.url!, ) - const handler = prismy( + const errorMiddleware = Middleware([rawUrlSelector], async (next, url) => { + try { + return await next() + } catch (error) { + return Result(`${url} : ${(error as any).message}`, 500) + } + }) + const handler = Handler( [], () => { throw new Error('Hey!') }, - [errorMiddleware] + [errorMiddleware], ) - await testHandler(handler, async (url) => { - const response = await got(url, { - throwHttpErrors: false, - }) - expect(response).toMatchObject({ - statusCode: 500, - body: '/ : Hey!', - }) - }) + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('/ : Hey!') + expect(res.status).toBe(500) }) it('accepts async selectors', async () => { - const asyncRawUrlSelector: AsyncSelector = async (context) => - context.req.url! - const errorMiddleware = middleware( + const asyncRawUrlSelector = createPrismySelector( + async () => getPrismyContext().req.url!, + ) + const errorMiddleware = Middleware( [asyncRawUrlSelector], - (next) => async (url) => { + async (next, url) => { try { return await next() } catch (error) { - return res(`${url} : ${(error as any).message}`, 500) + return Result(`${url} : ${(error as any).message}`, 500) } - } + }, ) - const handler = prismy( + const handler = Handler( [], () => { throw new Error('Hey!') }, - [errorMiddleware] + [errorMiddleware], ) - await testHandler(handler, async (url) => { - const response = await got(url, { - throwHttpErrors: false, - }) - expect(response).toMatchObject({ - statusCode: 500, - body: '/ : Hey!', - }) + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('/ : Hey!') + expect(res.status).toBe(500) + }) + + it('creates without selectors(shorthand)', async () => { + const errorMiddleware = Middleware(async (next) => { + try { + return await next() + } catch (error) { + return Result(`Customized : ${(error as any).message}`, 500) + } }) + const handler = Handler( + [], + () => { + throw new Error('Hey!') + }, + [errorMiddleware], + ) + + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('Customized : Hey!') + expect(res.status).toBe(500) }) }) diff --git a/specs/prismy.spec.ts b/specs/prismy.spec.ts index 0341db7..d38b5d4 100644 --- a/specs/prismy.spec.ts +++ b/specs/prismy.spec.ts @@ -1,157 +1,156 @@ -import got from 'got' -import { testHandler } from './helpers' -import { prismy, res, Selector, PrismyPureMiddleware, err } from '../src' +import { + prismy, + getPrismyContext, + Middleware, + Result, + ErrorResult, + createPrismySelector, +} from '../src' +import { TestServer } from '../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) describe('prismy', () => { it('returns node.js request handler', async () => { - const handler = prismy([], () => res('Hello, World!')) - - await testHandler(handler, async url => { - const response = await got(url) - expect(response).toMatchObject({ - statusCode: 200, - body: 'Hello, World!' - }) - }) - }) + const listener = prismy([], () => Result('Hello, World!')) - it('selects value from context via selector', async () => { - const rawUrlSelector: Selector = context => context.req.url! - const handler = prismy([rawUrlSelector], url => res(url)) - - await testHandler(handler, async url => { - const response = await got(url) - expect(response).toMatchObject({ - statusCode: 200, - body: '/' - }) - }) + const res = await ts.loadRequestListener(listener).call() + + expect(await res.text()).toBe('Hello, World!') + expect(res.status).toBe(200) }) it('selects value from context via selector', async () => { - const asyncRawUrlSelector: Selector = async context => - context.req.url! - const handler = prismy([asyncRawUrlSelector], url => res(url)) - - await testHandler(handler, async url => { - const response = await got(url) - expect(response).toMatchObject({ - statusCode: 200, - body: '/' - }) + const rawUrlSelector = createPrismySelector(() => { + const { req } = getPrismyContext() + return req.url! }) - }) - - it('expose raw prismy handler for unit tests', () => { - const rawUrlSelector: Selector = context => context.req.url! - const handler = prismy([rawUrlSelector], url => res(url)) + const listener = prismy([rawUrlSelector], (url) => Result(url)) - const result = handler.handler('Hello, World!') + const res = await ts.loadRequestListener(listener).call() - expect(result).toEqual({ - body: 'Hello, World!', - headers: {}, - statusCode: 200 - }) + expect(await res.text()).toBe('/') + expect(res.status).toBe(200) }) - it('applys middleware', async () => { - const errorMiddleware: PrismyPureMiddleware = context => async next => { + it('applies middleware', async () => { + const errorMiddleware = Middleware([], async (next) => { try { return await next() } catch (error) { - return err(500, (error as any).message) + return ErrorResult(500, (error as any).message) } - } - const rawUrlSelector: Selector = context => context.req.url! - const handler = prismy( + }) + const rawUrlSelector = createPrismySelector( + () => getPrismyContext().req.url!, + ) + const listener = prismy( [rawUrlSelector], - url => { + (url) => { throw new Error('Hey!') }, - [errorMiddleware] + [errorMiddleware], ) - await testHandler(handler, async url => { - const response = await got(url, { - throwHttpErrors: false - }) - expect(response).toMatchObject({ - statusCode: 500, - body: 'Hey!' - }) - }) + const res = await ts.loadRequestListener(listener).call() + + expect(await res.text()).toBe('Hey!') + expect(res.status).toBe(500) }) - it('applys middleware orderly', async () => { - const problematicMiddleware: PrismyPureMiddleware = context => async next => { + it('applies middleware in order (later = deeper)', async () => { + const problematicMiddleware = Middleware([], () => { throw new Error('Hey!') - } - const errorMiddleware: PrismyPureMiddleware = context => async next => { + }) + const errorMiddleware = Middleware([], async (next) => { try { return await next() } catch (error) { - return res((error as any).message, 500) + return ErrorResult(500, 'Something is wrong!') } - } - const rawUrlSelector: Selector = context => context.req.url! + }) + const rawUrlSelector = createPrismySelector( + () => getPrismyContext().req.url!, + ) const handler = prismy( [rawUrlSelector], - url => { - return res(url) + (url) => { + return Result(url) }, - [problematicMiddleware, errorMiddleware] + [problematicMiddleware, errorMiddleware], ) - await testHandler(handler, async url => { - const response = await got(url, { - throwHttpErrors: false - }) - expect(response).toMatchObject({ - statusCode: 500, - body: 'Hey!' - }) - }) + const res = await ts.loadRequestListener(handler).call() + + expect(await res.text()).toBe('Something is wrong!') + expect(res.status).toBe(500) }) - it('handles unhandled errors from handlers', async () => { - const handler = prismy( + it('handles errors from handlers by default', async () => { + const listener = prismy( [], () => { throw new Error('Hey!') }, - [] + [], ) - await testHandler(handler, async url => { - const response = await got(url, { - throwHttpErrors: false - }) - expect(response).toMatchObject({ - statusCode: 500, - body: expect.stringContaining('Error: Hey!') - }) - }) + + const res = await ts.loadRequestListener(listener).call() + + expect(await res.text()).toContain('Error: Hey!') + expect(res.status).toBe(500) }) - it('handles unhandled errors from selectors', async () => { - const rawUrlSelector: Selector = context => { + it('handles errors from selectors by default', async () => { + const rawUrlSelector = createPrismySelector(() => { throw new Error('Hey!') - } - const handler = prismy( + }) + const listener = prismy( [rawUrlSelector], - url => { - return res(url) + (url) => { + return Result(url) }, - [] + [], ) - await testHandler(handler, async url => { - const response = await got(url, { - throwHttpErrors: false - }) - expect(response).toMatchObject({ - statusCode: 500, - body: expect.stringContaining('Error: Hey!') - }) + + const res = await ts.loadRequestListener(listener).call() + + expect(await res.text()).toContain('Error: Hey!') + expect(res.status).toBe(500) + }) + + it('handles errors from middleware by default', async () => { + const middleware = Middleware([], () => { + throw new Error('Hey!') }) + const listener = prismy( + [], + () => { + return Result('Hello!') + }, + [middleware], + ) + + const res = await ts.loadRequestListener(listener).call() + + expect(await res.text()).toContain('Error: Hey!') + expect(res.status).toBe(500) + }) +}) + +describe('getPrismyContext', () => { + it('throws if context is not set(resolved outside of prismy)', () => { + expect(() => { + const context = getPrismyContext() + throw new Error(`should fail to get context. ${context}`) + }).toThrow('Prismy context is not loaded.') }) }) diff --git a/specs/result.spec.ts b/specs/result.spec.ts new file mode 100644 index 0000000..8927267 --- /dev/null +++ b/specs/result.spec.ts @@ -0,0 +1,342 @@ +import { + RedirectResult, + Result, + Handler, + ErrorResult, + PrismyResult, + PrismyErrorResult, + isErrorResult, + assertErrorResult, + assertNoErrorResult, + isRedirectResult, + assertRedirectResult, + assertNoRedirectResult, +} from '../src' +import { TestServer } from '../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('ErrorResult', () => { + it('creates error PrismyResult', () => { + const errorResult = ErrorResult(400, 'Invalid Format') + + expect(errorResult).toBeInstanceOf(PrismyResult) + expect(errorResult).toBeInstanceOf(PrismyErrorResult) + expect(errorResult.statusCode).toBe(400) + expect(errorResult.body).toBe('Invalid Format') + }) +}) + +describe('PrismyResult', () => { + describe('#setStatusCode', () => { + it('set body', async () => { + const handler = Handler([], () => Result('Hello!').setBody('Hola!')) + + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('Hola!') + }) + + it('sets status code', async () => { + const handler = Handler([], () => + Result('Hello, World!').setStatusCode(201), + ) + + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('Hello, World!') + expect(res.status).toBe(201) + }) + }) + + describe('#updateHeaders', () => { + it('adds headers', async () => { + const handler = Handler([], () => + Result('Hello, World!', 200, { + 'existing-header': 'Hello', + }).updateHeaders({ + 'new-header': 'Hola', + }), + ) + + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('Hello, World!') + expect(res.status).toBe(200) + expect(res.headers.get('existing-header')).toBe('Hello') + expect(res.headers.get('new-header')).toBe('Hola') + }) + + it('replaces existing headers if duplicated, but other headers are still intact', async () => { + const handler = Handler([], () => + Result('Hello, World!', 200, { + 'existing-header': 'Hello', + 'other-existing-header': 'World', + }).updateHeaders({ + 'existing-header': 'Hola', + }), + ) + + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('Hello, World!') + expect(res.status).toBe(200) + expect(res.headers.get('existing-header')).toBe('Hola') + expect(res.headers.get('other-existing-header')).toBe('World') + }) + }) + + describe('#setHeaders', () => { + it('replaces headers', async () => { + const handler = Handler([], () => + Result('Hello, World!', 200, { + 'existing-header': 'Hello', + }).setHeaders({ + 'new-header': 'Hola', + }), + ) + + const res = await ts.load(handler).call() + + expect(await res.text()).toBe('Hello, World!') + expect(res.status).toBe(200) + expect(res.headers.get('existing-header')).toBeNull() + expect(res.headers.get('new-header')).toBe('Hola') + }) + }) +}) + +describe('isErrorResult', () => { + it('returns false if result is NOT an error result', () => { + const result = Result(null) + + const value = isErrorResult(result) + + expect(value).toBe(false) + }) + + it('returns true if result is an error result', () => { + const result = ErrorResult(400, null) + + const value = isErrorResult(result) + + expect(value).toBe(true) + }) +}) + +describe('assertErrorResult', () => { + it('throws error if result is NOT an error result', () => { + const result = Result(null) + try { + assertErrorResult(result) + } catch (error) { + expect((error as Error).message).toEqual( + [ + 'The given PrismyResult is NOT an error result.', + '', + 'Result:', + JSON.stringify(result, null, 2), + ].join('\n'), + ) + return + } + throw new Error('must throw') + }) + + it('does not throw if result is an error result', () => { + const result = ErrorResult(400, null) + + assertErrorResult(result) + }) +}) + +describe('assertErrorResult', () => { + it('throws error if result is an error result', () => { + const result = ErrorResult(400, null) + try { + assertNoErrorResult(result) + } catch (error) { + expect((error as Error).message).toEqual( + [ + 'The given PrismyResult is an error result.', + '', + 'Result:', + JSON.stringify(result, null, 2), + ].join('\n'), + ) + return + } + throw new Error('must throw') + }) + + it('does not throw if result is NOT an error result', () => { + const result = Result(null) + + assertNoErrorResult(result) + }) +}) + +describe('RedirectResult', () => { + it('redirects', async () => { + const handler = Handler([], () => RedirectResult('https://github.com/')) + + const res = await ts.load(handler).call('/', { + redirect: 'manual', + }) + + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('https://github.com/') + }) + + it('sets statusCode', async () => { + const handler = Handler([], () => + RedirectResult('https://github.com/', 301), + ) + + const res = await ts.load(handler).call('/', { + redirect: 'manual', + }) + + expect(res.status).toBe(301) + expect(res.headers.get('location')).toBe('https://github.com/') + }) + + it('sets headers', async () => { + const handler = Handler([], () => + RedirectResult('https://github.com/', 302, { + 'custom-header': 'Hello!', + }), + ) + + const res = await ts.load(handler).call('/', { + redirect: 'manual', + }) + + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('https://github.com/') + expect(res.headers.get('custom-header')).toBe('Hello!') + }) + + it('sets cookies', async () => { + const handler = Handler([], () => + Result(null) + .setCookie('testCookie', 'testValue', { + secure: true, + domain: 'https://example.com', + }) + .setCookie('testCookie2', 'testValue2', { + httpOnly: true, + }), + ) + + const res = await ts.load(handler).call('/') + + expect(res.status).toBe(200) + expect(res.headers.getSetCookie()).toEqual([ + 'testCookie=testValue; Domain=https://example.com; Secure', + 'testCookie2=testValue2; HttpOnly', + ]) + }) + + it('appends set cookie header', async () => { + const handler = Handler([], () => + Result(null) + .updateHeaders({ + 'set-cookie': 'testCookie=testValue', + }) + .setCookie('testCookie2', 'testValue2'), + ) + + const res = await ts.load(handler).call('/') + + expect(res.status).toBe(200) + expect(res.headers.getSetCookie()).toEqual([ + 'testCookie=testValue', + 'testCookie2=testValue2', + ]) + }) +}) + +describe('isRedirectResult', () => { + it('returns false if result is NOT an error result', () => { + const result = Result(null) + + const value = isRedirectResult(result) + + expect(value).toBe(false) + }) + + it('returns true if result is an error result', () => { + const result = RedirectResult('/') + + const value = isRedirectResult(result) + + expect(value).toBe(true) + }) +}) + +describe('assertRedirectResult', () => { + it('throws error if result is NOT an error result', () => { + const result = Result(null) + try { + assertRedirectResult(result) + } catch (error) { + expect((error as Error).message).toEqual( + [ + 'The given PrismyResult is NOT a redirect result.', + '', + 'Result:', + JSON.stringify(result, null, 2), + ].join('\n'), + ) + return + } + throw new Error('must throw') + }) + + it('does not throw if result is an error result', () => { + const result = RedirectResult('/') + try { + assertRedirectResult(result) + } catch (error) { + throw new Error('must NOT throw') + } + }) +}) + +describe('assertRedirectResult', () => { + it('throws error if result is an error result', () => { + const result = RedirectResult('/') + try { + assertNoRedirectResult(result) + } catch (error) { + expect((error as Error).message).toEqual( + [ + 'The given PrismyResult is a redirect result.', + '', + 'Result:', + JSON.stringify(result, null, 2), + ].join('\n'), + ) + return + } + throw new Error('must throw') + }) + + it('does not throw if result is NOT an error result', () => { + const result = Result(null) + try { + assertNoRedirectResult(result) + } catch (error) { + throw new Error('must NOT throw') + } + }) +}) diff --git a/specs/router.spec.ts b/specs/router.spec.ts index 538f931..837244e 100644 --- a/specs/router.spec.ts +++ b/specs/router.spec.ts @@ -1,182 +1,467 @@ -import got from 'got' -import { testHandler } from './helpers' -import { createRouteParamSelector, prismy, res, router } from '../src' -import { join } from 'path' +import { + RouteParamSelector, + Result, + Router, + Route, + Middleware, + getPrismyContext, + ErrorResult, + createPrismySelector, + OptionalRouteParamSelector, +} from '../src' +import { Handler } from '../src/handler' +import { InjectSelector } from '../src/selectors/inject' +import { TestServer } from '../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) describe('router', () => { it('routes with pathname', async () => { - expect.hasAssertions() - const handlerA = prismy([], () => { - return res('a') + const handlerA = Handler(() => { + return Result('a') }) - const handlerB = prismy([], () => { - return res('b') + const handlerB = Handler(() => { + return Result('b') }) + const routerHandler = Router([Route('/a', handlerA), Route('/b', handlerB)]) + + const res = await ts.load(routerHandler).call('/b') - const routerHandler = router([ - ['/a', handlerA], - ['/b', handlerB], + expect(await res.text()).toBe('b') + }) + + it('routes with pathname(shorthand)', async () => { + const routerHandler = Router([ + Route('/a', [InjectSelector('a')], (data) => Result(data)), + Route('/b', [InjectSelector('b')], (data) => Result(data)), ]) - await testHandler(routerHandler, async (url) => { - const response = await got(join(url, 'b'), { - method: 'GET', - }) + const res = await ts.load(routerHandler).call('/b') - expect(response).toMatchObject({ - statusCode: 200, - body: 'b', - }) - }) + expect(await res.text()).toBe('b') + }) + + it('routes with pathname(shorthand w/o selectors)', async () => { + const routerHandler = Router([ + Route('/a', () => Result('a')), + Route('/b', () => Result('b')), + ]) + + const res = await ts.load(routerHandler).call('/b') + + expect(await res.text()).toBe('b') }) it('routes with method', async () => { - expect.assertions(2) - const handlerA = prismy([], () => { - return res('a') + const handlerA = Handler([], () => { + return Result('a') }) - const handlerB = prismy([], () => { - return res('b') + const handlerB = Handler([], () => { + return Result('b') }) - const routerHandler = router([ - [['/', 'get'], handlerA], - [['/', 'post'], handlerB], + const routerHandler = Router([ + Route(['/', 'get'], handlerA), + Route(['/', 'post'], handlerB), ]) - await testHandler(routerHandler, async (url) => { - const response = await got(url, { - method: 'GET', - }) + const res1 = await ts.load(routerHandler).call('/') - expect(response).toMatchObject({ - statusCode: 200, - body: 'a', - }) + expect(await res1.text()).toBe('a') + + const res2 = await ts.call('/', { method: 'post' }) + + expect(await res2.text()).toBe('b') + }) + + it('throws 404 error when no route found', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([], () => { + return Result('b') }) - await testHandler(routerHandler, async (url) => { - const response = await got(url, { - method: 'POST', - }) + const routerHandler = Router([ + Route(['/', 'get'], handlerA), + Route(['/', 'post'], handlerB), + ]) - expect(response).toMatchObject({ - statusCode: 200, - body: 'b', - }) + const res = await ts.load(routerHandler).call('/', { + method: 'put', }) + + expect(await res.text()).toContain('Error: Not Found') + expect(res.status).toBe(404) }) - it('resolve params', async () => { - expect.hasAssertions() - const handlerA = prismy([], () => { - return res('a') + it('uses custom not found handler if set', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([], () => { + return Result('b') }) - const handlerB = prismy([createRouteParamSelector('id')], (id) => { - return res(id) + const customNotFoundHandler = Handler([], () => { + return Result('Error: Customized Not Found Response', 404) }) - const routerHandler = router([ - ['/a', handlerA], - ['/b/:id', handlerB], - ]) + const routerHandler = Router( + [Route(['/', 'get'], handlerA), Route(['/', 'post'], handlerB)], + { + notFoundHandler: customNotFoundHandler, + }, + ) - await testHandler(routerHandler, async (url) => { - const response = await got(join(url, 'b/test-param'), { - method: 'GET', - }) + const res = await ts.load(routerHandler).call('/', { + method: 'put', + }) - expect(response).toMatchObject({ - statusCode: 200, - body: 'test-param', - }) + expect(res.status).toBe(404) + expect(await res.text()).toContain('Error: Customized Not Found Response') + }) + + it('prepends prefix to route path', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([], () => { + return Result('b') }) + + const routerHandler = Router( + [Route(['/', 'get'], handlerA), Route(['/', 'post'], handlerB)], + { + prefix: '/admin', + }, + ) + + const res = await ts.load(routerHandler).call('/admin') + + expect(res.status).toBe(200) + expect(await res.text()).toContain('a') }) - it('resolves null if param is missing', async () => { + it('prepends prefix to route path (without root `/`)', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([], () => { + return Result('b') + }) + + const routerHandler = Router( + [Route(['/', 'get'], handlerA), Route(['/', 'post'], handlerB)], + { + prefix: 'admin', + }, + ) + + const res = await ts.load(routerHandler).call('/admin') + + expect(res.status).toBe(200) + expect(await res.text()).toContain('a') + }) + + it('applies middleware', async () => { expect.hasAssertions() - const handlerA = prismy([], () => { - return res('a') + + const weakMap = new WeakMap() + const handlerA = Handler([], () => { + const context = getPrismyContext() + + return Result(weakMap.get(context)) }) - const handlerB = prismy([createRouteParamSelector('not-id')], (notId) => { - return res(notId) + + const routerHandler = Router([Route(['/', 'get'], handlerA)], { + middleware: [ + Middleware([InjectSelector('a')], (next, value) => { + const context = getPrismyContext() + weakMap.set(context, (weakMap.get(context) || '') + value) + return next() + }), + Middleware([InjectSelector('b')], (next, value) => { + const context = getPrismyContext() + weakMap.set(context, (weakMap.get(context) || '') + value) + return next() + }), + ], }) - const routerHandler = router([ - ['/a', handlerA], - ['/b/:id', handlerB], - ]) + const res = await ts.load(routerHandler).call() - await testHandler(routerHandler, async (url) => { - const response = await got(join(url, 'b/test-param'), { - method: 'GET', - }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('ba') + }) - expect(response).toMatchObject({ - statusCode: 200, - body: '', - }) + it('applies error handler middleware(Error from handler', async () => { + expect.hasAssertions() + + const handler = Handler([], () => { + throw new Error('Hello, Error!') }) + + const routerHandler = Router([Route(['/', 'get'], handler)], { + middleware: [ + Middleware([], async (next) => { + try { + return await next() + } catch (error) { + return ErrorResult(500, 'hijacked') + } + }), + ], + }) + + const res = await ts.load(routerHandler).call() + + expect(res.status).toBe(500) + expect(await res.text()).toBe('hijacked') }) - it('throws 404 error when no route found', async () => { - expect.assertions(1) - const handlerA = prismy([], () => { - return res('a') + it('applies error handler middleware(Error from handler selectors)', async () => { + expect.hasAssertions() + + const handler = Handler( + [ + createPrismySelector(() => { + throw new Error('Hello from a selector') + }), + ], + () => { + return Result(null) + }, + ) + + const routerHandler = Router([Route(['/', 'get'], handler)], { + middleware: [ + Middleware([], async (next) => { + try { + return await next() + } catch (error) { + return ErrorResult(500, 'hijacked') + } + }), + ], }) - const handlerB = prismy([], () => { - return res('b') + + const res = await ts.load(routerHandler).call() + + expect(res.status).toBe(500) + expect(await res.text()).toBe('hijacked') + }) +}) + +describe('RouteParamSelector', () => { + it('throws an error if the param is missing', async () => { + expect.hasAssertions() + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([RouteParamSelector('not-id')], (notId) => { + return Result(notId) }) - const routerHandler = router([ - [['/', 'get'], handlerA], - [['/', 'post'], handlerB], + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:id', handlerB), ]) - await testHandler(routerHandler, async (url) => { - const response = await got(url, { - method: 'PUT', - throwHttpErrors: false, - }) + const res = await ts.load(routerHandler).call('/b/test-param') - expect(response).toMatchObject({ - statusCode: 404, - body: expect.stringContaining('Error: Not Found'), - }) - }) + expect(res.status).toBe(404) + expect(await res.text()).toContain( + 'Error: Route parameter not-id not found', + ) }) - it('uses custom 404 error handler', async () => { - expect.assertions(1) - const handlerA = prismy([], () => { - return res('a') + it('resolves a param (named parameter)', async () => { + const handlerA = Handler([], () => { + return Result('a') }) - const handlerB = prismy([], () => { - return res('b') + const handlerB = Handler([RouteParamSelector('id')], (id) => { + return Result(id) }) - const routerHandler = router( + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:id', handlerB), + ]) + + const res = await ts.load(routerHandler).call('/b/test-param') + + expect(res.status).toBe(200) + expect(await res.text()).toBe('test-param') + }) + + it('resolves params (custom suffix)', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler( [ - [['/', 'get'], handlerA], - [['/', 'post'], handlerB], + OptionalRouteParamSelector('attr1'), + OptionalRouteParamSelector('attr2'), + OptionalRouteParamSelector('attr3'), ], - { - notFoundHandler: prismy([], () => { - return res('Not Found(Customized)', 404) - }), - } + (attr1, attr2, attr3) => { + return Result({ + attr1, + attr2, + attr3, + }) + }, + ) + + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:attr1?{-:attr2}?{-:attr3}?', handlerB), + ]) + + const res = await ts.load(routerHandler).call('/b/test1-test2-test3') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + attr1: 'test1', + attr2: 'test2', + attr3: 'test3', + }) + + const res2 = await ts.call('/b/test1-test2') + expect(res2.status).toBe(200) + expect(await res2.json()).toEqual({ + attr1: 'test1', + attr2: 'test2', + attr3: null, + }) + }) + + it('resolves a param (unnamed parameter)', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler( + [RouteParamSelector('id'), RouteParamSelector('0')], + (id, unnamedParam) => { + return Result({ + id, + unnamedParam, + }) + }, ) - await testHandler(routerHandler, async (url) => { - const response = await got(url, { - method: 'PUT', - throwHttpErrors: false, + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:id/(.*)', handlerB), + ]) + + const res = await ts.load(routerHandler).call('/b/test1/test2/test3') + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + id: 'test1', + unnamedParam: 'test2/test3', + }) + }) + + it('resolves a param (optional)', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler( + [RouteParamSelector('param1'), OptionalRouteParamSelector('param2')], + (param1, param2) => { + return Result({ + param1, + param2, + }) + }, + ) + + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:param1/:param2?', handlerB), + ]) + + const res1 = await ts.load(routerHandler).call('/b/test1/test2') + + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ + param1: 'test1', + param2: 'test2', + }) + + const res2 = await ts.load(routerHandler).call('/b/test1') + + expect(res2.status).toBe(200) + expect(await res2.json()).toEqual({ + param1: 'test1', + param2: null, + }) + }) + + it('resolves the first param only (zero or more)', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([OptionalRouteParamSelector('param')], (param) => { + return Result({ + param, }) + }) + + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:param*', handlerB), + ]) + + const res1 = await ts.load(routerHandler).call('/b/test1/test2') + + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ + param: 'test1', + }) + + const res2 = await ts.load(routerHandler).call('/b') + + expect(res2.status).toBe(200) + expect(await res2.json()).toEqual({ + param: null, + }) + }) - expect(response).toMatchObject({ - statusCode: 404, - body: 'Not Found(Customized)', + it('resolves the first param only (one or more)', async () => { + const handlerA = Handler([], () => { + return Result('a') + }) + const handlerB = Handler([RouteParamSelector('param')], (param) => { + return Result({ + param, }) }) + + const routerHandler = Router([ + Route('/a', handlerA), + Route('/b/:param+', handlerB), + ]) + + const res1 = await ts.load(routerHandler).call('/b/test1/test2') + + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ + param: 'test1', + }) + + const res2 = await ts.load(routerHandler).call('/b') + + expect(res2.status).toBe(404) }) }) diff --git a/specs/selectors/body.spec.ts b/specs/selectors/body.spec.ts index 4d7e766..8d845dc 100644 --- a/specs/selectors/body.spec.ts +++ b/specs/selectors/body.spec.ts @@ -1,73 +1,60 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { createBodySelector } from '../../src/selectors' -import { prismy, res } from '../../src' +import { Handler, Result, BodySelector } from '../../src' +import { TestServer } from '../../src/test' -describe('createBodySelector', () => { - it('returns text body', async () => { - expect.hasAssertions() - const bodySelector = createBodySelector() - const handler = prismy([bodySelector], body => { - return res(`${body.constructor.name}: ${body}`) - }) +const ts = TestServer() - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - body: 'Hello, World!' - }) +beforeAll(async () => { + await ts.start() +}) - expect(response).toMatchObject({ - statusCode: 200, - body: `String: Hello, World!` - }) +afterAll(async () => { + await ts.close() +}) + +describe('BodySelector', () => { + it('selects text body', async () => { + const handler = Handler([BodySelector()], (body) => { + return Result(body) }) + + const res = await ts.load(handler).call('/', { + method: 'post', + body: 'Hello!', + }) + + expect(await res.text()).toBe('Hello!') }) - it('returns parsed url encoded body', async () => { - expect.hasAssertions() - const bodySelector = createBodySelector() - const handler = prismy([bodySelector], body => { - return res(body) + it('selects parsed form', async () => { + const handler = Handler([BodySelector()], (body) => { + return Result(body) }) - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - responseType: 'json', - form: { - message: 'Hello, World!' - } - }) + const res = await ts.load(handler).call('/', { + method: 'post', + body: new URLSearchParams([['message', 'Hello!']]), + }) - expect(response).toMatchObject({ - statusCode: 200, - body: { - message: 'Hello, World!' - } - }) + expect(await res.json()).toEqual({ + message: 'Hello!', }) }) - it('returns JSON object body', async () => { - expect.hasAssertions() - const bodySelector = createBodySelector() - const handler = prismy([bodySelector], body => { - return res(body) + it('selects json body', async () => { + const handler = Handler([BodySelector()], (body) => { + return Result(body) }) - await testHandler(handler, async url => { - const target = { - foo: 'bar' - } - const response = await got(url, { - method: 'POST', - responseType: 'json', - json: target - }) + const res = await ts.load(handler).call('/', { + method: 'post', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ message: 'Hello!' }), + }) - expect(response.statusCode).toBe(200) - expect(response.body).toMatchObject(target) + expect(await res.json()).toEqual({ + message: 'Hello!', }) }) }) diff --git a/specs/selectors/bufferBody.spec.ts b/specs/selectors/bufferBody.spec.ts index 92942f9..1f7e8c1 100644 --- a/specs/selectors/bufferBody.spec.ts +++ b/specs/selectors/bufferBody.spec.ts @@ -1,21 +1,27 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { createBufferBodySelector, prismy, res } from '../../src' +import { BufferBodySelector, Handler, Result } from '../../src' +import { TestServer } from '../../src/test' -describe('createBufferBodySelector', () => { +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('BufferBodySelector', () => { it('creates buffer body selector', async () => { - const bufferBodySelector = createBufferBodySelector() - const handler = prismy([bufferBodySelector], body => { - return res(`${body.constructor.name}: ${body}`) + const handler = Handler([BufferBodySelector()], (body) => { + return Result(`${body.constructor.name}: ${body}`) }) - await testHandler(handler, async url => { - const response = await got(url, { method: 'POST', body: 'Hello, World!' }) - - expect(response).toMatchObject({ - statusCode: 200, - body: 'Buffer: Hello, World!' - }) + const res = await ts.load(handler).call('/', { + method: 'post', + body: Buffer.from('Hello!'), }) + + expect(await res.text()).toBe('Buffer: Hello!') }) }) diff --git a/specs/selectors/context.spec.ts b/specs/selectors/context.spec.ts deleted file mode 100644 index 0580e2e..0000000 --- a/specs/selectors/context.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { contextSelector, prismy, res, headersSelector } from '../../src' - -describe('contextSelector', () => { - it('select context', async () => { - const handler = prismy([contextSelector], async context => { - const headers = await headersSelector(context) - return res(headers['x-test']) - }) - - await testHandler(handler, async url => { - const response = await got(url, { - headers: { - 'x-test': 'Hello, World!' - } - }) - - expect(response).toMatchObject({ - statusCode: 200, - body: 'Hello, World!' - }) - }) - }) -}) diff --git a/specs/selectors/cookie.spec.ts b/specs/selectors/cookie.spec.ts new file mode 100644 index 0000000..cf2dffc --- /dev/null +++ b/specs/selectors/cookie.spec.ts @@ -0,0 +1,44 @@ +import { CookieSelector, Handler, Result } from '../../src' +import { TestServer } from '../../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('CookieSelector', () => { + it('selects a cookie value', async () => { + const handler = Handler([CookieSelector('test')], (cookieValue) => { + return Result({ cookieValue }) + }) + + const res = await ts.load(handler).call('/', { + headers: { + cookie: 'test=Hello!', + }, + }) + + expect(await res.json()).toMatchObject({ + cookieValue: 'Hello!', + }) + expect(res.status).toBe(200) + }) + + it('selects null if cookie is not set', async () => { + const handler = Handler([CookieSelector('test')], (cookieValue) => { + return Result({ cookieValue }) + }) + + const res = await ts.load(handler).call('/') + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + cookieValue: null, + }) + }) +}) diff --git a/specs/selectors/headers.spec.ts b/specs/selectors/headers.spec.ts index a3745c5..10c0186 100644 --- a/specs/selectors/headers.spec.ts +++ b/specs/selectors/headers.spec.ts @@ -1,24 +1,28 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { headersSelector, prismy, res } from '../../src' +import { Handler, HeadersSelector, Result } from '../../src' +import { TestServer } from '../../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) describe('headersSelector', () => { it('select headers', async () => { - const handler = prismy([headersSelector], headers => { - return res(headers['x-test']) + const handler = Handler([HeadersSelector()], (headers) => { + return Result(headers['x-test']) }) - await testHandler(handler, async url => { - const response = await got(url, { - headers: { - 'x-test': 'Hello, World!' - } - }) - - expect(response).toMatchObject({ - statusCode: 200, - body: 'Hello, World!' - }) + const res = await ts.load(handler).call('/', { + headers: { + 'x-test': 'Hello, World!', + }, }) + + expect(await res.text()).toBe('Hello, World!') }) }) diff --git a/specs/selectors/jsonBody.spec.ts b/specs/selectors/jsonBody.spec.ts index abbbbc8..c25eb5e 100644 --- a/specs/selectors/jsonBody.spec.ts +++ b/specs/selectors/jsonBody.spec.ts @@ -1,108 +1,47 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { createJsonBodySelector, prismy, res } from '../../src' +import { Handler, JsonBodySelector, Result } from '../../src' +import { TestServer } from '../../src/test' -describe('createJsonBodySelector', () => { - it('creates json body selector', async () => { - const jsonBodySelector = createJsonBodySelector() - const handler = prismy([jsonBodySelector], body => { - return res(body) - }) - - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - responseType: 'json', - json: { - message: 'Hello, World!' - } - }) - - expect(response).toMatchObject({ - statusCode: 200, - body: { - message: 'Hello, World!' - } - }) - }) - }) +const ts = TestServer() - it('throws if content type of a request is not application/json #1 (Anti CSRF)', async () => { - const jsonBodySelector = createJsonBodySelector() - const handler = prismy([jsonBodySelector], body => { - return res(body) - }) +beforeAll(async () => { + await ts.start() +}) - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - body: JSON.stringify({ - message: 'Hello, World!' - }), - throwHttpErrors: false - }) +afterAll(async () => { + await ts.close() +}) - expect(response).toMatchObject({ - statusCode: 400, - body: expect.stringContaining( - 'Error: Content type must be application/json. (Current: undefined)' - ) - }) +describe('JsonBodySelector', () => { + it('creates json body selector', async () => { + const handler = Handler([JsonBodySelector()], (body) => { + return Result(body) }) - }) - it('throws if content type of a request is not application/json #2 (Anti CSRF)', async () => { - const jsonBodySelector = createJsonBodySelector() - const handler = prismy([jsonBodySelector], body => { - return res(body) + const res = await ts.load(handler).call('/', { + method: 'post', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ message: 'Hello!' }), }) - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - json: { - message: 'Hello, World!' - }, - headers: { - 'content-type': 'text/plain' - }, - throwHttpErrors: false - }) - - expect(response).toMatchObject({ - statusCode: 400, - body: expect.stringContaining( - 'Error: Content type must be application/json. (Current: text/plain)' - ) - }) + expect(await res.json()).toEqual({ + message: 'Hello!', }) }) - it('skips content-type checking if the option is given', async () => { - const jsonBodySelector = createJsonBodySelector({ - skipContentTypeCheck: true + it('throw if content type of a request is not application/json', async () => { + const handler = Handler([JsonBodySelector()], (body) => { + return Result(body) }) - const handler = prismy([jsonBodySelector], body => { - return res(body) - }) - - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - json: { - message: 'Hello, World!' - }, - headers: { - 'content-type': 'text/plain' - } - }) - expect(response).toMatchObject({ - statusCode: 200, - body: JSON.stringify({ - message: 'Hello, World!' - }) - }) + const res = await ts.load(handler).call('/', { + method: 'post', + body: JSON.stringify({ message: 'Hello!' }), }) + + expect(await res.text()).toContain( + 'Error: Content type must be application/json. (Current: text/plain', + ) }) }) diff --git a/specs/selectors/method.spec.ts b/specs/selectors/method.spec.ts index 18c15a0..5cde8f9 100644 --- a/specs/selectors/method.spec.ts +++ b/specs/selectors/method.spec.ts @@ -1,20 +1,32 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { methodSelector, prismy, res } from '../../src' +import { Handler, MethodSelector, Result } from '../../src' +import { TestServer } from '../../src/test' -describe('methodSelector', () => { +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('MethodSelector', () => { it('selects method', async () => { - const handler = prismy([methodSelector], method => { - return res(method) + const handler = Handler([MethodSelector()], (method) => { + return Result(method) + }) + + const res = await ts.load(handler).call('/', { + method: 'get', }) - await testHandler(handler, async url => { - const response = await got(url) + expect(await res.text()).toBe('GET') - expect(response).toMatchObject({ - statusCode: 200, - body: 'GET' - }) + const res2 = await ts.call('/', { + method: 'put', }) + + expect(await res2.text()).toBe('PUT') }) }) diff --git a/specs/selectors/query.spec.ts b/specs/selectors/query.spec.ts deleted file mode 100644 index b1a1e0b..0000000 --- a/specs/selectors/query.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { querySelector, prismy, res } from '../../src' - -describe('querySelector', () => { - it('selects query', async () => { - const handler = prismy([querySelector], query => { - return res(query) - }) - - await testHandler(handler, async url => { - const response = await got(url, { - searchParams: { message: 'Hello, World!' }, - responseType: 'json' - }) - - expect(response).toMatchObject({ - statusCode: 200, - body: { message: 'Hello, World!' } - }) - }) - }) - - it('reuses parsed query', async () => { - const handler = prismy([querySelector, querySelector], (query, query2) => { - return res(JSON.stringify(query === query2)) - }) - - await testHandler(handler, async url => { - const response = await got(url) - - expect(response).toMatchObject({ - statusCode: 200, - body: 'true' - }) - }) - }) -}) diff --git a/specs/selectors/searchParam.spec.ts b/specs/selectors/searchParam.spec.ts new file mode 100644 index 0000000..34399a8 --- /dev/null +++ b/specs/selectors/searchParam.spec.ts @@ -0,0 +1,77 @@ +import { + SearchParamSelector, + SearchParamListSelector, + Result, + Handler, +} from '../../src' +import { TestServer } from '../../src/test' + +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('SearchParamSelector', () => { + it('selects a search param', async () => { + const handler = Handler([SearchParamSelector('message')], (message) => { + return Result({ message }) + }) + + const res = await ts.load(handler).call('/?message=Hello!') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + message: 'Hello!', + }) + }) + + it('selects null if there is no param with the name', async () => { + const handler = Handler([SearchParamSelector('message')], (message) => { + return Result({ message }) + }) + + const res = await ts.load(handler).call('/') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + message: null, + }) + }) +}) + +describe('SearchParamListSelector', () => { + it('selects a search param list', async () => { + const handler = Handler( + [SearchParamListSelector('message')], + (messages) => { + return Result({ messages }) + }, + ) + + const res = await ts.load(handler).call('?message=Hello!&message=Hi!') + + expect(await res.json()).toEqual({ + messages: ['Hello!', 'Hi!'], + }) + }) + + it('selects an empty array if there is no param with the name', async () => { + const handler = Handler( + [SearchParamListSelector('message')], + (messages) => { + return Result({ messages }) + }, + ) + + const res = await ts.load(handler).call('/') + + expect(await res.json()).toEqual({ + messages: [], + }) + }) +}) diff --git a/specs/selectors/textBody.spec.ts b/specs/selectors/textBody.spec.ts index e2cb8fd..31ba327 100644 --- a/specs/selectors/textBody.spec.ts +++ b/specs/selectors/textBody.spec.ts @@ -1,21 +1,27 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { createTextBodySelector, prismy, res } from '../../src' +import { Handler, Result, TextBodySelector } from '../../src' +import { TestServer } from '../../src/test' -describe('createTextBodySelector', () => { +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('TextBodySelector', () => { it('creates buffer body selector', async () => { - const textBodySelector = createTextBodySelector() - const handler = prismy([textBodySelector], body => { - return res(`${body.constructor.name}: ${body}`) + const handler = Handler([TextBodySelector()], (body) => { + return Result(`${typeof body}: ${body}`) }) - await testHandler(handler, async url => { - const response = await got(url, { method: 'POST', body: 'Hello, World!' }) - - expect(response).toMatchObject({ - statusCode: 200, - body: 'String: Hello, World!' - }) + const res = await ts.load(handler).call('/', { + method: 'post', + body: 'Hello, World!', }) + + expect(await res.text()).toBe('string: Hello, World!') }) }) diff --git a/specs/selectors/url.spec.ts b/specs/selectors/url.spec.ts index fba16a3..e8ea1e4 100644 --- a/specs/selectors/url.spec.ts +++ b/specs/selectors/url.spec.ts @@ -1,39 +1,40 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { urlSelector, prismy, res } from '../../src' +import { Handler, Result, UrlSelector } from '../../src' +import { TestServer } from '../../src/test' -describe('urlSelector', () => { +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) + +afterAll(async () => { + await ts.close() +}) + +describe('UrlSelector', () => { it('selects url', async () => { - const handler = prismy([urlSelector], url => { - return res(url) + const handler = Handler([UrlSelector()], (url) => { + return Result({ + pathname: url.pathname, + search: url.search, + }) }) - await testHandler(handler, async url => { - const response = await got(url, { - responseType: 'json' - }) + const res = await ts.load(handler).call('/test?query=true#hash') - expect(response).toMatchObject({ - statusCode: 200, - body: expect.objectContaining({ - path: '/' - }) - }) + expect(await res.json()).toEqual({ + pathname: '/test', + search: '?query=true', }) }) it('reuses parsed url', async () => { - const handler = prismy([urlSelector, urlSelector], (url, url2) => { - return res(JSON.stringify(url === url2)) + const handler = Handler([UrlSelector(), UrlSelector()], (url, url2) => { + return Result(JSON.stringify(url === url2)) }) - await testHandler(handler, async url => { - const response = await got(url) + const res = await ts.load(handler).call('/test?query=true#hash') - expect(response).toMatchObject({ - statusCode: 200, - body: 'true' - }) - }) + expect(await res.json()).toEqual(true) }) }) diff --git a/specs/selectors/urlEncodedBody.spec.ts b/specs/selectors/urlEncodedBody.spec.ts index ef2932c..26ba119 100644 --- a/specs/selectors/urlEncodedBody.spec.ts +++ b/specs/selectors/urlEncodedBody.spec.ts @@ -1,30 +1,31 @@ -import got from 'got' -import { testHandler } from '../helpers' -import { createUrlEncodedBodySelector, prismy, res } from '../../src' +import { Handler, Result, UrlEncodedBodySelector } from '../../src' +import { TestServer } from '../../src/test' -describe('URLEncodedBody', () => { - it('injects parsed url encoded body', async () => { - const urlEncodedBodySelector = createUrlEncodedBodySelector() +const ts = TestServer() + +beforeAll(async () => { + await ts.start() +}) - const handler = prismy([urlEncodedBodySelector], body => { - return res(body) +afterAll(async () => { + await ts.close() +}) + +describe('UrlEncodedBody', () => { + it('injects parsed url encoded body', async () => { + const handler = Handler([UrlEncodedBodySelector()], (body) => { + return Result(body) }) + const body = new URLSearchParams() + body.append('message', 'Hello, World!') - await testHandler(handler, async url => { - const response = await got(url, { - method: 'POST', - responseType: 'json', - form: { - message: 'Hello, World!' - } - }) + const res = await ts.load(handler).call('/', { + method: 'post', + body: body, + }) - expect(response).toMatchObject({ - statusCode: 200, - body: { - message: 'Hello, World!' - } - }) + expect(await res.json()).toEqual({ + message: 'Hello, World!', }) }) }) diff --git a/specs/send.spec.ts b/specs/send.spec.ts index 9471286..2acf101 100644 --- a/specs/send.spec.ts +++ b/specs/send.spec.ts @@ -1,281 +1,142 @@ -import got from 'got' import { IncomingMessage, RequestListener, ServerResponse } from 'http' import { Readable } from 'stream' -import { send } from '../src/send' -import { testHandler } from './helpers' +import { Result } from '../src' +import { sendPrismyResult } from '../src/send' +import { TestServer } from '../src/test' -describe('send', () => { - it('sends empty body when body is null', async () => { - expect.hasAssertions() - - const handler: RequestListener = (req, res) => { - send(req, res, {}) - } +const ts = TestServer() - await testHandler(handler, async (url) => { - const response = await got(url) - expect(response.body).toBeFalsy() - }) - }) +beforeAll(async () => { + await ts.start() +}) - it('sends string body', async () => { - expect.hasAssertions() +afterAll(async () => { + await ts.close() +}) +describe('send', () => { + it('sends empty body when body is null', async () => { const handler: RequestListener = (req, res) => { - send(req, res, { body: 'test' }) + sendPrismyResult(req, res, Result(null)) } - await testHandler(handler, async (url) => { - const response = await got(url) - expect(response.body).toEqual('test') - }) - }) + const res = await ts.loadRequestListener(handler).call('/') - it('sends buffer body', async () => { - expect.hasAssertions() + expect(await res.text()).toBe('') + }) - const targetBuffer = Buffer.from('Hello, world!') + it('sends string body', async () => { const handler: RequestListener = (req, res) => { - res.setHeader('Content-Type', 'application/octet-stream') - const statusCode = res.statusCode - send(req, res, { statusCode, body: targetBuffer }) + sendPrismyResult(req, res, Result('test')) } - await testHandler(handler, async (url) => { - const responsePromise = got(url) - const bufferPromise = responsePromise.buffer() - const [response, buffer] = await Promise.all([ - responsePromise, - bufferPromise, - ]) + const res = await ts.loadRequestListener(handler).call('/') - expect(targetBuffer.equals(buffer)).toBe(true) - expect(response.headers['content-length']).toBe( - targetBuffer.length.toString() - ) - }) + expect(await res.text()).toBe('test') }) - it('sets header when Content-Type header is not given (buffer)', async () => { - expect.hasAssertions() - + it('sends buffer body', async () => { const targetBuffer = Buffer.from('Hello, world!') const handler: RequestListener = (req, res) => { - const statusCode = res.statusCode - send(req, res, { - statusCode, - body: targetBuffer, - }) + sendPrismyResult(req, res, Result(targetBuffer)) } - await testHandler(handler, async (url) => { - const responsePromise = got(url) - const bufferPromise = responsePromise.buffer() - const [response, buffer] = await Promise.all([ - responsePromise, - bufferPromise, - ]) + const res = await ts.loadRequestListener(handler).call('/') - expect(targetBuffer.equals(buffer)).toBe(true) - expect(response.headers['content-length']).toBe( - targetBuffer.length.toString() - ) - expect(response.headers['content-type']).toBe('application/octet-stream') - }) + const resBodyBuffer = Buffer.from(await res.arrayBuffer()) + expect(resBodyBuffer.equals(targetBuffer)).toBeTruthy() + expect(res.headers.get('content-length')).toBe( + targetBuffer.length.toString(), + ) + expect(res.headers.get('content-type')).toBe('application/octet-stream') }) - it('sends buffer body when body is stream', async () => { + it('sends buffer body when body is stream (lenght is not available)', async () => { expect.hasAssertions() const targetBuffer = Buffer.from('Hello, world!') const stream = Readable.from(targetBuffer.toString()) const handler: RequestListener = (req, res) => { - res.setHeader('Content-Type', 'application/octet-stream') const statusCode = res.statusCode - send(req, res, { - statusCode, - body: stream, - }) + sendPrismyResult(req, res, Result(stream, statusCode)) } - await testHandler(handler, async (url) => { - const response = await got(url, { - responseType: 'buffer', - }) - expect(targetBuffer.equals(response.body)).toBe(true) - }) + const res = await ts.loadRequestListener(handler).call('/') + + const resBodyBuffer = Buffer.from(await res.arrayBuffer()) + expect(resBodyBuffer.equals(targetBuffer)).toBeTruthy() + expect(res.headers.get('content-length')).toBeNull() + expect(res.headers.get('content-type')).toBe('application/octet-stream') }) - it('uses handler when body is function', async () => { - expect.hasAssertions() + it('delegates response handling if body is a function', async () => { const sendHandler = ( _request: IncomingMessage, - response: ServerResponse + response: ServerResponse, ) => { response.end('test') } const handler: RequestListener = (req, res) => { - send(req, res, sendHandler) + sendPrismyResult(req, res, Result(sendHandler)) } - await testHandler(handler, async (url) => { - const response = await got(url) - expect(response.body).toEqual('test') - }) - }) - - it('sets header when Content-Type header is not given (stream)', async () => { - expect.hasAssertions() + const res = await ts.loadRequestListener(handler).call('/') - const targetBuffer = Buffer.from('Hello, world!') - const stream = Readable.from(targetBuffer.toString()) - const handler: RequestListener = (req, res) => { - const statusCode = res.statusCode - send(req, res, { statusCode, body: stream }) - } - - await testHandler(handler, async (url) => { - const responsePromise = got(url) - const bufferPromise = responsePromise.buffer() - const [response, buffer] = await Promise.all([ - responsePromise, - bufferPromise, - ]) - expect(targetBuffer.equals(buffer)).toBe(true) - expect(response.headers['content-type']).toBe('application/octet-stream') - }) + expect(await res.text()).toBe('test') }) - it('sends stringified JSON object when body is object', async () => { - expect.hasAssertions() - + it('sends stringified JSON object when body is an JSON stringifiable object', async () => { const target = { foo: 'bar', } const handler: RequestListener = (req, res) => { - res.setHeader('Content-Type', 'application/json; charset=utf-8') - const statusCode = res.statusCode - send(req, res, { statusCode, body: target }) + sendPrismyResult(req, res, Result(target)) } - await testHandler(handler, async (url) => { - const response = await got(url, { - responseType: 'json', - }) - expect(response.body).toMatchObject(target) - expect(response.headers['content-length']).toBe( - JSON.stringify(target).length.toString() - ) - }) - }) + const res = await ts.loadRequestListener(handler).call('/') - it('sets header when Content-Type header is not given (object)', async () => { - expect.hasAssertions() - - const target = { + expect(await res.json()).toEqual({ foo: 'bar', - } - const handler: RequestListener = (req, res) => { - const statusCode = res.statusCode - send(req, res, { statusCode, body: target }) - } - - await testHandler(handler, async (url) => { - const response = await got(url, { - responseType: 'json', - }) - expect(response.body).toMatchObject(target) - expect(response.headers['content-length']).toBe( - JSON.stringify(target).length.toString() - ) - expect(response.headers['content-type']).toBe( - 'application/json; charset=utf-8' - ) }) + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(res.headers.get('content-length')).toEqual( + JSON.stringify(target).length.toString(), + ) }) it('sends stringified JSON object when body is number', async () => { - expect.hasAssertions() - - const target = 1004 - const handler: RequestListener = (req, res) => { - res.setHeader('Content-Type', 'application/json; charset=utf-8') - const statusCode = res.statusCode - send(req, res, { - statusCode, - body: target, - }) - } - - await testHandler(handler, async (url) => { - const response = await got(url) - const stringifiedTarget = JSON.stringify(target) - expect(response.body).toBe(stringifiedTarget) - expect(response.headers['content-length']).toBe( - stringifiedTarget.length.toString() - ) - }) - }) - - it('sets header when Content-Type header is not given (number)', async () => { - expect.hasAssertions() - - const target = 1004 + const target = 777 const handler: RequestListener = (req, res) => { - const statusCode = res.statusCode - send(req, res, { - statusCode, - body: target, - }) + sendPrismyResult(req, res, Result(target)) } - await testHandler(handler, async (url) => { - const response = await got(url) - const stringifiedTarget = JSON.stringify(target) - expect(response.body).toBe(stringifiedTarget) - expect(response.headers['content-length']).toBe( - stringifiedTarget.length.toString() - ) - expect(response.headers['content-type']).toBe( - 'application/json; charset=utf-8' - ) - }) - }) - - it('sends with header', async () => { - expect.hasAssertions() - - const handler: RequestListener = (req, res) => { - const statusCode = res.statusCode - send(req, res, { - statusCode, - headers: { - test: 'test value', - }, - }) - } + const res = await ts.loadRequestListener(handler).call('/') - await testHandler(handler, async (url) => { - const response = await got(url) - expect(response.body).toBeFalsy() - expect(response.headers['test']).toEqual('test value') - }) + expect(await res.json()).toEqual(777) + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(res.headers.get('content-length')).toEqual( + JSON.stringify(target).length.toString(), + ) }) - it('sends with header', async () => { - expect.hasAssertions() + it('sets headers', async () => { const handler: RequestListener = (req, res) => { - send(req, res, { - headers: { - test: 'test value', - }, - }) + sendPrismyResult( + req, + res, + Result(null, 201, { + test1: 'test value1', + test2: 'test value2', + }), + ) } - - await testHandler(handler, async (url) => { - const response = await got(url) - expect(response.body).toBeFalsy() - expect(response.headers['test']).toEqual('test value') - }) + const res = await ts.loadRequestListener(handler).call('/') + expect(res.headers.get('test1')).toBe('test value1') + expect(res.headers.get('test2')).toBe('test value2') + expect(res.status).toBe(201) }) }) diff --git a/specs/test.spec.ts b/specs/test.spec.ts new file mode 100644 index 0000000..bb1e73a --- /dev/null +++ b/specs/test.spec.ts @@ -0,0 +1,16 @@ +import { TestServer } from '../src/test' + +describe('PrismyTestServer', () => { + it('throws when calling server without starting', async () => { + const error = await TestServer() + .call() + .catch((error) => error) + expect(error.toString()).toMatch('PrismyTestServer: Server is not ready.') + }) + + it('default listener throws', () => { + expect(TestServer().listener).toThrow( + 'PrismyTestServer: Listener is not set.', + ) + }) +}) diff --git a/specs/types/basic.ts b/specs/types/basic.ts new file mode 100644 index 0000000..5dca01e --- /dev/null +++ b/specs/types/basic.ts @@ -0,0 +1,156 @@ +import { + BodySelector, + createPrismySelector, + ErrorResult, + Handler, + MaybePromise, + MethodSelector, + Middleware, + prismy, + PrismyErrorResult, + PrismyHandler, + PrismyMiddleware, + PrismyNextFunction, + PrismyResult, + PrismyRoute, + Result, + Route, + UrlSelector, +} from '../../src' +import http from 'http' +import { InjectSelector } from '../../src/selectors/inject' +import { PrismySelector } from '../../src/selector' + +function expectType(value: T): void {} + +const handler1 = Handler( + [UrlSelector(), MethodSelector()], + async (url, method) => { + expectType(url) + expectType(method) + return Result('') + }, +) + +expectType< + ( + url: URL, + method: string | undefined, + url2: URL, + ) => PrismyResult | Promise +>(handler1.handle) + +expectType(Handler([BodySelector()], () => Result(null))) + +expectType>>( + handler1.handle(new URL('...'), 'get'), +) + +http.createServer(prismy(handler1)) + +// @ts-expect-error +Handler([BodySelector], () => Result(null)) + +const middleware1 = Middleware( + [UrlSelector(), MethodSelector()], + async (next, url, method) => { + expectType(url) + expectType(method) + return next() + }, +) + +expectType< + (next: PrismyNextFunction, url: URL, method: string | undefined) => any +>(middleware1.handler) + +const middleware2 = Middleware((next) => { + return next() +}) + +expectType>(middleware2) +expectType<(next: PrismyNextFunction) => any>(middleware2.handler) + +// @ts-expect-error +Middleware([BodySelector], () => Result(null)) + +http.createServer(prismy([], () => Result(''), [middleware1])) + +abstract class MailService {} +class ProductionMailService extends MailService {} +class TestMailService extends MailService {} + +const mailService: MailService = + process.env.NODE_ENV === 'production' + ? new ProductionMailService() + : new TestMailService() + +const mailServiceSelector = InjectSelector(mailService) + +const handler = Handler([mailServiceSelector], (mailService) => { + expectType(mailService) + return Result(null) +}) + +const mailHandlerRoute = Route('/', handler) +expectType]>>(mailHandlerRoute) + +const shortRoute = Route( + '/', + [UrlSelector(), MethodSelector()], + (url, method) => { + expectType(url) + expectType(method) + return Result(null) + }, +) + +expectType< + PrismyRoute<[PrismySelector, PrismySelector]> +>(shortRoute) +expectType< + (url: URL, method: string | undefined) => MaybePromise> +>(shortRoute.handler.handle) + +expectType>( + createPrismySelector(() => { + return 0 + }), +) + +const UrlPortSelector = () => + createPrismySelector([UrlSelector()], (url) => { + return { + pathname: url.pathname, + hash: url.hash, + } + }) + +expectType<{ + pathname: string + hash: string +}>(await UrlPortSelector().select(new URL(''))) + +Handler(() => { + return Result('') +}) + +Handler((): PrismyResult<{ data: string }> | PrismyErrorResult => { + if ('' === '') { + return ErrorResult(400, null) + } else if ('' === '') { + // @ts-expect-error + return Result({ test: 'test' }) + } + return Result({ data: '123' }) +}) + +Handler((): PrismyResult<{ data: string }> | PrismyErrorResult => { + if ('' === '') { + return ErrorResult(400, '') + } else if ('' === '') { + // @ts-expect-error + return ErrorResult(400, null) + } + return Result({ data: '123' }) +}) diff --git a/specs/types/middleware.ts b/specs/types/middleware.ts deleted file mode 100644 index 95a7186..0000000 --- a/specs/types/middleware.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - middleware, - urlSelector, - methodSelector, - AsyncSelector, - ResponseObject, - prismy, - res -} from '../../src' -import { UrlWithStringQuery } from 'url' -import { expectType } from '../helpers' - -const asyncUrlSelector: AsyncSelector = async context => - urlSelector(context) - -const middleware1 = middleware( - [urlSelector, methodSelector, asyncUrlSelector], - next => async (url, method, url2) => { - expectType(url) - expectType(method) - expectType(url2) - return next() - } -) - -expectType< - ( - next: () => Promise> - ) => ( - url: UrlWithStringQuery, - method: string | undefined, - url2: UrlWithStringQuery - ) => ResponseObject | Promise> ->(middleware1.mhandler) - -expectType< - ( - next: () => Promise> - ) => ( - url: UrlWithStringQuery, - method: string | undefined, - url2: UrlWithStringQuery - ) => ResponseObject | Promise> ->(middleware1.mhandler) - -prismy([], () => res(''), [middleware1]) diff --git a/specs/types/prismy.ts b/specs/types/prismy.ts deleted file mode 100644 index 76d935c..0000000 --- a/specs/types/prismy.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - prismy, - urlSelector, - methodSelector, - res, - AsyncSelector, - ResponseObject -} from '../../src' -import { UrlWithStringQuery } from 'url' -import { expectType } from '../helpers' - -const asyncUrlSelector: AsyncSelector = async context => - urlSelector(context) - -const handler1 = prismy( - [urlSelector, methodSelector, asyncUrlSelector], - (url, method, url2) => { - expectType(url) - expectType(method) - expectType(url2) - return res('') - } -) - -expectType< - ( - url: UrlWithStringQuery, - method: string | undefined, - url2: UrlWithStringQuery - ) => ResponseObject | Promise> ->(handler1.handler) diff --git a/specs/types/reaemd.md b/specs/types/reaemd.md new file mode 100644 index 0000000..b0e32d6 --- /dev/null +++ b/specs/types/reaemd.md @@ -0,0 +1,3 @@ +# Don't run script in this directoroy. + +`.ts` files in this folders are just for testing types. diff --git a/specs/utils.spec.ts b/specs/utils.spec.ts deleted file mode 100644 index 2bd41e5..0000000 --- a/specs/utils.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import got from 'got' -import { testHandler } from './helpers' -import { - prismy, - res, - redirect, - setBody, - setStatusCode, - updateHeaders, - setHeaders -} from '../src' - -describe('redirect', () => { - it('redirects', async () => { - const handler = prismy([], () => { - return redirect('https://github.com') - }) - - await testHandler(handler, async url => { - const response = await got(url, { - followRedirect: false - }) - expect(response).toMatchObject({ - statusCode: 302, - headers: { - location: 'https://github.com' - } - }) - }) - }) - - it('redirects with specific statusCode', async () => { - const handler = prismy([], () => { - return redirect('https://github.com', 301) - }) - - await testHandler(handler, async url => { - const response = await got(url, { - followRedirect: false - }) - expect(response).toMatchObject({ - statusCode: 301, - headers: { - location: 'https://github.com' - } - }) - }) - }) - - it('redirects with specific headers', async () => { - const handler = prismy([], () => { - return redirect('https://github.com', undefined, { - 'x-test': 'Hello, World!' - }) - }) - - await testHandler(handler, async url => { - const response = await got(url, { - followRedirect: false - }) - expect(response).toMatchObject({ - statusCode: 302, - headers: { - location: 'https://github.com', - 'x-test': 'Hello, World!' - } - }) - }) - }) -}) - -describe('setBody', () => { - it('replaces body', () => { - const resObj = res('Hello, World!') - - const newResObj = setBody(resObj, 'Good Bye!') - - expect(newResObj).toEqual({ - body: 'Good Bye!', - statusCode: 200, - headers: {} - }) - }) -}) - -describe('setStatusCode', () => { - it('replaces statusCode', () => { - const resObj = res('Hello, World!') - - const newResObj = setStatusCode(resObj, 201) - - expect(newResObj).toEqual({ - body: 'Hello, World!', - statusCode: 201, - headers: {} - }) - }) -}) - -describe('updateHeaders', () => { - it('update headers', () => { - const resObj = res(null, 200, { - 'x-test-message': 'Hello, World!' - }) - - const newResObj = updateHeaders(resObj, { - 'x-test-message': 'Good Bye!', - 'x-extra-message': 'Adios!' - }) - - expect(newResObj).toEqual({ - body: null, - statusCode: 200, - headers: { - 'x-test-message': 'Good Bye!', - 'x-extra-message': 'Adios!' - } - }) - }) -}) - -describe('setHeaders', () => { - it('replace headers', () => { - const resObj = res(null, 200, { - 'x-test-message': 'Hello, World!' - }) - - const newResObj = setHeaders(resObj, { - 'x-extra-message': 'Hola!' - }) - - expect(newResObj).toEqual({ - body: null, - statusCode: 200, - headers: { - 'x-extra-message': 'Hola!' - } - }) - }) -}) diff --git a/src/bodyReaders.ts b/src/bodyReaders.ts index 4105836..a399286 100644 --- a/src/bodyReaders.ts +++ b/src/bodyReaders.ts @@ -9,6 +9,8 @@ const rawBodyMap = new WeakMap() /** * An async function to buffer the incoming request body * + * **This method is not checking content-type of request header. Please check it manually or use JsonBodySelector.** + * * @remarks * Can be called multiple times, as it caches the raw request body the first time * @@ -20,7 +22,7 @@ const rawBodyMap = new WeakMap() */ export const readBufferBody = async ( req: IncomingMessage, - options?: BufferBodyOptions + options?: BufferBodyOptions, ): Promise => { const { limit, encoding } = resolveBufferBodyOptions(req, options) const length = req.headers['content-length'] @@ -39,7 +41,7 @@ export const readBufferBody = async ( return buffer } catch (error) { if ((error as any).type === 'entity.too.large') { - throw createError(413, `Body exceeded ${limit} limit`, error) + throw createError(413, `Body is too large (limit: ${limit})`, error) } else { throw createError(400, `Invalid body`, error) } @@ -57,11 +59,11 @@ export const readBufferBody = async ( */ export const readTextBody = async ( req: IncomingMessage, - options?: BufferBodyOptions + options?: BufferBodyOptions, ): Promise => { const { encoding } = resolveBufferBodyOptions(req, options) const body = await readBufferBody(req, options) - return body.toString(encoding) + return body.toString(encoding as any) } /** @@ -75,7 +77,7 @@ export const readTextBody = async ( */ export const readJsonBody = async ( req: IncomingMessage, - options?: BufferBodyOptions + options?: BufferBodyOptions, ): Promise => { const body = await readTextBody(req, options) try { @@ -87,7 +89,7 @@ export const readJsonBody = async ( function resolveBufferBodyOptions( req: IncomingMessage, - options?: BufferBodyOptions + options?: BufferBodyOptions, ): BufferBodyOptions { const type = req.headers['content-type'] || 'text/plain' let { limit = '1mb', encoding } = options || {} diff --git a/src/error.ts b/src/error.ts index 2227254..96cf015 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,4 +1,4 @@ -import { res } from './utils' +import { PrismyResult, Result } from './result' /** * Creates a response object from an error @@ -11,16 +11,20 @@ import { res } from './utils' * * @public */ -export function createErrorResObject(error: any) { +export function createErrorResultFromError(error: any) { + if (error instanceof PrismyResult) { + return error + } + const statusCode = error.statusCode || error.status || 500 /* istanbul ignore next */ const message = process.env.NODE_ENV === 'production' ? error.message : error.stack - return res(message, statusCode) + return Result(message, statusCode) } -class PrismyError extends Error { +export class PrismyError extends Error { statusCode?: number originalError?: unknown } @@ -28,7 +32,7 @@ class PrismyError extends Error { export function createError( statusCode: number, message: string, - originalError?: any + originalError?: any, ): PrismyError { const error = new PrismyError(message) diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..ce007b5 --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,93 @@ +import { + PrismyMiddleware, + PrismyNextFunction, + MaybePromise, + SelectorReturnTypeTuple, + PrismyResult, + resolveSelectors, +} from '.' +import { PrismySelector } from './selector' + +export class PrismyHandler< + S extends PrismySelector[] = PrismySelector[], + R extends PrismyResult = PrismyResult, +> { + constructor( + public selectors: [...S], + /** + * PrismyHandler exposes `handler` for unit testing the handler. + * @param args selected arguments + */ + public handle: (...args: SelectorReturnTypeTuple) => MaybePromise, + public middlewareList: PrismyMiddleware[], + ) {} + + async __internal__handler(): Promise> { + const next: PrismyNextFunction = async () => { + return this.handle(...(await resolveSelectors(this.selectors))) + } + + const pipe = this.middlewareList.reduce((next, middleware) => { + return middleware.pipe(next) + }, next) + + return await pipe() + } +} + +/** + * Generates a handler to be used by http.Server + * + * @example + * ```ts + * const worldSelector: Selector = () => "world"! + * + * const handler = Handler([ worldSelector ], async world => { + * return Result(`Hello ${world}!`) // Hello world! + * }) + * ``` + * + * @remarks + * Selectors must be a tuple (`[PrismySelector, PrismySelector]`) not an + * array (`Selector|Selector[] `). Be careful when declaring the + * array outside of the function call. + * + * @param selectors - Tuple of Selectors to generate arguments for handler + * @param handlerFunction - Business logic handling the request + * @param middlewareList - Middleware to pass request and response through + * + * @public * + */ +export function Handler< + S extends PrismySelector[], + R extends PrismyResult = PrismyResult, +>( + selectors: [...S], + handlerFunction: (...args: SelectorReturnTypeTuple) => MaybePromise, + middlewareList?: PrismyMiddleware[]>[], +): PrismyHandler +export function Handler = PrismyResult>( + handlerFunction: () => MaybePromise, + middlewareList?: PrismyMiddleware[]>[], +): PrismyHandler<[], R> +export function Handler< + S extends PrismySelector[], + R extends PrismyResult = PrismyResult, +>( + selectorsOrHandler: any, + handlerFunctionOrMiddlewareList?: any | any[], + middlewareList?: any[], +) { + if (Array.isArray(selectorsOrHandler)) { + return new PrismyHandler( + selectorsOrHandler, + handlerFunctionOrMiddlewareList, + middlewareList || [], + ) + } + return new PrismyHandler( + [], + selectorsOrHandler, + handlerFunctionOrMiddlewareList || [], + ) +} diff --git a/src/index.ts b/src/index.ts index e95575b..7468862 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ export * from './types' -export * from './utils' export * from './prismy' export * from './middleware' +export * from './selector' export * from './selectors' export * from './error' export * from './router' +export * from './result' +export * from './handler' diff --git a/src/middleware.ts b/src/middleware.ts index 3b0cf6e..fa790be 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,28 +1,44 @@ -import { - ResponseObject, - Selector, - SelectorReturnTypeTuple, - PrismyMiddleware, - Context -} from './types' -import { compileHandler } from './utils' +import { PrismyNextFunction, PrismyResult, resolveSelectors } from '.' +import { PrismySelector } from './selector' +import { SelectorReturnTypeTuple } from './types' + +export class PrismyMiddleware< + S extends PrismySelector[] = PrismySelector[], +> { + constructor( + public selectors: [...S], + /** + * PrismyHandler exposes `handler` for unit testing the handler. + * @param args selected arguments + */ + public handler: ( + next: PrismyNextFunction, + ...args: SelectorReturnTypeTuple + ) => Promise, + ) {} + + pipe(next: PrismyNextFunction): PrismyNextFunction { + return async () => { + return this.handler(next, ...(await resolveSelectors(this.selectors))) + } + } +} /** - * Factory function to create a prismy compatible middleware. Accepts selectors to help with + * Factory function to create a prismy middleware. Accepts selectors to help with * testing, DI etc. * * @example * Simple Example * ```ts * - * const withCors = middleware([], next => async () => { + * const withCors = Middleware([], next => async () => { * const resObject = await next() * * return updateHeaders(resObject, { * 'access-control-allow-origin': '*' * }) * }) - * * ``` * * @remarks @@ -30,28 +46,36 @@ import { compileHandler } from './utils' * array (`Selector|Selector[] `). Be careful when declaring the * array outside of the function call. * - * Be carefuly to remember the mhandler is a function which returns an _async_ function. + * Be carefuly to remember the handler is a function which returns an _async_ function. * Not returning an async function can lead to strange type error messages. * * Another reason for long type error messages is not having `{"strict": true}` setting in * tsconfig.json or not compiling with --strict. * * @param selectors - Tuple of selectors - * @param mhandler - Middleware handler + * @param handler - Middleware handler * @returns A prismy compatible middleware * * @public */ -export function middleware[]>( +export function Middleware[]>( selectors: [...SS], - mhandler: ( - next: () => Promise> - ) => (...args: SelectorReturnTypeTuple) => Promise> -): PrismyMiddleware> { - const middleware = (context: Context) => async ( - next: () => Promise> - ) => compileHandler(selectors, mhandler(next))(context) - middleware.mhandler = mhandler - - return middleware + handler: ( + next: PrismyNextFunction, + ...args: SelectorReturnTypeTuple + ) => Promise, +): PrismyMiddleware +export function Middleware( + handler: (next: PrismyNextFunction) => Promise, +): PrismyMiddleware<[]> +export function Middleware( + selectorsOrHandler: + | any[] + | ((next: PrismyNextFunction) => Promise), + handler?: (next: PrismyNextFunction, ...args: any[]) => Promise, +): PrismyMiddleware { + if (Array.isArray(selectorsOrHandler)) { + return new PrismyMiddleware(selectorsOrHandler, handler!) + } + return new PrismyMiddleware([], selectorsOrHandler) } diff --git a/src/prismy.ts b/src/prismy.ts index ccda6ff..9f308d8 100644 --- a/src/prismy.ts +++ b/src/prismy.ts @@ -1,85 +1,88 @@ -import { IncomingMessage, ServerResponse } from 'http' -import { createErrorResObject } from './error' -import { send } from './send' -import { - ResponseObject, - Selector, - PrismyPureMiddleware, - Promisable, - Context, - ContextHandler, - PrismyHandler, - SelectorReturnTypeTuple, -} from './types' -import { compileHandler } from './utils' +import { AsyncLocalStorage } from 'async_hooks' +import { IncomingMessage, RequestListener, ServerResponse } from 'http' +import { PrismyMiddleware } from './middleware' +import { Handler, PrismyHandler } from './handler' +import { PrismySelector } from './selector' +import { MaybePromise, PrismyContext, SelectorReturnTypeTuple } from './types' +import { PrismyResult } from './result' +import { createErrorResultFromError } from './error' + +export const prismyContextStorage = new AsyncLocalStorage() +export function getPrismyContext(): PrismyContext { + const context = prismyContextStorage.getStore() + if (context == null) { + throw new Error('Prismy context is not loaded.') + } + return context +} /** - * Generates a handler to be used by http.Server + * Make a RequestListener from PrismyHandler * * @example * ```ts - * const worldSelector: Selector = () => "world"! - * - * export default prismy([ worldSelector ], async world => { - * return res(`Hello ${world}!`) // Hello world! - * }) - * ``` + * const handler = Handler([], () => { ... }) * - * @remarks - * Selectors must be a tuple (`[Selector, Selector]`) not an - * array (`Selector|Selector[] `). Be careful when declaring the - * array outside of the function call. + * const listener = prismy(handler) + * // Or + * // const listener = prismy([], () => {...}) * - * @param selectors - Tuple of Selectors to generate arguments for handler - * @param handler - Business logic handling the request - * @param middlewareList - Middleware to pass request and response through + * http.createServerlistener) + * ``` * - * @public + * @param prismyHandler + */ +export function prismy[]>( + prismyHandler: PrismyHandler, +): RequestListener + +/** + * Make a RequestListener from PrismyHandler * + * @param selectors + * @param handlerFunction + * @param middlewareList */ -export function prismy[]>( +export function prismy[]>( selectors: [...S], - handler: ( + handlerFunction: ( ...args: SelectorReturnTypeTuple - ) => Promisable>, - middlewareList: PrismyPureMiddleware[] = [] -): PrismyHandler> { - const contextHandler: ContextHandler = async (context: Context) => { - const next = async () => compileHandler(selectors, handler)(context) - - const pipe = middlewareList.reduce((next, middleware) => { - return () => middleware(context)(next) - }, next) - - let resObject - try { - resObject = await pipe() - } catch (error) { - /* istanbul ignore next */ - if (process.env.NODE_ENV !== 'test') { - console.error(error) - } - resObject = createErrorResObject(error) - } - - return resObject - } + ) => MaybePromise, + middlewareList?: PrismyMiddleware[]>[], +): RequestListener +export function prismy[]>( + selectorsOrPrismyHandler: [...S] | PrismyHandler, + handlerFunction?: ( + ...args: SelectorReturnTypeTuple + ) => MaybePromise, + middlewareList?: PrismyMiddleware[]>[], +): RequestListener { + const injectedHandler = + selectorsOrPrismyHandler instanceof PrismyHandler + ? selectorsOrPrismyHandler + : Handler(selectorsOrPrismyHandler, handlerFunction!, middlewareList) async function requestListener( request: IncomingMessage, - response: ServerResponse + response: ServerResponse, ) { - const context = { + const context: PrismyContext = { req: request, } + prismyContextStorage.run(context, async () => { + try { + const result = await injectedHandler.__internal__handler() - const resObject = await contextHandler(context) - - await send(request, response, resObject) + result.resolve(request, response) + } catch (error) { + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'test') { + console.error(error) + } + createErrorResultFromError(error).resolve(request, response) + } + }) } - requestListener.handler = handler - requestListener.contextHandler = contextHandler - return requestListener } diff --git a/src/result.ts b/src/result.ts new file mode 100644 index 0000000..eb1ec97 --- /dev/null +++ b/src/result.ts @@ -0,0 +1,286 @@ +import { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http' +import { sendPrismyResult } from './send' +import cookie from 'cookie' + +export class PrismyResult { + constructor( + public readonly body: B, + public readonly statusCode: number, + public readonly headers: OutgoingHttpHeaders, + ) {} + + /** + * Resolve function used by http.Server + * @param request + * @param response + */ + resolve(request: IncomingMessage, response: ServerResponse) { + sendPrismyResult(request, response, this) + } + + /** + * Creates a new result with a new status code + * + * @param statusCode - HTTP status code + * @returns New {@link PrismyResult} + * + * @public + */ + setStatusCode(statusCode: number) { + return new PrismyResult(this.body, statusCode, this.headers) + } + + /** + * Creates a new result with a new body + * + * @param body - Body to be set + * @returns New {@link PrismyResult} + * + * @public + */ + setBody(body: BB) { + return new PrismyResult(body, this.statusCode, this.headers) + } + + /** + * Creates a new result with the extra headers. + * Merging new headers into existing headers. But duplicated headers will be replaced with new ones. + * `{...existingHeaders, ...newHeaders}` + * + * @param newHeaders - HTTP response headers + * @returns New {@link PrismyResult} + * + * To set multiple headers with same name, use an array. + * @example + * ```ts + * const existingHeaderValue = result.headers['multiple-values'] + * let newHeaderValues = [...] + * + * if (existingHeaderValue != null) { + * if (Array.isArray(existingHeaderValue)) { + * newHeaderValues = [...existingHeaderValue, ...newHeaderValues] + * } else { + * newHeaderValues = [existingHeaderValue, ...newHeaderValues] + * } + * } + * + * result.updateHeaders({ + * 'multiple-values': newHeaderValues + * }) + * ``` + * + * @public + */ + updateHeaders(newHeaders: OutgoingHttpHeaders) { + return new PrismyResult(this.body, this.statusCode, { + ...this.headers, + ...newHeaders, + }) + } + + /** + * Appends `set-cookie` header. This method won't replace existing `set-cookie` header. + * To remove or replace existing headers, please use `updateHeaders` method. + * + * @param key Cookie key + * @param value Cookie value + * @param options Cookie options + * @returns New {@link PrismyResult} + */ + setCookie( + key: string, + value: string, + options?: cookie.CookieSerializeOptions, + ) { + const existingSetCookieHeaders = this.headers['set-cookie'] + const newSetCookieHeader = cookie.serialize(key, value, options) + + return this.updateHeaders({ + 'set-cookie': + existingSetCookieHeaders == null + ? [newSetCookieHeader] + : Array.isArray(existingSetCookieHeaders) + ? [...existingSetCookieHeaders, newSetCookieHeader] + : [existingSetCookieHeaders, newSetCookieHeader], + }) + } + + /** + * Creates a new result with the new headers. + * This will flush all existing headers and set new ones only. + * + * @param newHeaders - HTTP response headers + * @returns New {@link PrismyResult} + * + * @public + */ + setHeaders(headers: OutgoingHttpHeaders) { + return new PrismyResult(this.body, this.statusCode, headers) + } +} + +/** + * Factory function for creating http responses + * + * @param body - Body of the response + * @param statusCode - HTTP status code of the response + * @param headers - HTTP headers for the response + * @returns A {@link PrismyResult} containing necessary information + * + * @public + */ +export function Result( + body: B, + statusCode: number = 200, + headers: OutgoingHttpHeaders = {}, +): PrismyResult { + return new PrismyResult(body, statusCode, headers) +} + +/** + * Factory function for creating error http responses + * Technically, it is functionally identical to `Result` but its arguments order is different from `Result`. + * + * @param body - Body of the response + * @param statusCode - HTTP status code of the response + * @param headers - HTTP headers for the response + * @returns A {@link PrismyResult} containing necessary information + * + * @public + */ +export function ErrorResult( + statusCode: number, + body: B, + headers: OutgoingHttpHeaders = {}, +): PrismyErrorResult { + return new PrismyErrorResult(body, statusCode, headers) +} + +export class PrismyErrorResult extends PrismyResult { + readonly __isError = true +} + +export function isErrorResult( + result: PrismyResult, +): result is PrismyErrorResult { + if ((result as PrismyErrorResult).__isError) { + return true + } + return false +} + +export function assertErrorResult( + result: PrismyResult, +): asserts result is PrismyErrorResult { + if (isErrorResult(result)) { + return + } + throw new Error( + [ + 'The given PrismyResult is NOT an error result.', + '', + 'Result:', + jsonStringifyRecursive(result), + ].join('\n'), + ) +} + +export function assertNoErrorResult

( + result: P, +): asserts result is P extends PrismyErrorResult ? never : P { + if (!isErrorResult(result)) { + return + } + throw new Error( + [ + 'The given PrismyResult is an error result.', + '', + 'Result:', + jsonStringifyRecursive(result), + ].join('\n'), + ) +} + +/** + * Factory function for easily generating a redirect response + * + * @param location - URL to redirect to + * @param statusCode - Status code for response. Defaults to 302 + * @param extraHeaders - Additional headers of the response + * @returns A redirect {@link ResponseObject | response} to location + * + * @public + */ +export function RedirectResult( + location: string, + statusCode: number = 302, + extraHeaders: OutgoingHttpHeaders = {}, +): PrismyRedirectResult { + return new PrismyRedirectResult(null, statusCode, { + location, + ...extraHeaders, + }) +} + +export class PrismyRedirectResult extends PrismyResult { + readonly __isRedirect = true +} + +export function isRedirectResult( + result: PrismyResult, +): result is PrismyRedirectResult { + if ((result as PrismyRedirectResult).__isRedirect) { + return true + } + return false +} + +export function assertRedirectResult( + result: PrismyResult, +): asserts result is PrismyRedirectResult { + if (isRedirectResult(result)) { + return + } + throw new Error( + [ + 'The given PrismyResult is NOT a redirect result.', + '', + 'Result:', + jsonStringifyRecursive(result), + ].join('\n'), + ) +} + +export function assertNoRedirectResult

( + result: P, +): asserts result is P extends PrismyRedirectResult ? never : P { + if (!isRedirectResult(result)) { + return + } + throw new Error( + [ + 'The given PrismyResult is a redirect result.', + '', + 'Result:', + jsonStringifyRecursive(result), + ].join('\n'), + ) +} + +/* istanbul ignore next */ +function jsonStringifyRecursive(value: any) { + const cache = new Set() + return JSON.stringify( + value, + (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.has(value)) { + return '(Recursive)' + } + cache.add(value) + } + return value + }, + 2, + ) +} diff --git a/src/router.ts b/src/router.ts index 1b8e89f..698b323 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,8 +1,12 @@ -import { Context, Selector, SyncSelector, PrismyHandler } from './types' -import { contextSelector, methodSelector, urlSelector } from './selectors' +import { MaybePromise, PrismyContext, SelectorReturnTypeTuple } from './types' +import { MethodSelector, UrlSelector } from './selectors' import { match as createMatchFunction } from 'path-to-regexp' -import { prismy } from './prismy' +import { getPrismyContext } from './prismy' import { createError } from './error' +import { createPrismySelector, PrismySelector } from './selector' +import { PrismyMiddleware, PrismyResult } from '.' +import { Handler, PrismyHandler } from './handler' +import { join as joinPath } from 'path' export type RouteMethod = | 'get' @@ -13,25 +17,34 @@ export type RouteMethod = | 'options' | '*' export type RouteIndicator = [string, RouteMethod] -export type RouteParams = [ - string | RouteIndicator, - PrismyHandler -] -type Route = { +type Route = { indicator: RouteIndicator - listener: PrismyHandler + listener: PrismyHandler[]> } -export function router( - routes: RouteParams[], - options: PrismyRouterOptions = {} +export class PrismyRoute< + S extends PrismySelector[] = PrismySelector[], +> { + indicator: RouteIndicator + handler: PrismyHandler + + constructor(indicator: RouteIndicator, handler: PrismyHandler) { + this.indicator = indicator + this.handler = handler + } +} + +export function Router( + routes: PrismyRoute[], + { prefix = '/', middleware = [], notFoundHandler }: PrismyRouterOptions = {}, ) { - const { notFoundHandler } = options - const compiledRoutes = routes.map((routeParams) => { - const { indicator, listener } = createRoute(routeParams) + const compiledRoutes = routes.map((route) => { + const { indicator, handler: listener } = route const [targetPath, method] = indicator - const compiledTargetPath = removeTralingSlash(targetPath) + const compiledTargetPath = removeTralingSlash( + joinPath('/', prefix, targetPath), + ) const match = createMatchFunction(compiledTargetPath, { strict: false }) return { method, @@ -40,11 +53,13 @@ export function router( targetPath: compiledTargetPath, } }) - return prismy( - [methodSelector, urlSelector, contextSelector], - (method, url, context) => { + + return Handler( + [MethodSelector(), UrlSelector()], + (method, url) => { + const prismyContext = getPrismyContext() /* istanbul ignore next */ - const normalizedMethod = method?.toLowerCase() + const normalizedMethod = method != null ? method.toLowerCase() : null /* istanbul ignore next */ const normalizedPath = removeTralingSlash(url.pathname || '/') @@ -59,56 +74,93 @@ export function router( continue } - setRouteParamsToPrismyContext(context, result.params) + setRouteParamsToPrismyContext(prismyContext, result.params) - return route.listener.contextHandler(context) + return route.listener.__internal__handler() } - if (notFoundHandler == null) { - throw createError(404, 'Not Found') - } else { - return notFoundHandler.contextHandler(context) + if (notFoundHandler != null) { + return notFoundHandler.__internal__handler() } - } + throw createError(404, 'Not Found') + }, + middleware, ) } -function createRoute( - routeParams: RouteParams[]> -): Route[]> { - const [indicator, listener] = routeParams +export function Route[]>( + indicator: RouteIndicator | string, + handler: PrismyHandler, +): PrismyRoute +export function Route[]>( + indicator: RouteIndicator | string, + handler: (...args: SelectorReturnTypeTuple) => MaybePromise, + middlewareList?: PrismyMiddleware[]>[], +): PrismyRoute +export function Route[]>( + indicator: RouteIndicator | string, + selectors: [...S], + handlerFunction?: ( + ...args: SelectorReturnTypeTuple + ) => MaybePromise, + middlewareList?: PrismyMiddleware[]>[], +): PrismyRoute +export function Route( + indicator: RouteIndicator | string, + selectorsOrPrismyHandler: any, + handlerFunction?: any, + middlewareList?: any, +): PrismyRoute { + const handler = + selectorsOrPrismyHandler instanceof PrismyHandler + ? selectorsOrPrismyHandler + : Array.isArray(selectorsOrPrismyHandler) + ? Handler(selectorsOrPrismyHandler, handlerFunction!, middlewareList) + : Handler([], selectorsOrPrismyHandler, handlerFunction) if (typeof indicator === 'string') { - return { - indicator: [indicator, 'get'], - listener, - } - } - return { - indicator, - listener, + return new PrismyRoute([indicator, 'get'], handler) } + return new PrismyRoute(indicator, handler) } -const routeParamsSymbol = Symbol('route params') -function setRouteParamsToPrismyContext(context: Context, params: object) { - ;(context as any)[routeParamsSymbol] = params +const routeParamsMap = new WeakMap() + +function setRouteParamsToPrismyContext(context: PrismyContext, params: object) { + routeParamsMap.set(context, params) } -function getRouteParamsFromPrismyContext(context: Context) { - return (context as any)[routeParamsSymbol] +function getRouteParamsFromPrismyContext(context: PrismyContext) { + return routeParamsMap.get(context) } -export function createRouteParamSelector( - paramName: string -): SyncSelector { - return (context) => { - const param = getRouteParamsFromPrismyContext(context)[paramName] - return param != null ? param : null - } +function resolveRouteParam(paramName: string) { + const context = getPrismyContext() + const param = getRouteParamsFromPrismyContext(context)[paramName] + return param != null ? (Array.isArray(param) ? param[0] : param) : null +} + +export function RouteParamSelector(paramName: string): PrismySelector { + return createPrismySelector(() => { + const resolvedParam = resolveRouteParam(paramName) + if (resolvedParam == null) { + throw createError(404, `Route parameter ${paramName} not found`) + } + return resolvedParam + }) +} + +export function OptionalRouteParamSelector( + paramName: string, +): PrismySelector { + return createPrismySelector(() => { + return resolveRouteParam(paramName) + }) } interface PrismyRouterOptions { - notFoundHandler?: PrismyHandler + prefix?: string + middleware?: PrismyMiddleware[]>[] + notFoundHandler?: PrismyHandler } function removeTralingSlash(value: string) { diff --git a/src/selector.ts b/src/selector.ts new file mode 100644 index 0000000..a71ea8a --- /dev/null +++ b/src/selector.ts @@ -0,0 +1,57 @@ +import { SelectorReturnTypeTuple } from '.' + +export class PrismySelector< + T, + S extends PrismySelector[] = PrismySelector[], +> { + constructor( + public selectors: [...S], + public select: (...args: SelectorReturnTypeTuple) => Promise | T, + ) {} + + async __internal__selector(): Promise { + return this.select(...(await resolveSelectors(this.selectors))) + } +} + +export function createPrismySelector< + T, + S extends PrismySelector[] = PrismySelector[], +>( + selectors: [...S], + selectorFunction: (...args: SelectorReturnTypeTuple) => T | Promise, +): PrismySelector +export function createPrismySelector( + selectorFunction: () => T | Promise, +): PrismySelector +export function createPrismySelector( + selectorsOrFn: any, + selectorFunction?: any, +) { + if (selectorFunction == null) { + return new PrismySelector([], selectorsOrFn) + } + return new PrismySelector(selectorsOrFn, selectorFunction) +} + +/** + * Executes the selectors and produces an array of args to be passed to + * a handler + * + * @param context - Context object to be passed to the selectors + * @param selectors - array of selectos + * @returns arguments for a handler + * + * @internal + */ +export async function resolveSelectors[]>( + selectors: [...S], +): Promise> { + const resolvedValues = [] + for (const selector of selectors) { + const resolvedValue = await selector.__internal__selector() + resolvedValues.push(resolvedValue) + } + + return resolvedValues as SelectorReturnTypeTuple +} diff --git a/src/selectors/body.ts b/src/selectors/body.ts index 971126f..547128a 100644 --- a/src/selectors/body.ts +++ b/src/selectors/body.ts @@ -1,10 +1,11 @@ import { parse } from 'querystring' +import { getPrismyContext } from '../prismy' import { readJsonBody, readTextBody } from '../bodyReaders' import { createError } from '../error' -import { AsyncSelector } from '../types' +import { createPrismySelector, PrismySelector } from '../selector' /** - * Options for {@link createBodySelector} + * Options for {@link bodySelector} * * @public */ @@ -38,15 +39,20 @@ export interface BodySelectorOptions { * * @public */ -export function createBodySelector( - options?: BodySelectorOptions -): AsyncSelector { - return async ({ req }) => { - const type = req.headers['content-type'] +export function BodySelector( + options?: BodySelectorOptions, +): PrismySelector { + return createPrismySelector(async () => { + const { req } = getPrismyContext() + /* istanbul ignore next */ + const type = req.headers['content-type'] || '' - if (type === 'application/json' || type === 'application/ld+json') { + if ( + type.startsWith('application/json') || + type.startsWith('application/ld+json') + ) { return readJsonBody(req, options) - } else if (type === 'application/x-www-form-urlencoded') { + } else if (type.startsWith('application/x-www-form-urlencoded')) { const textBody = await readTextBody(req, options) try { return parse(textBody) @@ -57,5 +63,10 @@ export function createBodySelector( } else { return readTextBody(req, options) } - } + }) } + +/** + * @deprecated Use `BodySelector` + */ +export const createBodySelector = BodySelector diff --git a/src/selectors/bufferBody.ts b/src/selectors/bufferBody.ts index d58ccd9..1f2dcdc 100644 --- a/src/selectors/bufferBody.ts +++ b/src/selectors/bufferBody.ts @@ -1,5 +1,6 @@ -import { AsyncSelector } from '../types' import { readBufferBody } from '../bodyReaders' +import { getPrismyContext } from '../prismy' +import { createPrismySelector, PrismySelector } from '../selector' /** * Options for {@link createBufferBodySelector} @@ -36,10 +37,11 @@ export interface BufferBodySelectorOptions { * * @public */ -export function createBufferBodySelector( - options?: BufferBodySelectorOptions -): AsyncSelector { - return ({ req }) => { +export function BufferBodySelector( + options?: BufferBodySelectorOptions, +): PrismySelector { + return createPrismySelector(() => { + const { req } = getPrismyContext() return readBufferBody(req, options) - } + }) } diff --git a/src/selectors/context.ts b/src/selectors/context.ts deleted file mode 100644 index 91933e9..0000000 --- a/src/selectors/context.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Context, SyncSelector } from '../types' - -/** - * Selector to extract the request context - * - * @example - * Simple example - * ```ts - * - * const prismyHandler = prismy( - * [contextSelector], - * context => { - * ... - * } - * ) - * ``` - * - * @param context - The request context - * @returns The request context - * - * @public - */ -export const contextSelector: SyncSelector = context => context diff --git a/src/selectors/cookie.ts b/src/selectors/cookie.ts new file mode 100644 index 0000000..8eee8fb --- /dev/null +++ b/src/selectors/cookie.ts @@ -0,0 +1,23 @@ +import cookie from 'cookie' +import { getPrismyContext } from '../prismy' +import { createPrismySelector } from '../selector' + +const cookieMap = new WeakMap() + +export function CookieSelector(key: string) { + return createPrismySelector((): string | null => { + const context = getPrismyContext() + let parsedCookie: { [key: string]: string | undefined } = + cookieMap.get(context) + if (parsedCookie == null) { + const { req } = context + parsedCookie = + req.headers['cookie'] != null ? cookie.parse(req.headers['cookie']) : {} + cookieMap.set(context, parsedCookie) + } + + const cookieValue = parsedCookie[key] + + return cookieValue != null ? cookieValue : null + }) +} diff --git a/src/selectors/headers.ts b/src/selectors/headers.ts index 723c839..bb4fa62 100644 --- a/src/selectors/headers.ts +++ b/src/selectors/headers.ts @@ -1,25 +1,32 @@ import { IncomingHttpHeaders } from 'http' -import { SyncSelector } from '../types' +import { getPrismyContext } from '../prismy' +import { createPrismySelector, PrismySelector } from '../selector' + +const headersSelector: PrismySelector = + createPrismySelector(() => { + const { req } = getPrismyContext() + return req.headers + }) /** - * A selector to extract the headers of a request + * Returns a selector to extract the headers of a request * * @example * Simple example * ```ts * * const prismyHandler = prismy( - * [headerSelector], + * [HeaderSelector()], * headers => { * ... * } * ) * ``` * - * @param context - The request context - * @returns The request headers + * @returns PrismySelector * * @public */ -export const headersSelector: SyncSelector = context => - context.req.headers +export function HeadersSelector() { + return headersSelector +} diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 7f3fcaa..ce35009 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -1,10 +1,10 @@ export * from './body' export * from './bufferBody' -export * from './context' export * from './headers' export * from './jsonBody' export * from './method' -export * from './query' +export * from './searchParam' export * from './url' export * from './urlEncodedBody' export * from './textBody' +export * from './cookie' diff --git a/src/selectors/inject.ts b/src/selectors/inject.ts new file mode 100644 index 0000000..7d44e9e --- /dev/null +++ b/src/selectors/inject.ts @@ -0,0 +1,5 @@ +import { createPrismySelector, PrismySelector } from '../selector' + +export function InjectSelector(value: V): PrismySelector { + return createPrismySelector(() => value) +} diff --git a/src/selectors/jsonBody.ts b/src/selectors/jsonBody.ts index b965762..2ef4735 100644 --- a/src/selectors/jsonBody.ts +++ b/src/selectors/jsonBody.ts @@ -1,7 +1,7 @@ import { readJsonBody } from '../bodyReaders' import { createError } from '../error' -import { AsyncSelector } from '../types' -import { headersSelector } from './headers' +import { getPrismyContext } from '../prismy' +import { createPrismySelector, PrismySelector } from '../selector' /** * Options for {@link createJsonBodySelector} @@ -9,7 +9,6 @@ import { headersSelector } from './headers' * @public */ export interface JsonBodySelectorOptions { - skipContentTypeCheck?: boolean limit?: string | number encoding?: string } @@ -42,25 +41,25 @@ export interface JsonBodySelectorOptions { * * @public */ -export function createJsonBodySelector( - options?: JsonBodySelectorOptions -): AsyncSelector { - return context => { - const { skipContentTypeCheck = false } = options || {} - if (!skipContentTypeCheck) { - const contentType = headersSelector(context)['content-type'] - if (!isContentTypeIsApplicationJSON(contentType)) { - throw createError( - 400, - `Content type must be application/json. (Current: ${contentType})` - ) - } +export function JsonBodySelector( + options?: JsonBodySelectorOptions, +): PrismySelector { + return createPrismySelector(() => { + const { req } = getPrismyContext() + const contentType = req.headers['content-type'] + if (!isContentTypeApplicationJSON(contentType)) { + throw createError( + 400, + `Content type must be application/json. (Current: ${contentType})`, + ) } - return readJsonBody(context.req, options) - } + + return readJsonBody(req, options) + }) } -function isContentTypeIsApplicationJSON(contentType: string | undefined) { +function isContentTypeApplicationJSON(contentType: string | undefined) { + /* istanbul ignore next */ if (typeof contentType !== 'string') return false if (!contentType.startsWith('application/json')) return false return true diff --git a/src/selectors/method.ts b/src/selectors/method.ts index 02a70f2..02eb751 100644 --- a/src/selectors/method.ts +++ b/src/selectors/method.ts @@ -1,14 +1,22 @@ -import { SyncSelector } from '../types' +import { getPrismyContext } from '../prismy' +import { createPrismySelector, PrismySelector } from '../selector' + +const methodSelector: PrismySelector = createPrismySelector( + () => { + const { req } = getPrismyContext() + return req.method + }, +) /** - * Selector to extract the HTTP method from the request + * Returns a selector to extract the HTTP method from the request * * @example * Simple example * ```ts * * const prismyHandler = prismy( - * [methodSelector], + * [MethodSelector()], * method => { * if (method !== 'GET') { * throw createError(405) @@ -17,11 +25,11 @@ import { SyncSelector } from '../types' * ) * ``` * - * @param context - The request context - * @returns the http request method + * @returns PrismySelector * * @public */ -export const methodSelector: SyncSelector = ({ req }) => { - return req.method + +export function MethodSelector() { + return methodSelector } diff --git a/src/selectors/query.ts b/src/selectors/query.ts deleted file mode 100644 index 2111bdd..0000000 --- a/src/selectors/query.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ParsedUrlQuery, parse } from 'querystring' -import { SyncSelector } from '../types' -import { urlSelector } from './url' - -const querySymbol = Symbol('prismy-query') - -/** - * Selector to extract the parsed query from the request URL - * - * @example - * Simple example - * ```ts - * - * const prismyHandler = prismy( - * [querySelector], - * query => { - * doSomethingWithQuery(query) - * } - * ) - * ``` - * - * @param context - Request context - * @returns a selector for the url query - * - * @public - */ -export const querySelector: SyncSelector = context => { - let query: ParsedUrlQuery | undefined = context[querySymbol] - if (query == null) { - const url = urlSelector(context) - /* istanbul ignore next */ - context[querySymbol] = query = url.query != null ? parse(url.query) : {} - } - return query -} diff --git a/src/selectors/searchParam.ts b/src/selectors/searchParam.ts new file mode 100644 index 0000000..5eff2f9 --- /dev/null +++ b/src/selectors/searchParam.ts @@ -0,0 +1,57 @@ +import { createPrismySelector, PrismySelector } from '../selector' +import { UrlSelector } from './url' + +/** + * Create a selector which resolves the first value of the search param. + * Using `url.searchParams.get(name)` internally. + * + * @example + * Simple example + * ```ts + * // Expecting a request like `/?user_id=123` + * const prismyHandler = prismy( + * [SearchParamSelector('user_id')], + * userId => { + * doSomethingWithUserId(userId) + * } + * ) + * ``` + * + * @param name + * @returns a selector for the search param + */ +export const SearchParamSelector: ( + name: string, +) => PrismySelector = (name) => + createPrismySelector([UrlSelector()], async (url) => { + return url.searchParams.get(name) + }) + +/** + * Create a selector which resolves a value list of the search param. + * Useful when you expect to have multiple values for the same name. + * Using `url.searchParams.getAll(name)` internally. + * + * @example + * Simple example + * ```ts + * // Expecting a request like `/?user_id=123&user_id=456` + * const prismyHandler = prismy( + * [SearchParamSelector('user_id')], + * userIdList => { + * if (userIdList.length > 0) { + * doSomethingWithUserIdList(userIdList) + * } + * } + * ) + * ``` + * + * @param name + * @returns a selector for the search param list + */ +export const SearchParamListSelector: ( + name: string, +) => PrismySelector = (name) => + createPrismySelector([UrlSelector()], async (url) => { + return url.searchParams.getAll(name) + }) diff --git a/src/selectors/textBody.ts b/src/selectors/textBody.ts index c5c98ee..05b3bb1 100644 --- a/src/selectors/textBody.ts +++ b/src/selectors/textBody.ts @@ -1,8 +1,9 @@ +import { getPrismyContext } from '../prismy' import { readTextBody } from '../bodyReaders' -import { AsyncSelector } from '../types' +import { createPrismySelector, PrismySelector } from '../selector' /** - * Options for {@link createTextBodySelector} + * Options for {@link textBodySelector} * * @public */ @@ -36,10 +37,11 @@ export interface TextBodySelectorOptions { * * @public */ -export function createTextBodySelector( - options?: TextBodySelectorOptions -): AsyncSelector { - return ({ req }) => { +export function TextBodySelector( + options?: TextBodySelectorOptions, +): PrismySelector { + return createPrismySelector(() => { + const { req } = getPrismyContext() return readTextBody(req, options) - } + }) } diff --git a/src/selectors/url.ts b/src/selectors/url.ts index d74712e..f90206f 100644 --- a/src/selectors/url.ts +++ b/src/selectors/url.ts @@ -1,7 +1,20 @@ -import { UrlWithStringQuery, parse } from 'url' -import { SyncSelector } from '../types' +import { URL } from 'url' +import { getPrismyContext } from '../prismy' +import { createPrismySelector } from '../selector' -const urlSymbol = Symbol('prismy-url') +const urlMap = new WeakMap() + +const urlSelector = createPrismySelector((): URL => { + const context = getPrismyContext() + let url: URL | undefined = urlMap.get(context) + if (url == null) { + const { req } = context + /* istanbul ignore next */ + url = new URL(req.url == null ? '' : req.url, `http://${req.headers.host}`) + urlMap.set(context, url) + } + return url +}) /** * Selector for extracting the requested URL @@ -13,22 +26,15 @@ const urlSymbol = Symbol('prismy-url') * const prismyHandler = prismy( * [urlSelector], * url => { - * return res(url.path) + * return Result(url.path) * } * ) * ``` * - * @param context - Request context * @returns The url of the request * * @public */ -export const urlSelector: SyncSelector = context => { - let url: UrlWithStringQuery | undefined = context[urlSymbol] - if (url == null) { - const { req } = context - /* istanbul ignore next */ - url = context[urlSymbol] = parse(req.url == null ? '' : req.url) - } - return url +export function UrlSelector() { + return urlSelector } diff --git a/src/selectors/urlEncodedBody.ts b/src/selectors/urlEncodedBody.ts index 7f0d08c..def15ed 100644 --- a/src/selectors/urlEncodedBody.ts +++ b/src/selectors/urlEncodedBody.ts @@ -1,7 +1,8 @@ import { ParsedUrlQuery, parse } from 'querystring' +import { getPrismyContext } from '../prismy' import { readTextBody } from '../bodyReaders' import { createError } from '../error' -import { AsyncSelector } from '../types' +import { createPrismySelector, PrismySelector } from '../selector' /** * Options for {@link createUrlEncodedBodySelector} @@ -40,10 +41,11 @@ export interface UrlEncodedBodySelectorOptions { * * @public */ -export function createUrlEncodedBodySelector( - options?: UrlEncodedBodySelectorOptions -): AsyncSelector { - return async ({ req }) => { +export function UrlEncodedBodySelector( + options?: UrlEncodedBodySelectorOptions, +): PrismySelector { + return createPrismySelector(async () => { + const { req } = getPrismyContext() const textBody = await readTextBody(req, options) try { return parse(textBody) @@ -51,5 +53,5 @@ export function createUrlEncodedBodySelector( /* istanbul ignore next */ throw createError(400, 'Invalid url-encoded body', error) } - } + }) } diff --git a/src/send.ts b/src/send.ts index 665cc32..c6cf958 100644 --- a/src/send.ts +++ b/src/send.ts @@ -1,7 +1,7 @@ import { IncomingMessage, ServerResponse } from 'http' import { readable } from 'is-stream' import { Stream } from 'stream' -import { ResponseObject } from './types' +import { PrismyResult } from './result' /** * Function to send data to the client @@ -12,18 +12,16 @@ import { ResponseObject } from './types' * * @public */ -export const send = ( +export const sendPrismyResult = ( request: IncomingMessage, response: ServerResponse, - resObject: - | ResponseObject - | ((request: IncomingMessage, response: ServerResponse) => void) + sendable: PrismyResult, ) => { - if (typeof resObject === 'function') { - resObject(request, response) + if (typeof sendable.body === 'function') { + sendable.body(request, response) return } - const { statusCode = 200, body, headers = [] } = resObject + const { statusCode, body, headers } = sendable Object.entries(headers).forEach(([key, value]) => { /* istanbul ignore if */ if (value == null) { diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..3d17d8c --- /dev/null +++ b/src/test.ts @@ -0,0 +1,71 @@ +import http, { IncomingMessage, RequestListener, ServerResponse } from 'http' +import listen from 'async-listen' +import { prismy, PrismyHandler } from './' + +export class PrismyTestServer { + server: http.Server | null = null + url: string = '' + /* istanbul ignore next */ + listener: RequestListener = () => { + throw new Error('PrismyTestServer: Listener is not set.') + } + + loadRequestListener(listener: RequestListener) { + this.listener = listener + return this + } + + load(handler: PrismyHandler) { + this.listener = prismy(handler) + return this + } + + private listen(req: IncomingMessage, res: ServerResponse) { + this.listener(req, res) + } + + async call(url: string = '/', options?: RequestInit) { + if (this.server == null) { + throw new Error( + 'PrismyTestServer: Server is not ready. Please call `.start()` and wait till it finish.', + ) + } + return fetch(this.url + url, options) + } + + async start() { + if (this.server == null) { + const server = new http.Server(this.listen.bind(this)) + const url = await listen(server) + + this.server = server + this.url = url.origin + } + } + + async close() { + /* istanbul ignore next */ + if (this.server == null) { + return + } + + const server = this.server + this.server = null + this.url = '' + + await new Promise((resolve, reject) => { + server!.close((error) => { + /* istanbul ignore next */ + if (error != null) { + reject(error) + } else { + resolve(null) + } + }) + }) + } +} + +export function TestServer() { + return new PrismyTestServer() +} diff --git a/src/types.ts b/src/types.ts index 3f16e93..ae47eda 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,33 +1,16 @@ -import { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'http' +import { IncomingMessage } from 'http' +import { PrismyResult } from './result' +import { PrismySelector } from './selector' /** * Request context used in selectors * * @public */ -export interface Context { +export interface PrismyContext { req: IncomingMessage } -/** - * A Synchronous argument selector - * - * @public - */ -export type SyncSelector = (context: Context) => T -/** - * An asynchronous argument selector - * - * @public - */ -export type AsyncSelector = (context: Context) => Promise -/** - * An argument selector to extract arguments for the handler - * - * @public - */ -export type Selector = SyncSelector | AsyncSelector - /** * Get the return type array of Selectors * @@ -35,8 +18,8 @@ export type Selector = SyncSelector | AsyncSelector */ export type SelectorTuple = [ ...{ - [I in keyof SS]: Selector - } + [I in keyof SS]: PrismySelector + }, ] /** @@ -44,17 +27,19 @@ export type SelectorTuple = [ * * @public */ -export type SelectorReturnType = S extends Selector ? T : never +export type SelectorReturnType = S extends PrismySelector + ? T + : never /** * Get the return type array of Selectors * * @public */ -export type SelectorReturnTypeTuple[]> = [ +export type SelectorReturnTypeTuple[]> = [ ...{ [I in keyof SS]: SelectorReturnType - } + }, ] /** @@ -65,68 +50,9 @@ export type PromiseResolve = T extends Promise ? U : T /** * @public */ -export type Promisable = T | Promise - -/** - * prismy's representation of a response - * - * @public - */ -export interface ResponseObject { - body?: B - statusCode?: number - headers?: OutgoingHttpHeaders -} - -/** - * shorter type alias for ResponseObject - * - * @public - */ -export type Res = ResponseObject - -/** - * alias for Promise> for user with async handlers - * - * @public - */ -export type AsyncRes = Promise> - -/** - * prismy compaticble middleware - * - * @public - */ -export interface PrismyPureMiddleware { - (context: Context): ( - next: () => Promise> - ) => Promise> -} -/** - * prismy compatible middleware - * - * @public - */ -export interface PrismyMiddleware - extends PrismyPureMiddleware { - mhandler( - next: () => Promise> - ): (...args: A) => Promise> -} - -/** - * @public - */ -export type ContextHandler = (context: Context) => Promise> +export type MaybePromise = T | Promise -/** - * @public - */ -export interface PrismyHandler { - (req: IncomingMessage, res: ServerResponse): void - handler(...args: A): Promisable> - contextHandler: ContextHandler -} +export type PrismyNextFunction = () => Promise> /** * @public diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index a16707a..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { OutgoingHttpHeaders } from 'http' -import { - ResponseObject, - Selector, - SelectorReturnTypeTuple, - Context -} from './types' - -/** - * Factory function for creating http responses - * - * @param body - Body of the response - * @param statusCode - HTTP status code of the response - * @param headers - HTTP headers for the response - * @returns A {@link ResponseObject | response object} containing necessary information - * - * @public - */ -export function res( - body: B, - statusCode: number = 200, - headers: OutgoingHttpHeaders = {} -): ResponseObject { - return { - body, - statusCode, - headers - } -} - -export function err( - statusCode: number, - body: B, - headers?: OutgoingHttpHeaders -): ResponseObject { - return res(body, statusCode, headers) -} - -/** - * Factory function for easily generating a redirect response - * - * @param location - URL to redirect to - * @param statusCode - Status code for response. Defaults to 302 - * @param extraHeaders - Additional headers of the response - * @returns A redirect {@link ResponseObject | response} to location - * - * @public - */ -export function redirect( - location: string, - statusCode: number = 302, - extraHeaders: OutgoingHttpHeaders = {} -): ResponseObject { - return res(null, statusCode, { - location, - ...extraHeaders - }) -} - -/** - * Creates a new response with a new body - * - * @param resObject - The response to set the body on - * @param body - Body to be set - * @returns New {@link ResponseObject | response} with the new body - * - * @public - */ -export function setBody( - resObject: ResponseObject, - body: B2 -): ResponseObject { - return { - ...resObject, - body - } -} - -/** - * Creates a new response with a new status code - * - * @param resObject - The response to set the code to - * @param statusCode - HTTP status code - * @returns New {@link ResponseObject | response} with the new statusCode - * - * @public - */ -export function setStatusCode( - resObject: ResponseObject, - statusCode: number -): ResponseObject { - return { - ...resObject, - statusCode - } -} - -/** - * Creates a new response with the extra headers. - * - * @param resObject - The response to add the new headers to - * @param extraHeaders - HTTP response headers - * @returns New {@link ResponseObject | response} with the extra headers - * - * @public - */ -export function updateHeaders( - resObject: ResponseObject, - extraHeaders: OutgoingHttpHeaders -): ResponseObject { - return { - ...resObject, - headers: { - ...resObject.headers, - ...extraHeaders - } - } -} - -/** - * Creates a new response overriting all headers with new ones. - * - * @param resObject - response to set new headers on - * @param headers - HTTP response headers to set - * @returns New {@link ResponseObject | response} with new headers set - * - * @public - */ -export function setHeaders( - resObject: ResponseObject, - headers: OutgoingHttpHeaders -): ResponseObject { - return { - ...resObject, - headers - } -} - -/** - * Compile a handler into a runnable function by resolving selectors - * and injecting the arguments into the handler. - * - * @param selectors - Selectors to gather handler arguments from - * @param handler - Handler to be compiled - * @returns compiled handler ready to be used - * - * @internal - */ -export function compileHandler[], R>( - selectors: [...S], - handler: (...args: SelectorReturnTypeTuple) => R -): (context: Context) => Promise { - return async (context: Context) => { - return handler(...(await resolveSelectors(context, selectors))) - } -} - -/** - * Executes the selectors and produces an array of args to be passed to - * a handler - * - * @param context - Context object to be passed to the selectors - * @param selectors - array of selectos - * @returns arguments for a handler - * - * @internal - */ -export async function resolveSelectors[]>( - context: Context, - selectors: [...S] -): Promise> { - const resolvedValues = [] - for (const selector of selectors) { - const resolvedValue = await selector(context) - resolvedValues.push(resolvedValue) - } - - return resolvedValues as SelectorReturnTypeTuple -} diff --git a/tsconfig.es.json b/tsconfig.es.json new file mode 100644 index 0000000..5b68901 --- /dev/null +++ b/tsconfig.es.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "node16", + "outDir": "dist/es", + "declaration": false + }, + + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index e2aafd3..23f512d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "include": ["src/**/*.ts", "specs/**/*.ts"], "compilerOptions": { - "target": "es2018", - "lib": ["es2018"], - "module": "commonjs", + "lib": ["ES2022"], + "module": "node16", + "target": "ES2022", "moduleResolution": "node", "importHelpers": true, "esModuleInterop": true, @@ -27,6 +27,6 @@ }, "typedocOptions": { "out": "out", - "mode": "file", + "mode": "file" } } diff --git a/v4-todo.md b/v4-todo.md new file mode 100644 index 0000000..7afd8e9 --- /dev/null +++ b/v4-todo.md @@ -0,0 +1,241 @@ +# V4 changes(TEMP) +- Legacy support: Provide snippets which can provide compatibility +- [x] Improve server test(Replace legacy got with node-fetch@2) + - server listner replacer? +- [x] Reimplement type test ~~Introduce tsd to test types properly~~ +- [x] Seperate Handler and Prismy + ```ts + import http from 'http' + + // Take handler + const requestListener: http.RequestListener = prismy(Handler([...], () => {})) + // Can omit Handler + // const serverHandler = Prismy([...], () => {}) + + http.createServer(requestListener).listen() + + ``` +- [ ] Update docs +- [ ] Update examples +- [x] Shorthand Prismy, Route +- [x] redesigned router interface + - [x] introduced route methodexamples + - [x] Add tests + - [x] Wildcard parm handling + - [x] Router middleware test +- [x] Replace res with `PrismyResult` and `Result()` + - [x] Support PrismyResult + - [x] Discard res, res obj +- [x] Redesigned selector interface + - [x] Renamed factory method (ex: createBodySelector(Deprecated) => BodySelector) + ```ts + import { BodySelector } from 'prismy' + // Use `camelCase` when naming a created selector. + const bodySelector = BodySelector() + + prismy([ + // Below two are functionally identical. Use whichever style you like. + BodySelector(), + bodySelector + ], handler) + ``` + - [x] Changed selector interface and its name + - [x] Removed `Selector`, `SyncSelector` and `AsyncSelector` interface. Use `PrismySelector` + - [x] Selector is not a function anymore. It must be extends from `PrismySelector` Class. + - So typescript can throws an error if any of selectors in `prismy([...selectors], handler)` is not valid. + ```ts + // If the selector interface were a function, the statement below won't throw any type error although it is incorrect. + prismy([ + BodySelector(), // `() => Body` this function is a selector. + BodySelector // `() => PrismySelector`, this function creates a selector when called but definitely not a selector by itself. + ]) + ``` + - To create a custom selector, you must use `createPrismySelector` + ```ts + function selectSomethingFromContext(context: PrismyContext): T + + // Previous + const legacySelector: Selector = (context): T | Promise => { + const selected = selectSomethingFromContext(context) + return selected + } + + // Now + const newSelector = createPrismySelector(() => { + const context = getPrismyContext() + const selected = selectSomethingFromContext(context) + return selected + }) + ``` +- [x] `urlSelector` is now retruning WHATWG URL, not legacy `urlObject` to improve security. +- [x] Removed `skipContentTypeCheck` option from `JsonBodySelectorOptions` to improve security. +- [x] One param for one query selector like URL +- [x] Adopted async local storage to communicate between selectors, middleware and handlers + - [x] Added `getPrismyContext` method to get context. (must be used in the scope of selectors, middleware and handlers) + - [x] Removed `contextSelector`, use `getPrismyContext` +- [x] Simplified middleware interface + - Before + + ```ts + (context: Context) => (next: () => Promise) => Promise + ``` + + Now + + ```ts + (next: () => Promise) => Promise + ``` +- [x] Make middleware into a class +- [x] Include prismy-cookie +- [x] Added DI Selector +- [x] File uploading + - Interface is a bit confusing and too different and rewriting cost is too high. Should just provide it as an example instead of including this project. +- [x] Rewrite prismy-session +- [ ] Make middleware selectors omittable +- [x] Fix midelware behavior +- [ ] Combine Routers + + +# V5 TODO(TBD) + +- ESM support + - Replace jest with node-tap, mocha or ava +- Add `createConcurrentSelector(...selectors: PrismySelector[])` + +# ight not be worth to do. + +- Return without res +- Add combine router to put routers together + - notFoundHandler must be ignored when routers are combined. + +# Fix router + +# Goal + +```ts +import {Router, Route} from 'prismy' +const serverHandler = Router([ + Route(routeInfo, [selector], handler), + Route(routeInfo, prismyHandler), + Route(['/deprecated', '*'], ()=> redirect('/')), + NotFoundRoute(() => Result('Not Found', 404)) +], { + prefix: '...' + middleware: [...], +}) + +router([ + route(), + notFoundRoute() +], { + prefix: '...' + middleware: [...], + +}) + + +combineRouters( +...routers +) + +interface NodeServerHandler { + (req: Req, res: Res): void +} + +interface PrismyHandler extends NodeServerHandler{ + selectors: [] + handler: () => void + middleware: Middleware[] +} + +type Retunnable = + +``` + +subdomain routing +Dont use context anymore. Async storage is enough. + +Inject symbol to selectors to prevent mistake + + +## Callstack + +```ts +const handler = Handler([s1, s2, s3], (v1, v2, v3) => { + return res +}, [m1, m2, m3]) +``` + +`m3 { m2 { m1 { s1 -> s2 -> s3 -> handler } } }` + +Middleware can: +- Do something before running its inner middleware and handler (Validating token, Retrieving session) +- Do something after running its inner middleware and handler (Rotating cookie, Handling an error) +- Skip running its inner middleware and handler (When token validation fails, should throw an error) + +But it cannot control its outer middleware + +```ts +const middleware = Middleware([], next => async () => { + // Do something before the handler is triggered + const result = doSomethingBefore() + if (isBad(result)) { + // Skip running `next` handler + return Result() + } + const result = await next() + + // Do something after the handler is triggered + doSomethingAfter() + + return result +}) +``` + +### Middleware order + +Later is the outer. +Outer has more responsibility. + +```ts +[sessionMiddleware, ...., loggingMiddleware, errorMiddleware] +``` + +### Selector order + +First one goes first. +If the first one fails, latter ones never triggered. + +If selectors don't cause side effect, you can even run them concurrently. + +## Middleare issue +Handler can resolve error by itself. it should not handle the error. error must be handled in prismy + +Handler should throw error but middleware must be applied. +Default error handling behavior must be performed in prismy not in handlers. + +## Combine Routers + +There might be multiple notFoundHandlers +Override it when combining routers +If it is not givenm, use last one should be used + +Not gonna be implemented. Must be handled outside of this lib + +## Simplify middleware + +Simplify structrue + +```js +// from +createMiddleware([...selctors]() => (next) => (...selectedValues) => ResObj) + +// To +Middleware([...selectors], (next, ...selectedValues) => ResObj) +``` + +Make selectors optionable + +```js +Middleware((next) => ResObj) +```