Repository: serverless/serverless (JavaScript)
Date: 2017-02-03 04:17:10+00:00
Task type: Bug Fix

Issue:
${file(filename)} Syntax does not work for functions
# This is a Bug Report

## Attempting to include another YML or JSON file from within the "functions" declaration fails to load with the error:

`Cannot assign to read only property 'events' of $`

This traces back to the following section of the "load" routine in Service.js
```
// setup function.name property
        const stageNameForFunction = options.stage || this.provider.stage;
        _.forEach(that.functions, (functionObj, functionName) => {
          if (!functionObj.events) {
            that.functions[functionName].events = [];
          }
          if (!_.isArray(functionObj.events)) {
            throw new SError(`Events for "${functionName}" must be an array,` +
                             ` not an ${typeof functionObj.events}`);
          }

          if (!functionObj.name) {
            that.functions[functionName].name =
              `${that.service}-${stageNameForFunction}-${functionName}`;
          }
        });
```

Stepping through the code shows that at the time the above code is run, the variables have not been expanded yet. As a result the text is treated like literal text instead of having the file contents. This is what throws the error since none of the function attributes are set.

Commenting out the whole above block *appears* to run properly with no immediately foreseeable issues, but I'm not sure how critical the defaulting of the name attribute is on the function. Clearly the above code was added for a reason, even if I can't see it.

If the above code is not necissary, it could be removed to solve this issue. Alternatively, if the setting of the name attribute happens instead in the Run() method of Serverless.js after the variable substitution happens (Looks like this currently happens on line 85 of Serverless.js), this could work. 
## Additional Data

* ***Serverless Framework Version you're using***: 1.4.0
* ***Operating System***: Windows 10 Pro
* ***Stack Trace***: TypeError: Cannot assign to read only property 'events' of $
    at C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\lib\classes\Service.js:160:49
    at index (C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\node_modules\lodash\lodash.js:4946:15)
    at Function.forEach (C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\node_modules\lodash\lodash.js:9344:14)
    at C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\lib\classes\Service.js:158:11
* ***Provider Error messages***: Cannot assign to read only property 'events' of $

${file(filename)} Syntax does not work for functions
# This is a Bug Report

## Attempting to include another YML or JSON file from within the "functions" declaration fails to load with the error:

`Cannot assign to read only property 'events' of $`

This traces back to the following section of the "load" routine in Service.js
```
// setup function.name property
        const stageNameForFunction = options.stage || this.provider.stage;
        _.forEach(that.functions, (functionObj, functionName) => {
          if (!functionObj.events) {
            that.functions[functionName].events = [];
          }
          if (!_.isArray(functionObj.events)) {
            throw new SError(`Events for "${functionName}" must be an array,` +
                             ` not an ${typeof functionObj.events}`);
          }

          if (!functionObj.name) {
            that.functions[functionName].name =
              `${that.service}-${stageNameForFunction}-${functionName}`;
          }
        });
```

Stepping through the code shows that at the time the above code is run, the variables have not been expanded yet. As a result the text is treated like literal text instead of having the file contents. This is what throws the error since none of the function attributes are set.

Commenting out the whole above block *appears* to run properly with no immediately foreseeable issues, but I'm not sure how critical the defaulting of the name attribute is on the function. Clearly the above code was added for a reason, even if I can't see it.

If the above code is not necissary, it could be removed to solve this issue. Alternatively, if the setting of the name attribute happens instead in the Run() method of Serverless.js after the variable substitution happens (Looks like this currently happens on line 85 of Serverless.js), this could work. 
## Additional Data

* ***Serverless Framework Version you're using***: 1.4.0
* ***Operating System***: Windows 10 Pro
* ***Stack Trace***: TypeError: Cannot assign to read only property 'events' of $
    at C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\lib\classes\Service.js:160:49
    at index (C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\node_modules\lodash\lodash.js:4946:15)
    at Function.forEach (C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\node_modules\lodash\lodash.js:9344:14)
    at C:\Users\%USER%\AppData\Roaming\npm\node_modules\serverless\lib\classes\Service.js:158:11
* ***Provider Error messages***: Cannot assign to read only property 'events' of $



Issue discussion:
I don't know why my searching didn't pull this up, but this is definitely related to Issue #2418 .
Pull request #2434 from a while back should fix this
@securityvoid @fruffin #2418 and #2434 didn't fix this issue. That fixed the ability to externalize just the events. This issue is that you cant externalize functions as a whole.  It's due to this block of code in Service.js

```
const stageNameForFunction = options.stage || this.provider.stage;
        _.forEach(that.functions, (functionObj, functionName) => {
          if (!functionObj.events) {
            that.functions[functionName].events = [];
          }

          if (!functionObj.name) {
            that.functions[functionName].name =
              `${that.service}-${stageNameForFunction}-${functionName}`;
          }
        });
```

It's trying to make sure that each function has an array of events, even if empty, and also assigns a name to each function if not already populated. If `this.functions` is just the `${file(...)}` string that hasn't been expanded yet, it ends up iterating over the characters of that string.  I'm still looking to see if I can put together a PR to fix this, other than removing that block of code alltogether
I don't know why my searching didn't pull this up, but this is definitely related to Issue #2418 .
Pull request #2434 from a while back should fix this
@securityvoid @fruffin #2418 and #2434 didn't fix this issue. That fixed the ability to externalize just the events. This issue is that you cant externalize functions as a whole.  It's due to this block of code in Service.js

```
const stageNameForFunction = options.stage || this.provider.stage;
        _.forEach(that.functions, (functionObj, functionName) => {
          if (!functionObj.events) {
            that.functions[functionName].events = [];
          }

          if (!functionObj.name) {
            that.functions[functionName].name =
              `${that.service}-${stageNameForFunction}-${functionName}`;
          }
        });
```

It's trying to make sure that each function has an array of events, even if empty, and also assigns a name to each function if not already populated. If `this.functions` is just the `${file(...)}` string that hasn't been expanded yet, it ends up iterating over the characters of that string.  I'm still looking to see if I can put together a PR to fix this, other than removing that block of code alltogether

Solution patch:
diff --git a/lib/Serverless.js b/lib/Serverless.js
index fd25eab2088..4b61b125a7a 100644
--- a/lib/Serverless.js
+++ b/lib/Serverless.js
@@ -84,6 +84,10 @@ class Serverless {
     // (https://github.com/serverless/serverless/issues/2041)
     this.variables.populateService(this.pluginManager.cliOptions);
 
+    // populate function names after variables are loaded in case functions were externalized
+    // (https://github.com/serverless/serverless/issues/2997)
+    this.service.setFunctionNames(this.processedInput.options);
+
     // validate the service configuration, now that variables are loaded
     this.service.validate();
 
diff --git a/lib/classes/Service.js b/lib/classes/Service.js
index fe5550d1e50..1a1679e802d 100644
--- a/lib/classes/Service.js
+++ b/lib/classes/Service.js
@@ -108,23 +108,30 @@ class Service {
           that.package.include = serverlessFile.package.include;
         }
 
-        // setup function.name property
-        const stageNameForFunction = options.stage || this.provider.stage;
-        _.forEach(that.functions, (functionObj, functionName) => {
-          if (!functionObj.events) {
-            that.functions[functionName].events = [];
-          }
-
-          if (!functionObj.name) {
-            that.functions[functionName].name =
-              `${that.service}-${stageNameForFunction}-${functionName}`;
-          }
-        });
-
         return this;
       });
   }
 
+  setFunctionNames(rawOptions) {
+    const that = this;
+    const options = rawOptions || {};
+    options.stage = options.stage || options.s;
+    options.region = options.region || options.r;
+
+    // setup function.name property
+    const stageNameForFunction = options.stage || this.provider.stage;
+    _.forEach(that.functions, (functionObj, functionName) => {
+      if (!functionObj.events) {
+        that.functions[functionName].events = [];
+      }
+
+      if (!functionObj.name) {
+        that.functions[functionName].name =
+          `${that.service}-${stageNameForFunction}-${functionName}`;
+      }
+    });
+  }
+
   validate() {
     _.forEach(this.functions, (functionObj, functionName) => {
       if (!_.isArray(functionObj.events)) {


Test patch:
diff --git a/lib/classes/Service.test.js b/lib/classes/Service.test.js
index 67d044e7e10..74e56f7418e 100644
--- a/lib/classes/Service.test.js
+++ b/lib/classes/Service.test.js
@@ -199,47 +199,6 @@ describe('Service', () => {
       });
     });
 
-    it('should make sure function name contains the default stage', () => {
-      const SUtils = new Utils();
-      const serverlessYml = {
-        service: 'new-service',
-        provider: {
-          name: 'aws',
-          stage: 'dev',
-          region: 'us-east-1',
-          variableSyntax: '\\${{([\\s\\S]+?)}}',
-        },
-        plugins: ['testPlugin'],
-        functions: {
-          functionA: {},
-        },
-        resources: {
-          aws: {
-            resourcesProp: 'value',
-          },
-          azure: {},
-          google: {},
-        },
-        package: {
-          exclude: ['exclude-me'],
-          include: ['include-me'],
-          artifact: 'some/path/foo.zip',
-        },
-      };
-
-      SUtils.writeFileSync(path.join(tmpDirPath, 'serverless.yml'),
-        YAML.dump(serverlessYml));
-
-      const serverless = new Serverless();
-      serverless.init();
-      serverless.config.update({ servicePath: tmpDirPath });
-      serviceInstance = new Service(serverless);
-
-      return serviceInstance.load().then(() => {
-        expect(serviceInstance.functions.functionA.name).to.be.equal('new-service-dev-functionA');
-      });
-    });
-
     it('should support Serverless file with a non-aws provider', () => {
       const SUtils = new Utils();
       const serverlessYaml = {
@@ -259,6 +218,7 @@ describe('Service', () => {
       serviceInstance = new Service(serverless);
 
       return serviceInstance.load().then(() => {
+        serviceInstance.setFunctionNames();
         const expectedFunc = {
           functionA: {
             name: 'customFunctionName',
@@ -290,6 +250,7 @@ describe('Service', () => {
       serviceInstance = new Service(serverless);
 
       return serviceInstance.load().then(() => {
+        serviceInstance.setFunctionNames();
         const expectedFunc = {
           functionA: {
             name: 'customFunctionName',
@@ -319,6 +280,7 @@ describe('Service', () => {
       serviceInstance = new Service(serverless);
 
       return serviceInstance.load({ stage: 'dev' }).then(() => {
+        serviceInstance.setFunctionNames();
         const expectedFunc = {
           functionA: {
             name: 'my-service-dev-functionA',
@@ -503,6 +465,57 @@ describe('Service', () => {
     });
   });
 
+  describe('#setFunctionNames()', () => {
+    let serviceInstance;
+    let tmpDirPath;
+
+    beforeEach(() => {
+      tmpDirPath = testUtils.getTmpDirPath();
+    });
+
+    it('should make sure function name contains the default stage', () => {
+      const SUtils = new Utils();
+      const serverlessYml = {
+        service: 'new-service',
+        provider: {
+          name: 'aws',
+          stage: 'dev',
+          region: 'us-east-1',
+          variableSyntax: '\\${{([\\s\\S]+?)}}',
+        },
+        plugins: ['testPlugin'],
+        functions: {
+          functionA: {},
+        },
+        resources: {
+          aws: {
+            resourcesProp: 'value',
+          },
+          azure: {},
+          google: {},
+        },
+        package: {
+          exclude: ['exclude-me'],
+          include: ['include-me'],
+          artifact: 'some/path/foo.zip',
+        },
+      };
+
+      SUtils.writeFileSync(path.join(tmpDirPath, 'serverless.yml'),
+        YAML.dump(serverlessYml));
+
+      const serverless = new Serverless();
+      serverless.init();
+      serverless.config.update({ servicePath: tmpDirPath });
+      serviceInstance = new Service(serverless);
+
+      return serviceInstance.load().then(() => {
+        serviceInstance.setFunctionNames();
+        expect(serviceInstance.functions.functionA.name).to.be.equal('new-service-dev-functionA');
+      });
+    });
+  });
+
   describe('#update()', () => {
     it('should update service instance data', () => {
       const serverless = new Serverless();