From 4dcffe7394a6e9b7cf26f1024046099761247a19 Mon Sep 17 00:00:00 2001 From: Corey Butler Date: Fri, 7 Aug 2020 16:17:44 -0500 Subject: [PATCH] Added redirect reply method. --- README.md | 57 +++++++++++++ index.js | 27 +++++++ package-lock.json | 2 +- package.json | 2 +- test/sanity.js | 200 +++++++++++++++++++++++++++++++++++----------- 5 files changed, 240 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 85a7d4a..ea33217 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ const server = app.listen(() => console.log('Server is running.')) - [501](#501) - [NOT_IMPLEMENTED](#NOT_IMPLEMENTED) - [Other HTTP Status Codes](#OTHER_STATUS_CODES) +- [redirect(url, [permanent, moved])](#redirecturl-permanent-moved) - [reply(anything)](#replyanything) - [replyWithError(res, [status, message]|error)](#replywitherrorres-status-messageerror) - [replyWithMaskedError(res, [status, message]|error)](#replywithmaskederrorres-status-messageerror) @@ -447,6 +448,62 @@ The [joke status](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/418), - `HTTP418()` - `IM_A_TEAPOT()` +### redirect(url, [permanent, moved]) + +A helper method for redirecting requests to another location. This is not a proxy, it does not actually forward requests. It tells the client the URL being requested is outdated and/or has been moved permanently/temporarily. + +This method exists since redirects have been used incorrectly in the past (by the entire industry). The redirect HTTP status codes changed in [RFC 7231](https://tools.ietf.org/html/rfc7231) (2014), but are still commonly disregarded. This method supplies the appropriate HTTP status codes without having to remember which code is proper for each circumstance. + +```javascript +app.get('/path', API.redirect('https://elsewhere.com')) // 307 +// ^ equivalent of: app.get('/path', API.redirect('https://elsewhere.com', false, false)) + +app.get('/path', API.redirect('https://elsewhere.com', true, false)) // 308 +app.get('/path', API.redirect('https://elsewhere.com', true, true)) // 301 +app.get('/path', API.redirect('https://elsewhere.com', false, true)) // 303 +``` + +
+HTTP Status Codes + +| Code | Purpose | Description | +| - | - |- | +|301|Moved Permanently|This and all future requests should be directed to the given URI.| +|303|See Other|The response to the request can be found under another URI using the GET method. When received in response to a POST (or PUT/DELETE), the client should presume that the server has received the data and should issue a new GET request to the given URI.| +|307|Temporary Redirect|In this case, the request should be repeated with another URI; however, future requests should still use the original URI. In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request. For example, a POST request should be repeated using another POST request.| +|308|Permanent Redirect|The request and all future requests should be repeated using another URI. 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. So, for example, submitting a form to a permanently redirected resource may continue smoothly.| + +> **302** (Found) is not a valid redirect code. +> +> Tells the client to look at (browse to) another URL. 302 has been superseded by 303 and 307. This is an example of industry practice contradicting the standard. The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect (the original describing phrase was "Moved Temporarily"),[21] but popular browsers implemented 302 with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 to distinguish between the two behaviours.[22] However, some Web applications and frameworks use the 302 status code as if it were the 303. +
+
+From the API docs + +```javascript +/** + * Redirect the request to another location. + * @param {string} url + * The location to redirect to. + * @param {boolean} [permanent=false] + * Instruct the client that the redirect is permanent, + * suggesting all future requests should be made directly + * to the new location. When this is `false`, a HTTP `307` + * code is returned. When `true`, a HTTP `308` is returned. + * @param {boolean} [moved=false] + * Inform the client that the destination has been moved. + * When _moved_ is `true` and _permanent_ is `false`, an + * HTTP `303` (Found) status is returned, informing the + * client the request has been received and a `GET` request + * should be issued to the new location to retrieve it. When + * _permanent_ is `true`, a HTTP `301` is returned, + * indicating all future requests should be made directly to + * the new location. + */ +``` + +
+ ### reply(anything) A helper method to send objects as a JSON response, or to send plain text. This function attempts to automatically determine the appropriate response header type. diff --git a/index.js b/index.js index 1d94dd4..f33e949 100644 --- a/index.js +++ b/index.js @@ -476,6 +476,33 @@ class Endpoint { } } + /** + * Redirect the request to another location. + * @param {string} url + * The location to redirect to. + * @param {boolean} [permanent=false] + * Instruct the client that the redirect is permanent, + * suggesting all future requests should be made directly + * to the new location. When this is `false`, a HTTP `307` + * code is returned. When `true`, a HTTP `308` is returned. + * @param {boolean} [moved=false] + * Inform the client that the destination has been moved. + * When _moved_ is `true` and _permanent_ is `false`, an + * HTTP `303` (Found) status is returned, informing the + * client the request has been received and a `GET` request + * should be issued to the new location to retrieve it. When + * _permanent_ is `true`, a HTTP `301` is returned, + * indicating all future requests should be made directly to + * the new location. + */ + redirect (url, permanent = false, moved = false) { + return (req, res) => { + const code = permanent ? (moved ? 301 : 308) : (moved ? 303 : 307) + res.header('location', url) + res.sendStatus(code) + } + } + // Create a UUIDv4 unique ID. createUUID () { return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => diff --git a/package-lock.json b/package-lock.json index db539e2..ea3a975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@butlerlogic/common-api", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b65a082..513f846 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@butlerlogic/common-api", - "version": "1.4.0", + "version": "1.5.0", "description": "An API engineering productivity kit for Express.", "main": "index.js", "scripts": { diff --git a/test/sanity.js b/test/sanity.js index 747d57b..9693f16 100644 --- a/test/sanity.js +++ b/test/sanity.js @@ -4,7 +4,6 @@ const http = require('http') const app = express() const bodyParser = require('body-parser') const server = http.createServer(app) -const request = require('request') const TaskRunner = require('shortbus') const tasks = new TaskRunner() const API = require('../index') @@ -32,16 +31,72 @@ app.get('/authtest', API.basicauth('user', 'pass'), API.OK) app.get('/bearertest', API.bearer('mytoken'), API.OK) app.get('/baseurl/test', (req, res) => res.send(API.applyBaseUrl(req, '/fakeid'))) app.get('/baseurl/test2', (req, res) => res.send(API.applyRelativeUrl(req, '/fakeid'))) +app.get('/redirect/1', API.redirect('https://google.com')) +app.get('/redirect/2', API.redirect('https://google.com', false, true)) +app.get('/redirect/3', API.redirect('https://google.com', true, false)) +app.get('/redirect/4', API.redirect('https://google.com', true, true)) let service // eslint-disable-line no-unused-vars -let client let baseUrl +const client = new Proxy({}, { + get (target, prop) { + const method = prop.toUpperCase() + return function (url, opts = {}) { + return new Promise((resolve, reject) => { + opts.method = method + + let body = null + if (opts.body) { + if (typeof opts.body === 'object') { + opts.headers = opts.headers || {} + opts.headers['Content-Type'] = 'application/json' + body = JSON.stringify(opts.body) + } else { + body = opts.body + } + delete opts.body + } + + if (typeof opts.auth === 'object') { + opts.auth = `${opts.auth.username}:${opts.auth.password}` + } + + const req = http.request(baseUrl + url, opts, res => { + let body = '' + res.on('data', c => { body += c }) + res.on('error', reject) + res.on('end', () => { + res.body = body + resolve(res) + }) + }) + + req.on('error', reject) + req.setNoDelay(true) + + if (body) { + req.write(body) + } + + req.end() + }) + } + } +}) + +function abort (t, next) { + return e => { + console.error(e) + t.fail(e) + t.end() + next && next() + } +} + tasks.add('Launch Test Server', next => { service = server.listen(0, '127.0.0.1', () => { baseUrl = `http://${server.address().address}:${server.address().port}` - client = request.defaults({ baseUrl }) - next() }) }) @@ -50,11 +105,13 @@ let statusCodes = [200, 201, 401, 404, 501] statusCodes.forEach((code, i) => { tasks.add(next => { test(`HTTP ${code} Status`, t => { - client.get(`/test${i + 1}`, { timeout: 1500 }).on('response', res => { - t.ok(res.statusCode === code, `Successfully received an HTTP ${code} status.`) - t.end() - next() - }) + client.get(`/test${i + 1}`, { timeout: 1500 }) + .then(res => { + t.ok(res.statusCode === code, `Successfully received an HTTP ${code} status.`) + t.end() + next() + }) + .catch(abort(t, next)) }) }) }) @@ -63,40 +120,47 @@ let messageCodes = ['OK', 'CREATED', 'UANUTHORIZED', 'NOT_FOUND', 'NOT_IMPLEMENT messageCodes.forEach((code, i) => { tasks.add(next => { test(`HTTP ${code} Status`, t => { - client.get(`/test${i + statusCodes.length + 1}`, { timeout: 1500 }).on('response', res => { - t.ok(res.statusCode === statusCodes[i], `Successfully received an HTTP ${statusCodes[i]} status for ${code} message.`) - t.end() - next() - }) + client.get(`/test${i + statusCodes.length + 1}`, { timeout: 1500 }) + .then(res => { + t.ok(res.statusCode === statusCodes[i], `Successfully received an HTTP ${statusCodes[i]} status for ${code} message.`) + t.end() + next() + }) + .catch(abort(t, next)) }) }) }) test('Custom Error', t => { - client.get('/forcederror').on('response', res => { - t.ok(res.statusCode === 477, 'Custom error code received.') - t.ok(res.statusMessage === 'custom_error', 'Custom error message received.') - t.end() - }) + client.get('/forcederror') + .then(res => { + t.ok(res.statusCode === 477, 'Custom error code received.') + t.ok(res.statusMessage === 'custom_error', 'Custom error message received.') + t.end() + }) + .catch(abort(t)) }) test('Custom Masked Error', t => { - client.get('/forcederror2').on('response', res => { + client.get('/forcederror2').then(res => { t.ok(res.statusCode === 477, 'Custom error code received.') t.ok(res.statusMessage.indexOf('Reference:') > 0, 'Masked error message received.') t.end() }) + .catch(abort(t)) }) test('Valid JSON Body', t => { let subtasks = new TaskRunner() subtasks.add(next => { - client.post('/validbody', { json: true, body: { test: true }, timeout: 2500 }) - .on('response', res => { + client.post('/validbody', { body: { test: true }, timeout: 2500 }) + .then(res => { + console.log(res.statusCode) t.ok(res.statusCode === 200, 'Validated JSON Body') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { @@ -104,26 +168,29 @@ test('Valid JSON Body', t => { body: 'test', headers: { 'Content-Type': 'application/json' } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 400, 'Invalid JSON body is rejected.') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { client.post('/validbody2', { json: true, body: { test: true } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 200, 'Validated JSON Body with specified arguments') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { client.post('/validbody2', { json: true, body: { different: true } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 400, 'Invalid JSON body (missing parameters) is rejected.') next() }) + .catch(abort(t, next)) }) subtasks.on('complete', () => t.end()) @@ -136,18 +203,20 @@ test('Validate numeric ID', t => { subtasks.add(next => { client.get('/entity/12345', { timeout: 1500 }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 200, 'Successfully validated numeric ID parameter.') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { client.get('/entity/textid', { timeout: 1500 }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 400, 'Unsuccessfully used a non-numeric ID parameter when a numeric ID is required.') next() }) + .catch(abort(t, next)) }) subtasks.on('complete', () => t.end()) @@ -156,10 +225,11 @@ test('Validate numeric ID', t => { test('Validate any ID', t => { client.get('/entity2/test', { timeout: 1500 }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 200, 'Successfully validated numeric ID parameter.') t.end() }) + .catch(abort(t)) }) test('Authentication', t => { @@ -172,10 +242,11 @@ test('Authentication', t => { password: 'pass' } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 200, 'Successfully authenticated with basic auth.') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { @@ -185,10 +256,11 @@ test('Authentication', t => { password: 'pass' } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 401, 'Invalid credentials receive "unauthorized" response with basic auth.') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { @@ -197,10 +269,11 @@ test('Authentication', t => { Authorization: 'Bearer mytoken' } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 200, 'Successfully authenticated with bearer token.') next() }) + .catch(abort(t, next)) }) subtasks.add(next => { @@ -209,10 +282,11 @@ test('Authentication', t => { Authorization: 'Bearer badtoken' } }) - .on('response', res => { + .then(res => { t.ok(res.statusCode === 401, 'Invalid credentials receive "unauthorized" response with bearer token.') next() }) + .catch(abort(t, next)) }) subtasks.on('complete', t.end) @@ -221,26 +295,60 @@ test('Authentication', t => { test('Apply BaseURL', t => { client.get('/baseurl/test', { timeout: 1500 }) - .on('response', res => { - let data = '' - res.on('data', chunk => { data += chunk.toString() }) - res.on('end', () => { - t.ok(data === `${baseUrl}/fakeid`, 'Successfully identified base URL.') - t.end() - }) + .then(res => { + t.ok(res.body === `${baseUrl}/fakeid`, 'Successfully identified base URL.') + t.end() }) + .catch(abort(t)) }) test('Apply Relative URL', t => { client.get('/baseurl/test2', { timeout: 1500 }) - .on('response', res => { - let data = '' - res.on('data', chunk => { data += chunk.toString() }) - res.on('end', () => { - t.ok(data === `${baseUrl}/baseurl/test2/fakeid`, 'Successfully identified relative URL.') - t.end() - }) + .then(res => { + t.ok(res.body === `${baseUrl}/baseurl/test2/fakeid`, 'Successfully identified relative URL.') + t.end() + }) + .catch(abort(t)) +}) + +test('Redirect: Temporary', t => { + client.get('/redirect/1', { timeout: 1500 }) + .then(res => { + t.ok(res.headers && res.headers['location'] === 'https://google.com', 'Contains location header') + t.ok(res.statusCode === 307, 'HTTP 307 response') + t.end() + }) + .catch(abort(t)) +}) + +test('Redirect: Permanent', t => { + client.get('/redirect/3', { timeout: 1500 }) + .then(res => { + t.ok(res.headers && res.headers['location'] === 'https://google.com', 'Contains location header') + t.ok(res.statusCode === 308, 'HTTP 308 response') + t.end() + }) + .catch(abort(t)) +}) + +test('Moved: Temporary', t => { + client.get('/redirect/2', { timeout: 1500 }) + .then(res => { + t.ok(res.headers && res.headers['location'] === 'https://google.com', 'Contains location header') + t.ok(res.statusCode === 303, 'HTTP 303 response') + t.end() + }) + .catch(abort(t)) +}) + +test('Moved: Permanent', t => { + client.get('/redirect/4', { timeout: 1500 }) + .then(res => { + t.ok(res.headers && res.headers['location'] === 'https://google.com', 'Contains location header') + t.ok(res.statusCode === 301, 'HTTP 301 response') + t.end() }) + .catch(abort(t)) }) tasks.on('complete', () => {