├── pnpm-workspace.yaml ├── .github ├── assets │ ├── code_dark.png │ └── code_light.png ├── workflows │ ├── test.yml │ └── coverage.yml ├── MIGRATE_V2.md └── PAGES_ROUTER.md ├── packages └── next-api-compose │ ├── jest.setup.js │ ├── jest.config.js │ ├── tsconfig.json │ ├── package.json │ ├── test │ ├── __stubs__ │ │ └── middleware.ts │ ├── pages.test.ts │ └── app.test.ts │ └── src │ ├── pages.ts │ └── app.ts ├── .gitignore ├── examples ├── example-app-router │ ├── src │ │ ├── app │ │ │ ├── favicon.ico │ │ │ ├── layout.tsx │ │ │ ├── api │ │ │ │ └── hello │ │ │ │ │ └── route.ts │ │ │ ├── globals.css │ │ │ ├── page.tsx │ │ │ └── page.module.css │ │ └── middleware │ │ │ ├── with-hello.ts │ │ │ └── with-validation.ts │ ├── next.config.js │ ├── .gitignore │ ├── public │ │ ├── vercel.svg │ │ └── next.svg │ ├── package.json │ ├── tsconfig.json │ └── README.md └── example-pages-router │ ├── pages │ ├── _app.tsx │ ├── api │ │ ├── basic-express-middleware.js │ │ ├── basic-error-handling.js │ │ ├── basic-typescript.ts │ │ └── advanced-typescript-complete.ts │ └── index.tsx │ ├── next-env.d.ts │ ├── package.json │ ├── .gitignore │ ├── tsconfig.json │ └── README.md ├── turbo.json ├── package.json ├── LICENSE ├── .all-contributorsrc └── README.md /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "examples/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /.github/assets/code_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neg4n/next-api-compose/HEAD/.github/assets/code_dark.png -------------------------------------------------------------------------------- /.github/assets/code_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neg4n/next-api-compose/HEAD/.github/assets/code_light.png -------------------------------------------------------------------------------- /packages/next-api-compose/jest.setup.js: -------------------------------------------------------------------------------- 1 | const { Response } = require('undici') 2 | 3 | global.Response = Response 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | .vscode 5 | .DS_Store 6 | dist 7 | report.json 8 | .turbo 9 | example-app -------------------------------------------------------------------------------- /examples/example-app-router/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neg4n/next-api-compose/HEAD/examples/example-app-router/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/example-pages-router/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | export default function MyApp({ Component, pageProps }) { 2 | return 3 | } 4 | -------------------------------------------------------------------------------- /examples/example-app-router/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /examples/example-app-router/src/middleware/with-hello.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | 3 | const hello = (request: NextRequest & { hello: string }) => { 4 | request.hello = "hello"; 5 | }; 6 | 7 | export { hello }; 8 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "examples": { 5 | "outputs": [".next/**", "!.next/cache/**"], 6 | "cache": false, 7 | "persistent": true 8 | }, 9 | "build": {}, 10 | "test": {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/example-pages-router/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repository", 3 | "private": true, 4 | "scripts": { 5 | "examples": "turbo examples --parallel --filter=example-app-router --filter=next-api-compose", 6 | "test": "turbo test --filter=next-api-compose" 7 | }, 8 | "devDependencies": { 9 | "turbo": "^1.10.12" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/next-api-compose/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | collectCoverageFrom: ['src/*'], 6 | coverageReporters: ['html', 'json', 'lcov'], 7 | setupFilesAfterEnv: ['./jest.setup.js'], 8 | coverageProvider: 'v8', 9 | } 10 | -------------------------------------------------------------------------------- /packages/next-api-compose/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["es6", "dom"], 5 | "strictNullChecks": true, 6 | "moduleResolution": "node", 7 | "rootDir": "src", 8 | "declaration": true, 9 | "module": "commonjs", 10 | "esModuleInterop": true 11 | }, 12 | "include": ["src/**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: pnpm/action-setup@v2 11 | with: 12 | version: 8 13 | - name: Install modules 14 | run: pnpm install 15 | - name: Run tests for next-api-compose 16 | run: pnpm test 17 | -------------------------------------------------------------------------------- /examples/example-app-router/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | 3 | export const metadata = { 4 | title: 'Create Next App', 5 | description: 'Generated by create next app', 6 | } 7 | 8 | export default function RootLayout({ 9 | children, 10 | }: { 11 | children: React.ReactNode 12 | }) { 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /examples/example-pages-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-pages-router", 3 | "private": true, 4 | "scripts": { 5 | "examples": "next dev -p 3001" 6 | }, 7 | "dependencies": { 8 | "helmet": "^4.6.0", 9 | "next": "11.1.2", 10 | "next-api-compose": "file:../", 11 | "react": "17.0.2", 12 | "react-dom": "17.0.2" 13 | }, 14 | "devDependencies": { 15 | "@types/node": "^16.9.1", 16 | "@types/react": "^17.0.21", 17 | "typescript": "^4.4.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/example-app-router/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /examples/example-pages-router/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /examples/example-app-router/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - main 8 | - development 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: pnpm/action-setup@v2 15 | with: 16 | version: 8 17 | - name: Jest coverage report 18 | uses: ArtiomTr/jest-coverage-report-action@v2.2.4 19 | with: 20 | package-manager: pnpm 21 | working-directory: "./packages/next-api-compose/" 22 | test-script: pnpm jest --json --coverage --testLocationInResults --outputFile=report.json 23 | -------------------------------------------------------------------------------- /examples/example-pages-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /examples/example-app-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-app-router", 3 | "private": true, 4 | "scripts": { 5 | "examples": "next dev -p 3000" 6 | }, 7 | "prettier": { 8 | "printWidth": 90, 9 | "tabWidth": 2, 10 | "useTabs": false, 11 | "semi": false, 12 | "singleQuote": true, 13 | "trailingComma": "none", 14 | "bracketSpacing": true 15 | }, 16 | "dependencies": { 17 | "@types/node": "20.4.10", 18 | "@types/react": "18.2.20", 19 | "@types/react-dom": "18.2.7", 20 | "next": "13.4.13", 21 | "next-api-compose": "workspace:*", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "typescript": "5.1.6", 25 | "zod": "^3.22.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/MIGRATE_V2.md: -------------------------------------------------------------------------------- 1 | # Migration guide for next-api-compose v2.0.0 2 | 3 | The old version supported only the Pages Directory API Routes. The new version supports both Pages Directory API Routes and the App Directory API Routes. 4 | 5 | In order to migrate from versions below v2.0.0, the only thing you need to do is to change the import statement from: 6 | 7 | ```js 8 | import { compose } from "next-api-compose"; 9 | ``` 10 | 11 | to: 12 | 13 | ```js 14 | import { compose } from "next-api-compose/pages"; 15 | ``` 16 | 17 | any other imported functions from the library should be imported from the same module. 18 | 19 | e.g. 20 | 21 | ```js 22 | import { compose, convert } from "next-api-compose/pages"; 23 | ``` 24 | 25 | Back to the [README.md](./README.md) 26 | -------------------------------------------------------------------------------- /examples/example-app-router/src/app/api/hello/route.ts: -------------------------------------------------------------------------------- 1 | import { compose } from 'next-api-compose' 2 | import { z } from 'zod' 3 | 4 | import { validation } from '@/middleware/with-validation' 5 | import { hello } from '@/middleware/with-hello' 6 | 7 | const schema = z 8 | .object({ 9 | foo: z.string(), 10 | bar: z.string().default('bar') 11 | }) 12 | .strict() 13 | 14 | const { GET, POST } = compose({ 15 | GET: async () => { 16 | return new Response('haha') 17 | }, 18 | POST: [ 19 | [validation('body', schema), hello], 20 | async (request /* Correctly inferred 🚀 */) => { 21 | const { foo, bar } = request.validData 22 | return new Response(`${request.hello} ${foo}${bar}`) 23 | } 24 | ] 25 | }) 26 | 27 | export { GET, POST } -------------------------------------------------------------------------------- /examples/example-pages-router/pages/api/basic-express-middleware.js: -------------------------------------------------------------------------------- 1 | import { compose, convert } from 'next-api-compose' 2 | import helmet from 'helmet' 3 | 4 | const withHelmet = convert(helmet()) 5 | 6 | export default compose([withBar, withFoo, withHelmet], async (request, response) => { 7 | const { foo, bar } = request 8 | response.status(200).json({ foo, bar }) 9 | }) 10 | 11 | function withFoo(handler) { 12 | return async function (request, response) { 13 | console.log('withFoo [basic-express-middleware]') 14 | request.foo = 'foo' 15 | return handler(request, response) 16 | } 17 | } 18 | 19 | function withBar(handler) { 20 | return async function (request, response) { 21 | console.log('withBar [basic-express-middleware]') 22 | request.bar = 'bar' 23 | return handler(request, response) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/example-app-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strictNullChecks": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ], 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | }, 31 | "strict": false 32 | }, 33 | "include": [ 34 | "next-env.d.ts", 35 | "**/*.ts", 36 | "**/*.tsx", 37 | ".next/types/**/*.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /examples/example-app-router/src/middleware/with-validation.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { z } from "zod"; 3 | 4 | const validation = 5 | (type: "body" | "query", schema: T) => 6 | async (request: NextRequest & { validData: z.infer }) => { 7 | if (type === "body" && request.body == null) { 8 | return new Response("Request must have a JSON body", { 9 | status: 400, 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | }); 14 | } 15 | 16 | const dataToValidate = 17 | type === "body" ? await request.json() : new URLSearchParams(request.url); 18 | 19 | const parsed = await schema.safeParseAsync(dataToValidate); 20 | if (!parsed.success) { 21 | return new Response(JSON.stringify(parsed.error.format()), { 22 | status: 400, 23 | headers: { 24 | "Content-Type": "application/json", 25 | }, 26 | }); 27 | } 28 | 29 | request.validData = parsed.data; 30 | }; 31 | 32 | export { validation }; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 neg4n / Igor Klepacki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "next-api-compose", 3 | "projectOwner": "neg4n", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "commitType": "docs", 10 | "commitConvention": "angular", 11 | "contributorsPerLine": 7, 12 | "contributors": [ 13 | { 14 | "login": "neg4n", 15 | "name": "Igor", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/57688858?v=4", 17 | "profile": "https://neg4n.dev/", 18 | "contributions": [ 19 | "code", 20 | "test", 21 | "example" 22 | ] 23 | }, 24 | { 25 | "login": "mgrabka", 26 | "name": "Maksymilian Grabka", 27 | "avatar_url": "https://avatars.githubusercontent.com/u/116151164?v=4", 28 | "profile": "https://github.com/mgrabka", 29 | "contributions": [ 30 | "test", 31 | "code" 32 | ] 33 | }, 34 | null, 35 | { 36 | "login": "kacper3123", 37 | "name": "kacper3123", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/89151689?v=4", 39 | "profile": "https://github.com/kacper3123", 40 | "contributions": [ 41 | "doc" 42 | ] 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /examples/example-pages-router/pages/api/basic-error-handling.js: -------------------------------------------------------------------------------- 1 | import { compose } from 'next-api-compose' 2 | 3 | export default compose( 4 | { 5 | sharedErrorHandler: handleErrors, 6 | middlewareChain: [withBar, withFoo, withThrowError] 7 | }, 8 | (request, response) => { 9 | console.log('API Route [basic-error-handling]') 10 | const { foo, bar } = request 11 | response.status(200).json({ foo, bar }) 12 | } 13 | ) 14 | 15 | function handleErrors(error, _request, response) { 16 | console.log('handleErrors [basic-error-handling]') 17 | response.status(500).json({ message: error.message }) 18 | } 19 | 20 | function withFoo(handler) { 21 | return async function (request, response) { 22 | console.log('withFoo [basic-error-handling]') 23 | request.foo = 'foo' 24 | return handler(request, response) 25 | } 26 | } 27 | 28 | function withBar(handler) { 29 | return async function (request, response) { 30 | console.log('withBar [basic-error-handling]') 31 | request.bar = 'bar' 32 | return handler(request, response) 33 | } 34 | } 35 | 36 | function withThrowError() { 37 | return async function () { 38 | console.log('withThrowError [basic-error-handling]') 39 | throw new Error('Thats an error. :(') 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/example-pages-router/pages/api/basic-typescript.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import type { ExtendedNextApiHandler } from 'next-api-compose' 3 | import { compose } from 'next-api-compose' 4 | 5 | type NextApiRequestWithFoo = NextApiRequest & Partial<{ foo: string }> 6 | type NextApiRequestWithBar = NextApiRequest & Partial<{ bar: string }> 7 | 8 | type NextApiRequestWithFooBarHello = NextApiRequestWithFoo & NextApiRequestWithBar 9 | 10 | export default compose( 11 | [withFoo, withBar], 12 | (request, response) => { 13 | const { foo, bar } = request 14 | response.status(200).json({ foo, bar }) 15 | } 16 | ) 17 | 18 | function withFoo(handler: ExtendedNextApiHandler) { 19 | return async function (request: NextApiRequestWithFoo, response: NextApiResponse) { 20 | console.log('withFoo [basic-typescript]') 21 | request.foo = 'foo' 22 | return handler(request, response) 23 | } 24 | } 25 | 26 | function withBar(handler: ExtendedNextApiHandler) { 27 | return async function (request: NextApiRequestWithBar, response: NextApiResponse) { 28 | console.log('withBar [basic-typescript]') 29 | request.bar = 'bar' 30 | return handler(request, response) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/example-app-router/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/example-pages-router/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import path from 'path' 3 | 4 | export default function HomePage({ apiRoutes }) { 5 | return ( 6 |
19 |
20 |

Choose an example:

21 | 33 |
34 |
35 | ) 36 | } 37 | 38 | export async function getStaticProps() { 39 | const apiFiles = await fs.readdir(path.resolve('./pages/api')) 40 | 41 | const apiRoutes = apiFiles 42 | .filter((file) => !file.startsWith('.')) 43 | .map((file) => file.substr(0, file.indexOf('.'))) 44 | 45 | return { 46 | props: { 47 | apiRoutes 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/example-pages-router/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /examples/example-app-router/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | [http://localhost:3000/api/hello](http://localhost:3000/api/hello) is an endpoint that uses [Route Handlers](https://beta.nextjs.org/docs/routing/route-handlers). This endpoint can be edited in `app/api/hello/route.ts`. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /packages/next-api-compose/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-api-compose", 3 | "version": "2.1.0", 4 | "description": "Compose middleware chain in Next.js API Routes. Supports Pages and App router", 5 | "author": { 6 | "name": "Igor Klepacki", 7 | "email": "neg4n@icloud.com", 8 | "url": "https://neg4n.dev/" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/neg4n/next-api-compose.git" 13 | }, 14 | "keywords": [ 15 | "nextjs", 16 | "nextjs-plugin", 17 | "app-router", 18 | "next", 19 | "next-app", 20 | "pages-router", 21 | "middleware", 22 | "next.js" 23 | ], 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/neg4n/next-api-compose/issues" 27 | }, 28 | "homepage": "https://github.com/neg4n/next-api-compose/#readme", 29 | "main": "./dist/app.js", 30 | "files": ["dist"], 31 | "exports": { 32 | ".": "./dist/app.js", 33 | "./app": "./dist/app.js", 34 | "./pages": "./dist/pages.js" 35 | }, 36 | "scripts": { 37 | "prepublishOnly": "pnpm run build", 38 | "prebuild": "rimraf dist", 39 | "build": "tsup src/app.ts src/pages.ts --dts --minify", 40 | "examples": "pnpm run build:watch", 41 | "build:watch": "pnpm run build --watch", 42 | "test": "jest", 43 | "test:coverage": "pnpm run test --coverage" 44 | }, 45 | "peerDependencies": { 46 | "next": ">=13.4.13", 47 | "react": ">=18.2.0", 48 | "react-dom": ">=18.2.0" 49 | }, 50 | "devDependencies": { 51 | "@jest/globals": "^29.7.0", 52 | "@jest/types": "^29.6.3", 53 | "@types/node": "20.8.3", 54 | "@types/supertest": "^2.0.11", 55 | "jest": "^27.2.1", 56 | "prettier": "^2.4.0", 57 | "rimraf": "^3.0.2", 58 | "supertest": "^6.1.6", 59 | "ts-jest": "^27.0.5", 60 | "ts-node": "^10.2.1", 61 | "ts-toolbelt": "^9.6.0", 62 | "tsup": "^7.2.0", 63 | "type-fest": "^4.2.0", 64 | "typescript": "^5.1.6", 65 | "undici": "^5.28.2" 66 | }, 67 | "prettier": { 68 | "printWidth": 90, 69 | "tabWidth": 2, 70 | "useTabs": false, 71 | "semi": false, 72 | "singleQuote": true, 73 | "trailingComma": "none", 74 | "bracketSpacing": true 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/next-api-compose/test/__stubs__/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import type { IncomingMessage, OutgoingMessage, ServerResponse } from 'http' 3 | 4 | export type MockedNextRequestWithFoo = NextApiRequest & Partial<{ foo: string }> 5 | export type MockedNextResponseWithBar = NextApiResponse & Partial<{ bar: string }> 6 | type MockedRequestWithFoo = IncomingMessage & Partial<{ foo: string }> 7 | type MockedResponseWithBar = OutgoingMessage & Partial<{ bar: string }> 8 | 9 | type MockedNextFooBarHandler = ( 10 | request: MockedNextRequestWithFoo, 11 | response: MockedNextResponseWithBar 12 | ) => void 13 | 14 | export type MockedNextRequestWithFizz = NextApiRequest & Partial<{ fizz: string }> 15 | export type MockedNextResponseWithBuzz = NextApiResponse & Partial<{ buzz: string }> 16 | type MockedRequestWithFizz = IncomingMessage & Partial<{ fizz: string }> 17 | type MockedResponseWithBuzz = OutgoingMessage & Partial<{ buzz: string }> 18 | 19 | type MockedNextFizzBuzzHandler = ( 20 | request: MockedNextRequestWithFizz, 21 | response: MockedNextResponseWithBuzz 22 | ) => void 23 | 24 | export function withMockedFooBar(handler: MockedNextFooBarHandler) { 25 | return (request: MockedNextRequestWithFoo, response: MockedNextResponseWithBar) => { 26 | request.foo = 'foo' 27 | response.bar = 'bar' 28 | handler(request, response) 29 | } 30 | } 31 | 32 | export function withMockedFizzBuzz(handler: MockedNextFizzBuzzHandler) { 33 | return (request: MockedNextRequestWithFizz, response: MockedNextResponseWithBuzz) => { 34 | request.fizz = 'fizz' 35 | response.buzz = 'buzz' 36 | handler(request, response) 37 | } 38 | } 39 | 40 | export function connectMockedFooBar( 41 | request: MockedRequestWithFoo, 42 | response: MockedResponseWithBar, 43 | next: () => void 44 | ) { 45 | request.foo = 'foo' 46 | response.bar = 'bar' 47 | next() 48 | } 49 | 50 | export function connectMockedFizzBuzz( 51 | request: MockedRequestWithFizz, 52 | response: MockedResponseWithBuzz, 53 | next: () => void 54 | ) { 55 | request.fizz = 'fizz' 56 | response.buzz = 'buzz' 57 | next() 58 | } 59 | 60 | export function withThrowError() { 61 | return () => { 62 | throw new Error('im a teapot error message') 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/example-pages-router/pages/api/advanced-typescript-complete.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'http' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import type { 4 | ConnectExpressMiddleware, 5 | ExtendedNextApiHandler, 6 | NextApiComposeMiddlewares, 7 | NextApiComposeOptions 8 | } from 'next-api-compose' 9 | import { compose, convert } from 'next-api-compose' 10 | 11 | type NextApiRequestWithFoo = NextApiRequest & Partial<{ foo: string }> 12 | type NextApiRequestWithBar = NextApiRequest & Partial<{ bar: string }> 13 | type IncomingMessageWithHello = IncomingMessage & Partial<{ hello: string }> 14 | 15 | type NextApiRequestWithFooBarHello = NextApiRequestWithFoo & 16 | NextApiRequestWithBar & 17 | IncomingMessageWithHello 18 | 19 | const withHello = convert(helloMiddleware as ConnectExpressMiddleware) 20 | 21 | const mws: NextApiComposeMiddlewares = [ 22 | withFoo, 23 | withBar, 24 | withHello 25 | ] 26 | const options: NextApiComposeOptions = { 27 | sharedErrorHandler: handleErrors, 28 | middlewareChain: mws 29 | } 30 | 31 | export default compose(options, (request, response) => { 32 | console.log('API Route [advanced-typescript-complete]') 33 | const { foo, bar, hello } = request 34 | response.status(200).json({ foo, bar, hello }) 35 | }) 36 | 37 | function handleErrors(error: Error, _request: NextApiRequest, response: NextApiResponse) { 38 | console.log('handleErrors [advanced-typescript-complete]') 39 | response.status(418).json({ error: error.message }) 40 | } 41 | 42 | function helloMiddleware( 43 | request: IncomingMessageWithHello, 44 | _response: ServerResponse, 45 | next: () => void 46 | ) { 47 | console.log('helloMiddleware (Connect/Express) [advanced-typescript-complete]') 48 | request.hello = 'hello' 49 | next() 50 | } 51 | 52 | function withFoo(handler: ExtendedNextApiHandler) { 53 | return async function (request: NextApiRequestWithFoo, response: NextApiResponse) { 54 | console.log('withFoo [advanced-typescript-complete]') 55 | request.foo = 'foo' 56 | return handler(request, response) 57 | } 58 | } 59 | 60 | function withBar(handler: ExtendedNextApiHandler) { 61 | return async function (request: NextApiRequestWithBar, response: NextApiResponse) { 62 | console.log('withBar [advanced-typescript-complete]') 63 | request.bar = 'bar' 64 | return handler(request, response) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/example-app-router/src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /examples/example-app-router/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { Inter } from 'next/font/google' 3 | import styles from './page.module.css' 4 | 5 | const inter = Inter({ subsets: ['latin'] }) 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 |

12 | Get started by editing  13 | src/app/page.tsx 14 |

15 | 32 |
33 | 34 |
35 | Next.js Logo 43 |
44 | 45 | 100 |
101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /examples/example-app-router/src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo { 108 | position: relative; 109 | } 110 | /* Enable hover only on non-touch devices */ 111 | @media (hover: hover) and (pointer: fine) { 112 | .card:hover { 113 | background: rgba(var(--card-rgb), 0.1); 114 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 115 | } 116 | 117 | .card:hover span { 118 | transform: translateX(4px); 119 | } 120 | } 121 | 122 | @media (prefers-reduced-motion) { 123 | .card:hover span { 124 | transform: none; 125 | } 126 | } 127 | 128 | /* Mobile */ 129 | @media (max-width: 700px) { 130 | .content { 131 | padding: 4rem; 132 | } 133 | 134 | .grid { 135 | grid-template-columns: 1fr; 136 | margin-bottom: 120px; 137 | max-width: 320px; 138 | text-align: center; 139 | } 140 | 141 | .card { 142 | padding: 1rem 2.5rem; 143 | } 144 | 145 | .card h2 { 146 | margin-bottom: 0.5rem; 147 | } 148 | 149 | .center { 150 | padding: 8rem 0 6rem; 151 | } 152 | 153 | .center::before { 154 | transform: none; 155 | height: 300px; 156 | } 157 | 158 | .description { 159 | font-size: 0.8rem; 160 | } 161 | 162 | .description a { 163 | padding: 1rem; 164 | } 165 | 166 | .description p, 167 | .description div { 168 | display: flex; 169 | justify-content: center; 170 | position: fixed; 171 | width: 100%; 172 | } 173 | 174 | .description p { 175 | align-items: center; 176 | inset: 0 0 auto; 177 | padding: 2rem 1rem 1.4rem; 178 | border-radius: 0; 179 | border: none; 180 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 181 | background: linear-gradient( 182 | to bottom, 183 | rgba(var(--background-start-rgb), 1), 184 | rgba(var(--callout-rgb), 0.5) 185 | ); 186 | background-clip: padding-box; 187 | backdrop-filter: blur(24px); 188 | } 189 | 190 | .description div { 191 | align-items: flex-end; 192 | pointer-events: none; 193 | inset: auto 0 0; 194 | padding: 2rem; 195 | height: 200px; 196 | background: linear-gradient( 197 | to bottom, 198 | transparent 0%, 199 | rgb(var(--background-end-rgb)) 40% 200 | ); 201 | z-index: 1; 202 | } 203 | } 204 | 205 | /* Tablet and Smaller Desktop */ 206 | @media (min-width: 701px) and (max-width: 1120px) { 207 | .grid { 208 | grid-template-columns: repeat(2, 50%); 209 | } 210 | } 211 | 212 | @media (prefers-color-scheme: dark) { 213 | .vercelLogo { 214 | filter: invert(1); 215 | } 216 | 217 | .logo { 218 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 219 | } 220 | } 221 | 222 | @keyframes rotate { 223 | from { 224 | transform: rotate(360deg); 225 | } 226 | to { 227 | transform: rotate(0deg); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /packages/next-api-compose/src/pages.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import type { IncomingMessage, OutgoingMessage, ServerResponse } from 'http' 3 | 4 | export type ExtendableNextApiRequest = T extends NextApiRequest ? T : NextApiRequest 5 | export type ExtendableNextApiResponse = T extends NextApiResponse 6 | ? T 7 | : NextApiResponse 8 | 9 | export type ExtendedNextApiHandler< 10 | RequestType, 11 | ResponseType = NextApiResponse, 12 | DataType = any 13 | > = ( 14 | request: ExtendableNextApiRequest, 15 | response: ExtendableNextApiResponse 16 | ) => void | Promise 17 | 18 | export type NextApiComposeMiddlewares< 19 | RequestType, 20 | ResponseType = NextApiResponse, 21 | DataType = any 22 | > = Array< 23 | ( 24 | handler: ExtendedNextApiHandler 25 | ) => ExtendedNextApiHandler 26 | > 27 | 28 | export type NextApiComposeOptions< 29 | RequestType, 30 | ResponseType = NextApiResponse, 31 | DataType = any 32 | > = { 33 | sharedErrorHandler: ( 34 | error: Error, 35 | request: ExtendableNextApiRequest, 36 | response: ExtendableNextApiResponse 37 | ) => void | Promise 38 | middlewareChain: NextApiComposeMiddlewares 39 | } 40 | 41 | export type ConnectExpressMiddleware = ( 42 | request: IncomingMessage, 43 | response: OutgoingMessage | ServerResponse, 44 | next: (error?: Error) => void 45 | ) => void | Promise 46 | 47 | /** 48 | * Higher order function that composes multiple middlewares into one API Handler. 49 | * 50 | * @param {NextApiComposeMiddlewares | NextApiComposeOptions} middlewareOrOptions Middlewares array **(order matters)** or options object with previously mentioned middlewares array as `middlewareChain` property and error handler shared by every middleware in the array as `sharedErrorHandler` property. 51 | * @param {NextApiHandler} handler Next.js API handler. 52 | * @returns Middleware composed with Next.js API handler. 53 | */ 54 | export function compose( 55 | middlewareOrOptions: 56 | | NextApiComposeMiddlewares 57 | | NextApiComposeOptions, 58 | handler: ( 59 | request: ExtendableNextApiRequest, 60 | response: ExtendableNextApiResponse 61 | ) => void | Promise 62 | ) { 63 | const isOptions = !Array.isArray(middlewareOrOptions) 64 | const chain = isOptions ? middlewareOrOptions.middlewareChain : middlewareOrOptions 65 | 66 | return async ( 67 | request: ExtendableNextApiRequest, 68 | response: ExtendableNextApiResponse 69 | ) => { 70 | if (chain.length === 0) { 71 | return handler(request, response) 72 | } 73 | 74 | return chain.reduceRight>( 75 | (previousMiddleware, currentMiddleware) => { 76 | return async (request, response) => { 77 | try { 78 | await currentMiddleware(previousMiddleware)(request, response) 79 | } catch (error) { 80 | if (isOptions && middlewareOrOptions.sharedErrorHandler) { 81 | await middlewareOrOptions.sharedErrorHandler(error, request, response) 82 | } 83 | } 84 | } 85 | }, 86 | handler 87 | )(request, response) 88 | } 89 | } 90 | 91 | /** 92 | * Higher order function that converts [Connect]/[Express] middleware into middleware compatible with `next-api-compose`. 93 | * 94 | * @param {ConnectExpressMiddleware} middleware [Connect]/[Express] middleware to convert. 95 | * @returns Middleware compatible with `next-api-compose`. 96 | * 97 | * [connect]: https://github.com/senchalabs/connect 98 | * [express]: https://expressjs.com 99 | */ 100 | export function convert( 101 | middleware: ConnectExpressMiddleware 102 | ) { 103 | return function (handler: ExtendedNextApiHandler) { 104 | return async ( 105 | request: ExtendableNextApiRequest, 106 | response: ExtendableNextApiResponse 107 | ) => { 108 | await middleware(request, response, () => handler(request, response)) 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Higher order function that is used to simplify Next.js API middleware configuration. 115 | */ 116 | export function configure< 117 | ConfigType, 118 | RequestType, 119 | ResponseType = NextApiResponse, 120 | DataType = any 121 | >( 122 | middleware: ( 123 | handler: ExtendedNextApiHandler, 124 | config: ConfigType 125 | ) => ExtendedNextApiHandler, 126 | config: ConfigType 127 | ) { 128 | return (handler: ExtendedNextApiHandler) => { 129 | return middleware(handler, config) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /.github/PAGES_ROUTER.md: -------------------------------------------------------------------------------- 1 | # Next.js API Compose · [![version](https://badgen.net/npm/v/next-api-compose)](https://www.npmjs.com/package/next-api-compose) [![npm bundle size](https://badgen.net/bundlephobia/minzip/next-api-compose)](https://bundlephobia.com/package/next-api-compose) 2 | 3 | ## Introduction 4 | 5 | This library provides simple yet complete higher order function 6 | with responsibility of composing multiple middleware functions into one [Next.js API route][next-api-routes] handler in the pages directory router configuration. 7 | 8 | Learn more about using the library for App Directory's Route Handlers [here](./README.md). 9 | 10 | The library for pages router **does not** contain routing utilities. I believe mechanism built in 11 | [Next.js][next-homepage] itself or [next-connect][next-connect] library are sufficient solutions. 12 | 13 | ## Features 14 | 15 | - [x] 😇 Simple and powerful API 16 | - [x] 🥷 TypeScript support 17 | - [x] 🧬 Maintaining order of middleware chain 18 | - [x] 🔧 Compatible with [Express][express]/[Connect][connect] middleware 19 | - [x] 💢 Error handling 20 | - [x] 📦 No dependencies 21 | - [x] 💯 100% Test coverage for pages router 22 | 23 | ## Installing 24 | 25 | ```sh 26 | npm i next-api-compose -S 27 | # or 28 | yarn add next-api-compose 29 | # or 30 | pnpm i next-api-compose 31 | ``` 32 | 33 | ## Basic usage: 34 | 35 | ```js 36 | import { compose } from "next-api-compose/pages"; 37 | 38 | export default compose([withBar, withFoo], (request, response) => { 39 | const { foo, bar } = request; 40 | response.status(200).json({ foo, bar }); 41 | }); 42 | ``` 43 | 44 | _the `withBar` middleware will append `bar` property to `request` object, then `withFoo` will do accordingly the same but with `foo` property_ 45 | 46 | ## Using Express or Connect middleware 47 | 48 | If you want to use `next-api-compose` along with [Connect][connect] middleware that is widely used eg. in [Express][express] framework, there is special utility function for it. 49 | 50 | ```js 51 | import { compose, convert } from "next-api-compose/pages"; 52 | import helmet from "helmet"; 53 | 54 | const withHelmet = convert(helmet()); 55 | 56 | export default compose([withBar, withFoo, withHelmet], (request, response) => { 57 | const { foo, bar } = request; 58 | response.status(200).json({ foo, bar }); 59 | }); 60 | ``` 61 | 62 | _in this example, popular middleware [helmet][helmet] is converted using utility function from `next-api-compose` and passed as one element in middleware chain_ 63 | 64 | ## Examples 65 | 66 | You can find more examples here: 67 | 68 | - JavaScript 69 | - [Basic usage with error handling][basic-error-handling] 70 | - [Basic usage with Connect/Express middleware][basic-express-middleware] 71 | - TypeScript 72 | - [Basic usage with TypeScript][basic-typescript] 73 | - [Advanced & complete usage with TypeScript][advanced-typescript-complete] 74 | 75 | _the `example/` directory contains simple [Next.js][next-homepage] application implementing `next-api-compose` . To fully explore examples implemented in it by yourself - simply do `cd examples && npm i && npm run dev` then navigate to http://localhost:3000/_ 76 | 77 | ## Caveats 78 | 79 | 1. You may need to add 80 | 81 | ```js 82 | export const config = { 83 | api: { 84 | externalResolver: true, 85 | }, 86 | }; 87 | ``` 88 | 89 | to your [Next.js API route configuration][next-api-routes-config] in order to dismiss false positive 90 | about stalled API requests. 91 | Discussion about this can be found [on the Next.js GitHub repository page][next-stalled-requests-discussion]. 92 | 93 | 2. If you are using TypeScript and strict types _(no `any` at all)_, you may want to use [Partial][typescript-partial] 94 | 95 | ```ts 96 | type NextApiRequestWithFoo = NextApiRequest & Partial<{ foo: string }>; 97 | ``` 98 | 99 | when extending [API Route parameters' objects][next-extending-api-parameters] to avoid type errors during usage of `compose`. 100 | 101 | ## License 102 | 103 | This project is licensed under the MIT license. 104 | All contributions are welcome. 105 | 106 | [helmet]: https://github.com/helmetjs/helmet 107 | [connect]: https://github.com/senchalabs/connect 108 | [express]: https://expressjs.com 109 | [next-homepage]: https://nextjs.org/ 110 | [next-stalled-requests-discussion]: https://github.com/vercel/next.js/issues/10439#issuecomment-583214126 111 | [typescript-partial]: https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype 112 | [next-connect]: https://github.com/hoangvvo/next-connect 113 | [next-extending-api-parameters]: https://nextjs.org/docs/api-routes/api-middlewares#extending-the-reqres-objects-with-typescript 114 | [next-api-routes-config]: https://nextjs.org/docs/api-routes/api-middlewares#custom-config 115 | [next-api-routes]: https://nextjs.org/docs/api-routes/introduction 116 | [basic-error-handling]: https://github.com/neg4n/next-api-compose/tree/main/example/pages/api/basic-error-handling.js 117 | [basic-express-middleware]: https://github.com/neg4n/next-api-compose/tree/main/example/pages/api/basic-express-middleware.js 118 | [basic-typescript]: https://github.com/neg4n/next-api-compose/tree/main/example/pages/api/basic-typescript.ts 119 | [advanced-typescript-complete]: https://github.com/neg4n/next-api-compose/tree/main/example/pages/api/advanced-typescript-complete.ts 120 | -------------------------------------------------------------------------------- /packages/next-api-compose/src/app.ts: -------------------------------------------------------------------------------- 1 | import type { Promisable, PartialDeep } from 'type-fest' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import type { NextResponse } from 'next/server' 4 | 5 | type ParamType = T extends (...args: [infer P]) => any ? P : never 6 | 7 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( 8 | k: infer I 9 | ) => void 10 | ? I 11 | : never 12 | 13 | type NextApiRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' 14 | 15 | type NextApiMethodHandler = ( 16 | request: NextApiRequest 17 | ) => Promisable | Promisable 18 | 19 | type ComposeSettings = PartialDeep<{ 20 | sharedErrorHandler: { 21 | /** 22 | * @param {NextApiRouteMethod} method HTTP method of the composed route handler that failed. 23 | * @param {Error} error Error that was thrown by the middleware or the handler. 24 | */ 25 | handler: (method: NextApiRouteMethod, error: Error) => Promisable 26 | /** 27 | * Whether to include the route handler in the error handled area. 28 | * 29 | * By default only middlewares are included (being caught by the sharedErrorHandler). 30 | */ 31 | includeRouteHandler: boolean 32 | } 33 | }> 34 | 35 | type ComposeParameters< 36 | Methods extends NextApiRouteMethod, 37 | MiddlewareChain extends Array< 38 | ( 39 | request: any 40 | ) => 41 | | Promisable 42 | | Promisable 43 | | Promisable 44 | > 45 | > = Record< 46 | Methods, 47 | | NextApiMethodHandler 48 | | [ 49 | MiddlewareChain, 50 | (request: UnionToIntersection>) => any 51 | ] 52 | > 53 | 54 | /** 55 | * Function that allows to define complex API structure in Next.js App router's Route Handlers. 56 | * 57 | * @param {ComposeParameters} parameters Middlewares array **(order matters)** or options object with previously mentioned middlewares array as `middlewareChain` property and error handler shared by every middleware in the array as `sharedErrorHandler` property. 58 | * @param {ComposeSettings} composeSettings Settings object that allows to configure the compose function. 59 | * @returns Method handlers with applied middleware. 60 | */ 61 | export function compose< 62 | UsedMethods extends NextApiRouteMethod, 63 | MiddlewareChain extends Array< 64 | ( 65 | request: any 66 | ) => 67 | | Promisable 68 | | Promisable 69 | | Promisable 70 | > 71 | >( 72 | parameters: ComposeParameters, 73 | composeSettings?: ComposeSettings 74 | ) { 75 | const defaultComposeSettings = { 76 | sharedErrorHandler: { 77 | handler: undefined, 78 | includeRouteHandler: false 79 | } 80 | } 81 | 82 | const mergedComposeSettings = { 83 | ...defaultComposeSettings, 84 | ...composeSettings 85 | } 86 | 87 | const modified = Object.entries(parameters).map( 88 | ([method, composeForMethodData]: [ 89 | UsedMethods, 90 | ( 91 | | NextApiMethodHandler 92 | | [ 93 | [(request: any) => any], 94 | (request: UnionToIntersection>) => any 95 | ] 96 | ) 97 | ]) => ({ 98 | [method]: async (request: any) => { 99 | if (typeof composeForMethodData === 'function') { 100 | const handler = composeForMethodData 101 | if ( 102 | mergedComposeSettings.sharedErrorHandler.includeRouteHandler && 103 | mergedComposeSettings.sharedErrorHandler.handler != null 104 | ) { 105 | try { 106 | return await handler(request) 107 | } catch (error) { 108 | const composeSharedErrorHandlerResult = 109 | await mergedComposeSettings.sharedErrorHandler.handler(method, error) 110 | 111 | if ( 112 | composeSharedErrorHandlerResult != null && 113 | composeSharedErrorHandlerResult instanceof Response 114 | ) { 115 | return composeSharedErrorHandlerResult 116 | } 117 | } 118 | } 119 | 120 | return await handler(request) 121 | } 122 | 123 | const [middlewareChain, handler] = composeForMethodData 124 | 125 | for (const middleware of middlewareChain) { 126 | if (mergedComposeSettings.sharedErrorHandler.handler != null) { 127 | try { 128 | const abortedMiddleware = await middleware(request) 129 | 130 | if (abortedMiddleware != null && abortedMiddleware instanceof Response) 131 | return abortedMiddleware 132 | } catch (error) { 133 | const composeSharedErrorHandlerResult = 134 | await mergedComposeSettings.sharedErrorHandler.handler(method, error) 135 | 136 | if ( 137 | composeSharedErrorHandlerResult != null && 138 | composeSharedErrorHandlerResult instanceof Response 139 | ) { 140 | return composeSharedErrorHandlerResult 141 | } 142 | } 143 | } 144 | 145 | const abortedMiddleware = await middleware(request) 146 | 147 | if (abortedMiddleware != null && abortedMiddleware instanceof Response) 148 | return abortedMiddleware 149 | } 150 | 151 | return await handler(request) 152 | } 153 | }) 154 | ) 155 | 156 | return modified.reduce>( 157 | (acc, obj) => ({ ...acc, ...obj }), 158 | {} as Record 159 | ) 160 | } 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | next-api-compose example code theme aware 5 | 6 | 7 | # Next.js API Compose · [![version](https://badgen.net/npm/v/next-api-compose)](https://www.npmjs.com/package/next-api-compose) [![npm bundle size](https://badgen.net/bundlephobia/minzip/next-api-compose)](https://bundlephobia.com/package/next-api-compose) 8 | 9 | ## Introduction 10 | 11 | This library provides a hassle-free way of composing multiple middleware functions into one [Next.js API Route Handler][next-api-route-handlers]'s method in the **[App Directory][next-app-router]** router. 12 | 13 | > [!IMPORTANT] 14 | > The `2.0.0` version of the library supports both [app][next-app-router] and [pages][next-app-router] directory oriented API utilities. If you're still using Pages Router and you want to migrate from versions below `2.0.0`, please read [migration guide](./.github/MIGRATE_V2.md) and ocassionally consider checking out [intro to the App Router][next-app-router-intro]. 15 | 16 | ## Features 17 | 18 | - [x] 😇 Simple and powerful API 19 | - [x] 🚀 Designed both for Pages Router and App Router 20 | - [x] 🧪 Production-ready. 100% test coverage, even type inference is tested! 21 | - [x] 🥷 Excellent TypeScript support 22 | - [x] 🧬 Maintaining order of middleware chain 23 | - [x] 📦 No dependencies, small footprint 24 | 25 | ## Installing and basic usage 26 | 27 | Install the package by running: 28 | 29 | ```sh 30 | npm i next-api-compose -S 31 | # or 32 | yarn add next-api-compose 33 | # or 34 | pnpm i next-api-compose 35 | ``` 36 | 37 | then create an API Route Handler in the **[App Directory][next-app-router-intro]**: 38 | 39 | in TypeScript **(recommended)** 40 | 41 | ```ts 42 | import type { NextRequest } from "next/server"; 43 | import { compose } from "next-api-compose"; 44 | 45 | function someMiddleware(request: NextRequest & { foo: string }) { 46 | request.foo = "bar"; 47 | } 48 | 49 | const { GET } = compose({ 50 | GET: [ 51 | [someMiddleware], 52 | (request /* This is automatically inferred */) => { 53 | return new Response(request.foo); 54 | // ^ (property) foo: string - autocomplete works here 55 | }, 56 | ], 57 | }); 58 | 59 | export { GET }; 60 | ``` 61 | 62 | in JavaScript: 63 | 64 | ```js 65 | import { compose } from "next-api-compose"; 66 | 67 | function someMiddleware(request) { 68 | request.foo = "bar"; 69 | } 70 | 71 | const { GET } = compose({ 72 | GET: [ 73 | [someMiddleware], 74 | (request) => { 75 | return new Response(request.foo); 76 | }, 77 | ], 78 | }); 79 | 80 | export { GET }; 81 | ``` 82 | 83 | ## Error handling 84 | 85 | Handling errors both in middleware and in the main handler is as simple as providing `sharedErrorHandler` to the `compose` function's second parameter _(a.k.a compose settings)_. Main goal of the shared error handler is to provide clear and easy way to e.g. send the error metadata to Sentry or other error tracking service. 86 | 87 | By default, shared error handler looks like this: 88 | 89 | ```ts 90 | sharedErrorHandler: { 91 | handler: undefined; 92 | // ^^^^ This is the handler function. By default there is no handler, so the error is being just thrown. 93 | includeRouteHandler: false; 94 | // ^^^^^^^^^^^^^^^^ This toggles whether the route handler itself should be included in a error handled area. 95 | // By default only middlewares are being caught by the sharedErrorHandler 96 | } 97 | ``` 98 | 99 | ... and some usage example: 100 | 101 | ```ts 102 | // [...] 103 | function errorMiddleware() { 104 | throw new Error("foo"); 105 | } 106 | 107 | const { GET } = compose( 108 | { 109 | GET: [ 110 | [errorMiddleware], 111 | () => { 112 | // Unreachable code due to errorMiddleware throwing an error and halting the chain 113 | return new Response(JSON.stringify({ foo: "bar" })); 114 | }, 115 | ], 116 | }, 117 | { 118 | sharedErrorHandler: { 119 | handler: (_method, error) => { 120 | return new Response(JSON.stringify({ error: error.message }), { 121 | status: 500, 122 | }); 123 | }, 124 | }, 125 | } 126 | ); 127 | // [...] 128 | ``` 129 | 130 | will return `{"error": "foo"}` along with `500` status code instead of throwing an error. 131 | 132 | ## Theory and caveats 133 | 134 | 1. Unfortunately there is no way to dynamically export named ESModules _(or at least I did not find a way)_ so you have to use `export { GET, POST }` syntax instead of something like `export compose(...)` if you're composing GET and POST methods :( 135 | 136 | 2. Middleware is executed as specified in the per-method array, so if you want to execute middleware in a specific order, you have to be careful about it. Early returned `new Response()` halts the middleware chain. 137 | 138 | ## Contributors 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 |
Igor
Igor

💻 ⚠️ 💡
Maksymilian Grabka
Maksymilian Grabka

⚠️ 💻
kacper3123
kacper3123

📖
152 | 153 | 154 | 155 | 156 | 157 | 158 | ## License and acknowledgements 159 | 160 | The project is licensed under The MIT License. Thanks for all the contributions! Feel free to open an issue or a pull request even if it is just a question 🙌 161 | 162 | [next-api-route-handlers]: https://nextjs.org/docs/app/building-your-application/routing/route-handlers 163 | [next-app-router-intro]: https://nextjs.org/docs/app/building-your-application/routing#the-app-router 164 | [next-app-router]: https://nextjs.org/docs/app 165 | [next-pages-router]: https://nextjs.org/docs/pages 166 | -------------------------------------------------------------------------------- /packages/next-api-compose/test/pages.test.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { createServer } from 'http' 3 | import request from 'supertest' 4 | import { describe, it, expect } from '@jest/globals' 5 | 6 | import type { 7 | MockedNextRequestWithFoo, 8 | MockedNextResponseWithBar, 9 | MockedNextRequestWithFizz, 10 | MockedNextResponseWithBuzz 11 | } from './__stubs__/middleware' 12 | import { 13 | withMockedFizzBuzz, 14 | withMockedFooBar, 15 | withThrowError, 16 | connectMockedFizzBuzz, 17 | connectMockedFooBar 18 | } from './__stubs__/middleware' 19 | import { compose, convert, configure } from '../src/pages' 20 | 21 | type MockedRequestWithFooFizz = MockedNextRequestWithFoo & MockedNextRequestWithFizz 22 | type MockedResponseWithBarBuzz = MockedNextResponseWithBar & MockedNextResponseWithBuzz 23 | 24 | function createDummyNextServer(handler) { 25 | return createServer((request, response) => { 26 | return handler(request as NextApiRequest, response as NextApiResponse) 27 | }) 28 | } 29 | 30 | describe("compose's http functionality", () => { 31 | it('should execute final handler if empty middleware chain is provided', async () => { 32 | const handler = compose([], (request, response) => { 33 | response.end('im empty') 34 | }) 35 | 36 | const server = createDummyNextServer(handler) 37 | const response = await request(server).get('/') 38 | 39 | expect(response.text).toBe('im empty') 40 | }) 41 | 42 | it('should compose 2 middleware chain and intercept its request and response objects', async () => { 43 | const mockedNextApiRoute = compose< 44 | MockedRequestWithFooFizz, 45 | MockedResponseWithBarBuzz 46 | >([withMockedFizzBuzz, withMockedFooBar], (request, response) => { 47 | response.setHeader('Content-Type', 'application/json') 48 | response.end(JSON.stringify({ foo: request.foo, fizz: request.fizz })) 49 | }) 50 | 51 | const server = createDummyNextServer(mockedNextApiRoute) 52 | const response = await request(server).get('/') 53 | 54 | expect(response.body).toStrictEqual({ foo: 'foo', fizz: 'fizz' }) 55 | }) 56 | 57 | it('should compose 2 middleware chain from options object and intercept its request and response objects', async () => { 58 | const mockedNextApiRoute = compose< 59 | MockedRequestWithFooFizz, 60 | MockedResponseWithBarBuzz 61 | >( 62 | { 63 | sharedErrorHandler: () => { 64 | return 65 | }, 66 | middlewareChain: [withMockedFizzBuzz, withMockedFooBar] 67 | }, 68 | (request, response) => { 69 | response.setHeader('Content-Type', 'application/json') 70 | response.end(JSON.stringify({ foo: request.foo, fizz: request.fizz })) 71 | } 72 | ) 73 | 74 | const server = createDummyNextServer(mockedNextApiRoute) 75 | const response = await request(server).get('/') 76 | 77 | expect(response.body).toStrictEqual({ fizz: 'fizz', foo: 'foo' }) 78 | }) 79 | 80 | it('should compose 3 middleware chain in which one throws error and return 418 with error message in body from sharedErrorHandler', async () => { 81 | const mockedNextApiRoute = compose< 82 | MockedRequestWithFooFizz, 83 | MockedResponseWithBarBuzz 84 | >( 85 | { 86 | sharedErrorHandler: (error, request, response) => { 87 | response.statusCode = 418 88 | response.setHeader('Content-Type', 'application/json') 89 | response.end(JSON.stringify({ message: error.message })) 90 | }, 91 | middlewareChain: [withMockedFizzBuzz, withThrowError, withMockedFooBar] 92 | }, 93 | (request, response) => { 94 | response.setHeader('Content-Type', 'application/json') 95 | response.end(JSON.stringify({ foo: request.foo, fizz: request.fizz })) 96 | } 97 | ) 98 | 99 | const server = createDummyNextServer(mockedNextApiRoute) 100 | const response = await request(server).get('/') 101 | 102 | expect(response.status).toBe(418) 103 | expect(response.body).toStrictEqual({ message: 'im a teapot error message' }) 104 | }) 105 | }) 106 | 107 | describe('convert used along with compose http functionality', () => { 108 | it('should compose 2 converted connect/express middleware to hofs and intercept its request and response objects', async () => { 109 | const withMockedConnectFooBar = convert(connectMockedFooBar) 110 | const withMockedConnectFizzBuzz = convert(connectMockedFizzBuzz) 111 | 112 | const mockedNextApiRoute = compose< 113 | MockedRequestWithFooFizz, 114 | MockedResponseWithBarBuzz 115 | >([withMockedConnectFizzBuzz, withMockedConnectFooBar], (request, response) => { 116 | response.setHeader('Content-Type', 'application/json') 117 | response.end(JSON.stringify({ foo: request.foo, fizz: request.fizz })) 118 | }) 119 | 120 | const server = createDummyNextServer(mockedNextApiRoute) 121 | const response = await request(server).get('/') 122 | 123 | expect(response.body).toStrictEqual({ fizz: 'fizz', foo: 'foo' }) 124 | }) 125 | 126 | it('should compose converted connect/express middleware with regular hof middleware and intercept its request and response objects', async () => { 127 | const withMockedConnectFooBar = convert(connectMockedFooBar) 128 | 129 | const mockedNextApiRoute = compose< 130 | MockedRequestWithFooFizz, 131 | MockedResponseWithBarBuzz 132 | >([withMockedFizzBuzz, withMockedConnectFooBar], (request, response) => { 133 | response.setHeader('Content-Type', 'application/json') 134 | response.end(JSON.stringify({ foo: request.foo, fizz: request.fizz })) 135 | }) 136 | 137 | const server = createDummyNextServer(mockedNextApiRoute) 138 | const response = await request(server).get('/') 139 | 140 | expect(response.body).toStrictEqual({ fizz: 'fizz', foo: 'foo' }) 141 | }) 142 | }) 143 | 144 | describe("configure's functionality", () => { 145 | const customMiddleware = (handler, config) => (request, response) => { 146 | if (config.foo) { 147 | request.foo = config.foo 148 | } else { 149 | request.foo = 'foo' 150 | } 151 | handler(request, response) 152 | } 153 | 154 | it('should configure middleware with provided configuration and pass it correctly using compose', async () => { 155 | const config = { foo: 'bar' } 156 | 157 | const configuredMiddleware = configure(customMiddleware, config) 158 | 159 | const composedHandler = compose([configuredMiddleware], (request, response) => { 160 | response.setHeader('Content-Type', 'application/json') 161 | response.end(JSON.stringify({ foo: request.foo })) 162 | }) 163 | 164 | const server = createDummyNextServer(composedHandler) 165 | const response = await request(server).get('/') 166 | 167 | expect(response.body.foo).toBe('bar') 168 | }) 169 | 170 | it('should use default value when configuration is not provided', async () => { 171 | const config = {} 172 | const composedHandler = compose( 173 | [configure(customMiddleware, config)], 174 | (request, response) => { 175 | response.setHeader('Content-Type', 'application/json') 176 | response.end(JSON.stringify({ foo: request.foo })) 177 | } 178 | ) 179 | 180 | const server = createDummyNextServer(composedHandler) 181 | const response = await request(server).get('/') 182 | 183 | expect(response.body.foo).toBe('foo') 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /packages/next-api-compose/test/app.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http' 2 | import request from 'supertest' 3 | import { it, describe, expect } from '@jest/globals' 4 | import type { NextRequest } from 'next/server' 5 | 6 | import { A, O } from 'ts-toolbelt' 7 | import type { IncomingMessage } from 'http' 8 | 9 | import { compose } from '../src/app' 10 | 11 | type HandlerFunction = (req: IncomingMessage) => Promise 12 | 13 | async function streamToJson(stream: ReadableStream) { 14 | const reader = stream.getReader() 15 | let chunks: Uint8Array[] = [] 16 | 17 | while (true) { 18 | const { done, value } = await reader.read() 19 | if (done) break 20 | chunks.push(value as any) 21 | } 22 | 23 | const string = new TextDecoder().decode( 24 | Uint8Array.from(chunks.flatMap((chunk) => Array.from(chunk))) 25 | ) 26 | return JSON.parse(string) 27 | } 28 | 29 | function createTestServer(handler: HandlerFunction) { 30 | return createServer(async (req, res) => { 31 | const response = await handler(req) 32 | if (response.body) { 33 | const body = await streamToJson(response.body) 34 | res.writeHead(response.status, { 'Content-Type': 'application/json' }) 35 | res.end(JSON.stringify(body)) 36 | } 37 | }) 38 | } 39 | 40 | describe("composed route handler's http functionality", () => { 41 | it('should correctly execute and return the response when no middleware is provided', async () => { 42 | const { GET } = compose({ 43 | GET: [ 44 | [], 45 | () => { 46 | return new Response(JSON.stringify({ foo: 'bar' })) 47 | } 48 | ] 49 | }) 50 | 51 | const app = createTestServer(GET) 52 | const response = await request(app).get('/') 53 | 54 | expect(response.status).toBe(200) 55 | expect(response.body.foo).toBe('bar') 56 | }) 57 | 58 | it('should correctly execute middlewares in the order they are provided', async () => { 59 | function setFooMiddleware(request) { 60 | request.foo = 'foo' 61 | } 62 | 63 | function appendBarToFooMiddleware(request) { 64 | request.foo += 'bar' 65 | } 66 | const { GET } = compose({ 67 | GET: [ 68 | [setFooMiddleware, appendBarToFooMiddleware], 69 | (request) => { 70 | return new Response(JSON.stringify({ foo: request.foo })) 71 | } 72 | ] 73 | }) 74 | 75 | const app = createTestServer(GET) 76 | const response = await request(app).get('/') 77 | 78 | expect(response.status).toBe(200) 79 | expect(response.body.foo).toBe('foobar') 80 | }) 81 | 82 | it("should handle errors thrown by handler when no middleware is provided and return a 500 response with the error's message", async () => { 83 | const { GET } = compose( 84 | { 85 | GET: () => { 86 | throw new Error('foo') 87 | } 88 | }, 89 | { 90 | sharedErrorHandler: { 91 | includeRouteHandler: true, 92 | handler: (_method, error) => { 93 | return new Response(JSON.stringify({ error: error.message }), { status: 500 }) 94 | } 95 | } 96 | } 97 | ) 98 | 99 | const app = createTestServer(GET) 100 | const response = await request(app).get('/') 101 | 102 | expect(response.status).toBe(500) 103 | expect(response.body.error).toBe('foo') 104 | }) 105 | 106 | it("should handle errors thrown by middlewares and return a 500 response with the error's message", async () => { 107 | function errorMiddleware() { 108 | throw new Error('foo') 109 | } 110 | 111 | const { GET } = compose( 112 | { 113 | GET: [ 114 | [errorMiddleware], 115 | () => { 116 | return new Response(JSON.stringify({ foo: 'bar' })) 117 | } 118 | ] 119 | }, 120 | { 121 | sharedErrorHandler: { 122 | handler: (_method, error) => { 123 | return new Response(JSON.stringify({ error: error.message }), { 124 | status: 500 125 | }) 126 | } 127 | } 128 | } 129 | ) 130 | 131 | const app = createTestServer(GET) 132 | const response = await request(app).get('/') 133 | 134 | expect(response.status).toBe(500) 135 | expect(response.body.error).toBe('foo') 136 | }) 137 | 138 | 139 | it("should abort (halt) further middleware and handler execution with no error scenario when shared error handler is provided", async () => { 140 | function haltingMiddleware() { 141 | return new Response(JSON.stringify({ foo: 'bar' })) 142 | } 143 | 144 | const { GET } = compose( 145 | { 146 | GET: [ 147 | [haltingMiddleware], 148 | () => { 149 | return new Response(JSON.stringify({ foo: 'bar' })) 150 | } 151 | ] 152 | }, 153 | { 154 | sharedErrorHandler: { 155 | handler: (_method, error) => { 156 | return new Response(JSON.stringify({ error: error.message }), { 157 | status: 500 158 | }) 159 | } 160 | } 161 | } 162 | ) 163 | 164 | const app = createTestServer(GET) 165 | const response = await request(app).get('/') 166 | 167 | expect(response.status).toBe(200) 168 | expect(response.body.foo).toBe('bar') 169 | }) 170 | 171 | it('should correctly execute handler without middleware chain provided', async () => { 172 | const { GET } = compose({ 173 | GET: (request) => { 174 | return new Response(JSON.stringify({ foo: 'bar' })) 175 | } 176 | }) 177 | 178 | const app = createTestServer(GET) 179 | const response = await request(app).get('/') 180 | 181 | expect(response.status).toBe(200) 182 | expect(response.body.foo).toBe('bar') 183 | }) 184 | 185 | it('should wait for asynchronous middlewares to resolve before moving to the next middleware or handler', async () => { 186 | async function setFooAsyncMiddleware(request) { 187 | await new Promise((resolve) => setTimeout(resolve, 100)) 188 | request.foo = 'foo' 189 | } 190 | 191 | function appendBarToFooMiddleware(request) { 192 | request.foo += 'bar' 193 | } 194 | const { GET } = compose({ 195 | GET: [ 196 | [setFooAsyncMiddleware, appendBarToFooMiddleware], 197 | (request) => { 198 | return new Response(JSON.stringify({ foo: request.foo })) 199 | } 200 | ] 201 | }) 202 | 203 | const app = createTestServer(GET) 204 | const response = await request(app).get('/') 205 | 206 | expect(response.status).toBe(200) 207 | expect(response.body.foo).toBe('foobar') 208 | }) 209 | 210 | it('should abort (halt) further middleware and handler execution and return the response if a middleware returns a Response instance.', async () => { 211 | function abortMiddleware(request) { 212 | request.foo = 'bar' 213 | return new Response(JSON.stringify({ foo: request.foo }), { status: 418 }) 214 | } 215 | 216 | function setFooMiddleware(request) { 217 | request.foo = 'foo' 218 | } 219 | 220 | const { GET } = compose({ 221 | GET: [ 222 | [abortMiddleware, setFooMiddleware], 223 | () => { 224 | return new Response(JSON.stringify({ foo: 'unreachable fizz' })) 225 | } 226 | ] 227 | }) 228 | 229 | const app = createTestServer(GET) 230 | const response = await request(app).get('/') 231 | 232 | expect(response.status).toBe(418) 233 | expect(response.body.foo).toBe('bar') 234 | }) 235 | }) 236 | 237 | describe("composed route handler's code features", () => { 238 | it("should correctly return multiple method handlers when they're composed", async () => { 239 | const composedMethods = compose({ 240 | GET: (request) => { 241 | return new Response(JSON.stringify({ foo: 'bar' })) 242 | }, 243 | POST: (request) => { 244 | return new Response(JSON.stringify({ fizz: 'buzz' })) 245 | } 246 | }) 247 | 248 | expect(composedMethods).toHaveProperty('GET') 249 | expect(composedMethods).toHaveProperty('POST') 250 | }) 251 | }) 252 | 253 | // It simply won't compile if there is an error in the inference, no need for runtime assertions 254 | describe('type inference', () => { 255 | it("should correctly infer final handler's intercepted request object's types", async () => { 256 | function someMiddleware(request: NextRequest & { foo?: string }) { 257 | request.foo = 'bar' 258 | } 259 | 260 | compose({ 261 | GET: [ 262 | [someMiddleware], 263 | (request) => { 264 | type HandlerRequestType = typeof request 265 | type ExpectedRequestType = NextRequest & { foo?: string } 266 | type IsExactType = A.Equals 267 | 268 | const assertIsExactType: IsExactType = 1 269 | 270 | type HasFooProperty = O.Has 271 | const assertHasFooProperty: HasFooProperty = 1 272 | 273 | return new Response(request.foo) 274 | } 275 | ] 276 | }) 277 | }) 278 | }) 279 | --------------------------------------------------------------------------------