├── 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 |
22 | {apiRoutes.map((route: string) => (
23 | -
24 |
28 | {route}
29 |
30 |
31 | ))}
32 |
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 |
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 · [](https://www.npmjs.com/package/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 |
5 |
6 |
7 | # Next.js API Compose · [](https://www.npmjs.com/package/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 |
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 |
--------------------------------------------------------------------------------