From fedfbd9d5c980ca8948ab7ee0f53846a660e4122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Sat, 11 May 2019 13:18:56 +0200 Subject: [PATCH] Experimental API support (#7296) * First basic API support * Change require functionality * Change 501 to 404 * Change wording * Fix test --- package.json | 1 + .../next-server/server/load-components.ts | 2 +- packages/next-server/server/next-server.ts | 36 ++++++++++++- packages/next/server/next-dev-server.js | 17 ++++++ .../api-support/pages/api/posts/index.js | 6 +++ .../api-support/pages/api/users.js | 15 ++++++ test/integration/api-support/pages/index.js | 1 + test/integration/api-support/pages/user.js | 1 + .../api-support/test/index.test.js | 54 +++++++++++++++++++ yarn.lock | 12 +++++ 10 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 test/integration/api-support/pages/api/posts/index.js create mode 100644 test/integration/api-support/pages/api/users.js create mode 100644 test/integration/api-support/pages/index.js create mode 100644 test/integration/api-support/pages/user.js create mode 100644 test/integration/api-support/test/index.test.js diff --git a/package.json b/package.json index 86a09efbd2e047f..7829d51d64b4d52 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@babel/preset-flow": "7.0.0", "@babel/preset-react": "7.0.0", "@mdx-js/loader": "0.18.0", + "@types/jest": "24.0.12", "@types/string-hash": "1.1.1", "@zeit/next-css": "1.0.2-canary.2", "@zeit/next-sass": "1.0.2-canary.2", diff --git a/packages/next-server/server/load-components.ts b/packages/next-server/server/load-components.ts index ff5a0651b09def3..19e9d821026049f 100644 --- a/packages/next-server/server/load-components.ts +++ b/packages/next-server/server/load-components.ts @@ -3,7 +3,7 @@ import { join } from 'path'; import { requirePage } from './require'; -function interopDefault(mod: any) { +export function interopDefault(mod: any) { return mod.default || mod } diff --git a/packages/next-server/server/next-server.ts b/packages/next-server/server/next-server.ts index 7c79bd660d88a6f..83a9a0f201d5e93 100644 --- a/packages/next-server/server/next-server.ts +++ b/packages/next-server/server/next-server.ts @@ -21,7 +21,8 @@ import { PAGES_MANIFEST, } from '../lib/constants' import * as envConfig from '../lib/runtime-config' -import { loadComponents } from './load-components' +import { loadComponents, interopDefault } from './load-components' +import { getPagePath } from './require'; type NextConfig = any @@ -199,6 +200,13 @@ export default class Server { await this.serveStatic(req, res, p, parsedUrl) }, }, + { + match: route('/api/:path*'), + fn: async (req, res, params, parsedUrl) => { + const { pathname } = parsedUrl + await this.handleApiRequest(req, res, pathname!) + }, + }, ] if (fs.existsSync(this.publicDir)) { @@ -226,6 +234,32 @@ export default class Server { return routes } + /** + * Resolves `API` request, in development builds on demand + * @param req http request + * @param res http response + * @param pathname path of request + */ + private async handleApiRequest(req: IncomingMessage, res: ServerResponse, pathname: string) { + const resolverFunction = await this.resolveApiRequest(pathname) + if (resolverFunction === null) { + res.statusCode = 404 + res.end('Not Found') + return + } + + const resolver = interopDefault(require(resolverFunction)) + resolver(req, res) + } + + /** + * Resolves path to resolver function + * @param pathname path of request + */ + private resolveApiRequest(pathname: string) { + return getPagePath(pathname, this.distDir) + } + private generatePublicRoutes(): Route[] { const routes: Route[] = [] const publicFiles = recursiveReadDirSync(this.publicDir) diff --git a/packages/next/server/next-dev-server.js b/packages/next/server/next-dev-server.js index ac3f609e246c0ab..61465e78c55ef31 100644 --- a/packages/next/server/next-dev-server.js +++ b/packages/next/server/next-dev-server.js @@ -141,6 +141,23 @@ export default class DevServer extends Server { return !snippet.includes('data-amp-development-mode-only') } + /** + * Check if resolver function is build or request new build for this function + * @param {string} pathname + */ + async resolveApiRequest (pathname) { + try { + await this.hotReloader.ensurePage(pathname) + } catch (err) { + // API route dosn't exist => return 404 + if (err.code === 'ENOENT') { + return null + } + } + const resolvedPath = await super.resolveApiRequest(pathname) + return resolvedPath + } + async renderToHTML (req, res, pathname, query, options = {}) { const compilationErr = await this.getCompilationError(pathname) if (compilationErr) { diff --git a/test/integration/api-support/pages/api/posts/index.js b/test/integration/api-support/pages/api/posts/index.js new file mode 100644 index 000000000000000..931b0057eabfa44 --- /dev/null +++ b/test/integration/api-support/pages/api/posts/index.js @@ -0,0 +1,6 @@ +export default (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + + const json = JSON.stringify([{ title: 'Cool Post!' }]) + res.end(json) +} diff --git a/test/integration/api-support/pages/api/users.js b/test/integration/api-support/pages/api/users.js new file mode 100644 index 000000000000000..d304e25642b3f57 --- /dev/null +++ b/test/integration/api-support/pages/api/users.js @@ -0,0 +1,15 @@ +import url from 'url' + +export default (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + + const { query } = url.parse(req.url, true) + + const users = [{ name: 'Tim' }, { name: 'Jon' }] + + const response = + query && query.name ? users.filter(user => user.name === query.name) : users + + const json = JSON.stringify(response) + res.end(json) +} diff --git a/test/integration/api-support/pages/index.js b/test/integration/api-support/pages/index.js new file mode 100644 index 000000000000000..6846de76a7d7c50 --- /dev/null +++ b/test/integration/api-support/pages/index.js @@ -0,0 +1 @@ +export default () =>
API - support
diff --git a/test/integration/api-support/pages/user.js b/test/integration/api-support/pages/user.js new file mode 100644 index 000000000000000..6846de76a7d7c50 --- /dev/null +++ b/test/integration/api-support/pages/user.js @@ -0,0 +1 @@ +export default () =>
API - support
diff --git a/test/integration/api-support/test/index.test.js b/test/integration/api-support/test/index.test.js new file mode 100644 index 000000000000000..bdb45bdd3e33e3e --- /dev/null +++ b/test/integration/api-support/test/index.test.js @@ -0,0 +1,54 @@ +/* eslint-env jest */ +/* global jasmine */ +import { join } from 'path' +import { + killApp, + findPort, + launchApp, + fetchViaHTTP + // renderViaHTTP, +} from 'next-test-utils' + +const appDir = join(__dirname, '../') +let appPort +let server +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 + +describe('API support', () => { + beforeAll(async () => { + appPort = await findPort() + server = await launchApp(appDir, appPort) + }) + afterAll(() => killApp(server)) + + it('API request to undefined path', async () => { + const { status } = await fetchViaHTTP(appPort, '/api/unexisting', null, { + Accept: 'application/json' + }) + expect(status).toEqual(404) + }) + + it('API request to list of users', async () => { + const data = await fetchViaHTTP(appPort, '/api/users', null, { + Accept: 'application/json' + }).then(res => res.ok && res.json()) + + expect(data).toEqual([{ name: 'Tim' }, { name: 'Jon' }]) + }) + + it('API request to list of users with query parameter', async () => { + const data = await fetchViaHTTP(appPort, '/api/users?name=Tim', null, { + Accept: 'application/json' + }).then(res => res.ok && res.json()) + + expect(data).toEqual([{ name: 'Tim' }]) + }) + + it('API request to nested posts', async () => { + const data = await fetchViaHTTP(appPort, '/api/posts', null, { + Accept: 'application/json' + }).then(res => res.ok && res.json()) + + expect(data).toEqual([{ title: 'Cool Post!' }]) + }) +}) diff --git a/yarn.lock b/yarn.lock index 8271ecd75b57cb8..789641de36c5f4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1589,6 +1589,18 @@ resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz#2cc2ca41051498382b43157c8227fea60363f94a" integrity sha512-ohkhb9LehJy+PA40rDtGAji61NCgdtKLAlFoYp4cnuuQEswwdK3vz9SOIkkyc3wrk8dzjphQApNs56yyXLStaQ== +"@types/jest-diff@*": + version "20.0.1" + resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" + integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA== + +"@types/jest@24.0.12": + version "24.0.12" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.12.tgz#0553dd0a5ac744e7dc4e8700da6d3baedbde3e8f" + integrity sha512-60sjqMhat7i7XntZckcSGV8iREJyXXI6yFHZkSZvCPUeOnEJ/VP1rU/WpEWQ56mvoh8NhC+sfKAuJRTyGtCOow== + dependencies: + "@types/jest-diff" "*" + "@types/loader-utils@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@types/loader-utils/-/loader-utils-1.1.3.tgz#82b9163f2ead596c68a8c03e450fbd6e089df401"