Skip to content

Commit

Permalink
API interface extensions (#7363)
Browse files Browse the repository at this point in the history
* Cookies and Query parsing for API request

* Adding JSON and SEND

* First body parsing

* Body parsing

* Remove extra try catch

* Fix tests

* Only server bundling for API routes

* Update packages/next-server/server/api-utils.ts

Co-Authored-By: Tim Neutkens <tim@timneutkens.nl>

* Revert on demand server changes

* Use content-type for parsing

* Update packages/next-server/server/api-utils.ts

Co-Authored-By: Jan Potoms <potoms.jan@gmail.com>

* Add tests for server compilation

* Add body limit

* Change API to function chaining

* Limit test
  • Loading branch information
huv1k authored and timneutkens committed Jun 5, 2019
1 parent 93aaacd commit c821e83
Show file tree
Hide file tree
Showing 14 changed files with 714 additions and 40 deletions.
41 changes: 40 additions & 1 deletion packages/next-server/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,48 @@ export type DocumentProps = DocumentInitialProps & {
}

/**
* Utils
* Next `API` route request
*/
export type NextApiRequest = IncomingMessage & {
/**
* Object of `query` values from url
*/
query: {
[key: string]: string | string[]
}
/**
* Object of `cookies` from header
*/
cookies: {
[key: string]: string
}

body: any
}

/**
* Send body of response
*/
type Send = (body: any) => void

/**
* Next `API` route response
*/
export type NextApiResponse = ServerResponse & {
/**
* Send data `any` data in reponse
*/
send: Send
/**
* Send data `json` data in reponse
*/
json: Send
status: (statusCode: number) => void
}

/**
* Utils
*/
export function execOnce(this: any, fn: () => any) {
let used = false
return (...args: any) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/next-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@
},
"dependencies": {
"amp-toolbox-optimizer": "1.2.0-alpha.1",
"content-type": "1.0.4",
"cookie": "0.4.0",
"etag": "1.8.1",
"find-up": "4.0.0",
"fresh": "0.5.2",
"path-to-regexp": "2.1.0",
"prop-types": "15.7.2",
"raw-body": "2.4.0",
"react-is": "16.8.6",
"send": "0.16.1",
"url": "0.11.0"
Expand All @@ -55,6 +58,8 @@
"devDependencies": {
"@taskr/clear": "1.1.0",
"@taskr/watch": "1.1.0",
"@types/content-type": "1.1.3",
"@types/cookie": "0.3.2",
"@types/react": "16.8.18",
"@types/react-dom": "16.8.4",
"@types/react-is": "16.7.1",
Expand Down
168 changes: 168 additions & 0 deletions packages/next-server/server/api-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { IncomingMessage } from 'http'
import { NextApiResponse, NextApiRequest } from '../lib/utils'
import { Stream } from 'stream'
import getRawBody from 'raw-body'
import { URL } from 'url'
import { parse } from 'content-type'

/**
* Parse incoming message like `json` or `urlencoded`
* @param req
*/
export async function parseBody(req: NextApiRequest, limit: string = '1mb') {
const contentType = parse(req.headers['content-type'] || 'text/plain')
const { type, parameters } = contentType
const encoding = parameters.charset || 'utf-8'

let buffer

try {
buffer = await getRawBody(req, { encoding, limit })
} catch (e) {
if (e.type === 'entity.too.large') {
throw new ApiError(413, `Body exceeded ${limit} limit`)
} else {
throw new ApiError(400, 'Invalid body')
}
}

const body = buffer.toString()

if (type === 'application/json' || type === 'application/ld+json') {
return parseJson(body)
} else if (type === 'application/x-www-form-urlencoded') {
const qs = require('querystring')
return qs.decode(body)
} else {
return body
}
}

/**
* Parse `JSON` and handles invalid `JSON` strings
* @param str `JSON` string
*/
function parseJson(str: string) {
try {
return JSON.parse(str)
} catch (e) {
throw new ApiError(400, 'Invalid JSON')
}
}

/**
* Parsing query arguments from request `url` string
* @param url of request
* @returns Object with key name of query argument and its value
*/
export function parseQuery({ url }: IncomingMessage) {
if (url) {
// This is just for parsing search params, base it's not important
const params = new URL(url, 'https://n').searchParams

return reduceParams(params.entries())
} else {
return {}
}
}

/**
*
* @param res response object
* @param statusCode `HTTP` status code of response
*/
export function sendStatusCode(res: NextApiResponse, statusCode: number) {
res.statusCode = statusCode
return res
}

/**
* Send `any` body to response
* @param res response object
* @param body of response
*/
export function sendData(res: NextApiResponse, body: any) {
if (body === null) {
res.end()
return
}

const contentType = res.getHeader('Content-Type')

if (Buffer.isBuffer(body)) {
if (!contentType) {
res.setHeader('Content-Type', 'application/octet-stream')
}
res.setHeader('Content-Length', body.length)
res.end(body)
return
}

if (body instanceof Stream) {
if (!contentType) {
res.setHeader('Content-Type', 'application/octet-stream')
}
body.pipe(res)
return
}

let str = body

// Stringify JSON body
if (typeof body === 'object' || typeof body === 'number') {
str = JSON.stringify(body)
res.setHeader('Content-Type', 'application/json; charset=utf-8')
}

res.setHeader('Content-Length', Buffer.byteLength(str))
res.end(str)
}

/**
* Send `JSON` object
* @param res response object
* @param jsonBody of data
*/
export function sendJson(res: NextApiResponse, jsonBody: any): void {
// Set header to application/json
res.setHeader('Content-Type', 'application/json; charset=utf-8')

// Use send to handle request
res.send(jsonBody)
}

function reduceParams(params: IterableIterator<[string, string]>) {
const obj: any = {}
for (const [key, value] of params) {
obj[key] = value
}
return obj
}

/**
* Custom error class
*/
export class ApiError extends Error {
readonly statusCode: number

constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}

/**
* Sends error in `response`
* @param res response object
* @param statusCode of response
* @param message of response
*/
export function sendError(
res: NextApiResponse,
statusCode: number,
message: string
) {
res.statusCode = statusCode
res.statusMessage = message
res.end()
}
44 changes: 39 additions & 5 deletions packages/next-server/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ import {
getSortedRoutes,
} from '../lib/router/utils'
import * as envConfig from '../lib/runtime-config'
import { NextApiRequest, NextApiResponse } from '../lib/utils'
import { parse as parseCookies } from 'cookie'
import {
parseQuery,
sendJson,
sendData,
parseBody,
sendError,
ApiError,
sendStatusCode,
} from './api-utils'
import loadConfig from './config'
import { recursiveReadDirSync } from './lib/recursive-readdir-sync'
import {
Expand Down Expand Up @@ -217,7 +228,11 @@ export default class Server {
match: route('/api/:path*'),
fn: async (req, res, params, parsedUrl) => {
const { pathname } = parsedUrl
await this.handleApiRequest(req, res, pathname!)
await this.handleApiRequest(
req as NextApiRequest,
res as NextApiResponse,
pathname!
)
},
},
]
Expand Down Expand Up @@ -258,8 +273,8 @@ export default class Server {
* @param pathname path of request
*/
private async handleApiRequest(
req: IncomingMessage,
res: ServerResponse,
req: NextApiRequest,
res: NextApiResponse,
pathname: string
) {
const resolverFunction = await this.resolveApiRequest(pathname)
Expand All @@ -269,8 +284,27 @@ export default class Server {
return
}

const resolver = interopDefault(require(resolverFunction))
resolver(req, res)
try {
// Parsing of cookies
req.cookies = parseCookies(req.headers.cookie || '')
// Parsing query string
req.query = parseQuery(req)
// // Parsing of body
req.body = await parseBody(req)

res.status = statusCode => sendStatusCode(res, statusCode)
res.send = data => sendData(res, data)
res.json = data => sendJson(res, data)

const resolver = interopDefault(require(resolverFunction))
resolver(req, res)
} catch (e) {
if (e instanceof ApiError) {
sendError(res, e.statusCode, e.message)
} else {
sendError(res, 500, e.message)
}
}
}

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/next/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import { NextPageContext, NextComponentType } from 'next-server/dist/lib/utils'

import { NextPageContext, NextComponentType, NextApiResponse, NextApiRequest } from 'next-server/dist/lib/utils'

/// <reference types="node" />
/// <reference types="react" />
Expand Down Expand Up @@ -37,4 +38,4 @@ export type NextPage<P = {}, IP = P> = {
getInitialProps?(ctx: NextPageContext): Promise<IP>
}

export { NextPageContext, NextComponentType }
export { NextPageContext, NextComponentType, NextApiResponse, NextApiRequest }
303 changes: 303 additions & 0 deletions test/integration/api-support/big.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions test/integration/api-support/pages/api/cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default (req, res) => {
res.status(200).send(req.cookies)
}
3 changes: 3 additions & 0 deletions test/integration/api-support/pages/api/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default (req, res) => {
res.status(500).json({ error: 'Server error!' })
}
3 changes: 3 additions & 0 deletions test/integration/api-support/pages/api/parse/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default (req, res) => {
res.status(200).json(req.body)
}
15 changes: 4 additions & 11 deletions test/integration/api-support/pages/api/posts/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import url from 'url'

export default (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' })

if (req.method === 'POST') {
const { query } = url.parse(req.url, true)
const json = JSON.stringify([{ title: query.title }])
res.end(json)
export default ({ query, method }, res) => {
if (method === 'POST') {
res.status(200).json([{ title: query.title }])
} else {
const json = JSON.stringify([{ title: 'Cool Post!' }])
res.end(json)
res.status(200).json([{ title: 'Cool Post!' }])
}
}
16 changes: 5 additions & 11 deletions test/integration/api-support/pages/api/users.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import url from 'url'

export default (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' })

const { query } = url.parse(req.url, true)

export default ({ query }, res) => {
const users = [{ name: 'Tim' }, { name: 'Jon' }]

const response =
query && query.name ? users.filter(user => user.name === query.name) : users
const response = query.name
? users.filter(user => user.name === query.name)
: users

const json = JSON.stringify(response)
res.end(json)
res.status(200).json(response)
}
2 changes: 1 addition & 1 deletion test/integration/api-support/pages/user.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default () => <div>API - support</div>
export default () => <div>User</div>
Loading

0 comments on commit c821e83

Please sign in to comment.