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 | components: {
|
37 | cookieJar: "{cookieJar}"
|
38 | },
|
39 | termMap: {}
|
40 | });
|
41 |
|
42 | fluid.defaults("kettle.dataSource.URL.writable", {
|
43 | gradeNames: ["kettle.dataSource.writable"],
|
44 | invokers: {
|
45 | setImpl: {
|
46 | funcName: "kettle.dataSource.URL.handle",
|
47 | args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] // options, directModel, model
|
48 | }
|
49 | }
|
50 | });
|
51 |
|
52 | /**
|
53 | * 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
|
54 | * 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
|
55 | * the termMap is prefixed with `noencode:`. Secondly,
|
56 | * 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
|
57 | * used to expand the entire URL.
|
58 | * @param {String} url a url or path to expand.
|
59 | * @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
|
60 | * 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
|
61 | * begins with the prefix "%", or else directly for interpolation
|
62 | * @param {Object} directModel a set of data used to expand the url template.
|
63 | * @return {String} resolved/expanded url.
|
64 | */
|
65 | kettle.dataSource.URL.resolveUrl = function (url, termMap, directModel, noencode) {
|
66 | var map = fluid.transform(termMap, function resolve(entry) {
|
67 | entry = String(entry);
|
68 | var encode = !noencode;
|
69 | if (entry.indexOf("noencode:") === 0) {
|
70 | encode = false;
|
71 | entry = entry.substring("noencode:".length);
|
72 | }
|
73 | var value = entry.charAt(0) === "%" ? fluid.get(directModel, entry.substring(1)) : entry;
|
74 | if (encode) {
|
75 | value = encodeURIComponent(value);
|
76 | }
|
77 | return value;
|
78 | });
|
79 | var replaced = fluid.stringTemplate(url, map);
|
80 | return replaced;
|
81 | };
|
82 |
|
83 | kettle.dataSource.URL.requestOptions = ["url", "protocol", "host", "hostname", "family", "port", "localAddress", "socketPath", "method", "path", "headers", "auth", "agent", "termMap"];
|
84 |
|
85 | // TODO: Deal with the anomalous status of "charEncoding" - in theory it could be set per-request but currently can't be. Currently all
|
86 | // "requestOptions" have a common fate in that they end up as the arguments to http.request. We need to split these into two levels,
|
87 | // httpRequestOptions and the outer level with everything else. We also need to do something similar for kettle.dataSource.file
|
88 |
|
89 | /** Assemble the `requestOptions` structure that will be sent to `http.request` by `kettle.dataSource.URL` by fusing together values from the user, the component
|
90 | * 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
|
91 | * reformed as a proper merge pipeline.
|
92 | */
|
93 |
|
94 | kettle.dataSource.URL.prepareRequestOptions = function (componentOptions, cookieJar, userOptions, permittedOptions, directModel, userStaticOptions, resolveUrl) {
|
95 | var staticOptions = fluid.filterKeys(componentOptions, permittedOptions);
|
96 | var requestOptions = fluid.extend(true, {headers: {}}, userStaticOptions, staticOptions, userOptions);
|
97 | var termMap = fluid.transform(requestOptions.termMap, encodeURIComponent);
|
98 |
|
99 | requestOptions.path = (resolveUrl || kettle.dataSource.URL.resolveUrl)(requestOptions.path, requestOptions.termMap, directModel);
|
100 |
|
101 | fluid.stringTemplate(requestOptions.path, termMap);
|
102 | if (cookieJar && cookieJar.cookie && componentOptions.storeCookies) {
|
103 | requestOptions.headers.Cookie = cookieJar.cookie;
|
104 | }
|
105 | return requestOptions;
|
106 | };
|
107 |
|
108 | /** Given an HTTP status code as returned by node's `http.IncomingMessage` class (or otherwise), determine whether it corresponds to
|
109 | * an error status. This performs a simple-minded check to see if it a number outside the range [200, 300).
|
110 | * @param {Number} statusCode The HTTP status code to be tested
|
111 | * @return {Boolean} `true` if the status code represents an error status
|
112 | */
|
113 |
|
114 | kettle.dataSource.URL.isErrorStatus = function (statusCode) {
|
115 | return statusCode < 200 || statusCode >= 300;
|
116 | };
|
117 |
|
118 | /**
|
119 | * Handles calls to a URL data source's get and set.
|
120 | * @param {kettle.dataSource.urlResolver} A URLResolver that will convert the contents of the
|
121 | * <code>directModel</code> supplied as the 3rd argument into a concrete URL used for this
|
122 | * HTTP request.
|
123 | * @param options {Object} an options block that encodes:
|
124 | * operation {String}: "set"/"get"
|
125 | * notFoundIsEmpty {Boolean}: <code>true</code> if a missing file on read should count as a successful empty payload rather than a rejection
|
126 | * writeMethod {String}: "PUT"/ "POST" (option - if not provided will be defaulted by the concrete dataSource implementation)
|
127 | * @param directModel {Object} a model holding the coordinates of the data to be read or written
|
128 | * @param model {Object} [Optional] - the payload to be written by this write operation
|
129 | * @return {Promise} a promise for the successful or failed datasource operation
|
130 | */
|
131 | kettle.dataSource.URL.handle = function (that, userOptions, directModel, model) {
|
132 | var permittedOptions = kettle.dataSource.URL.requestOptions;
|
133 | var url = that.resolveUrl(that.options.url, that.options.termMap, directModel);
|
134 | var parsed = fluid.filterKeys(urlModule.parse(url, true), permittedOptions);
|
135 | var requestOptions = kettle.dataSource.URL.prepareRequestOptions(that.options, that.cookieJar, userOptions, permittedOptions, directModel, parsed);
|
136 |
|
137 | return kettle.dataSource.URL.handle.http(that, requestOptions, model);
|
138 | };
|
139 |
|
140 | // Attempt to parse the error response as JSON, but if failed, just stick it into "message" quietly
|
141 | kettle.dataSource.URL.relayError = function (response, received, whileMsg) {
|
142 | var rejectPayload;
|
143 | try {
|
144 | rejectPayload = JSON.parse(received);
|
145 | } catch (e) {
|
146 | var message = typeof(received) === "string" && received.indexOf("<html") !== -1 ?
|
147 | kettle.extractHtmlError(received) : received;
|
148 | rejectPayload = {message: message};
|
149 | }
|
150 | rejectPayload.message = rejectPayload.message || rejectPayload.error;
|
151 | delete rejectPayload.error;
|
152 | rejectPayload.isError = true;
|
153 | rejectPayload.statusCode = response.statusCode;
|
154 | return kettle.upgradeError(rejectPayload, whileMsg);
|
155 | };
|
156 |
|
157 | /** Compute the callback to be supplied to `http.request` for this dataSource operation
|
158 | * @param requestOptions {Object} The fully merged options which were sent to `http.request`
|
159 | * @param promise {Promise} The outgoing promise to be returned to the user
|
160 | * @param whileMsg {String} A readable summary of the current operation (including HTTP method and URL) to be suffixed to any rejection message
|
161 | * @return {Function} The callback to be supplied to `http.request`
|
162 | */
|
163 |
|
164 | kettle.dataSource.URL.httpCallback = function (requestOptions, promise, whileMsg) {
|
165 | return function (res) {
|
166 | var received = "";
|
167 | res.setEncoding(requestOptions.charEncoding);
|
168 | res.on("data", function onData(chunk) {
|
169 | received += chunk;
|
170 | });
|
171 | res.on("end", kettle.wrapCallback(
|
172 | function () {
|
173 | if (kettle.dataSource.URL.isErrorStatus(res.statusCode)) {
|
174 | var relayed = kettle.dataSource.URL.relayError(res, received, whileMsg);
|
175 | if (requestOptions.notFoundIsEmpty && relayed.statusCode === 404) {
|
176 | promise.resolve(undefined);
|
177 | } else {
|
178 | promise.reject(relayed);
|
179 | }
|
180 | } else {
|
181 | promise.resolve(received);
|
182 | }
|
183 | })
|
184 | );
|
185 | };
|
186 | };
|
187 |
|
188 | /** Compute the listener to be attached to the `http.request` `error` event for this dataSource operation
|
189 | * @param promise {Promise} The outgoing promise to be returned to the user
|
190 | * @param whileMsg {String} A readable summary of the current operation (including HTTP method and URL) to be suffixed to any rejection message
|
191 | */
|
192 |
|
193 | kettle.dataSource.URL.errorCallback = function (promise, whileMsg) {
|
194 | return function (error) {
|
195 | error.isError = true;
|
196 | promise.reject(kettle.upgradeError(error, whileMsg));
|
197 | };
|
198 | };
|
199 |
|
200 | /** Central strategy point for all HTTP-backed DataSource operations (both read and write).
|
201 | * Accumulates options to be sent to the underlying node.js `http.request` primitives, collects and interprets the
|
202 | * results back into promise resolutions.
|
203 | * @param that {Component} The DataSource itself
|
204 | * @param baseOptions {Object} A partially merged form of the options sent to the top-level `dataSource.get` method together with relevant
|
205 | * static options configured on the component. Information in the `directModel` argument has already been encoded into the url member.
|
206 | * @param data {String} The `model` argument sent to top-level `dataSource.get/set` after it has been pushed through the transform chain
|
207 | */
|
208 |
|
209 | kettle.dataSource.URL.handle.http = function (that, baseOptions, data) {
|
210 | var promise = fluid.promise();
|
211 | var defaultOptions = {
|
212 | port: 80,
|
213 | method: "GET"
|
214 | };
|
215 |
|
216 | var dataBuffer;
|
217 |
|
218 | if (baseOptions.operation === "set") {
|
219 | dataBuffer = new Buffer(data);
|
220 | defaultOptions.headers = {
|
221 | "Content-Type": that.encoding.options.contentType,
|
222 | "Content-Length": dataBuffer.length
|
223 | };
|
224 | defaultOptions.method = baseOptions.writeMethod;
|
225 | }
|
226 | var requestOptions = fluid.extend(true, defaultOptions, baseOptions);
|
227 | var whileMsg = " while executing HTTP " + requestOptions.method + " on url " + requestOptions.url;
|
228 | fluid.log("DataSource Issuing " + (requestOptions.protocol.toUpperCase()).slice(0, -1) + " request with options ", requestOptions);
|
229 | promise.accumulateRejectionReason = function (originError) {
|
230 | return kettle.upgradeError(originError, whileMsg);
|
231 | };
|
232 | var callback = kettle.wrapCallback(kettle.dataSource.URL.httpCallback(requestOptions, promise, whileMsg));
|
233 | var req;
|
234 | if (requestOptions.protocol === "http:") {
|
235 | req = http.request(requestOptions, callback);
|
236 | } else if (requestOptions.protocol === "https:") {
|
237 | req = https.request(requestOptions, callback);
|
238 | } else {
|
239 | fluid.fail("kettle.dataSource.URL cannot handle unknown protocol " + requestOptions.protocol);
|
240 | }
|
241 | req.on("error", kettle.wrapCallback(kettle.dataSource.URL.errorCallback(promise, whileMsg)));
|
242 | req.end(dataBuffer ? dataBuffer : data);
|
243 | return promise;
|
244 | };
|