UNPKG

11.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 components: {
37 cookieJar: "{cookieJar}"
38 },
39 termMap: {}
40});
41
42fluid.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 */
65kettle.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
83kettle.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
94kettle.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
114kettle.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 */
131kettle.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
141kettle.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
164kettle.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
193kettle.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
209kettle.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};