Repository: serverless/serverless (JavaScript)
Date: 2020-04-03 20:57:42+00:00
Task type: Bug Fix

Issue:
Variables from javascript files must be a function
Right now, variables defined in JavaScript files must be functions, as referenced here: https://serverless.com/framework/docs/providers/aws/guide/variables#reference-variables-in-other-files

Although allowing functions is helpful, this is unfortunately very frustrating when exporting simple constant strings or numbers, as these simply fail. What I'd like is to be able to have:

file.js:

```js
module.exports = {
    num: 1,
    str: 'hello',
    other: () => 'thing',
};
```

serverless.yml:

```yml
custom:
    num: ${file(./file.js):num}
    str: ${file(./file.js):str}
    other: ${file(./file.js):other}
```

Yield:

```yml
custom:
    num: 1
    str: hello
    other: thing
```

`sls --version`:

```
Framework Core: 1.66.0
Plugin: 3.4.1
SDK: 2.3.0
Components: 2.22.3
```

I consider this a bug and not a feature request, because the current behaviour causes weird errors.


Issue discussion:
@clar-cmp thanks for reporting !

> I consider this a bug and not a feature request, because the current behaviour causes weird errors.

If error that's thrown for non-function case is cryptic, then definitely it's a bug in error handling that's worth addressing (PR is welcome!)

Concerning support for other values. It'll be good to investigate deeper on whether that's safe to do, and whether that won't happen to be too confusing.

Also question what is the use case?

If you want to export static values, why you do not export JSON or put it directly in a `custom` section?
There are plenty of reasons to export variables that aren't functions, for example, if they're dynamically generated; you only want to load them once when you import the file rather than every time you call the function. Having to then wrap the value in a `() => value` seems unnecessary.

Solution patch:
diff --git a/docs/providers/aws/guide/variables.md b/docs/providers/aws/guide/variables.md
index 9a95d2e38cd..a9ec8700eb8 100644
--- a/docs/providers/aws/guide/variables.md
+++ b/docs/providers/aws/guide/variables.md
@@ -428,16 +428,13 @@ functions:
 
 You can reference JavaScript files to add dynamic data into your variables.
 
-References can be either named or unnamed exports. To use the exported `someModule` in `myFile.js` you'd use the following code `${file(./myFile.js):someModule}`. For an unnamed export you'd write `${file(./myFile.js)}`. The first argument to your export will be a reference to the Serverless object, containing your configuration.
+References can be either named or unnamed exports. To use the exported `someModule` in `myFile.js` you'd use the following code `${file(./myFile.js):someModule}`. For an unnamed export you'd write `${file(./myFile.js)}`. If you export a function, the first argument will be a reference to the Serverless object, containing your configuration.
 
-Here are other examples:
+Here are some examples:
 
 ```js
 // scheduleConfig.js
-module.exports.rate = () => {
-  // Code that generates dynamic data
-  return 'rate (10 minutes)';
-};
+module.exports.rate = 'rate(10 minutes)';
 ```
 
 ```js
diff --git a/lib/classes/Variables.js b/lib/classes/Variables.js
index 4b47521a021..6f4648df2cf 100644
--- a/lib/classes/Variables.js
+++ b/lib/classes/Variables.js
@@ -12,6 +12,9 @@ const PromiseTracker = require('./PromiseTracker');
 
 const resolvedValuesWeak = new WeakSet();
 
+// Used when resolving variables from a JS file
+const unsupportedJsTypes = new Set(['undefined', 'symbol', 'function']);
+
 /**
  * Maintainer's notes:
  *
@@ -683,24 +686,20 @@ class Variables {
       // eslint-disable-next-line global-require, import/no-dynamic-require
       const jsFile = require(referencedFileFullPath);
       const variableArray = variableString.split(':');
-      let returnValueFunction;
+      let returnValue;
       if (variableArray[1]) {
         let jsModule = variableArray[1];
         jsModule = jsModule.split('.')[0];
-        returnValueFunction = jsFile[jsModule];
+        returnValue = jsFile[jsModule];
       } else {
-        returnValueFunction = jsFile;
+        returnValue = jsFile;
       }
 
-      if (typeof returnValueFunction !== 'function') {
-        const errorMessage = [
-          'Invalid variable syntax when referencing',
-          ` file "${referencedFileRelativePath}".`,
-          ' Check if your javascript is exporting a function that returns a value.',
-        ].join('');
-        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
+      if (typeof returnValue === 'function') {
+        valueToPopulate = returnValue.call(jsFile, this.serverless);
+      } else {
+        valueToPopulate = returnValue;
       }
-      valueToPopulate = returnValueFunction.call(jsFile, this.serverless);
 
       return BbPromise.resolve(valueToPopulate).then(valueToPopulateResolved => {
         let deepProperties = variableString.replace(matchedFileRefString, '');
@@ -708,7 +707,7 @@ class Variables {
         deepProperties.splice(0, 1);
         return this.getDeeperValue(deepProperties, valueToPopulateResolved).then(
           deepValueToPopulateResolved => {
-            if (typeof deepValueToPopulateResolved === 'undefined') {
+            if (unsupportedJsTypes.has(typeof deepValueToPopulateResolved)) {
               const errorMessage = [
                 'Invalid variable syntax when referencing',
                 ` file "${referencedFileRelativePath}".`,


Test patch:
diff --git a/lib/classes/Variables.test.js b/lib/classes/Variables.test.js
index c58e4118b03..1e00c51e73e 100644
--- a/lib/classes/Variables.test.js
+++ b/lib/classes/Variables.test.js
@@ -1870,7 +1870,7 @@ module.exports = {
         .should.become(configYml.test2.sub);
     });
 
-    it('should populate from a javascript file', () => {
+    it('should populate from a javascript file that exports a function', () => {
       const SUtils = new Utils();
       const tmpDirPath = getTmpDirPath();
       const jsData = 'module.exports.hello=function(){return "hello world";};';
@@ -1881,7 +1881,18 @@ module.exports = {
         .should.become('hello world');
     });
 
-    it('should populate an entire variable exported by a javascript file', () => {
+    it('should populate from a javascript file that exports a string', () => {
+      const SUtils = new Utils();
+      const tmpDirPath = getTmpDirPath();
+      const jsData = 'module.exports.hello="hello world";';
+      SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
+      serverless.config.update({ servicePath: tmpDirPath });
+      return serverless.variables
+        .getValueFromFile('file(./hello.js):hello')
+        .should.become('hello world');
+    });
+
+    it('should populate an entire variable exported by a javascript file function', () => {
       const SUtils = new Utils();
       const tmpDirPath = getTmpDirPath();
       const jsData = 'module.exports=function(){return { hello: "hello world" };};';
@@ -1892,7 +1903,7 @@ module.exports = {
         .should.become({ hello: 'hello world' });
     });
 
-    it('should throw if property exported by a javascript file is not a function', () => {
+    it('should populate an entire variable exported by a javascript file object', () => {
       const SUtils = new Utils();
       const tmpDirPath = getTmpDirPath();
       const jsData = 'module.exports={ hello: "hello world" };';
@@ -1900,7 +1911,16 @@ module.exports = {
       serverless.config.update({ servicePath: tmpDirPath });
       return serverless.variables
         .getValueFromFile('file(./hello.js)')
-        .should.be.rejectedWith(serverless.classes.Error);
+        .should.become({ hello: 'hello world' });
+    });
+
+    it('should populate an entire variable exported by a javascript file literal', () => {
+      const SUtils = new Utils();
+      const tmpDirPath = getTmpDirPath();
+      const jsData = 'module.exports="hello world";';
+      SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
+      serverless.config.update({ servicePath: tmpDirPath });
+      return serverless.variables.getValueFromFile('file(./hello.js)').should.become('hello world');
     });
 
     it('should populate deep object from a javascript file', () => {
@@ -1948,6 +1968,39 @@ module.exports = {
         .getValueFromFile('file(./config.yml).testObj.sub')
         .should.be.rejectedWith(serverless.classes.Error);
     });
+
+    it('should throw an error if resolved value is undefined', () => {
+      const SUtils = new Utils();
+      const tmpDirPath = getTmpDirPath();
+      const jsData = 'module.exports=undefined;';
+      SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
+      serverless.config.update({ servicePath: tmpDirPath });
+      return serverless.variables
+        .getValueFromFile('file(./hello.js)')
+        .should.be.rejectedWith(serverless.classes.Error);
+    });
+
+    it('should throw an error if resolved value is a symbol', () => {
+      const SUtils = new Utils();
+      const tmpDirPath = getTmpDirPath();
+      const jsData = 'module.exports=Symbol()';
+      SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
+      serverless.config.update({ servicePath: tmpDirPath });
+      return serverless.variables
+        .getValueFromFile('file(./hello.js)')
+        .should.be.rejectedWith(serverless.classes.Error);
+    });
+
+    it('should throw an error if resolved value is a function', () => {
+      const SUtils = new Utils();
+      const tmpDirPath = getTmpDirPath();
+      const jsData = 'module.exports=function(){ return function(){}; };';
+      SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
+      serverless.config.update({ servicePath: tmpDirPath });
+      return serverless.variables
+        .getValueFromFile('file(./hello.js)')
+        .should.be.rejectedWith(serverless.classes.Error);
+    });
   });
 
   describe('#getValueFromCf()', () => {