UNPKG

13.8 kBJavaScriptView Raw
1/*!
2Kettle File DataSource
3
4Copyright 2012-2013 OCAD University
5Copyright 2016 OCAD University
6
7Licensed under the New BSD license. You may not use this file except in
8compliance with this License.
9
10You may obtain a copy of the License at
11https://github.com/fluid-project/kettle/blob/master/LICENSE.txt
12*/
13
14"use strict";
15
16var fluid = fluid || require("infusion"),
17 kettle = fluid.registerNamespace("kettle"),
18 http = http || require("http"),
19 https = https || require("https"),
20 urlModule = urlModule || require("url");
21
22fluid.registerNamespace("kettle.dataSource");
23
24/**** URL DATASOURCE SUPPORT ****/
25
26fluid.defaults("kettle.dataSource.URL", {
27 gradeNames: ["kettle.dataSource"],
28 readOnlyGrade: "kettle.dataSource.URL",
29 invokers: {
30 resolveUrl: "kettle.dataSource.URL.resolveUrl", // url, termMap, directModel, noencode
31 getImpl: {
32 funcName: "kettle.dataSource.URL.handle",
33 args: ["{that}", "{arguments}.0", "{arguments}.1"] // options, directModel
34 }
35 },
36 censorRequestOptionsLog: {
37 auth: true,
38 "headers.Authorization": true
39 },
40 components: {
41 cookieJar: "{cookieJar}"
42 },
43 termMap: {}
44});
45
46fluid.defaults("kettle.dataSource.URL.writable", {
47 gradeNames: ["kettle.dataSource.writable"],
48 invokers: {
49 setImpl: {
50 funcName: "kettle.dataSource.URL.handle",
51 args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] // options, directModel, model
52 }
53 }
54});
55
56/**
57 * Resolves (expands) a url or path with respect to the `directModel` supplied to a dataSource's API (get or set). There are three rounds of expansion - firstly, the string
58 * entries as the values in "termMap" will be looked up as paths within `directModel`. The resulting values will then be URI Encoded, unless their value
59 * the termMap is prefixed with `noencode:`. Secondly,
60 * this resolved termMap will be used for a round of the standard fluid.stringTemplate algorithm applied to the supplied URL. Finally, any argument `expand` will be
61 * used to expand the entire URL.
62 * @param {String} url - A url or path to expand
63 * @param {Object.<String, String>} termMap - A map of terms to be used for string interpolation. Any values which begin with the prefix `noencode:` will have this prefix stripped off, and
64 * URI encoding will not be applied to the substituted value. After the value is normalised in this way, the remaining value may be used for indirection in the directModel if it
65 * begins with the prefix "%", or else directly for interpolation.
66 * @param {Object} directModel - a set of data used to expand the url template
67 * @param {Boolean} noencode - `true` if URLencoding of each interpolated value in the URL should be defeated by default
68 * @return {String} The resolved/expanded url
69 */
70kettle.dataSource.URL.resolveUrl = function (url, termMap, directModel, noencode) {
71 var map = fluid.transform(termMap, function resolve(entry) {
72 entry = String(entry);
73 var encode = !noencode;
74 if (entry.indexOf("noencode:") === 0) {
75 encode = false;
76 entry = entry.substring("noencode:".length);
77 }
78 var value = entry.charAt(0) === "%" ? fluid.get(directModel, entry.substring(1)) : entry;
79 if (encode) {
80 value = encodeURIComponent(value);
81 }
82 return value;
83 });
84 var replaced = fluid.stringTemplate(url, map);
85 return replaced;
86};
87
88kettle.dataSource.URL.requestOptions = ["url", "protocol", "host", "hostname", "family", "port", "localAddress", "socketPath", "method", "path", "headers", "auth", "agent", "termMap"];
89
90// TODO: Deal with the anomalous status of "charEncoding" - in theory it could be set per-request but currently can't be. Currently all
91// "requestOptions" have a common fate in that they end up as the arguments to http.request. We need to split these into two levels,
92// httpRequestOptions and the outer level with everything else. We also need to do something similar for kettle.dataSource.file
93
94/** Assemble the `requestOptions` structure that will be sent to `http.request` by `kettle.dataSource.URL` by fusing together values from the user, the component
95 * with filtration by a list of permitted options, e.g. those listed in `kettle.dataSource.URL.requestOptions`. A poorly factored method that needs to be
96 * reformed as a proper merge pipeline.
97 */
98
99kettle.dataSource.URL.prepareRequestOptions = function (componentOptions, cookieJar, userOptions, permittedOptions, directModel, userStaticOptions, resolveUrl) {
100 var staticOptions = fluid.filterKeys(componentOptions, permittedOptions);
101 var requestOptions = fluid.extend(true, {headers: {}}, userStaticOptions, staticOptions, userOptions);
102 // GPII-2147: replace "localhost" with "127.0.0.1" to allow running without a network connection in windows
103 if (requestOptions.hostname === "localhost") {
104 requestOptions.hostname = "127.0.0.1";
105 }
106 if (requestOptions.host === "localhost") {
107 requestOptions.host = "127.0.0.1";
108 }
109 var termMap = fluid.transform(requestOptions.termMap, encodeURIComponent);
110
111 requestOptions.path = (resolveUrl || kettle.dataSource.URL.resolveUrl)(requestOptions.path, requestOptions.termMap, directModel);
112
113 fluid.stringTemplate(requestOptions.path, termMap);
114 if (cookieJar && cookieJar.cookie && componentOptions.storeCookies) {
115 requestOptions.headers.Cookie = cookieJar.cookie;
116 }
117 return requestOptions;
118};
119
120/** Given an HTTP status code as returned by node's `http.IncomingMessage` class (or otherwise), determine whether it corresponds to
121 * an error status. This performs a simple-minded check to see if it a number outside the range [200, 300).
122 * @param {Number} statusCode The HTTP status code to be tested
123 * @return {Boolean} `true` if the status code represents an error status
124 */
125
126kettle.dataSource.URL.isErrorStatus = function (statusCode) {
127 return statusCode < 200 || statusCode >= 300;
128};
129
130/**
131 * Handles calls to a URL data source's get and set.
132 * @param {kettle.dataSource.urlResolver} that - A URLResolver that will convert the contents of the
133 * <code>directModel</code> supplied as the 3rd argument into a concrete URL used for this
134 * HTTP request.
135 * @param {Object} userOptions - An options block that encodes:
136 * @param {String} userOptions.operation - "set"/"get"
137 * @param {Boolean} userOptions.notFoundIsEmpty - <code>true</code> if a missing file on read should count as a successful empty payload rather than a rejection
138 * writeMethod {String}: "PUT"/ "POST" (optional - if not provided will be defaulted by the concrete dataSource implementation)
139 * @param {Object} directModel - a model holding the coordinates of the data to be read or written
140 * @param {Object} [model] - [optional] the payload to be written by this write operation
141 * @return {Promise} a promise for the successful or failed datasource operation
142 */
143kettle.dataSource.URL.handle = function (that, userOptions, directModel, model) {
144 var permittedOptions = kettle.dataSource.URL.requestOptions;
145 var url = that.resolveUrl(that.options.url, that.options.termMap, directModel);
146 var parsed = fluid.filterKeys(urlModule.parse(url, true), permittedOptions);
147 var requestOptions = kettle.dataSource.URL.prepareRequestOptions(that.options, that.cookieJar, userOptions, permittedOptions, directModel, parsed);
148
149 return kettle.dataSource.URL.handle.http(that, requestOptions, model);
150};
151
152// Attempt to parse the error response as JSON, but if failed, just stick it into "message" quietly
153kettle.dataSource.URL.relayError = function (response, received, whileMsg) {
154 var rejectPayload;
155 try {
156 rejectPayload = JSON.parse(received);
157 } catch (e) {
158 var message = typeof(received) === "string" && received.indexOf("<html") !== -1 ?
159 kettle.extractHtmlError(received) : received;
160 rejectPayload = {message: message};
161 }
162 rejectPayload.message = rejectPayload.message || rejectPayload.error;
163 delete rejectPayload.error;
164 rejectPayload.isError = true;
165 rejectPayload.statusCode = response.statusCode;
166 return kettle.upgradeError(rejectPayload, whileMsg);
167};
168
169/** Compute the callback to be supplied to `http.request` for this dataSource operation
170 * @param requestOptions {Object} The fully merged options which were sent to `http.request`
171 * @param promise {Promise} The outgoing promise to be returned to the user
172 * @param whileMsg {String} A readable summary of the current operation (including HTTP method and URL) to be suffixed to any rejection message
173 * @return {Function} The callback to be supplied to `http.request`
174 */
175
176kettle.dataSource.URL.httpCallback = function (requestOptions, promise, whileMsg) {
177 return function (res) {
178 var received = "";
179 res.setEncoding(requestOptions.charEncoding);
180 res.on("data", function onData(chunk) {
181 received += chunk;
182 });
183 res.on("end", kettle.wrapCallback(
184 function () {
185 if (kettle.dataSource.URL.isErrorStatus(res.statusCode)) {
186 var relayed = kettle.dataSource.URL.relayError(res, received, whileMsg);
187 if (requestOptions.notFoundIsEmpty && relayed.statusCode === 404) {
188 promise.resolve(undefined);
189 } else {
190 promise.reject(relayed);
191 }
192 } else {
193 promise.resolve(received);
194 }
195 })
196 );
197 };
198};
199
200/** Compute the listener to be attached to the `http.request` `error` event for this dataSource operation
201 * @param promise {Promise} The outgoing promise to be returned to the user
202 * @param whileMsg {String} A readable summary of the current operation (including HTTP method and URL) to be suffixed to any rejection message
203 */
204
205kettle.dataSource.URL.errorCallback = function (promise, whileMsg) {
206 return function (error) {
207 error.isError = true;
208 promise.reject(kettle.upgradeError(error, whileMsg));
209 };
210};
211
212/** Prepare a URL for logging by censoring sensitive parts of the URL (satisfy KETTLE-73, GPII-3309)
213 * @param requestOptions {Object} A hash of request options holding a set of parsed URL fields as returned from
214 * https://nodejs.org/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost , as well as some others
215 * including the overall `url`.
216 * @param toCensor {Object} A hash of member paths within `requestOptions` to `true`, holding those which should be censored
217 * @return A requestOptions object with the sensitive members removed where they appear at top level as well as where they might occur encoded
218 * within the `url` member
219 */
220
221kettle.dataSource.URL.censorRequestOptions = function (requestOptions, toCensor) {
222 var togo = fluid.copy(requestOptions);
223 fluid.each(toCensor, function (troo, key) {
224 var original = fluid.get(togo, key);
225 if (original) {
226 fluid.set(togo, key, "(SENSITIVE)");
227 }
228 });
229 // Compensate for our use of legacy member "path" throughout
230 var parsedPath = urlModule.parse(togo.path);
231 togo.pathname = parsedPath.pathname;
232 togo.search = parsedPath.search;
233 togo.url = urlModule.format(togo);
234 return togo;
235};
236
237/** Central strategy point for all HTTP-backed DataSource operations (both read and write).
238 * Accumulates options to be sent to the underlying node.js `http.request` primitives, collects and interprets the
239 * results back into promise resolutions.
240 * @param that {Component} The DataSource itself
241 * @param baseOptions {Object} A partially merged form of the options sent to the top-level `dataSource.get` method together with relevant
242 * static options configured on the component. Information in the `directModel` argument has already been encoded into the url member.
243 * @param data {String} The `model` argument sent to top-level `dataSource.get/set` after it has been pushed through the transform chain
244 */
245
246kettle.dataSource.URL.handle.http = function (that, baseOptions, data) {
247 var promise = fluid.promise();
248 var defaultOptions = {
249 port: 80,
250 method: "GET"
251 };
252
253 var dataBuffer;
254
255 if (baseOptions.operation === "set") {
256 dataBuffer = new Buffer(data);
257 defaultOptions.headers = {
258 "Content-Type": that.encoding.options.contentType,
259 "Content-Length": dataBuffer.length
260 };
261 defaultOptions.method = baseOptions.writeMethod;
262 }
263 var requestOptions = fluid.extend(true, defaultOptions, baseOptions);
264 var loggingOptions = kettle.dataSource.URL.censorRequestOptions(requestOptions, that.options.censorRequestOptionsLog);
265 var whileMsg = " while executing HTTP " + requestOptions.method + " on url " + loggingOptions.url;
266 fluid.log("DataSource Issuing " + (requestOptions.protocol.toUpperCase()).slice(0, -1) + " request with options ",
267 loggingOptions);
268 promise.accumulateRejectionReason = function (originError) {
269 return kettle.upgradeError(originError, whileMsg);
270 };
271 var callback = kettle.wrapCallback(kettle.dataSource.URL.httpCallback(requestOptions, promise, whileMsg));
272 var req;
273 if (requestOptions.protocol === "http:") {
274 req = http.request(requestOptions, callback);
275 } else if (requestOptions.protocol === "https:") {
276 req = https.request(requestOptions, callback);
277 } else {
278 fluid.fail("kettle.dataSource.URL cannot handle unknown protocol " + requestOptions.protocol);
279 }
280 req.on("error", kettle.wrapCallback(kettle.dataSource.URL.errorCallback(promise, whileMsg)));
281 req.end(dataBuffer ? dataBuffer : data);
282 return promise;
283};