Repository: serverless/serverless (JavaScript)
Date: 2018-12-30 04:13:26+00:00
Task type: Feature

Issue:
DX: Validate Cron Syntax Before Deploy & Improve Error Message
Cron is weird and not human friendly.

I'd love for the framework to validate my stupid human mistakes before I have to wait for the deploy to fail with a vague error message.

![image](https://cloud.githubusercontent.com/assets/532272/23385855/9156a364-fd07-11e6-96f9-90079819a675.png)

Ideally the framework would validate the cron expression validity before `deploy` runs and returns errors quickly for faster fixing.

Additionally it would be great if we started giving better error messages. Like for example a better error message would be:

```
 An error occurred while provisioning your stack: PublishScheduledPostCronEventsRuleSchedule1
     - Parameter ScheduleExpression is not valid..

Please check your cron syntax. Do additional AWS cron information see http://docs.aws.amazon.com/lambda/latest/dg/tutorial-scheduled-events-schedule-expressions.html
```

Little things like this go a long way in terms of developer experience.


Issue discussion:
Another example of user error pain: https://gitter.im/serverless/serverless?at=58b9c5a021d548df2c888cfc
another validation issue: http://forum.serverless.com/t/s3-trigger-is-not-registered-after-deployment/1858/3

Solution patch:
diff --git a/lib/plugins/aws/package/compile/events/schedule/index.js b/lib/plugins/aws/package/compile/events/schedule/index.js
index 42cd9f9978d..2639509ae02 100644
--- a/lib/plugins/aws/package/compile/events/schedule/index.js
+++ b/lib/plugins/aws/package/compile/events/schedule/index.js
@@ -2,6 +2,11 @@
 
 const _ = require('lodash');
 
+const rateSyntaxPattern =
+  /^rate\((?:1 (?:minute|hour|day)|(?:1\d+|[2-9]\d*) (?:minute|hour|day)s)\)$/;
+const cronSyntaxPattern =
+  /^cron\(\S+ \S+ \S+ \S+ \S+ \S+\)$/;
+
 class AwsCompileScheduledEvents {
   constructor(serverless) {
     this.serverless = serverless;
@@ -12,6 +17,16 @@ class AwsCompileScheduledEvents {
     };
   }
 
+  buildValidationErrorMessage(functionName) {
+    return [
+      `"rate" property for schedule event is missing or invalid in function ${functionName}.`,
+      ' The correct syntax is: `schedule: rate(10 minutes)`, `schedule: cron(0 12 * * ? *)`',
+      ' OR an object with "rate" property.',
+      ' Please check the docs for more info:',
+      ' https://serverless.com/framework/docs/providers/aws/events/schedule/',
+    ].join('');
+  }
+
   compileScheduledEvents() {
     this.serverless.service.getAllFunctions().forEach((functionName) => {
       const functionObj = this.serverless.service.getFunction(functionName);
@@ -28,15 +43,9 @@ class AwsCompileScheduledEvents {
             let Name;
             let Description;
 
-            // TODO validate rate syntax
             if (typeof event.schedule === 'object') {
-              if (!event.schedule.rate) {
-                const errorMessage = [
-                  `Missing "rate" property for schedule event in function ${functionName}`,
-                  ' The correct syntax is: schedule: rate(10 minutes)',
-                  ' OR an object with "rate" property.',
-                  ' Please check the docs for more info.',
-                ].join('');
+              if (!this.validateScheduleSyntax(event.schedule.rate)) {
+                const errorMessage = this.buildValidationErrorMessage(functionName);
                 throw new this.serverless.classes
                   .Error(errorMessage);
               }
@@ -81,16 +90,11 @@ class AwsCompileScheduledEvents {
                 // escape quotes to favor JSON.parse
                 Input = Input.replace(/\"/g, '\\"'); // eslint-disable-line
               }
-            } else if (typeof event.schedule === 'string') {
+            } else if (this.validateScheduleSyntax(event.schedule)) {
               ScheduleExpression = event.schedule;
               State = 'ENABLED';
             } else {
-              const errorMessage = [
-                `Schedule event of function ${functionName} is not an object nor a string`,
-                ' The correct syntax is: schedule: rate(10 minutes)',
-                ' OR an object with "rate" property.',
-                ' Please check the docs for more info.',
-              ].join('');
+              const errorMessage = this.buildValidationErrorMessage(functionName);
               throw new this.serverless.classes
                 .Error(errorMessage);
             }
@@ -149,6 +153,11 @@ class AwsCompileScheduledEvents {
       }
     });
   }
+
+  validateScheduleSyntax(input) {
+    return typeof input === 'string' &&
+      (rateSyntaxPattern.test(input) || cronSyntaxPattern.test(input));
+  }
 }
 
 module.exports = AwsCompileScheduledEvents;


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/schedule/index.test.js b/lib/plugins/aws/package/compile/events/schedule/index.test.js
index af6a7d0befd..09745ef20fe 100644
--- a/lib/plugins/aws/package/compile/events/schedule/index.test.js
+++ b/lib/plugins/aws/package/compile/events/schedule/index.test.js
@@ -53,6 +53,138 @@ describe('AwsCompileScheduledEvents', () => {
       expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
     });
 
+    describe('rate syntax validation: rate(value unit)', () => {
+      describe('schedule string', () => {
+        it('should throw an error if the value is 1 but the unit is plural', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: 'rate(1 days)',
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+
+        it('should throw an error if the value is >1 but the unit is not plural', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: 'rate(5 minute)',
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+      });
+
+      describe('schedule object', () => {
+        it('should throw an error if the value is 1 but the unit is plural', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: {
+                    rate: 'rate(1 days)',
+                  },
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+
+        it('should throw an error if the value is >1 but the unit is not plural', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: {
+                    rate: 'rate(5 minute)',
+                  },
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+      });
+    });
+
+    describe('cron syntax validation: cron(* * * * * 2018)', () => {
+      describe('schedule string', () => {
+        it('should throw an error if number of fields is less than 6', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: 'cron(0 12 * * ?)',
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+
+        it('should throw an error if number of fields is greater than 6', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: 'cron(0 12 * * ? * *)',
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+      });
+
+      describe('schedule object', () => {
+        it('should throw an error if number of fields is less than 6', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: {
+                    rate: 'cron(0 12 * * ?)',
+                  },
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+
+        it('should throw an error if number of fields is greater than 6', () => {
+          awsCompileScheduledEvents.serverless.service.functions = {
+            first: {
+              events: [
+                {
+                  schedule: {
+                    rate: 'cron(0 12 * * ? * *)',
+                  },
+                },
+              ],
+            },
+          };
+
+          expect(() => awsCompileScheduledEvents.compileScheduledEvents()).to.throw(Error);
+        });
+      });
+    });
+
     it('should create corresponding resources when schedule events are given', () => {
       awsCompileScheduledEvents.serverless.service.functions = {
         first: {
@@ -72,6 +204,9 @@ describe('AwsCompileScheduledEvents', () => {
             {
               schedule: 'rate(10 minutes)',
             },
+            {
+              schedule: 'cron(5,35 12 ? * 6l 2002-2005)',
+            },
           ],
         },
       };
@@ -99,6 +234,10 @@ describe('AwsCompileScheduledEvents', () => {
         .provider.compiledCloudFormationTemplate.Resources
         .FirstLambdaPermissionEventsRuleSchedule3.Type
       ).to.equal('AWS::Lambda::Permission');
+      expect(awsCompileScheduledEvents.serverless.service
+        .provider.compiledCloudFormationTemplate.Resources
+        .FirstLambdaPermissionEventsRuleSchedule4.Type
+      ).to.equal('AWS::Lambda::Permission');
     });
 
     it('should respect enabled variable, defaulting to true', () => {