Skip to content

Commit

Permalink
Remove MockedResponse in favor of real Response
Browse files Browse the repository at this point in the history
  • Loading branch information
neg4n committed Dec 2, 2023
1 parent ef6ff29 commit 1a3819c
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 157 deletions.
4 changes: 2 additions & 2 deletions packages/next-api-compose/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: ['src/*'],
coverageReporters: ['html', 'json', 'lcov'],
setupFilesAfterEnv: ['./jest.setup.js'],
coverageProvider: 'v8'
coverageProvider: 'v8',
}
9 changes: 3 additions & 6 deletions packages/next-api-compose/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
global.Response = class MockedResponse {
constructor(body, status) {
this.body = body
this.status = status
}
}
const { Response } = require('undici')

global.Response = Response
3 changes: 2 additions & 1 deletion packages/next-api-compose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"ts-toolbelt": "^9.6.0",
"tsup": "^7.2.0",
"type-fest": "^4.2.0",
"typescript": "^5.1.6"
"typescript": "^5.1.6",
"undici": "^5.28.2"
},
"prettier": {
"printWidth": 90,
Expand Down
65 changes: 2 additions & 63 deletions packages/next-api-compose/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { PartialDeep, Promisable } from 'type-fest'
import type { Promisable } from 'type-fest'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { NextResponse } from 'next/server'
import type { O } from 'ts-toolbelt'
import { RequiredDeep } from 'type-fest'

type ParamType<T> = T extends (...args: [infer P]) => any ? P : never

Expand Down Expand Up @@ -37,13 +35,6 @@ type ComposeParameters<
]
>

type ComposeSettings = PartialDeep<{
sharedErrorHandler: {
handler: (method: NextApiRouteMethod, error: Error) => Promisable<Response | void>
includeRouteHandler: boolean
}
}>

/**
* Function that allows to define complex API structure in Next.js App router's Route Handlers.
*
Expand All @@ -60,22 +51,7 @@ export function compose<
| Promisable<Response | undefined>
| Promisable<void | undefined>
>
>(
parameters: ComposeParameters<UsedMethods, MiddlewareChain>,
composeSettings?: ComposeSettings
) {
const defaultComposeSettings = {
sharedErrorHandler: {
handler: undefined,
includeRouteHandler: false
}
}

const mergedComposeSettings = {
...defaultComposeSettings,
...composeSettings
}

>(parameters: ComposeParameters<UsedMethods, MiddlewareChain>) {
const modified = Object.entries(parameters).map(
([method, composeForMethodData]: [
UsedMethods,
Expand All @@ -90,49 +66,12 @@ export function compose<
[method]: async (request: any) => {
if (typeof composeForMethodData === 'function') {
const handler = composeForMethodData
if (
mergedComposeSettings.sharedErrorHandler.includeRouteHandler &&
mergedComposeSettings.sharedErrorHandler.handler != null
) {
try {
return await handler(request)
} catch (error) {
const composeSharedErrorHandlerResult =
await mergedComposeSettings.sharedErrorHandler.handler(method, error)

if (
composeSharedErrorHandlerResult != null &&
composeSharedErrorHandlerResult instanceof Response
) {
return composeSharedErrorHandlerResult
}
}
}
return await handler(request)
}

const [middlewareChain, handler] = composeForMethodData

for (const middleware of middlewareChain) {
if (mergedComposeSettings.sharedErrorHandler.handler != null) {
try {
const abortedMiddleware = await middleware(request)

if (abortedMiddleware != null && abortedMiddleware instanceof Response)
return abortedMiddleware
} catch (error) {
const composeSharedErrorHandlerResult =
await mergedComposeSettings.sharedErrorHandler.handler(method, error)

if (
composeSharedErrorHandlerResult != null &&
composeSharedErrorHandlerResult instanceof Response
) {
return composeSharedErrorHandlerResult
}
}
}

const abortedMiddleware = await middleware(request)

if (abortedMiddleware != null && abortedMiddleware instanceof Response)
Expand Down
113 changes: 29 additions & 84 deletions packages/next-api-compose/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,32 @@ import type { IncomingMessage } from 'http'

import { compose } from '../src/app'

class MockedResponse {
body: any = null
status: number = 200
headers: { [key: string]: string } = {}

constructor(body?: any, status: number = 200) {
this.body = body
this.status = status
}
}
type HandlerFunction = (req: IncomingMessage) => Promise<Response>

Object.setPrototypeOf(MockedResponse.prototype, Response.prototype)
async function streamToJson<T>(stream: ReadableStream<T>) {
const reader = stream.getReader()
let chunks: Uint8Array[] = []

type HandlerFunction = (req: IncomingMessage) => Promise<MockedResponse>
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value as any)
}

const string = new TextDecoder().decode(
Uint8Array.from(chunks.flatMap((chunk) => Array.from(chunk)))
)
return JSON.parse(string)
}

function createTestServer(handler: HandlerFunction) {
return createServer(async (req, res) => {
const response: MockedResponse = await handler(req)

res.writeHead(response.status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(response.body))
const response = await handler(req)
if (response.body) {
const body = await streamToJson(response.body)
res.writeHead(response.status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
})
}

Expand All @@ -38,7 +43,7 @@ describe("composed route handler's http functionality", () => {
GET: [
[],
() => {
return new MockedResponse({ foo: 'bar' })
return new Response(JSON.stringify({ foo: 'bar' }))
}
]
})
Expand All @@ -62,7 +67,7 @@ describe("composed route handler's http functionality", () => {
GET: [
[setFooMiddleware, appendBarToFooMiddleware],
(request) => {
return new MockedResponse({ foo: request.foo })
return new Response(JSON.stringify({ foo: request.foo }))
}
]
})
Expand All @@ -77,7 +82,7 @@ describe("composed route handler's http functionality", () => {
it('should correctly execute handler without middleware chain provided', async () => {
const { GET } = compose({
GET: (request) => {
return new MockedResponse({ foo: 'bar' }) as any
return new Response(JSON.stringify({ foo: 'bar' }))
}
})

Expand All @@ -88,66 +93,6 @@ describe("composed route handler's http functionality", () => {
expect(response.body.foo).toBe('bar')
})

it("should handle errors thrown by handler when no middleware is provided and return a 500 response with the error's message", async () => {
const { GET } = compose(
{
GET: () => {
throw new Error('foo')
}
},
{
sharedErrorHandler: {
includeRouteHandler: true,
handler: (method, error) => {
return new MockedResponse(
{ error: error.message },
500
) as unknown as Response
}
}
}
)

const app = createTestServer(GET)
const response = await request(app).get('/')

expect(response.status).toBe(500)
expect(response.body.error).toBe('foo')
})

it("should handle errors thrown by middlewares and return a 500 response with the error's message", async () => {
function errorMiddleware() {
throw new Error('foo')
}

const { GET } = compose(
{
GET: [
[errorMiddleware],
() => {
return new MockedResponse({ foo: 'bar' })
}
]
},
{
sharedErrorHandler: {
handler: (method, error) => {
return new MockedResponse(
{ error: error.message },
500
) as unknown as Response
}
}
}
)

const app = createTestServer(GET)
const response = await request(app).get('/')

expect(response.status).toBe(500)
expect(response.body.error).toBe('foo')
})

it('should wait for asynchronous middlewares to resolve before moving to the next middleware or handler', async () => {
async function setFooAsyncMiddleware(request) {
await new Promise((resolve) => setTimeout(resolve, 100))
Expand All @@ -161,7 +106,7 @@ describe("composed route handler's http functionality", () => {
GET: [
[setFooAsyncMiddleware, appendBarToFooMiddleware],
(request) => {
return new MockedResponse({ foo: request.foo })
return new Response(JSON.stringify({ foo: request.foo }))
}
]
})
Expand All @@ -176,7 +121,7 @@ describe("composed route handler's http functionality", () => {
it('should abort further middleware execution and return the response if a middleware returns a Response instance.', async () => {
function abortMiddleware(request) {
request.foo = 'bar'
return new MockedResponse({ foo: request.foo }, 418)
return new Response(JSON.stringify({ foo: request.foo }), { status: 418 })
}

function setFooMiddleware(request) {
Expand All @@ -187,7 +132,7 @@ describe("composed route handler's http functionality", () => {
GET: [
[abortMiddleware, setFooMiddleware],
() => {
return new MockedResponse({ foo: 'unreachable fizz' })
return new Response(JSON.stringify({ foo: 'unreachable fizz' }))
}
]
})
Expand All @@ -204,10 +149,10 @@ describe("composed route handler's code features", () => {
it("should correctly return multiple method handlers when they're composed", async () => {
const composedMethods = compose({
GET: (request) => {
return new MockedResponse({ foo: 'bar' }) as any
return new Response(JSON.stringify({ foo: 'bar' }))
},
POST: (request) => {
return new MockedResponse({ fizz: 'buzz' }) as any
return new Response(JSON.stringify({ fizz: 'buzz' }))
}
})

Expand Down
1 change: 1 addition & 0 deletions packages/next-api-compose/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["es6", "dom"],
"strictNullChecks": true,
"moduleResolution": "node",
"rootDir": "src",
Expand Down
Loading

0 comments on commit 1a3819c

Please sign in to comment.