apostrophe
Version:
The Apostrophe Content Management System.
332 lines (310 loc) • 13.8 kB
JavaScript
const _ = require('lodash');
const qs = require('qs');
const fetch = require('node-fetch');
const tough = require('tough-cookie');
const escapeHost = require('../../../lib/escape-host');
const util = require('util');
module.exports = {
options: {
alias: 'http',
// 2 hour limit to process a "big upload,"
// which could be something like an entire site
// with its attachments
bigUploadMaxSeconds: 2 * 60 * 60
},
init(self) {
// Map friendly errors created via `apos.error` to status codes.
//
// Everything else comes through as a 500, you don't have to register that
// one, and shouldn't because clients should never be given sensitive
// details about 500 errors
self.errors = {
min: 400,
max: 400,
invalid: 400,
forbidden: 403,
notfound: 404,
required: 422,
conflict: 409,
locked: 409,
unprocessable: 422,
unimplemented: 501
};
_.merge(self.errors, self.options.addErrors);
},
handlers(self) {
// Wait for the db module to be ready
return {
'apostrophe:modulesRegistered': {
setCollection() {
self.bigUploads = self.apos.db.collection('aposBigUploads');
}
}
};
},
methods(self) {
return {
// Add another friendly error name to http status code mapping so you
// can throw `apos.error('name')` and get the status code `code`.
// Not used in core at the time of writing, but available as part of the
// API.
addError(name, code) {
self.errors[name] = code;
},
// Fetch the given URL and return the response body. Accepts the
// following options:
//
// `qs` (builds a query string with qs)
// `jar` (pass in a cookie jar obtained from apos.http.jar())
// `parse` (can be 'json` to always parse the response body as JSON,
// otherwise the response body is parsed as JSON only if the content-type
// is application/json) `headers` (an object containing header names and
// values) `fullResponse` (if true, return an object with `status`,
// `headers` and `body` properties, rather than returning the body
// directly; the individual `headers` are canonicalized to lowercase
// names. If a header appears multiple times an array is returned for it)
//
// If the status code is >= 400 an error is thrown. The error object will
// be similar to a `fullResponse` object, with a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async get(url, options) {
return self.remote('GET', url, options);
},
// Make a HEAD request for the given URL and return the response object,
// which will include a `status` property as well as `headers`.
//
// Options:
//
// `qs` (builds a query string with qs)
// `jar` (pass in a cookie jar obtained from apos.http.jar())
// `headers` (an object containing header names and values)
//
// If the status code is >= 400 an error is thrown. The error object
// will have a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async head(url, options) {
return self.remote('HEAD', url, options);
},
// Send a POST request to the given URL and return the response body.
// Accepts the following options:
//
// `qs` (pass object; builds a query string with qs)
// `jar` (pass in a cookie jar obtained from apos.http.jar())
// `send` (can be 'json' to always send `options.body` JSON encoded, or
// 'form' to send it URL-encoded) `body` (request body; if an object or
// array, sent as JSON, otherwise sent as-is, unless the `send` option is
// set) `parse` (can be 'json` to always parse the response body as JSON,
// otherwise the response body is parsed as JSON only if the content-type
// is application/json) `headers` (an object containing header names and
// values) `fullResponse` (if true, return an object with `status`,
// `headers` and `body` properties, rather than returning the body
// directly; the individual `headers` are canonicalized to lowercase
// names. If a header appears multiple times an array is returned for it)
//
// If the status code is >= 400 an error is thrown. The error object will
// be similar to a `fullResponse` object, with a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async post(url, options) {
return self.remote('POST', url, options);
},
// Send a DELETE request to the given URL and return the response body.
// Accepts the following options:
//
// `qs` (pass object; builds a query string with qs)
// `jar` (pass in a cookie jar obtained from apos.http.jar())
// `send` (can be 'json' to always send `options.body` JSON encoded, or
// 'form' to send it URL-encoded) `body` (request body; if an object or
// array, sent as JSON, otherwise sent as-is, unless the `send` option is
// set) `parse` (can be 'json` to always parse the response body as JSON,
// otherwise the response body is parsed as JSON only if the content-type
// is application/json) `headers` (an object containing header names and
// values) `fullResponse` (if true, return an object with `status`,
// `headers` and `body` properties, rather than returning the body
// directly; the individual `headers` are canonicalized to lowercase
// names. If a header appears multiple times an array is returned for it)
//
// If the status code is >= 400 an error is thrown. The error object will
// be similar to a `fullResponse` object, with a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async delete(url, options) {
return self.remote('DELETE', url, options);
},
// Send a PUT request to the given URL and return the response body.
// Accepts the following options:
//
// `qs` (pass object; builds a query string with qs)
// `jar` (pass in a cookie jar obtained from apos.http.jar())
// `send` (can be 'json' to always send `options.body` JSON encoded, or
// 'form' to send it URL-encoded) `body` (request body; if an object or
// array, sent as JSON, otherwise sent as-is, unless the `send` option is
// set) `parse` (can be 'json` to always parse the response body as JSON,
// otherwise the response body is parsed as JSON only if the content-type
// is application/json) `headers` (an object containing header names and
// values) `fullResponse` (if true, return an object with `status`,
// `headers` and `body` properties, rather than returning the body
// directly; the individual `headers` are canonicalized to lowercase
// names. If a header appears multiple times an array is returned for it)
//
// If the status code is >= 400 an error is thrown. The error object will
// be similar to a `fullResponse` object, with a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async put(url, options) {
return self.remote('PUT', url, options);
},
// Send a PATCH request to the given URL and return the response body.
// Accepts the following options:
//
// `qs` (pass object; builds a query string with qs)
// `jar` (pass in a cookie jar obtained from apos.http.jar())
// `send` (can be 'json' to always send `options.body` JSON encoded, or
// 'form' to send it URL-encoded) `body` (request body; if an object or
// array, sent as JSON, otherwise sent as-is, unless the `send` option is
// set) `parse` (can be 'json` to always parse the response body as JSON,
// otherwise the response body is parsed as JSON only if the content-type
// is application/json) `headers` (an object containing header names and
// values) `fullResponse` (if true, return an object with `status`,
// `headers` and `body` properties, rather than returning the body
// directly; the individual `headers` are canonicalized to lowercase
// names. If a header appears multiple times an array is returned for it)
// `originalResponse` (if true, return the response object exactly as it
// is returned by node-fetch)
//
// If the status code is >= 400 an error is thrown. The error object will
// be similar to a `fullResponse` object, with a `status` property.
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async patch(url, options) {
return self.remote('PATCH', url, options);
},
// Invoke a remote HTTP API with the named method. Use .get, .post, etc.
// This is an implementation method invoked by these
//
// If the URL is site-relative (starts with /) it will be requested from
// the apostrophe site itself.
async remote(method, url, options) {
let awaitedBody = false;
if (!options) {
options = {};
}
options = {
...options,
method
};
if (url.match(/^\//)) {
url = `${self.getBase()}${url}`;
}
if (options.qs) {
url += `?${qs.stringify(options.qs)}`;
}
if (options.jar) {
let cookies = options.jar.getCookiesSync(url);
cookies = cookies || [];
options.headers = options.headers || {};
options.headers.cookie = cookies.join('; ');
}
if (options.body && options.body.constructor && (options.body.constructor.name === 'FormData')) {
// If we don't do this multiparty will not parse it properly
const contentLength = await util.promisify((callback) => {
return options.body.getLength(callback);
})();
options.headers = options.headers || {};
options.headers['Content-Length'] = contentLength;
// node-fetch will set the Content-Type
} else if (((options.body != null) && ((typeof options.body) === 'object')) || (options.send === 'json')) {
options.body = JSON.stringify(options.body);
options.headers = options.headers || {};
options.headers['Content-Type'] = 'application/json';
} else if ((options.body !== null) && (options.send === 'form')) {
options.body = qs.stringify(options.body);
options.headers = options.headers || {};
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
const res = await fetch(url, options);
let body;
if (options.jar) {
// This is node-fetch specific, browsers limit what JS can see about
// this header
(res.headers.raw()['set-cookie'] || []).forEach(cookie => {
options.jar.setCookieSync(cookie, url);
});
}
if (options.originalResponse) {
return res;
}
awaitedBody = true;
body = await getBody();
if (res.status >= 400) {
if (!awaitedBody) {
body = await getBody();
}
const error = new Error(`HTTP error ${res.status}`);
Object.assign(error, fullResponse());
throw error;
}
if (options.fullResponse) {
return fullResponse();
}
return body;
function fullResponse() {
const headers = {};
res.headers.forEach((value, name) => {
// Optional support for fetching arrays of headers with the same
// name could be added at a later time if anyone really cares.
// Usually just a source of bugs
headers[name] = value;
});
return {
status: res.status,
headers,
body
};
}
async function getBody() {
let result = await res.text();
const contentType = (res.headers.get('content-type') || '').replace(/;.*$/, '');
if ((contentType === 'application/json') || (options.parse === 'json')) {
result = JSON.parse(result);
}
return result;
}
},
// Returns a cookie jar compatible
// with the `jar` option to `get`, `post`, etc. and
// the `getCookie` method (below). The use of other cookie
// stores is not recommended.
jar() {
return new tough.CookieJar();
},
// Given a cookie jar received from `apos.http.jar()` and a context URL,
// return the current value for the given cookie name, or undefined if
// there is none set
getCookie(jar, url, name) {
if (url.match(/^\//)) {
url = `${self.getBase()}${url}`;
}
const cookies = jar.getCookiesSync(url);
for (const cookie of cookies) {
if (cookie.key === name) {
return cookie.value;
}
}
},
getBase() {
const server = self.apos.modules['@apostrophecms/express'].server;
return `http://${escapeHost(server.address().address)}:${server.address().port}`;
},
...require('./lib/big-upload-middleware.js')(self)
};
}
};