Merge pull request #214 from knu/event_formatting_agent-regexp

Add regexp matching support to EventFormattingAgent.

Andrew Cantino 10 years ago
parent
commit
623e6db1b3
2 changed files with 160 additions and 7 deletions
  1. 119 3
      app/models/agents/event_formatting_agent.rb
  2. 41 4
      spec/models/agents/event_formatting_agent_spec.rb

+ 119 - 3
app/models/agents/event_formatting_agent.rb

@@ -12,6 +12,10 @@ module Agents
12 12
               "celsius": "18",
13 13
               "fahreinheit": "64"
14 14
             },
15
+            "date": {
16
+              "epoch": "1357959600",
17
+              "pretty": "10:00 PM EST on January 11, 2013"
18
+            },
15 19
             "conditions": "Rain showers",
16 20
             "data": "This is some data"
17 21
           }
@@ -33,6 +37,33 @@ module Agents
33 37
             "subject": "This is some data"
34 38
           }
35 39
 
40
+      In `matchers` setting you can perform regular expression matching against contents of events and expand the match data for use in `instructions` setting.  Here is an example:
41
+
42
+          {
43
+            "matchers": [
44
+              {
45
+                "path": "$.date.pretty",
46
+                "regexp": "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)",
47
+                "to": "pretty_date",
48
+              }
49
+            ]
50
+          }
51
+
52
+      This virtually merges the following hash into the original event hash:
53
+
54
+          "pretty_date": {
55
+            "time": "10:00 PM EST",
56
+            "0": "10:00 PM EST on January 11, 2013"
57
+            "1": "10:00 PM EST",
58
+          }
59
+
60
+      So you can use it in `instructions` like this:
61
+
62
+          "instructions": {
63
+            "message": "Today's conditions look like <$.conditions> with a high temperature of <$.high.celsius> degrees Celsius according to the forecast at <$.pretty_date.time>.",
64
+            "subject": "$.data"
65
+          }
66
+
36 67
       If you want to retain original contents of events and only add new keys, then set `mode` to `merge`, otherwise set it to `clean`.
37 68
 
38 69
       By default, the output event will have `agent` and `created_at` fields added as well, reflecting the original Agent type and Event creation time.  You can skip these outputs by setting `skip_agent` and `skip_created_at` to `true`.
@@ -46,8 +77,12 @@ module Agents
46 77
 
47 78
     event_description "User defined"
48 79
 
80
+    after_save :clear_matchers
81
+
49 82
     def validate_options
50 83
       errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? and options['mode'].present? and options['skip_agent'].present? and options['skip_created_at'].present?
84
+
85
+      validate_matchers
51 86
     end
52 87
 
53 88
     def default_options
@@ -56,6 +91,7 @@ module Agents
56 91
           'message' =>  "You received a text <$.text> from <$.fields.from>",
57 92
           'some_other_field' => "Looks like the weather is going to be <$.fields.weather>"
58 93
         },
94
+        'matchers' => [],
59 95
         'mode' => "clean",
60 96
         'skip_agent' => "false",
61 97
         'skip_created_at' => "false"
@@ -68,12 +104,92 @@ module Agents
68 104
 
69 105
     def receive(incoming_events)
70 106
       incoming_events.each do |event|
71
-        formatted_event = options['mode'].to_s == "merge" ? event.payload : {}
72
-        options['instructions'].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, event.payload) }
107
+        formatted_event = options['mode'].to_s == "merge" ? event.payload.dup : {}
108
+        payload = perform_matching(event.payload)
109
+        options['instructions'].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, payload) }
73 110
         formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless options['skip_agent'].to_s == "true"
74 111
         formatted_event['created_at'] = event.created_at unless options['skip_created_at'].to_s == "true"
75 112
         create_event :payload => formatted_event
76 113
       end
77 114
     end
115
+
116
+    private
117
+
118
+    def validate_matchers
119
+      matchers = options['matchers'] or return
120
+
121
+      unless matchers.is_a?(Array)
122
+        errors.add(:base, "matchers must be an array if present")
123
+        return
124
+      end
125
+
126
+      matchers.each do |matcher|
127
+        unless matcher.is_a?(Hash)
128
+          errors.add(:base, "each matcher must be a hash")
129
+          next
130
+        end
131
+
132
+        regexp, path, to = matcher.values_at(*%w[regexp path to])
133
+
134
+        if regexp.present?
135
+          begin
136
+            Regexp.new(regexp)
137
+          rescue
138
+            errors.add(:base, "bad regexp found in matchers: #{regexp}")
139
+          end
140
+        else
141
+          errors.add(:base, "regexp is mandatory for a matcher and must be a string")
142
+        end
143
+
144
+        errors.add(:base, "path is mandatory for a matcher and must be a string") if !path.present?
145
+
146
+        errors.add(:base, "to must be a string if present in a matcher") if to.present? && !to.is_a?(String)
147
+      end
148
+    end
149
+
150
+    def perform_matching(payload)
151
+      matchers.inject(payload.dup) { |hash, matcher|
152
+        matcher[hash]
153
+      }
154
+    end
155
+
156
+    def matchers
157
+      @matchers ||=
158
+        if matchers = options['matchers']
159
+          matchers.map { |matcher|
160
+            regexp, path, to = matcher.values_at(*%w[regexp path to])
161
+            re = Regexp.new(regexp)
162
+            proc { |hash|
163
+              mhash = {}
164
+              value = Utils.value_at(hash, path)
165
+              if value.is_a?(String) && (m = re.match(value))
166
+                m.to_a.each_with_index { |s, i|
167
+                  mhash[i.to_s] = s
168
+                }
169
+                m.names.each do |name|
170
+                  mhash[name] = m[name]
171
+                end if m.respond_to?(:names)
172
+              end
173
+              if to
174
+                case value = hash[to]
175
+                when Hash
176
+                  value.update(mhash)
177
+                else
178
+                  hash[to] = mhash
179
+                end
180
+              else
181
+                hash.update(mhash)
182
+              end
183
+              hash
184
+            }
185
+          }
186
+        else
187
+          []
188
+        end
189
+    end
190
+
191
+    def clear_matchers
192
+      @matchers = nil
193
+    end
78 194
   end
79
-end
195
+end

+ 41 - 4
spec/models/agents/event_formatting_agent_spec.rb

@@ -7,9 +7,16 @@ describe Agents::EventFormattingAgent do
7 7
         :options => {
8 8
             :instructions => {
9 9
                 :message => "Received <$.content.text.*> from <$.content.name> .",
10
-                :subject => "Weather looks like <$.conditions>"
10
+                :subject => "Weather looks like <$.conditions> according to the forecast at <$.pretty_date.time>"
11 11
             },
12 12
             :mode => "clean",
13
+            :matchers => [
14
+                {
15
+                    :path => "$.date.pretty",
16
+                    :regexp => "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)",
17
+                    :to => "pretty_date",
18
+                },
19
+            ],
13 20
             :skip_agent => "false",
14 21
             :skip_created_at => "false"
15 22
         }
@@ -24,7 +31,11 @@ describe Agents::EventFormattingAgent do
24 31
     @event.payload = {
25 32
         :content => {
26 33
             :text => "Some Lorem Ipsum",
27
-            :name => "somevalue"
34
+            :name => "somevalue",
35
+        },
36
+        :date => {
37
+            :epoch => "1357959600",
38
+            :pretty => "10:00 PM EST on January 11, 2013"
28 39
         },
29 40
         :conditions => "someothervalue"
30 41
     }
@@ -61,7 +72,11 @@ describe Agents::EventFormattingAgent do
61 72
     it "should handle JSONPaths in instructions" do
62 73
       @checker.receive([@event])
63 74
       Event.last.payload[:message].should == "Received Some Lorem Ipsum from somevalue ."
64
-      Event.last.payload[:subject].should == "Weather looks like someothervalue"
75
+    end
76
+
77
+    it "should handle matchers and JSONPaths in instructions" do
78
+      @checker.receive([@event])
79
+      Event.last.payload[:subject].should == "Weather looks like someothervalue according to the forecast at 10:00 PM EST"
65 80
     end
66 81
 
67 82
     it "should allow escaping" do
@@ -110,6 +125,28 @@ describe Agents::EventFormattingAgent do
110 125
       @checker.should_not be_valid
111 126
     end
112 127
 
128
+    it "should validate type of matchers" do
129
+      @checker.options[:matchers] = ""
130
+      @checker.should_not be_valid
131
+      @checker.options[:matchers] = {}
132
+      @checker.should_not be_valid
133
+    end
134
+
135
+    it "should validate the contents of matchers" do
136
+      @checker.options[:matchers] = [
137
+        {}
138
+      ]
139
+      @checker.should_not be_valid
140
+      @checker.options[:matchers] = [
141
+        { :regexp => "(not closed", :path => "text" }
142
+      ]
143
+      @checker.should_not be_valid
144
+      @checker.options[:matchers] = [
145
+        { :regexp => "(closed)", :path => "text", :to => "foo" }
146
+      ]
147
+      @checker.should be_valid
148
+    end
149
+
113 150
     it "should validate presence of mode" do
114 151
       @checker.options[:mode] = ""
115 152
       @checker.should_not be_valid
@@ -125,4 +162,4 @@ describe Agents::EventFormattingAgent do
125 162
       @checker.should_not be_valid
126 163
     end
127 164
   end
128
-end
165
+end