1 | /*!
|
2 | Kettle File DataSource
|
3 |
|
4 | Copyright 2012-2013 OCAD University
|
5 | Copyright 2016 OCAD University
|
6 |
|
7 | Licensed under the New BSD license. You may not use this file except in
|
8 | compliance with this License.
|
9 |
|
10 | You may obtain a copy of the License at
|
11 | https://github.com/fluid-project/kettle/blob/master/LICENSE.txt
|
12 | */
|
13 |
|
14 | ;
|
15 |
|
16 | var 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 |
|
22 | fluid.registerNamespace("kettle.dataSource");
|
23 |
|
24 | /**** URL DATASOURCE SUPPORT ****/
|
25 |
|
26 | fluid.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 |
|
46 | fluid.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 | */
|
70 | kettle.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 |
|
88 | kettle.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 |
|
99 | kettle.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 |
|
126 | kettle.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 | */
|
143 | kettle.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
|
153 | kettle.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 |
|
176 | kettle.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 |
|
205 | kettle.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 |
|
221 | kettle.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 |
|
246 | kettle.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 | };
|