UNPKG

11.9 kBJavaScriptView Raw
1/*eslint no-unused-expressions: [2, { allowTernary: true }]*/
2const vm = require('vm')
3const os = require('os')
4const path = require('path')
5const fs = require('fs')
6const ini = require('ini')
7const {cli} = require('cli-ux')
8const faunadb = require('faunadb')
9const escodegen = require('escodegen')
10const Errors = require('@oclif/errors')
11var rp = require('request-promise')
12
13const FAUNA_CLOUD_DOMAIN = 'db.fauna.com'
14const ERROR_NO_DEFAULT_ENDPOINT = "You need to set a default endpoint. \nTry running 'fauna default-endpoint ENDPOINT_ALIAS'."
15const ERROR_WRONG_CLOUD_ENDPOINT = "You already have an endpoint 'cloud' defined and it doesn't point to 'db.fauna.com'.\nPlease fix your '~/.fauna-shell' file."
16const ERROR_SPECIFY_SECRET_KEY = 'You must specify a secret key to connect to FaunaDB'
17
18/**
19* Takes a parsed endpointURL, an endpoint alias, and the endpoint secret,
20* and saves it to the .ini config file.
21*
22* - If the endpoint already exists, it will be overwritten, after asking confirmation
23* from the user.
24* - If no other endpoint exists, then the endpoint will be set as the default one.
25*/
26function saveEndpointOrError(newEndpoint, alias, secret) {
27 return loadEndpoints()
28 .then(function (endpoints) {
29 if (endpointExists(endpoints, alias)) {
30 return confirmEndpointOverwrite(alias)
31 .then(function (overwrite) {
32 if (overwrite) {
33 return saveEndpoint(endpoints, newEndpoint, alias, secret)
34 } else {
35 throw new Error('Try entering a different endpoint alias.')
36 }
37 })
38 } else {
39 return saveEndpoint(endpoints, newEndpoint, alias, secret)
40 }
41 })
42}
43
44function deleteEndpointOrError(alias) {
45 return loadEndpoints()
46 .then(function (endpoints) {
47 if (endpointExists(endpoints, alias)) {
48 return confirmEndpointDelete(alias)
49 .then(function (del) {
50 if (del) {
51 return deleteEndpoint(endpoints, alias)
52 } else {
53 throw new Error("Couldn't override endpoint")
54 }
55 })
56 } else {
57 throw new Error(`The endpoint '${alias}' doesn't exist`)
58 }
59 })
60 .catch(function (err) {
61 errorOut(err, 1)
62 })
63}
64
65/**
66* Validates that the 'cloud' endpoint points to FAUNA_CLOUD_DOMAIN.
67*/
68function validCloudEndpoint() {
69 return loadEndpoints().then(function (config) {
70 return new Promise(function (resolve, reject) {
71 if (config.cloud && config.cloud.domain !== FAUNA_CLOUD_DOMAIN) {
72 reject(new Error(ERROR_WRONG_CLOUD_ENDPOINT))
73 } else {
74 resolve(true)
75 }
76 })
77 })
78}
79
80/**
81* Sets `endpoint` as the default endpoint.
82* If `endpoint` doesn't exist, returns an error.
83*/
84function setDefaultEndpoint(endpoint) {
85 return loadEndpoints().then(function (endpoints) {
86 return new Promise(function (resolve, reject) {
87 if (endpoints[endpoint]) {
88 endpoints.default = endpoint
89 return saveConfig(endpoints)
90 .then(function (_) {
91 resolve(`Endpoint '${endpoint}' set as default endpoint.`)
92 })
93 .catch(function (err) {
94 reject(err)
95 })
96 } else {
97 reject(new Error(`Endpoint '${endpoint}' doesn't exist.`))
98 }
99 })
100 })
101}
102
103/**
104* Loads the endpoints from the ~/.fauna-shell file.
105* If the file doesn't exist, returns an empty object.
106*/
107function loadEndpoints() {
108 return readFile(getConfigFile())
109 .then(function (configData) {
110 return ini.parse(configData)
111 })
112 .catch(function (err) {
113 if (fileNotFound(err)) {
114 return {}
115 }
116 throw err
117 })
118}
119
120function endpointExists(endpoints, endpointAlias) {
121 return endpointAlias in endpoints
122}
123
124function confirmEndpointOverwrite(alias) {
125 return cli.confirm(`The '${alias}' endpoint already exists. Overwrite? [y/n]`)
126}
127
128function confirmEndpointDelete(alias) {
129 return cli.confirm(`Are you sure you want to delete the '${alias}' endpoint? [y/n]`)
130}
131
132function saveEndpoint(config, endpoint, alias, secret) {
133 var port = endpoint.port ? `:${endpoint.port}` : ''
134 var uri = `${endpoint.protocol}//${endpoint.host}${port}`
135 var options = {
136 method: 'HEAD',
137 uri: uri,
138 resolveWithFullResponse: true,
139 }
140 return rp(options)
141 .then(function (res) {
142 if ('x-faunadb-build' in res.headers) {
143 return saveConfig(addEndpoint(config, endpoint, alias, secret))
144 } else {
145 throw new Error(`'${alias}' is not a FaunaDB endopoint`)
146 }
147 })
148 .catch(function (err) {
149 // Fauna returns a 401 which is an error for the request-promise library
150 if (err.response !== undefined) {
151 if ('x-faunadb-build' in err.response.headers) {
152 return saveConfig(addEndpoint(config, endpoint, alias, secret))
153 } else {
154 throw new Error(`'${alias}' is not a FaunaDB endopoint`)
155 }
156 } else {
157 throw err
158 }
159 })
160}
161
162function addEndpoint(config, endpoint, alias, secret) {
163 if (shouldSetAsDefaultEndpoint(config)) {
164 config.default = alias
165 }
166 config[alias] = buildEndpointObject(endpoint, secret)
167 return config
168}
169
170function deleteEndpoint(endpoints, alias) {
171 if (endpoints.default === alias) {
172 delete endpoints.default
173 console.log(`Endpoint '${alias}' deleted. '${alias}' was the default endpoint.`)
174 console.log(ERROR_NO_DEFAULT_ENDPOINT)
175 }
176 delete endpoints[alias]
177 return saveConfig(endpoints)
178}
179
180function shouldSetAsDefaultEndpoint(config) {
181 return 'default' in config === false
182}
183
184function buildEndpointObject(endpoint, secret) {
185 var domain = endpoint.hostname
186 var port = endpoint.port
187 var scheme = endpoint.protocol.slice(0, -1) //the scheme is parsed as 'http:'
188 // if the value ends up being null, then Object.assign() will skip the property.
189 domain = domain === null ? null : {domain}
190 port = port === null ? null : {port}
191 scheme = scheme === null ? null : {scheme}
192 return Object.assign({}, domain, port, scheme, {secret})
193}
194
195/**
196* Converts the `config` data provided to INI format, and then saves it to the
197* ~/.fauna-shell file.
198*/
199function saveConfig(config) {
200 return writeFile(getConfigFile(), ini.stringify(config), 0o700)
201}
202
203/**
204* Returns the full path to the `.fauna-shell` config file
205*/
206function getConfigFile() {
207 return path.join(os.homedir(), '.fauna-shell')
208}
209
210/**
211* Wraps `fs.readFile` into a Promise.
212*/
213function readFile(fileName) {
214 return new Promise(function (resolve, reject) {
215 fs.readFile(fileName, 'utf8', (err, data) => {
216 err ? reject(err) : resolve(data)
217 })
218 })
219}
220
221/**
222* Wraps `fs.writeFile` into a Promise.
223*/
224function writeFile(fileName, data, mode) {
225 return new Promise(function (resolve, reject) {
226 fs.writeFile(fileName, data, {mode: mode}, err => {
227 err ? reject(err) : resolve(data)
228 })
229 })
230}
231
232/**
233* Tests if an error is of the type "file not found".
234*/
235function fileNotFound(err) {
236 return err.code === 'ENOENT' && err.syscall === 'open'
237}
238
239/**
240* Throws error with `msg` and exit code `code`.
241*/
242function errorOut(msg, code) {
243 code = code || 1
244 console.error(`Error: ${msg}`)
245 process.exit(code)
246 //TODO: Using process.exit is not the optimal solution.
247 //return Errors.error(msg, { exit: code })
248}
249
250/**
251* Builds the options provided to the faunajs client.
252* Tries to load the ~/.fauna-shell file and read the default endpoint from there.
253*
254* Assumes that if the file exists, it would have been created by fauna-shell,
255* therefore it would have a defined endpoint.
256*
257* Flags like --host, --port, etc., provided by the CLI take precedence over what's
258* stored in ~/.fauna-shell.
259*
260* The --endpoint flag overries the default endpoint from fauna-shell.
261*
262* If ~/.fauna-shell doesn't exist, tries to build the connection options from the
263* flags passed to the script.
264*
265* It always expect a secret key to be set in ~/.fauna-shell or provided via CLI
266* arguments.
267*
268* @param {Object} cmdFlags - flags passed via the CLI.
269* @param {string} dbScope - A database name to scope the connection to.
270* @param {string} role - A user role: 'admin'|'server'|'server-readonly'|'client'.
271*/
272function buildConnectionOptions(cmdFlags, dbScope, role) {
273 return new Promise(function (resolve, reject) {
274 readFile(getConfigFile())
275 .then(function (configData) {
276 var endpoint = {}
277 const config = ini.parse(configData)
278 // having a valid endpoint, assume there's a secret set
279 if (hasValidEndpoint(config, cmdFlags.endpoint)) {
280 endpoint = getEndpoint(config, cmdFlags.endpoint)
281 } else if (!cmdFlags.hasOwnProperty('secret')) {
282 reject(ERROR_NO_DEFAULT_ENDPOINT)
283 }
284 //TODO add a function endpointFromCmdFlags that builds an endpoint and clean up the code.
285 const connectionOptions = Object.assign(endpoint, cmdFlags)
286 //TODO refactor duplicated code
287 if (connectionOptions.secret) {
288 resolve(cleanUpConnectionOptions(maybeScopeKey(connectionOptions, dbScope, role)))
289 } else {
290 reject(ERROR_SPECIFY_SECRET_KEY)
291 }
292 })
293 .catch(function (err) {
294 if (fileNotFound(err)) {
295 if (cmdFlags.secret) {
296 resolve(cleanUpConnectionOptions(maybeScopeKey(cmdFlags, dbScope, role)))
297 } else {
298 reject(ERROR_SPECIFY_SECRET_KEY)
299 }
300 } else {
301 reject(err)
302 }
303 })
304 })
305}
306
307function getEndpoint(config, cmdEndpoint) {
308 const alias = cmdEndpoint ? cmdEndpoint : config.default
309 return config[alias]
310}
311
312function hasValidEndpoint(config, cmdEndpoint) {
313 if (cmdEndpoint) {
314 return config.hasOwnProperty(cmdEndpoint)
315 } else {
316 return config.hasOwnProperty('default') && config.hasOwnProperty(config.default)
317 }
318}
319
320/**
321* Makes sure the connectionOptions options passed to the js client
322* only contain valid properties.
323*/
324function cleanUpConnectionOptions(connectionOptions) {
325 const accepted = ['domain', 'scheme', 'port', 'secret', 'timeout']
326 const res = {}
327 accepted.forEach(function (key) {
328 if (connectionOptions[key]) {
329 res[key] = connectionOptions[key]
330 }
331 })
332 return res
333}
334
335/**
336* If `dbScope` and `role` aren't null, then the secret key is scoped to
337* the `dbScope` database for the provided user `role`.
338*/
339function maybeScopeKey(config, dbScope, role) {
340 var scopedSecret = config.secret
341 if (dbScope !== undefined && role !== undefined) {
342 scopedSecret = config.secret + ':' + dbScope + ':' + role
343 }
344 return Object.assign(config, {secret: scopedSecret})
345}
346
347// adapted from https://hackernoon.com/functional-javascript-resolving-promises-sequentially-7aac18c4431e
348function promiseSerial(fs) {
349 return fs.reduce(function (promise, f) {
350 return promise.then(function (result) {
351 return f().then(Array.prototype.concat.bind(result))
352 })
353 }, Promise.resolve([]))
354}
355
356class QueryError extends Error {
357 constructor(exp, faunaError, queryNumber, ...params) {
358 super(params)
359
360 if (Error.captureStackTrace) {
361 Error.captureStackTrace(this, QueryError)
362 }
363
364 this.exp = exp
365 this.faunaError = faunaError
366 this.queryNumber = queryNumber
367 }
368}
369
370function wrapQueries(expressions, client) {
371 const q = faunadb.query
372 vm.createContext(q)
373 return expressions.map(function (exp, queryNumber) {
374 return function () {
375 return client.query(vm.runInContext(escodegen.generate(exp), q))
376 .catch(function (err) {
377 throw new QueryError(escodegen.generate(exp), err, queryNumber + 1)
378 })
379 }
380 })
381}
382
383function runQueries(expressions, client) {
384 if (expressions.length == 1) {
385 var f = wrapQueries(expressions, client)[0]
386 return f()
387 } else {
388 return promiseSerial(wrapQueries(expressions, client))
389 }
390}
391
392module.exports = {
393 saveEndpointOrError: saveEndpointOrError,
394 deleteEndpointOrError: deleteEndpointOrError,
395 setDefaultEndpoint: setDefaultEndpoint,
396 validCloudEndpoint: validCloudEndpoint,
397 loadEndpoints: loadEndpoints,
398 buildConnectionOptions: buildConnectionOptions,
399 errorOut: errorOut,
400 readFile: readFile,
401 writeFile: writeFile,
402 runQueries: runQueries,
403}