├── .gitignore
├── .rspec
├── .travis.yml
├── Gemfile
├── README.md
├── Rakefile
├── _config.yml
├── bin
├── console
├── setup
└── snowly
├── config.ru
├── lib
├── schemas
│ └── snowplow_protocol.json
├── snowly.rb
└── snowly
│ ├── app
│ ├── collector.rb
│ └── views
│ │ ├── index.erb
│ │ └── js.erb
│ ├── each_validator.rb
│ ├── extensions
│ └── custom_dependencies.rb
│ ├── protocol_schema_finder.rb
│ ├── request.rb
│ ├── schema_cache.rb
│ ├── transformer.rb
│ ├── validator.rb
│ ├── validators
│ └── self_desc.rb
│ └── version.rb
├── snowly.gemspec
└── spec
├── fixtures
├── snowly
│ ├── context_test_0
│ │ └── jsonschema
│ │ │ └── 1-0-0
│ ├── context_test_1
│ │ └── jsonschema
│ │ │ └── 1-0-0
│ └── event_test
│ │ └── jsonschema
│ │ └── 1-0-0
├── snowplow_context.json
└── snowplow_ue.json
├── protocol_resolver
└── snowplow_protocol.json
├── snowly
├── app
│ └── collector_spec.rb
├── integration
│ └── validator_tracker_spec.rb
└── unit
│ ├── each_validator_spec.rb
│ ├── protocol_schema_finder_spec.rb
│ ├── request_spec.rb
│ ├── schema_cache_spec.rb
│ ├── transformer_spec.rb
│ └── validator_spec.rb
├── snowly_spec.rb
├── spec_helper.rb
└── support
├── emitter.rb
├── event_assignments.rb
├── server_starter.rb
└── tracker_patch.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /Gemfile.lock
4 | /_yardoc/
5 | /coverage/
6 | /doc/
7 | /pkg/
8 | /spec/reports/
9 | /tmp/
10 | /personal
11 | *.gem
12 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - 2.1.4
4 | before_install: gem install bundler -v 1.11.2
5 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | # Specify your gem's dependencies in snowly.gemspec
4 | gemspec
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Snowly - Snowplow Request Validator
2 |
3 | [](https://github.com/angelim/snowly-heroku)
4 |
5 | Debug your Snowplow implementation locally, without resorting to Snowplow's ETL tasks. It's like Facebook's URL Linter, but for Snowplow.
6 |
7 | Snowly is a minimal [Collector](https://github.com/snowplow/snowplow/wiki/Setting-up-a-collector) implementation intended to run on your development environment. It comes with a comprehensive validation engine, that will point out any schema requirement violations. You can easily validate your event requests before having to emit them to a cloudfront, closure or scala collector.
8 |
9 | ### Motivation
10 |
11 | Snowplow has an excellent toolset, but the first implementation stages can be hard. To run Snowplow properly you have to set up a lot of external dependencies like AWS permissions, Cloudfront distributions and EMR jobs. If you're tweaking the snowplow model to fit your needs or using trackers that don't enforce every requirement, you'll find yourself waiting for the ETL jobs to run in order to validate every implementation change.
12 |
13 | ### Who will get the most from Snowly
14 |
15 | - Teams that need to extend the snowplow model with custom contexts or unstructured events.
16 | - Applications that are constantly evolving their schemas and rules.
17 | - Developers trying out Snowplow before commiting to it.
18 |
19 | ### Features
20 |
21 | With Snowly you can use [Json Schemas](http://spacetelescope.github.io/understanding-json-schema/) to define more expressive event requirements. Aside from assuring that you're fully compatible with the snowplow protocol, you can go even further and extend it with a set of more specific rules. Snowly emulates both cloudfront and closure collectors and will handle its differences automatically.
22 |
23 | Use cases:
24 |
25 | - Validate custom contexts or unstructured event types and required fields.
26 | - Restrict values for any field, like using a custom dictionary for the structured event action field.
27 | - Define requirements based on the content of another field: If __event action__ is 'viewed_product', then __event property__ is required.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | gem install snowly
33 | ```
34 | That will copy a `snowly` executable to your system.
35 |
36 | ### Development Iglu Resolver Path
37 |
38 | If you still don't know anything about [Snowplow's Iglu Resolvers](https://github.com/snowplow/iglu), don't worry. It's pretty straightforward.
39 | Snowly must be able to find your custom context and unstructured event schemas, so you have to set up a local path to store them. You can also choose to use an [external resolver](https://github.com/snowplow/iglu/wiki/Static-repo-setup) pointing to an URL.
40 |
41 | For a local setup, store your schemas under any path accessible by your user(eg: ~/schemas). The only catch is that you must comply with snowplow's naming conventions for your json schemas. Snowplow References:[[1]](https://github.com/snowplow/snowplow/wiki/snowplow-tracker-protocol#custom-contexts),[[2]](https://github.com/snowplow/snowplow/wiki/snowplow-tracker-protocol#310-custom-unstructured-event-tracking)
42 |
43 | You must export an environment variable to make Snowly aware of that path. Add it to your .bash_profile or equivalent.
44 | ```bash
45 | # A local path is the recommended approach, as its easier to evolve your schemas
46 | # without the hassle of setting up an actual resolver.
47 | export DEVELOPMENT_IGLU_RESOLVER_PATH=~/schema
48 |
49 | # or host on a Static Website on Amazon Web Services, for instance.
50 | export DEVELOPMENT_IGLU_RESOLVER_PATH=http://my_resolver_bucket.s3-website-us-east-1.amazonaws.com
51 | ```
52 |
53 | Example:
54 | ```bash
55 | # create a user context
56 | mkdir -p ~/schemas/com.my_company/hero_user/jsonschema
57 | touch ~/schemas/com.my_company/hero_user/jsonschema/1-0-0
58 |
59 | # create a viewed product unstructured event
60 | mkdir -p ~/schemas/com.my_company/viewed_product/jsonschema
61 | touch ~/schemas/com.my_company/viewed_product/jsonschema/1-0-0
62 | ```
63 |
64 | `1-0-0` is the actual json schema file. You will find examples just ahead.
65 |
66 | ## Usage
67 |
68 | Just use `snowly` to start and `snowly -K` to stop. Where allowed, a browser window will open showing the collector's address. Use `snowly --help` for other options.
69 |
70 | ### Output
71 |
72 | When Snowly finds something wrong, it renders a parsed array of requests along with its errors.
73 |
74 | When everything is ok, Snowly delivers the default Snowplow pixel, unless you're using the debug mode.
75 |
76 | If you can't investigate the request's response, you can start Snowly in the foreground and in __Debug Mode__ to output the response to __STDOUT__.
77 | `snowly -d -F`
78 |
79 | Example:
80 | `http://0.0.0.0:5678/i?&e=pv&page=Root%20README&url=http%3A%2F%2Fgithub.com%2Fsnowplow%2Fsnowplow&aid=snowplow&p=i&tv=no-js-0.1.0&eid=ev-id-1`
81 | ```json
82 | [
83 | {
84 | "event_id": "ev-id-1",
85 | "errors": [
86 | "The property '#/platform' value \"i\" did not match one of the following values: web, mob, pc, srv, tv, cnsl, iot in schema snowplow_protocol.json",
87 | "The property '#/' did not contain a required property of 'useragent' in schema snowplow_protocol.json"
88 | ],
89 | "content": {
90 | "event": "pv",
91 | "page_title": "Root README",
92 | "page_url": "http://github.com/snowplow/snowplow",
93 | "app_id": "snowplow",
94 | "platform": "i",
95 | "v_tracker": "no-js-0.1.0",
96 | "event_id": "ev-id-1"
97 | }
98 | }
99 | ]
100 | ```
101 |
102 | If you're using the closure collector and can't see your requests firing up right away, try [manually flushing](https://github.com/snowplow/snowplow/wiki/Ruby-Tracker#54-manual-flushing) or change your emitter's buffer_size(number of events before flusing) to a lower value.
103 |
104 | In debug mode Snowly always renders the parsed contents of your requests. If you're using the javascript tracker, use the __post__ option to be able to read the response in your browser inspector. The js tracker implementation for __get__ requests works by changing an image src, so the inspector hides the response.
105 |
106 | ## JSON Schemas
107 |
108 | JSON Schema is a powerful tool for validating the structure of JSON data. I recommend reading this excellent [Guide](http://spacetelescope.github.io/understanding-json-schema/) from Michael Droettboom to understand all of its capabilities, but you can start with the examples bellow.
109 |
110 | Example:
111 |
112 | A user context. Well... Not just any user can get there.
113 |
114 | __Note that this is not valid json because of the comments.__
115 | ```ruby
116 | # ~/schemas/com.my_company/hero_user/jsonschema/1-0-0
117 | {
118 | # Your schema will also be checked against the Snowplow Self-Desc Schema requirements.
119 | "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
120 | "id": "com.my_company/hero_user/jsonschema/1-0-0", # Give your schema an id for better validation output
121 | "description": "My first Hero Context",
122 | "self": {
123 | "vendor": "com.my_company",
124 | "name": "hero_user",
125 | "format": "jsonschema",
126 | "version": "1-0-0"
127 | },
128 |
129 | "type": "object",
130 | "properties": {
131 | "name": {
132 | "type": "string",
133 | "maxLength": 100 # The hero's name can't be larger than 100 chars
134 | },
135 | "special_powers": {
136 | "type": "array",
137 | "minItems": 2, # This is not just any hero. He must have at least two special powers.
138 | "uniqueItems": true
139 | },
140 | "age": {
141 | "type": "integer", # Strings are not allowed.
142 | "minimum": 15, # The Powerpuff Girls aren't allowed
143 | "maximum": 100 # Wolverine is out
144 | },
145 | "cape_color": {
146 | "type": "string",
147 | "enum": ["red", "blue", "black"] # Vision is not welcome
148 | },
149 | "is_avenger": {
150 | "type": "boolean"
151 | },
152 | "rating": {
153 | "type": "number" # Allows for float values
154 | },
155 | "address": { # cascading objects with their own validation rules.
156 | "type": "object",
157 | "properties": {
158 | "street_name": {
159 | "type": "string"
160 | },
161 | "number": {
162 | "type": "integer"
163 | }
164 | }
165 | }
166 | },
167 | "required": ["name", "age"], # Name and Age must always be present
168 | "custom_dependencies": {
169 | "cape_color": { "name": "superman" } # If the hero's #name is 'superman', #cape_color has to be present.
170 | },
171 | "additionalProperties": false # No other unspecified attributes are allowed.
172 | }
173 | ```
174 |
175 | ### Extending Snowplow's Protocol
176 |
177 | Although the Snowplow's protocol isn't originally defined as a JSON schema, it doesn't hurt to do so and take advantage of all its perks. It's also here for the sake of consistency, right?
178 |
179 | By expressing the protocol in a JSON schema you can extend it to fit your particular needs and enforce domain rules that otherwise wouldn't be available. [Take a look](https://github.com/angelim/snowly/blob/master/lib/schemas/snowplow_protocol.json) at the default schema, derived from the rules specified on the [canonical model](https://github.com/snowplow/snowplow/wiki/canonical-event-model).
180 |
181 | Whenever possible, Snowly will output column names mapped from query string parameters. When two parameters can map to the same content(eg. regular and base64 versions), a common intuitive name is used(eg. contexts and unstruct_event).
182 |
183 | You can override the protocol schema by placing it anywhere inside your Local Resolver Path. As of now, the whole file has to be replaced:
184 |
185 | __It's important to name the file as `snowplow_protocol.json`.__
186 |
187 | One example of useful extensions.
188 | ```ruby
189 | ...
190 | "se_action": {
191 | "type": "string",
192 | "enum": ["product_view", "add_to_cart", "product_zoom"] # Only these values are allowed for an structured event action.
193 | }
194 |
195 | "custom_dependencies": {
196 | "true_tstamp": {"platform": "mob"} # You must submit the true timestamp when the platform is set to "mob".
197 | {
198 | ...
199 | ```
200 |
201 | ## Development
202 |
203 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
204 |
205 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
206 |
207 | ## Contributing
208 |
209 | Bug reports and pull requests are welcome on GitHub at https://github.com/angelim/snowly.
210 |
211 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rspec/core/rake_task"
3 |
4 | RSpec::Core::RakeTask.new(:spec)
5 |
6 | task :default => :spec
7 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-slate
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "snowly"
5 |
6 | # You can add fixtures and/or initialization code here to make experimenting
7 | # with your gem easier. You can also use a different console, if you like.
8 |
9 | # (If you use this, don't forget to add pry to your Gemfile!)
10 | # require "pry"
11 | # Pry.start
12 |
13 | require "irb"
14 | IRB.start
15 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/bin/snowly:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # /snowly
3 | require "bundler/setup"
4 | require 'snowly'
5 | require 'snowly/app/collector'
6 | require 'vegas'
7 | Snowly.debug_mode = true if ARGV.index{ |arg| arg == '-d' or arg == '--debug' }
8 | Vegas::Runner.new(Snowly::App::Collector, 'collector')
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'bundler'
3 | Bundler.setup
4 | require 'snowly'
5 | require 'snowly/app/collector'
6 | run Snowly::App::Collector
--------------------------------------------------------------------------------
/lib/schemas/snowplow_protocol.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-04/schema#",
3 | "id": "snowplow_protocol.json",
4 | "description": "Representation of Snowplow Protocol in JSON Schema format for validation",
5 | "type": "object",
6 | "properties": {
7 | "name_tracker": {
8 | "type": "string",
9 | "maxLength": 128
10 | },
11 | "event_vendor": {
12 | "type": "string",
13 | "maxLength": 1000
14 | },
15 | "app_id": {
16 | "type": "string",
17 | "maxLength": 255
18 | },
19 | "platform": {
20 | "type": "string",
21 | "enum": ["web", "mob", "pc", "srv", "tv", "cnsl", "iot"],
22 | "maxLength": 255
23 | },
24 | "dvce_created_tstamp": {
25 | "type": "integer"
26 | },
27 | "dvce_sent_tstamp": {
28 | "type": "integer"
29 | },
30 | "true_tstamp": {
31 | "type": "integer"
32 | },
33 | "os_timezone": {
34 | "type": "string",
35 | "maxLength": 255
36 | },
37 | "event": {
38 | "type": "string",
39 | "enum": ["se", "ev", "ue", "ad", "tr", "ti", "pv", "pp"]
40 | },
41 | "txn_id": {
42 | "type": "integer"
43 | },
44 | "event_id": {
45 | "type": "string",
46 | "maxLength": 36
47 | },
48 | "v_tracker": {
49 | "type": "string",
50 | "maxLength": 100
51 | },
52 | "domain_userid": {
53 | "type": "string",
54 | "maxLength": 36
55 | },
56 | "network_userid": {
57 | "type": "string",
58 | "maxLength": 38
59 | },
60 | "user_id": {
61 | "type": "string",
62 | "maxLength": 255
63 | },
64 | "domain_sessionidx": {
65 | "type": "integer"
66 | },
67 | "domain_sessionid": {
68 | "type": "string",
69 | "maxLength": 36
70 | },
71 | "user_ipaddress": {
72 | "type": "string",
73 | "maxLength": 45
74 | },
75 | "screen_res_width_x_height": {
76 | "type": "string",
77 | "pattern": "^[0-9]+x[0-9]+$"
78 | },
79 | "page_url": {
80 | "type": "string",
81 | "maxLength": 4096
82 | },
83 | "useragent": {
84 | "type": "string",
85 | "maxLength": 1000
86 | },
87 | "page_title": {
88 | "type": "string",
89 | "maxLength": 2000
90 | },
91 | "page_referer": {
92 | "type": "string",
93 | "maxLength": 4096
94 | },
95 | "user_fingerprint": {
96 | "type": "integer"
97 | },
98 | "br_cookies": {
99 | "type": "string",
100 | "enum": ["1", "0"]
101 | },
102 | "br_lang": {
103 | "type": "string",
104 | "maxLength": 255
105 | },
106 | "br_features_pdf": {
107 | "type": "string",
108 | "enum": ["1", "0"]
109 | },
110 | "br_features_quicktime": {
111 | "type": "string",
112 | "enum": ["1", "0"]
113 | },
114 | "br_features_realplayer": {
115 | "type": "string",
116 | "enum": ["1", "0"]
117 | },
118 | "br_features_windowsmedia": {
119 | "type": "string",
120 | "enum": ["1", "0"]
121 | },
122 | "br_features_director": {
123 | "type": "string",
124 | "enum": ["1", "0"]
125 | },
126 | "br_features_flash": {
127 | "type": "string",
128 | "enum": ["1", "0"]
129 | },
130 | "br_features_java": {
131 | "type": "string",
132 | "enum": ["1", "0"]
133 | },
134 | "br_features_gears": {
135 | "type": "string",
136 | "enum": ["1", "0"]
137 | },
138 | "br_features_silverlight": {
139 | "type": "string",
140 | "enum": ["1", "0"]
141 | },
142 | "br_colordepth": {
143 | "type": "integer"
144 | },
145 | "doc_width_x_height": {
146 | "type": "string",
147 | "pattern": "^[0-9]+x[0-9]+$"
148 | },
149 | "doc_charset": {
150 | "type": "string",
151 | "maxLength": 128
152 | },
153 | "browser_viewport_width_x_height": {
154 | "type": "string",
155 | "pattern": "^[0-9]+x[0-9]+$"
156 | },
157 | "mac_address": {
158 | "type": "string",
159 | "maxLength": 36
160 | },
161 | "pp_xoffset_min": {
162 | "type": "integer"
163 | },
164 | "pp_xoffset_max": {
165 | "type": "integer"
166 | },
167 | "pp_yoffset_min": {
168 | "type": "integer"
169 | },
170 | "pp_yoffset_max": {
171 | "type": "integer"
172 | },
173 | "tr_orderid": {
174 | "type": "string",
175 | "maxLength": 255
176 | },
177 | "tr_affiliation": {
178 | "type": "string",
179 | "maxLength": 255
180 | },
181 | "tr_total": {
182 | "type": "number"
183 | },
184 | "tr_tax": {
185 | "type": "number"
186 | },
187 | "tr_shipping": {
188 | "type": "number"
189 | },
190 | "tr_city": {
191 | "type": "string",
192 | "maxLength": 255
193 | },
194 | "tr_state": {
195 | "type": "string",
196 | "maxLength": 255
197 | },
198 | "tr_country": {
199 | "type": "string",
200 | "maxLength": 255
201 | },
202 | "tr_currency": {
203 | "type": "string",
204 | "maxLength": 255
205 | },
206 | "ti_orderid": {
207 | "type": "string",
208 | "maxLength": 255
209 | },
210 | "ti_sku": {
211 | "type": "string",
212 | "maxLength": 255
213 | },
214 | "ti_name": {
215 | "type": "string",
216 | "maxLength": 255
217 | },
218 | "ti_category": {
219 | "type": "string",
220 | "maxLength": 255
221 | },
222 | "ti_price": {
223 | "type": "number"
224 | },
225 | "ti_quantity": {
226 | "type": "integer"
227 | },
228 | "ti_currency": {
229 | "type": "string",
230 | "maxLength": 255
231 | },
232 | "se_category": {
233 | "type": "string",
234 | "maxLength": 255
235 | },
236 | "se_action": {
237 | "type": "string",
238 | "maxLength": 255
239 | },
240 | "se_label": {
241 | "type": "string",
242 | "maxLength": 255
243 | },
244 | "se_property": {
245 | "type": "string",
246 | "maxLength": 255
247 | },
248 | "se_value": {
249 | "type": "number"
250 | }
251 | },
252 | "required": ["app_id", "platform", "event", "event_id", "v_tracker"],
253 | "custom_dependencies": {
254 | "se_category": { "event": "se" },
255 | "se_action": {"event": "se" },
256 | "tr_orderid": { "event": "tr" },
257 | "tr_total": { "event": "tr" },
258 | "ti_orderid": { "event": "ti" },
259 | "ti_sku": { "event": "ti" },
260 | "ti_quantity": { "event": "ti" },
261 | "ti_price": { "event": "ti" },
262 | "unstruct_event": { "event": "ue"},
263 | "page_url": { "platform": "web" }
264 | }
265 | }
--------------------------------------------------------------------------------
/lib/snowly.rb:
--------------------------------------------------------------------------------
1 | require 'active_support/core_ext'
2 | require "pry"
3 | require 'json'
4 | require 'rack'
5 | require 'json-schema'
6 | require 'snowly/validators/self_desc'
7 | require "snowly/version"
8 | require 'snowly/each_validator'
9 | require 'snowly/validator'
10 | require 'snowly/schema_cache'
11 |
12 | module Snowly
13 | mattr_accessor :development_iglu_resolver_path, :debug_mode, :logger
14 |
15 | @@development_iglu_resolver_path = ENV['DEVELOPMENT_IGLU_RESOLVER_PATH']
16 | @@debug_mode = false
17 | @@logger = Logger.new(STDOUT)
18 |
19 | def self.config
20 | yield self
21 | end
22 | end
23 |
24 |
--------------------------------------------------------------------------------
/lib/snowly/app/collector.rb:
--------------------------------------------------------------------------------
1 | require 'thin'
2 | require 'erb'
3 | require 'base64'
4 | require 'sinatra'
5 | require 'sinatra/reloader' if development?
6 |
7 | module Snowly
8 | module App
9 | class Collector < Sinatra::Base
10 | set :server, 'thin'
11 | GIF = Base64.decode64('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==')
12 | configure :development do
13 | register Sinatra::Reloader
14 | end
15 |
16 | def handle_response(validator)
17 | content_type :json
18 | if validator.validate
19 | status 200
20 | if params[:debug] || Snowly.debug_mode
21 | content = validator.as_hash
22 | Snowly.logger.info content
23 | body(content.to_json)
24 | else
25 | content_type 'image/gif'
26 | Snowly::App::Collector::GIF
27 | end
28 | else
29 | status 422
30 | content = validator.as_hash
31 | Snowly.logger.error content
32 | body (content.to_json)
33 | end
34 | end
35 |
36 | get '/' do
37 | @resolved_schemas = if resolver = Snowly.development_iglu_resolver_path
38 | Dir[File.join(resolver,"/**/*")].select{ |e| File.file? e }
39 | else
40 | nil
41 | end
42 | erb :index
43 | end
44 |
45 | get '/i' do
46 | validator = Snowly::Validator.new request.query_string
47 | handle_response(validator)
48 | end
49 |
50 | get '/js' do
51 | erb :js
52 | end
53 |
54 | post '/com.snowplowanalytics.snowplow/tp2' do
55 | response.headers['Allow'] = 'HEAD,GET,PUT,POST,DELETE,OPTIONS'
56 | response.headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Cache-Control, Accept'
57 | response.headers['Access-Control-Allow-Credentials'] = 'true'
58 | response.headers['Access-Control-Allow-Origin'] = env['HTTP_ORIGIN'] || '*'
59 | payload = JSON.parse request.body.read
60 | validator = Snowly::Validator.new payload, batch: true
61 | handle_response(validator)
62 | end
63 |
64 | options '*' do
65 | response.headers['Allow'] = 'HEAD,GET,PUT,POST,DELETE,OPTIONS'
66 | response.headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Cache-Control, Accept'
67 | response.headers['Access-Control-Allow-Credentials'] = 'true'
68 | response.headers['Access-Control-Allow-Origin'] = env['HTTP_ORIGIN'] || '*'
69 | 200
70 | end
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/snowly/app/views/index.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Snowly - Snowplow Request Validator
5 |
6 |
7 |
8 |
9 |
10 |
19 |
20 |
21 |
Test your snowplow implementation locally!
22 |
Snowly is a minimal collector implementation intended to validate your event tracking requests before emitting them to cloudfront or a closure collector.
23 |
When Snowly finds something wrong, it renders the parsed request along with its errors.
24 |
If everything is ok, Snowly delivers the default Snowplow pixel, unless you're using the debug mode.
25 |
Point your collector URL to <%= request.env['HTTP_HOST'] %>
and have fun!
26 |
27 | See it working!
28 | Event with errors!
29 |
30 | <% unless Snowly.development_iglu_resolver_path %>
31 |
The Local Iglu Resolver Path is missing.
32 | <% end %>
33 |
34 | Use snowly -K
to stop the collector.
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Current Configuration
43 |
version <%= Snowly::VERSION %>
44 |
45 |
46 |
47 |
48 | Configuration
49 | Value
50 | Description
51 |
52 |
53 |
54 |
55 | Debug Mode
56 | <%= Snowly.debug_mode %>
57 | Renders parsed request instead of a pixel. Defaults to false
58 |
59 |
60 | DEVELOPMENT_IGLU_RESOLVER_PATH
61 | <%= Snowly.development_iglu_resolver_path %>
62 | Local path for contexts and unstructured event schemas.
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
Resolved Schemas
74 |
75 | <% if @resolved_schemas and not @resolved_schemas == [] %>
76 |
77 | <% @resolved_schemas.each do |r| %>
78 | <%= r %>
79 | <% end %>
80 |
81 | <% else %>
82 |
No resolved schemas
83 | <% end %>
84 |
85 |
86 |
87 |
88 |
Local Iglu Resolver
89 |
90 | Snowly must be able to find your custom context and unstructured event schemas.
91 | Just like the Resolver you may have already configured for the official ETL tools, Snowly needs a
92 | local path to find your custom schemas. You can store them under any path(eg: ~/schemas)
93 | Inside that folder you must create a resolver compatible structure:
94 | ~/schemas/com.yoursite/my_context/jsonschema/1-0-0
95 | ~/schemas/com.yoursite/my_event/jsonschema/1-0-0
96 | 1-0-0 is the file holding the schema.
97 |
98 |
99 | When you emmit events, use the schema path from the Resolver path
100 | { schema: 'iglu:com.yoursite/my_context/jsonschema/1-0-0', data: !some_schema_data! }
101 |
102 |
103 | Be sure to give your schemas an id , so Snowly can output more helpful validation error messages.
104 |
105 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/lib/snowly/app/views/js.erb:
--------------------------------------------------------------------------------
1 |
3 |
15 |
16 |
17 | Small asynchronous website/webapp examples for snowplow.js
18 |
19 |
20 |
21 |
50 |
51 |
52 |
53 |
131 |
132 |
133 |
134 | Small_asynchronous_examples_for_snowplow.js
135 |
136 | Warning: if your browser's Do Not Track feature is enabled and respectDoNotTrack is enabled, all tracking will be prevented.
137 | If you are viewing the page using a file URL, you must edit the script URL in the Snowplow tag to include an http scheme. Otherwise a file scheme will be inferred and the page will attempt to load sp.js from the local filesystem..
138 |
139 | Press the buttons below to trigger individual tracking events:
140 | Play a mix
141 | Add a product
142 | Add a product missing required field
143 | View a product
144 | Add an ecommerce transaction
145 |
146 | Link
147 |
148 | Ignored link
149 |
150 |
151 |
152 |
--------------------------------------------------------------------------------
/lib/snowly/each_validator.rb:
--------------------------------------------------------------------------------
1 | # Performs the validation for the root attributes and associated contexts and unstructured events.
2 | require 'snowly/request'
3 | require 'snowly/protocol_schema_finder'
4 | require 'snowly/extensions/custom_dependencies'
5 |
6 | module Snowly
7 | class EachValidator
8 | attr_reader :request, :errors
9 |
10 | def initialize(payload)
11 | @request = Request.new payload
12 | @errors = []
13 | end
14 |
15 | # If request is valid
16 | # @return [true, false] if valid
17 | def valid?
18 | @errors == []
19 | end
20 |
21 | # Entry point for validation.
22 | def validate
23 | validate_root
24 | validate_associated
25 | valid?
26 | end
27 |
28 | def protocol_schema
29 | @protocol_schema ||= ProtocolSchemaFinder.new.schema
30 | end
31 |
32 | def as_hash
33 | { event_id: request.as_hash['event_id'], errors: errors, content: request.as_hash }
34 | end
35 |
36 | private
37 |
38 | # @return [Hash] all contexts content and schema definitions
39 | def associated_contexts
40 | load_contexts request.as_hash['contexts']
41 | end
42 |
43 | # @return [Hash] all unstructured events content and schema definitions
44 | def associated_unstruct_event
45 | load_unstruct_event request.as_hash['unstruct_event']
46 | end
47 |
48 | # @return [Array] all associated content
49 | def associated_elements
50 | (Array(associated_contexts) + Array(associated_unstruct_event)).compact
51 | end
52 |
53 | # Performs initial validation for associated contexts and loads their contents and definitions.
54 | # @return [Array]
55 | def load_contexts(hash)
56 | return unless hash
57 | response = []
58 | unless hash['data']
59 | @errors << "All custom contexts must be contain a `data` element" and return
60 | end
61 | schema = SchemaCache.instance[hash['schema']]
62 | response << { content: hash['data'], definition: schema, schema_name: hash['schema'] }
63 | unless hash['data'].is_a? Array
64 | @errors << "All custom contexts must be wrapped in an Array" and return
65 | end
66 | hash['data'].each do |data_item|
67 | schema = SchemaCache.instance[data_item['schema']]
68 | response << { content: data_item['data'], definition: schema, schema_name: data_item['schema'] }
69 | end
70 | response
71 | end
72 |
73 | def register_missing_schema(name)
74 | @errors << "#{ name } wasn't found in any resolvers."
75 | end
76 |
77 | # Performs initial validation for associated unstructured events and loads their contents and definitions.
78 | # @return [Array]
79 | def load_unstruct_event(hash)
80 | return unless hash
81 | response = []
82 | unless hash['data']
83 | @errors << "All custom unstruct event must be contain a `data` element" and return
84 | end
85 | outer_data = hash['data']
86 | inner_data = outer_data['data']
87 | response << { content: outer_data, definition: SchemaCache.instance[hash['schema']], schema_name: hash['schema'] }
88 | response << { content: inner_data, definition: SchemaCache.instance[outer_data['schema']], schema_name: outer_data['schema'] }
89 | response
90 | end
91 |
92 | # Validates associated contexts and unstructured events
93 | def validate_associated
94 | return unless associated_elements
95 | missing_schemas, valid_elements = associated_elements.partition{ |el| el[:definition].blank? }
96 | missing_schemas.each { |element| register_missing_schema(element[:schema_name]) }
97 | valid_elements.each do |element|
98 | this_error = JSON::Validator.fully_validate JSON.parse(element[:definition]), element[:content]
99 | @errors += this_error if this_error.count > 0
100 | end
101 | end
102 |
103 | # Validates root attributes for the events table
104 | def validate_root
105 | this_error = JSON::Validator.fully_validate protocol_schema, request.as_hash
106 | @errors += this_error if this_error.count > 0
107 | end
108 | end
109 | end
--------------------------------------------------------------------------------
/lib/snowly/extensions/custom_dependencies.rb:
--------------------------------------------------------------------------------
1 | # Extended validation to require custom dependencies
2 | # This validation allows the schema designer to define requirements based on other attribute content.
3 | # @example Require apartment number if address type is `apartment`
4 | # {
5 | # 'address': { 'type': string' },
6 | # 'adress_type': { 'type': 'string', 'enum': ['house', 'apartment'] },
7 | # 'apartment_number': { 'type': 'number'},
8 | # 'custom_dependencies': {
9 | # apartment_number: { 'address_type': 'apartment' }
10 | # }
11 | # }
12 | # In this example if the address type is 'house', apartment_number if not required.
13 | # It only becomes a requirement if address type is set to 'apartment'.
14 | require 'json-schema/attribute'
15 |
16 | class CustomDependenciesAttribute < JSON::Schema::Attribute
17 | def self.validate(current_schema, data, fragments, processor, validator, options = {})
18 | return unless data.is_a?(Hash)
19 | current_schema.schema['custom_dependencies'].each do |property, dependency_value|
20 | next unless accept_value?(dependency_value)
21 | case dependency_value
22 | when Array
23 | dependency_value.each do |dependency_hash|
24 | validate_dependency(current_schema, data, property, dependency_hash, fragments, processor, self, options)
25 | end
26 | when Hash
27 | validate_dependency(current_schema, data, property, dependency_value, fragments, processor, self, options)
28 | end
29 | end
30 | end
31 |
32 | def self.validate_dependency(schema, data, property, dependency_hash, fragments, processor, attribute, options)
33 | key, value = Array(dependency_hash).flatten
34 | return unless data[key.to_s] == value.to_s
35 | return if data.has_key?(property.to_s) && not(data[property.to_s].blank?)
36 | message = "The property '#{build_fragment(fragments)}' did not contain a required property of '#{property}' when property '#{key}' is '#{value}'"
37 | validation_error(processor, message, fragments, schema, attribute, options[:record_errors])
38 | end
39 |
40 | def self.accept_value?(value)
41 | value.is_a?(Array) || value.is_a?(Hash)
42 | end
43 | end
44 |
45 | # Registers custom dependencies for Draft-4. This used when evaluating the protocol schema
46 | # snowly/schemas/snowplow_protocol.json
47 | class RootExtendedSchema < JSON::Schema::Validator
48 | def initialize
49 | super
50 | extend_schema_definition("http://json-schema.org/draft-04/schema#")
51 | @attributes["custom_dependencies"] = CustomDependenciesAttribute
52 | @uri = URI.parse("http://json-schema.org/draft-04/schema")
53 | end
54 | JSON::Validator.register_validator(self.new)
55 | end
56 |
57 | # Registers custom dependencies for Snowplot Self-Describing schema. Contexts and Unstructure events derive from it.
58 | class DescExtendedSchema < JSON::Schema::Validator
59 | def initialize
60 | super
61 | extend_schema_definition("http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#")
62 | @attributes["custom_dependencies"] = CustomDependenciesAttribute
63 | @uri = URI.parse("http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#")
64 | end
65 | JSON::Validator.register_validator(self.new)
66 | end
67 |
--------------------------------------------------------------------------------
/lib/snowly/protocol_schema_finder.rb:
--------------------------------------------------------------------------------
1 | module Snowly
2 | class ProtocolSchemaFinder
3 | PROTOCOL_FILE_NAME = 'snowplow_protocol.json'
4 | attr_reader :schema
5 |
6 | def initialize(custom_schema = nil)
7 | @custom_schema = custom_schema
8 | @schema = load_protocol_schema
9 | end
10 |
11 | def find_protocol_schema
12 | return @custom_schema if @custom_schema
13 | if resolver && alternative_protocol_schema
14 | alternative_protocol_schema
15 | else
16 | File.expand_path("../../schemas/#{PROTOCOL_FILE_NAME}", __FILE__)
17 | end
18 | end
19 |
20 | def resolver
21 | Snowly.development_iglu_resolver_path
22 | end
23 |
24 | def alternative_protocol_schema
25 | Dir[File.join(resolver,"/**/*")].select{ |f| File.basename(f) == PROTOCOL_FILE_NAME }[0]
26 | end
27 |
28 | # Loads the protocol schema created to describe snowplow events table attributes
29 | # @return [Hash] parsed schema
30 | def load_protocol_schema
31 | JSON.parse File.read(find_protocol_schema)
32 | end
33 | end
34 | end
--------------------------------------------------------------------------------
/lib/snowly/request.rb:
--------------------------------------------------------------------------------
1 | require 'snowly/transformer'
2 | module Snowly
3 | class Request
4 |
5 | attr_reader :parsed_payload
6 |
7 | def initialize(payload)
8 | @parsed_payload = payload.is_a?(String) ? parse_query(payload) : payload
9 | end
10 |
11 | # Retuns request as json, after transforming parameters into column names
12 | # @return [String] encoded JSON
13 | def as_json
14 | @json ||= as_hash.to_json
15 | end
16 |
17 | # Retuns request as hash, after transforming parameters into column names
18 | # @return [Hash]
19 | def as_hash
20 | @hash ||= Transformer.transform(parsed_payload)
21 | end
22 |
23 | # Returns query parameters as hash
24 | # @return [Hash]
25 | def parse_query(query_string)
26 | @parsed_query ||= Rack::Utils.parse_nested_query(query_string)
27 | end
28 |
29 | end
30 | end
--------------------------------------------------------------------------------
/lib/snowly/schema_cache.rb:
--------------------------------------------------------------------------------
1 | # Caches schemas found during validation so they don't have to be
2 | # retrieved a second time. Also uses the resolvers convert the iglu: location to an actual address (local or remote)/
3 | require 'singleton'
4 | module Snowly
5 | class SchemaCache
6 | include Singleton
7 | SNOWPLOW_IGLU_RESOLVER = 'http://iglucentral.com/schemas/'
8 | @@schema_cache = {}
9 |
10 | # Provides easy access to the schema cache based on its registered key
11 | # @param location [String] Location provided in the schema
12 | # @return [String] Json for schema
13 | def [](location)
14 | @@schema_cache[location] || save_in_cache(location)
15 | end
16 |
17 | # Resets the schema cache
18 | def reset_cache
19 | @@schema_cache = {}
20 | end
21 |
22 | # Accessor to the global cache
23 | def cache
24 | @@schema_cache
25 | end
26 |
27 | private
28 |
29 | def external?(location)
30 | location.match(/^(http|https):\/\//)
31 | end
32 |
33 | # Translate an iglu address to an actual local or remote location
34 | # @param location [String]
35 | # @param resolver [String] local or remote path to look for the schema
36 | # @return [String] Schema's actual location
37 | def resolve(location, resolver)
38 | path = location.sub(/^iglu\:/, '')
39 | File.join resolver, path
40 | end
41 |
42 | # Caches the schema content under its original location name
43 | # @param location [String]
44 | # @return [String] schema content
45 | def save_in_cache(location)
46 | content = begin
47 | full_path = resolve(location, (Snowly.development_iglu_resolver_path || SNOWPLOW_IGLU_RESOLVER) )
48 | external?(full_path) ? Net::HTTP.get(URI(full_path)) : File.read(full_path)
49 | rescue
50 | Snowly.logger.warn "Could't locate #{location} in development resolver. Attemping IgluCentral Server..."
51 | full_path = resolve(location, SNOWPLOW_IGLU_RESOLVER)
52 | begin
53 | result = Net::HTTP.get(URI(full_path))
54 | JSON.load(result) && result
55 | rescue
56 | Snowly.logger.error "#{location} schema is not available in any resolver"
57 | return nil
58 | end
59 | end
60 | @@schema_cache[location] = content
61 | end
62 | end
63 | end
--------------------------------------------------------------------------------
/lib/snowly/transformer.rb:
--------------------------------------------------------------------------------
1 | # Maps query string parameters to column names to provide more helpful references on validations.
2 | module Snowly
3 | module Transformer
4 | module_function
5 | # Boolean fields are mapped to string because the tracker sends '1' or '0' in the query string.
6 | # The actual conversion to boolean happens during the enrichment phase.
7 | MAP = {
8 | "e" => { field: "event", type: 'string' },
9 | "ip" => { field: "user_ipaddress", type: "string" },
10 | "aid" => { field: "app_id", type: "string" },
11 | "p" => { field: "platform", type: "string" },
12 | "tid" => { field: "txn_id", type: "integer" },
13 | "uid" => { field: "user_id", type: "string" },
14 | "duid" => { field: "domain_userid", type: "string" },
15 | "nuid" => { field: "network_userid", type: "string" },
16 | "ua" => { field: "useragent", type: "string" },
17 | "fp" => { field: "user_fingerprint", type: "integer" },
18 | "vid" => { field: "domain_sessionidx", type: "integer" },
19 | "sid" => { field: "domain_sessionid", type: "string" },
20 | "dtm" => { field: "dvce_created_tstamp", type: "integer" },
21 | "ttm" => { field: "true_tstamp", type: "integer" },
22 | "stm" => { field: "dvce_sent_tstamp", type: "integer" },
23 | "tna" => { field: "name_tracker", type: "string" },
24 | "tv" => { field: "v_tracker", type: "string" },
25 | "cv" => { field: "v_collector", type: "string" },
26 | "lang" => { field: "br_lang", type: "string" },
27 | "f_pdf" => { field: "br_features_pdf", type: "string" },
28 | "f_fla" => { field: "br_features_flash", type: "string" },
29 | "f_java" => { field: "br_features_java", type: "string" },
30 | "f_dir" => { field: "br_features_director", type: "string" },
31 | "f_qt" => { field: "br_features_quicktime", type: "string" },
32 | "f_realp" => { field: "br_features_realplayer", type: "string" },
33 | "f_wma" => { field: "br_features_windowsmedia", type: "string" },
34 | "f_gears" => { field: "br_features_gears", type: "string" },
35 | "f_ag" => { field: "br_features_silverlight", type: "string" },
36 | "cookie" => { field: "br_cookies", type: "string" },
37 | "res" => { field: "screen_res_width_x_height", type: "string" },
38 | "cd" => { field: "br_colordepth", type: "integer" },
39 | "tz" => { field: "os_timezone", type: "string" },
40 | "refr" => { field: "page_referrer", type: "string" },
41 | "url" => { field: "page_url", type: "string" },
42 | "page" => { field: "page_title", type: "string" },
43 | "cs" => { field: "doc_charset", type: "string" },
44 | "ds" => { field: "doc_width_x_height", type: "string" },
45 | "vp" => { field: "browser_viewport_width_x_height", type: "string" },
46 | "eid" => { field: "event_id", type: "string" },
47 | "co" => { field: "contexts", type: "json" },
48 | "cx" => { field: "contexts", type: "base64" },
49 | "ev_ca" => { field: "se_category", type: "string" },
50 | "ev_ac" => { field: "se_action", type: "string" },
51 | "ev_la" => { field: "se_label", type: "string" },
52 | "ev_pr" => { field: "se_property", type: "string" },
53 | "ev_va" => { field: "se_value", type: "string" },
54 | "se_ca" => { field: "se_category", type: "string" },
55 | "se_ac" => { field: "se_action", type: "string" },
56 | "se_la" => { field: "se_label", type: "string" },
57 | "se_pr" => { field: "se_property", type: "string" },
58 | "se_va" => { field: "se_value", type: "number" },
59 | "ue_pr" => { field: "unstruct_event", type: "json" },
60 | "ue_px" => { field: "unstruct_event", type: "base64" },
61 | "tr_id" => { field: "tr_orderid", type: "string" },
62 | "tr_af" => { field: "tr_affiliation", type: "string" },
63 | "tr_tt" => { field: "tr_total", type: "number" },
64 | "tr_tx" => { field: "tr_tax", type: "number" },
65 | "tr_sh" => { field: "tr_shipping", type: "number" },
66 | "tr_ci" => { field: "tr_city", type: "string" },
67 | "tr_st" => { field: "tr_state", type: "string" },
68 | "tr_co" => { field: "tr_country", type: "string" },
69 | "ti_id" => { field: "ti_orderid", type: "string" },
70 | "ti_sk" => { field: "ti_sku", type: "string" },
71 | "ti_na" => { field: "ti_name", type: "string" },
72 | "ti_nm" => { field: "ti_name", type: "string" },
73 | "ti_ca" => { field: "ti_category", type: "string" },
74 | "ti_pr" => { field: "ti_price", type: "number" },
75 | "ti_qu" => { field: "ti_quantity", type: "integer" },
76 | "pp_mix" => { field: "pp_xoffset_min", type: "integer" },
77 | "pp_max" => { field: "pp_xoffset_max", type: "integer" },
78 | "pp_miy" => { field: "pp_yoffset_min", type: "integer" },
79 | "pp_may" => { field: "pp_yoffset_max", type: "integer" },
80 | "tr_cu" => { field: "tr_currency", type: "string" },
81 | "ti_cu" => { field: "ti_currency", type: "integer" }
82 | }
83 |
84 | # Transforms the request params into column names
85 | # @param parsed_query [Hash] hash using parameter names for keys
86 | # @return [Hash] hash using column names for keys
87 | def transform(parsed_query)
88 | parsed_query.inject({}) do |all, (key, value)|
89 | if node = MAP[key]
90 | field = node[:field]
91 | all[field] = convert(value, node[:type])
92 | end
93 | all
94 | end
95 | end
96 |
97 | # Tries to cast or parse each value so they can be properly validated by json-schema
98 | # If the casting fails, leaves the value as string and it will be caught by the valication
99 | # @param value [String]
100 | # @param type [String] the intended param type
101 | def convert(value, type)
102 | begin
103 | case type
104 | when 'json' then JSON.parse(value)
105 | when 'base64' then JSON.parse(Base64.decode64(value))
106 | when 'integer' then Integer(value)
107 | when 'number' then Float(value)
108 | else
109 | value.to_s
110 | end
111 | rescue ArgumentError
112 | value.to_s
113 | end
114 | end
115 |
116 | end
117 | end
--------------------------------------------------------------------------------
/lib/snowly/validator.rb:
--------------------------------------------------------------------------------
1 | require 'snowly/each_validator'
2 | module Snowly
3 | class Validator
4 | attr_reader :validators
5 |
6 | def initialize(payload, batch: false)
7 | @validators = if batch
8 | payload['data'].map do |req|
9 | EachValidator.new(req)
10 | end
11 | else
12 | [ EachValidator.new(payload) ]
13 | end
14 | end
15 |
16 | def validate
17 | validators.each(&:validate)
18 | valid?
19 | end
20 |
21 | def valid?
22 | validators.all? { |v| v.valid? }
23 | end
24 |
25 | def as_hash
26 | validators.map(&:as_hash)
27 | end
28 | end
29 | end
--------------------------------------------------------------------------------
/lib/snowly/validators/self_desc.rb:
--------------------------------------------------------------------------------
1 | # Register a validator for the self-describing schema.
2 | # This is required to allow extended validations being attached to it.
3 | require 'json-schema/validators/draft4'
4 | module JSON
5 | class Schema
6 | class SelfDesc < Draft4
7 | URL = "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#"
8 | def initialize
9 | super
10 | @uri = JSON::Util::URI.parse(URL)
11 | end
12 |
13 | JSON::Validator.register_validator(self.new)
14 | end
15 | end
16 | end
--------------------------------------------------------------------------------
/lib/snowly/version.rb:
--------------------------------------------------------------------------------
1 | module Snowly
2 | VERSION = "0.2.4"
3 | end
4 |
--------------------------------------------------------------------------------
/snowly.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'snowly/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "snowly"
8 | spec.version = Snowly::VERSION
9 | spec.authors = ["Alexandre Angelim"]
10 | spec.email = ["angelim@angelim.com.br"]
11 |
12 | spec.summary = %q{Snowplow Request Validator}
13 | spec.description = %q{Snowly is a minimal collector implementation intended to validate your event tracking requests before emitting them to cloudfront or a closure collector.}
14 | spec.homepage = "https://github.com/angelim/snowly"
15 | spec.license = "MIT"
16 |
17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18 | spec.bindir = "bin"
19 | spec.executables << 'snowly'
20 | spec.require_paths = ["lib"]
21 |
22 |
23 | spec.add_dependency 'json-schema', '~> 2.6'
24 | spec.add_dependency 'rack', '~> 1.6'
25 | spec.add_dependency 'activesupport', "~> 3.0"
26 | spec.add_dependency 'sinatra', '~> 1.4'
27 | spec.add_dependency 'sinatra-contrib', '~> 1.4'
28 | spec.add_dependency 'vegas', '~> 0.1'
29 | spec.add_dependency 'thin', '~> 1.7'
30 | spec.add_dependency 'pry-byebug', '~> 3.3'
31 |
32 | spec.add_development_dependency 'bundler', '~> 1.11'
33 | spec.add_development_dependency 'rake', '~> 10.0'
34 | spec.add_development_dependency 'rspec', '~> 3.0'
35 | spec.add_development_dependency 'snowplow-tracker', '~> 0.5'
36 | spec.add_development_dependency 'webmock', '~> 2.0'
37 | spec.add_development_dependency "shotgun", '~> 0.9'
38 | end
39 |
--------------------------------------------------------------------------------
/spec/fixtures/snowly/context_test_0/jsonschema/1-0-0:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
3 | "id": "test_context",
4 | "description": "Test context",
5 | "self": {
6 | "vendor": "snowly",
7 | "name": "test_context",
8 | "format": "jsonschema",
9 | "version": "1-0-0"
10 | },
11 |
12 | "type": "object",
13 | "properties": {
14 | "name": {
15 | "type": "string",
16 | "maxLength": 10
17 | },
18 | "age": {
19 | "type": "integer",
20 | "maximum": 100
21 | }
22 | },
23 | "required": ["name", "age"],
24 | "additionalProperties": false
25 | }
26 |
--------------------------------------------------------------------------------
/spec/fixtures/snowly/context_test_1/jsonschema/1-0-0:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
3 | "id": "test_context_1",
4 | "description": "Context 1",
5 | "self": {
6 | "vendor": "snowly",
7 | "name": "test_context",
8 | "format": "jsonschema",
9 | "version": "1-0-1"
10 | },
11 |
12 | "type": "object",
13 | "properties": {
14 | "street": {
15 | "type": "string",
16 | "maxLength": 10
17 | },
18 | "number": {
19 | "type": "integer",
20 | "maximum": 100
21 | }
22 | },
23 | "custom_dependencies": {
24 | "number": { "street": "home" }
25 | },
26 | "additionalProperties": false
27 | }
28 |
--------------------------------------------------------------------------------
/spec/fixtures/snowly/event_test/jsonschema/1-0-0:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
3 | "id": "unstruct_event",
4 | "description": "Unstructured Event Test",
5 | "self": {
6 | "vendor": "snowly",
7 | "name": "test_ue",
8 | "format": "jsonschema",
9 | "version": "1-0-0"
10 | },
11 | "type": "object",
12 | "properties": {
13 | "category": {
14 | "type": "string",
15 | "maxLength": 50
16 | },
17 | "name": {
18 | "type": "string",
19 | "maxLength": 50
20 | },
21 | "elapsed_time": {
22 | "type": "integer",
23 | "maximum": 2147483647
24 | },
25 | "object_id": {
26 | "type": "string",
27 | "maxLength": 50
28 | },
29 | "string_property_name": {
30 | "type": "string",
31 | "maxLength": 20
32 | },
33 | "string_property_value": {
34 | "type": "string",
35 | "maxLength": 255
36 | },
37 | "number_property_name": {
38 | "type": "string",
39 | "maxLength": 20
40 | },
41 | "number_property_value": {
42 | "type": "number"
43 | }
44 | },
45 | "additionalProperties": false
46 | }
47 |
48 |
--------------------------------------------------------------------------------
/spec/fixtures/snowplow_context.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
3 | "description": "Schema for custom contexts",
4 | "self": {
5 | "vendor": "com.snowplowanalytics.snowplow",
6 | "name": "contexts",
7 | "format": "jsonschema",
8 | "version": "1-0-1"
9 | },
10 |
11 | "type": "array",
12 |
13 | "items": {
14 |
15 | "type": "object",
16 |
17 | "properties": {
18 |
19 | "schema": {
20 | "type": "string",
21 | "pattern": "^iglu:[a-zA-Z0-9-_.]+/[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+/[0-9]+-[0-9]+-[0-9]+$"
22 | },
23 |
24 | "data": {}
25 | },
26 |
27 | "required": ["schema", "data"],
28 | "additionalProperties": false
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/spec/fixtures/snowplow_ue.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
3 | "description": "Schema for a Snowplow unstructured event",
4 | "self": {
5 | "vendor": "com.snowplowanalytics.snowplow",
6 | "name": "unstruct_event",
7 | "format": "jsonschema",
8 | "version": "1-0-0"
9 | },
10 |
11 | "type": "object",
12 |
13 | "properties": {
14 |
15 | "schema": {
16 | "type": "string",
17 | "pattern": "^iglu:[a-zA-Z0-9-_.]+/[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+/[0-9]+-[0-9]+-[0-9]+$"
18 | },
19 |
20 | "data": {}
21 | },
22 |
23 | "required": ["schema", "data"],
24 | "additionalProperties": false
25 | }
26 |
--------------------------------------------------------------------------------
/spec/protocol_resolver/snowplow_protocol.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-04/schema#",
3 | "id": "snowplow_protocol.json",
4 | "description": "Alternative Snowplow Protocol",
5 | "type": "object",
6 | "properties": {
7 | "name_tracker": {
8 | "type": "string",
9 | "maxLength": 1
10 | },
11 | "event_vendor": {
12 | "type": "string",
13 | "maxLength": 1000
14 | },
15 | "app_id": {
16 | "type": "string",
17 | "maxLength": 255
18 | },
19 | "platform": {
20 | "type": "string",
21 | "enum": ["pc"],
22 | "maxLength": 255
23 | },
24 | "dvce_created_tstamp": {
25 | "type": "integer"
26 | },
27 | "dvce_sent_tstamp": {
28 | "type": "integer"
29 | },
30 | "true_tstamp": {
31 | "type": "integer"
32 | },
33 | "os_timezone": {
34 | "type": "string",
35 | "maxLength": 255
36 | },
37 | "event": {
38 | "type": "string",
39 | "enum": ["se", "ev", "ue", "ad", "tr", "ti", "pv", "pp"]
40 | },
41 | "txn_id": {
42 | "type": "integer"
43 | },
44 | "event_id": {
45 | "type": "string",
46 | "maxLength": 36
47 | },
48 | "v_tracker": {
49 | "type": "string",
50 | "maxLength": 100
51 | },
52 | "domain_userid": {
53 | "type": "string",
54 | "maxLength": 36
55 | },
56 | "network_userid": {
57 | "type": "string",
58 | "maxLength": 38
59 | },
60 | "user_id": {
61 | "type": "string",
62 | "maxLength": 255
63 | },
64 | "domain_sessionidx": {
65 | "type": "integer"
66 | },
67 | "domain_sessionid": {
68 | "type": "string",
69 | "maxLength": 36
70 | },
71 | "user_ipaddress": {
72 | "type": "string",
73 | "maxLength": 45
74 | },
75 | "screen_res_width_x_height": {
76 | "type": "string",
77 | "pattern": "^[0-9]+x[0-9]+$"
78 | },
79 | "page_url": {
80 | "type": "string",
81 | "maxLength": 4096
82 | },
83 | "useragent": {
84 | "type": "string",
85 | "maxLength": 1000
86 | },
87 | "page_title": {
88 | "type": "string",
89 | "maxLength": 2000
90 | },
91 | "page_referer": {
92 | "type": "string",
93 | "maxLength": 4096
94 | },
95 | "user_fingerprint": {
96 | "type": "integer"
97 | },
98 | "br_cookies": {
99 | "type": "string",
100 | "enum": ["1", "0"]
101 | },
102 | "br_lang": {
103 | "type": "string",
104 | "maxLength": 255
105 | },
106 | "br_features_pdf": {
107 | "type": "string",
108 | "enum": ["1", "0"]
109 | },
110 | "br_features_quicktime": {
111 | "type": "string",
112 | "enum": ["1", "0"]
113 | },
114 | "br_features_realplayer": {
115 | "type": "string",
116 | "enum": ["1", "0"]
117 | },
118 | "br_features_windowsmedia": {
119 | "type": "string",
120 | "enum": ["1", "0"]
121 | },
122 | "br_features_director": {
123 | "type": "string",
124 | "enum": ["1", "0"]
125 | },
126 | "br_features_flash": {
127 | "type": "string",
128 | "enum": ["1", "0"]
129 | },
130 | "br_features_java": {
131 | "type": "string",
132 | "enum": ["1", "0"]
133 | },
134 | "br_features_gears": {
135 | "type": "string",
136 | "enum": ["1", "0"]
137 | },
138 | "br_features_silverlight": {
139 | "type": "string",
140 | "enum": ["1", "0"]
141 | },
142 | "br_colordepth": {
143 | "type": "integer"
144 | },
145 | "doc_width_x_height": {
146 | "type": "string",
147 | "pattern": "^[0-9]+x[0-9]+$"
148 | },
149 | "doc_charset": {
150 | "type": "string",
151 | "maxLength": 128
152 | },
153 | "browser_viewport_width_x_height": {
154 | "type": "string",
155 | "pattern": "^[0-9]+x[0-9]+$"
156 | },
157 | "mac_address": {
158 | "type": "string",
159 | "maxLength": 36
160 | },
161 | "pp_xoffset_min": {
162 | "type": "integer"
163 | },
164 | "pp_xoffset_max": {
165 | "type": "integer"
166 | },
167 | "pp_yoffset_min": {
168 | "type": "integer"
169 | },
170 | "pp_yoffset_max": {
171 | "type": "integer"
172 | },
173 | "tr_orderid": {
174 | "type": "string",
175 | "maxLength": 255
176 | },
177 | "tr_affiliation": {
178 | "type": "string",
179 | "maxLength": 255
180 | },
181 | "tr_total": {
182 | "type": "number"
183 | },
184 | "tr_tax": {
185 | "type": "number"
186 | },
187 | "tr_shipping": {
188 | "type": "number"
189 | },
190 | "tr_city": {
191 | "type": "string",
192 | "maxLength": 255
193 | },
194 | "tr_state": {
195 | "type": "string",
196 | "maxLength": 255
197 | },
198 | "tr_country": {
199 | "type": "string",
200 | "maxLength": 255
201 | },
202 | "tr_currency": {
203 | "type": "string",
204 | "maxLength": 255
205 | },
206 | "ti_orderid": {
207 | "type": "string",
208 | "maxLength": 255
209 | },
210 | "ti_sku": {
211 | "type": "string",
212 | "maxLength": 255
213 | },
214 | "ti_name": {
215 | "type": "string",
216 | "maxLength": 255
217 | },
218 | "ti_category": {
219 | "type": "string",
220 | "maxLength": 255
221 | },
222 | "ti_price": {
223 | "type": "number"
224 | },
225 | "ti_quantity": {
226 | "type": "integer"
227 | },
228 | "ti_currency": {
229 | "type": "string",
230 | "maxLength": 255
231 | },
232 | "se_category": {
233 | "type": "string",
234 | "maxLength": 255
235 | },
236 | "se_action": {
237 | "type": "string",
238 | "maxLength": 255
239 | },
240 | "se_label": {
241 | "type": "string",
242 | "maxLength": 255
243 | },
244 | "se_property": {
245 | "type": "string",
246 | "maxLength": 255
247 | },
248 | "se_value": {
249 | "type": "number"
250 | }
251 | },
252 | "required": ["app_id", "platform", "event", "event_id", "v_tracker", "useragent"],
253 | "custom_dependencies": {
254 | "se_category": { "event": "se" },
255 | "se_action": {"event": "se" },
256 | "tr_orderid": { "event": "tr" },
257 | "tr_total": { "event": "tr" },
258 | "ti_orderid": { "event": "ti" },
259 | "ti_sku": { "event": "ti" },
260 | "ti_quantity": { "event": "ti" },
261 | "ti_price": { "event": "ti" },
262 | "unstruct_event": { "event": "ue"},
263 | "page_url": { "platform": "web" }
264 | }
265 | }
--------------------------------------------------------------------------------
/spec/snowly/app/collector_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe "Collector" do
4 | describe 'GET #index' do
5 | it "should allow accessing the home page" do
6 | get '/'
7 | expect(last_response).to be_ok
8 | end
9 |
10 | it 'has showcase links' do
11 | get '/'
12 | expect(last_response.body).to include("/i?")
13 | end
14 | context 'when the local resolver path is set' do
15 | before { Snowly.development_iglu_resolver_path = 'myresolverpath' }
16 | it 'shows local iglu resolver' do
17 | get '/'
18 | expect(last_response.body).to include("myresolverpath")
19 | end
20 | end
21 | context 'when no resolver has been set' do
22 | before { Snowly.development_iglu_resolver_path = nil }
23 | it 'shows warning' do
24 | get '/'
25 | expect(last_response.body).to include("The Local Iglu Resolver Path is missing")
26 | end
27 | end
28 | context 'when no schemas were resolved' do
29 | before { Snowly.development_iglu_resolver_path = nil }
30 | it 'shows empty resolver message' do
31 | get '/'
32 | expect(last_response.body).to include("No resolved schemas")
33 | end
34 | end
35 | context 'when there are resolved schemas' do
36 | before { Snowly.development_iglu_resolver_path = File.expand_path("../../../fixtures", __FILE__) }
37 | it 'shows schemas in local resolver' do
38 | get '/'
39 | expect(last_response.body).to include("fixtures/snowly/context_test_0/jsonschema/1-0-0")
40 | end
41 | end
42 | end
43 | describe 'GET /i' do
44 | context 'with a valid request' do
45 | let(:valid_request) { '/i?&e=pv&page=Root%20README&url=http%3A%2F%2Fgithub.com%2Fsnowplow%2Fsnowplow&aid=snowplow&p=web&tv=no-js-0.1.0&ua=firefox&&eid=u2i3' }
46 | it 'responds with 200' do
47 | get valid_request
48 | expect(last_response).to be_ok
49 | end
50 | context 'when in production mode' do
51 | it 'responds with image content type' do
52 | get valid_request
53 | expect(last_response.content_type).to eq 'image/gif'
54 | end
55 | end
56 | context 'when in debug mode' do
57 | before { Snowly.debug_mode = true }
58 | it 'responds with json content type' do
59 | get valid_request
60 | expect(last_response.content_type).to eq 'application/json'
61 | end
62 | end
63 | end
64 | context 'with an invalid request' do
65 | before { Snowly.debug_mode = false }
66 | let(:invalid_request) { '/i?&e=pv&page=Root%20README&url=http%3A%2F%2Fgithub.com%2Fsnowplow%2Fsnowplow&aid=snowplow&p=i&tv=no-js-0.1.0' }
67 | it 'responds with 500' do
68 | get invalid_request
69 | expect(last_response).not_to be_ok
70 | end
71 | it 'renders errors' do
72 | get invalid_request
73 | expect(last_response.body).to include("errors")
74 | end
75 | it 'always responds with json content type' do
76 | get invalid_request
77 | expect(last_response.content_type).to eq 'application/json'
78 | end
79 | end
80 | end
81 | describe 'POST /com.snowplowanalytics.snowplow/tp2' do
82 | let(:url) { '/com.snowplowanalytics.snowplow/tp2' }
83 | context 'with a valid request' do
84 | let(:valid_params) { {"data" => [{ "e"=>"pv", "page"=>"Root README", "url"=>"http://github.com/snowplow/snowplow", "aid"=>"snowplow", "p"=>"web", "tv"=>"no-js-0.1.0", "ua"=>"firefox", "eid"=>"u2i3" }] } }
85 | it 'responds with 200' do
86 | post url, JSON.dump(valid_params)
87 | expect(last_response).to be_ok
88 | end
89 | end
90 | context 'with an invalid request' do
91 | let(:invalid_params) { { "data" => [{ "e"=>"pv", "page"=>"Root README", "url"=>"http://github.com/snowplow/snowplow", "aid"=>"snowplow", "p"=>"web", "tv"=>"no-js-0.1.0" }] } }
92 | it 'responds with 500' do
93 | post url, invalid_params
94 | expect(last_response).not_to be_ok
95 | end
96 | it 'renders errors' do
97 | post url, JSON.dump(invalid_params)
98 | expect(last_response.body).to include("errors")
99 | end
100 | end
101 | end
102 | end
--------------------------------------------------------------------------------
/spec/snowly/integration/validator_tracker_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | describe 'Tracker Validation' do
3 | attr_reader :emitter
4 |
5 | before { emitter.reset_responses! }
6 |
7 | let(:snowplow_subject) do
8 | @snow_subject ||= begin
9 | SnowplowTracker::Subject.new.tap do |snow_subject|
10 | snow_subject.set_platform 'mob'
11 | snow_subject.set_user_id '1'
12 | snow_subject.set_useragent 'user agent'
13 | end
14 | end
15 | end
16 |
17 | let(:tracker) do
18 | SnowplowTracker::Tracker.new(emitter.emitter, snowplow_subject, nil, 'tracker-1')
19 | end
20 |
21 | with_event_definitions do
22 | context 'with two events' do
23 | before(:all) do
24 | @emitter ||= Snowly::Emitter.new(emitter_type: 'closure')
25 | end
26 | context 'when both are valid' do
27 | let(:se1) { Snowly::Transformer.transform(valid_se) }
28 | let(:se2) { Snowly::Transformer.transform(invalid_se) }
29 | it 'returns 200' do
30 | tracker.track_struct_event(*valid_se.values)
31 | tracker.track_struct_event(*valid_se.values)
32 | tracker.flush
33 | expect(response.code).to eq '200'
34 | end
35 | it 'returns empty errors' do
36 | tracker.track_struct_event(*valid_se.values)
37 | tracker.track_struct_event(*valid_se.values)
38 | tracker.flush
39 | expect(JSON.load(response.body).first['errors']).to be_empty
40 | expect(JSON.load(response.body).last['errors']).to be_empty
41 | end
42 | end
43 | context 'when one is invalid' do
44 | it 'returns 422' do
45 | tracker.track_struct_event(*valid_se.values)
46 | tracker.track_struct_event(*invalid_se.values)
47 | tracker.flush
48 | expect(response.code).to eq '422'
49 | end
50 | it 'returns errors' do
51 | tracker.track_struct_event(*valid_se.values)
52 | tracker.track_struct_event(*invalid_se.values)
53 | tracker.flush
54 | expect(JSON.load(response.body).first['errors']).to be_empty
55 | expect(JSON.load(response.body).last['errors'].count).to eq 2
56 | end
57 | end
58 | end
59 |
60 | %w(cloudfront closure).each do |emitter_type|
61 | let(:response) { emitter.responses.first }
62 | let(:body) { JSON.load(response.body).first['content'] }
63 | let(:errors) { JSON.load(response.body).first['errors'] }
64 |
65 | context "with emitter: #{emitter_type}" do
66 | before(:all) do
67 | @emitter ||= Snowly::Emitter.new(emitter_type: emitter_type)
68 | end
69 | context 'with valid structured event request' do
70 | let(:translated_se) { Snowly::Transformer.transform(valid_se) }
71 |
72 | it 'returns 200 for valid structured event request' do
73 | tracker.track_struct_event(*valid_se.values)
74 | tracker.flush
75 | expect(response.code).to eq '200'
76 | end
77 | it 'returns valid content' do
78 | tracker.track_struct_event(*valid_se.values)
79 | tracker.flush
80 | expect(body).to include translated_se
81 | end
82 | end
83 |
84 | context 'with invalid structured event' do
85 | let(:translated_se) { Snowly::Transformer.transform(invalid_se) }
86 | it 'returns 422 for invalid structured event request' do
87 | tracker.track_struct_event(*invalid_se.values)
88 | tracker.flush
89 | expect(response.code).to eq '422'
90 | end
91 | it 'returns errors' do
92 | tracker.track_struct_event(*invalid_se.values)
93 | tracker.flush
94 | expect(errors.count).to eq 2
95 | end
96 | end
97 | context 'with context' do
98 | context 'and context is valid' do
99 | before { stub_request(:get, context_url).to_return(body: context_content, status: 200) }
100 | let(:valid_se_co) { valid_se.merge(co: [valid_co_object]) }
101 | it 'returns 200' do
102 | tracker.track_struct_event(*valid_se_co.values)
103 | tracker.flush
104 | expect(response.code).to eq '200'
105 | end
106 | end
107 | context 'and context is invalid' do
108 | let(:invalid_se_co) { valid_se.merge(co: [invalid_co_object]) }
109 | it 'returns errors' do
110 | tracker.track_struct_event(*invalid_se_co.values)
111 | tracker.flush
112 | expect(errors.count).to eq 2
113 | end
114 | end
115 | end
116 | context 'with unstructured event' do
117 | before { stub_request(:get, context_url).to_return(body: context_content, status: 200) }
118 | context 'when valid' do
119 | it 'returns 200' do
120 | tracker.track_unstruct_event(valid_ue_object)
121 | tracker.flush
122 | expect(response.code).to eq '200'
123 | end
124 | end
125 | context 'when invalid' do
126 | it 'returns errors' do
127 | tracker.track_unstruct_event(invalid_ue_object)
128 | tracker.flush
129 | expect(errors.count).to eq 1
130 | end
131 | end
132 | end
133 | end
134 | end
135 | end
136 |
137 | end
--------------------------------------------------------------------------------
/spec/snowly/unit/each_validator_spec.rb:
--------------------------------------------------------------------------------
1 | require 'yaml'
2 | require 'rack/utils'
3 | require 'spec_helper'
4 |
5 | describe Snowly::EachValidator do
6 | def to_query(hash)
7 | Rack::Utils.build_nested_query(hash)
8 | end
9 | before { Snowly.development_iglu_resolver_path = File.expand_path("../../../fixtures", __FILE__)+"/" }
10 | before { Snowly::SchemaCache.instance.reset_cache }
11 |
12 | let(:validator) { Snowly::EachValidator.new to_query(hash) }
13 |
14 | with_event_definitions do
15 | context 'with mininum required attributes' do
16 | let(:hash) { valid_root.merge(e: 'ad') }
17 | it 'returns true' do
18 | expect(validator.validate).to be true
19 | end
20 | it 'does not set errors' do
21 | validator.validate
22 | expect(validator.errors).to eq []
23 | end
24 | context 'and an alternative more restrictive protocol schema' do
25 | let(:custom_schema) { Snowly::ProtocolSchemaFinder.new(alternative_protocol_schema).schema }
26 | before do
27 | allow_any_instance_of(Snowly::EachValidator)
28 | .to receive(:protocol_schema)
29 | .and_return(custom_schema)
30 | end
31 | it 'set errors' do
32 | validator.validate
33 | puts validator.errors
34 | expect(validator.errors.count).to eq 2
35 | end
36 | end
37 | end
38 | context 'with wrong type for attribute' do
39 | let(:hash) { valid_root.merge(e: 'ad', tid: 'none') }
40 | it 'sets error' do
41 | validator.validate
42 | puts validator.errors
43 | expect(validator.errors.count).to eq 1
44 | end
45 | end
46 | describe 'CustomDependency' do
47 | context 'with missing custom dependency' do
48 | let(:hash) { valid_root.merge(invalid_se) }
49 | it 'sets error' do
50 | validator.validate
51 | puts validator.errors
52 | expect(validator.errors.count).to eq 2
53 | end
54 | end
55 | end
56 | context 'with missing dependency' do
57 | let(:hash) { valid_root.merge(valid_se).tap{|n| n.delete(:p)} }
58 | it 'sets error' do
59 | validator.validate
60 | puts validator.errors
61 | expect(validator.errors.count).to eq 1
62 | end
63 | end
64 | context 'with context' do
65 | before { stub_request(:get, context_url).to_return(body: context_content, status: 200) }
66 | context 'and context is base64 and valid' do
67 | let(:hash) { valid_root.merge(e: 'ad', cx: valid_base64_co) }
68 | it 'returns true' do
69 | expect(validator.validate).to be true
70 | end
71 | end
72 | context 'and context is urlsafe_base64 and valid' do
73 | let(:hash) { valid_root.merge(e: 'ad', cx: valid_urlsafe_base64_co) }
74 | it 'returns true' do
75 | expect(validator.validate).to be true
76 | end
77 | end
78 | context 'and context is not an array' do
79 | let(:hash) { valid_root.merge(e: 'ad', co: not_array_co) }
80 | it 'returns false' do
81 | expect(validator.validate).to be false
82 | end
83 | end
84 | context 'and context is valid' do
85 | let(:hash) { valid_root.merge(e: 'ad', co: valid_co) }
86 | it 'returns true' do
87 | expect(validator.validate).to be true
88 | end
89 | it 'does not set errors' do
90 | validator.validate
91 | expect(validator.errors).to eq []
92 | end
93 | end
94 | context 'and context is invalid' do
95 | let(:hash) { valid_root.merge(e: 'ad', co: invalid_co) }
96 | it 'sets error' do
97 | validator.validate
98 | puts validator.errors
99 | expect(validator.errors.count).to eq 1
100 | end
101 | end
102 | end
103 | context 'with multiple contexts' do
104 | before { stub_request(:get, context_url).to_return(body: context_content, status: 200) }
105 | context "and they're both valid" do
106 | let(:hash) { valid_root.merge(e: 'ad', co: valid_co_multiple) }
107 | it 'returns true' do
108 | expect(validator.validate).to be true
109 | end
110 | end
111 | context 'and one is invalid' do
112 | let(:hash) { valid_root.merge(e: 'ad', co: invalid_co_multiple) }
113 | it 'returns true' do
114 | validator.validate
115 | puts validator.errors
116 | expect(validator.errors.count).to eq 1
117 | end
118 | end
119 | end
120 | context 'with unstruct event' do
121 | before { stub_request(:get, ue_url).to_return(body: ue_content, status: 200) }
122 | context 'and event is valid' do
123 | let(:hash) { valid_root.merge(e: 'ue', ue_pr: valid_ue) }
124 | it 'returns true' do
125 | expect(validator.validate).to be true
126 | end
127 | end
128 | context 'and event is base64 and valid' do
129 | let(:hash) { valid_root.merge(e: 'ue', ue_px: valid_base64_ue) }
130 | it 'returns true' do
131 | expect(validator.validate).to be true
132 | end
133 | end
134 | context 'when event is invalid' do
135 | let(:hash) { valid_root.merge(e: 'ue', ue_pr: invalid_ue) }
136 | it 'returns true' do
137 | validator.validate
138 | puts validator.errors
139 | expect(validator.errors.count).to eq 1
140 | end
141 | end
142 | end
143 | context 'with unstruct event and context' do
144 | before { stub_request(:get, context_url).to_return(body: context_content, status: 200) }
145 | before { stub_request(:get, ue_url).to_return(body: ue_content, status: 200) }
146 | context 'and event is valid' do
147 | let(:hash) { valid_root.merge(e: 'ue', ue_pr: valid_ue, co: valid_co) }
148 | it 'returns true' do
149 | expect(validator.validate).to be true
150 | end
151 | end
152 | context 'and context is invalid' do
153 | let(:hash) { valid_root.merge(e: 'ue', ue_pr: valid_ue, co: invalid_co) }
154 | it 'sets error' do
155 | validator.validate
156 | puts validator.errors
157 | expect(validator.errors.count).to eq 1
158 | end
159 | end
160 | context 'and event is invalid' do
161 | let(:hash) { valid_root.merge(e: 'ue', ue_pr: invalid_ue, co: valid_co) }
162 | it 'returns true' do
163 | validator.validate
164 | puts validator.errors
165 | expect(validator.errors.count).to eq 1
166 | end
167 | end
168 | context 'and event and context are invalid' do
169 | let(:hash) { valid_root.merge(e: 'ue', ue_pr: invalid_ue, co: invalid_co) }
170 | it 'returns true' do
171 | validator.validate
172 | puts validator.errors
173 | expect(validator.errors.count).to eq 2
174 | end
175 | end
176 | end
177 | end
178 | end
--------------------------------------------------------------------------------
/spec/snowly/unit/protocol_schema_finder_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Snowly::ProtocolSchemaFinder do
4 | let(:default_description) { 'Representation of Snowplow Protocol in JSON Schema format for validation' }
5 |
6 | context 'with default protocol schema' do
7 | let(:schema_description) { Snowly::ProtocolSchemaFinder.new.schema['description'] }
8 | it 'returns the correct schema' do
9 | expect(schema_description).to eq default_description
10 | end
11 | end
12 |
13 | context 'with custom protocol schema' do
14 | let(:alternative_description) { 'Alternative Snowplow Protocol' }
15 | let(:alternative_schema_path) { File.expand_path('../../../protocol_resolver/snowplow_protocol.json', __FILE__) }
16 |
17 | context 'when schema is given' do
18 | let(:schema_description) { Snowly::ProtocolSchemaFinder.new(alternative_schema_path).schema['description'] }
19 | it 'returns the correct schema' do
20 | expect(schema_description).to eq alternative_description
21 | end
22 | end
23 |
24 | context 'when schema is in resolver path' do
25 | before do
26 | allow(Snowly).to receive(:development_iglu_resolver_path).and_return(File.expand_path("../../../protocol_resolver", __FILE__)+"/")
27 | end
28 | let(:schema_description) { Snowly::ProtocolSchemaFinder.new.schema['description'] }
29 | it 'returns the correct schema' do
30 | expect(schema_description).to eq alternative_description
31 | end
32 | end
33 | end
34 | end
--------------------------------------------------------------------------------
/spec/snowly/unit/request_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Snowly::Request do
4 | let(:query) { "e=se&aid=app&tna=1.0"}
5 | let(:parsed_hash) { {"e" => 'se', 'aid' => 'app', 'tna' => '1.0'} }
6 | let(:hash) { {"event" => "se", "app_id" => "app", "name_tracker" => "1.0" } }
7 | let(:json) { hash.to_json }
8 | let(:get_request) { Snowly::Request.new query }
9 | let(:post_request) { Snowly::Request.new(parsed_hash) }
10 |
11 | describe '#as_json' do
12 | context 'with querystring' do
13 | it 'returns transformed JSON' do
14 | expect(get_request.as_json).to eq json
15 | end
16 | end
17 | context 'with post payload' do
18 | it 'returns transformed JSON', :focus do
19 | expect(post_request.as_json).to eq json
20 | end
21 | end
22 | end
23 |
24 | describe '#as_hash' do
25 | context 'with querystring' do
26 | it 'returns transformed hash' do
27 | expect(get_request.as_hash).to eq hash
28 | end
29 | end
30 | context 'with post payload' do
31 | it 'returns transformed hash' do
32 | expect(post_request.as_hash).to eq hash
33 | end
34 | end
35 | end
36 |
37 | describe '#parse_query' do
38 | it 'returns hash from query string' do
39 | parsed_payload = get_request.parse_query(query)
40 | expect(parsed_payload).to eq parsed_hash
41 | end
42 | end
43 | end
--------------------------------------------------------------------------------
/spec/snowly/unit/schema_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Snowly::SchemaCache do
4 | it 'is singleton' do
5 | expect(Snowly::SchemaCache.instance).to eq Snowly::SchemaCache.instance
6 | end
7 |
8 | let(:url) { "http://iglucentral.com/schemas/com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0" }
9 | let(:location) { 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0'}
10 | let(:file_content) { File.read(File.expand_path('../../../fixtures/snowly/context_test_0/jsonschema/1-0-0', __FILE__)) }
11 |
12 | context 'without development resolver' do
13 | before { Snowly.development_iglu_resolver_path = nil }
14 | before { Snowly::SchemaCache.instance.reset_cache }
15 | context 'when already loaded' do
16 | before do
17 | stub_request(:get, url).to_return(body: file_content, status: 200)
18 | Snowly::SchemaCache.instance[location]
19 | end
20 | it 'does not save on cache' do
21 | expect(Snowly::SchemaCache.instance).not_to receive(:save_in_cache).with(location)
22 | Snowly::SchemaCache.instance[location]
23 | end
24 | it 'loads schema from cache' do
25 | expect(Snowly::SchemaCache.instance[location]).to eq file_content
26 | end
27 | end
28 | context 'when not yet loaded' do
29 | it 'saves on cache' do
30 | stub_request(:get, url).to_return(body: file_content, status: 200)
31 | expect(Snowly::SchemaCache.instance).to receive(:save_in_cache).with(location)
32 | Snowly::SchemaCache.instance[location]
33 | end
34 | it 'loads and returns schema' do
35 | stub_request(:get, url).to_return(body: file_content, status: 200)
36 | expect(Snowly::SchemaCache.instance[location]).to eq file_content
37 | end
38 | end
39 | end
40 |
41 | context 'with development resolver' do
42 | before { Snowly.development_iglu_resolver_path = File.expand_path("../../../fixtures", __FILE__)+"/" }
43 | before { Snowly::SchemaCache.instance.reset_cache }
44 | let(:location) { 'iglu:snowly/context_test_0/jsonschema/1-0-0'}
45 | context 'when schema is found' do
46 | context 'when already loaded' do
47 | before { Snowly::SchemaCache.instance[location] }
48 | it 'does not save on cache' do
49 | expect(Snowly::SchemaCache.instance).not_to receive(:save_in_cache).with(location)
50 | Snowly::SchemaCache.instance[location]
51 | end
52 | it 'loads schema from cache' do
53 | expect(Snowly::SchemaCache.instance[location]).to eq file_content
54 | end
55 | end
56 | context 'when not yet loaded' do
57 | it 'saves on cache' do
58 | expect(Snowly::SchemaCache.instance).to receive(:save_in_cache).with(location)
59 | Snowly::SchemaCache.instance[location]
60 | end
61 | it 'loads and returns schema' do
62 | expect(Snowly::SchemaCache.instance[location]).to eq file_content
63 | end
64 | end
65 | context 'when using external resolver' do
66 | before { Snowly.development_iglu_resolver_path = "http://snowly"}
67 | let(:url) { "http://snowly/context_test_0/jsonschema/1-0-0" }
68 | it 'saves on cache' do
69 | stub_request(:get, url).to_return(body: file_content, status: 200)
70 | expect(Snowly::SchemaCache.instance).to receive(:save_in_cache).with(location)
71 | Snowly::SchemaCache.instance[location]
72 | end
73 | end
74 | end
75 | context 'when schema is not in development resolver' do
76 | let(:location) { 'iglu:snowly/context_test_0/jsonschema/2-0-0'}
77 | it 'warns about not finding schema in local resolver', :focus do
78 | stub_request(:get, url).to_return(body: file_content, status: 200)
79 | expect(Snowly.logger).to receive(:warn)
80 | Snowly::SchemaCache.instance[location]
81 | end
82 | it 'loads schema from iglucentral' do
83 | expect(Snowly::SchemaCache.instance).to receive(:save_in_cache).with(location)
84 | Snowly::SchemaCache.instance[location]
85 | end
86 | end
87 | context 'when schema is not in any resolver' do
88 | let(:location) { 'iglu:snowly/context_test_0/jsonschema/2-0-0'}
89 | it 'logs an error', :focus do
90 | stub_request(:get, url).to_return(body: 'not json', status: 200)
91 | expect(Snowly.logger).to receive(:error)
92 | Snowly::SchemaCache.instance[location]
93 | end
94 | it 'sets schema cache to nil' do
95 | expect(Snowly::SchemaCache.instance[location]).to be_nil
96 | end
97 | end
98 | end
99 | end
100 |
101 |
--------------------------------------------------------------------------------
/spec/snowly/unit/transformer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 | describe Snowly::Transformer do
3 | let(:source) do
4 | {
5 | "e" => "e",
6 | "ip" => "ip",
7 | "aid" => "aid",
8 | "p" => "p",
9 | "tid" => "tid",
10 | "uid" => "uid",
11 | "duid" => "duid",
12 | "nuid" => "nuid",
13 | "ua" => "ua",
14 | "fp" => "fp",
15 | "vid" => "vid",
16 | "sid" => "sid",
17 | "dtm" => "dtm",
18 | "ttm" => "ttm",
19 | "stm" => "stm",
20 | "tna" => "tna",
21 | "tv" => "tv",
22 | "cv" => "cv",
23 | "lang" => "lang",
24 | "f_pdf" => "f_pdf",
25 | "f_fla" => "f_fla",
26 | "f_java" => "f_java",
27 | "f_dir" => "f_dir",
28 | "f_qt" => "f_qt",
29 | "f_realp" => "f_realp",
30 | "f_wma" => "f_wma",
31 | "f_gears" => "f_gears",
32 | "f_ag" => "f_ag",
33 | "cookie" => "cookie",
34 | "res" => "res",
35 | "cd" => "cd",
36 | "tz" => "tz",
37 | "refr" => "refr",
38 | "url" => "url",
39 | "page" => "page",
40 | "cs" => "cs",
41 | "ds" => "ds",
42 | "vp" => "vp",
43 | "eid" => "eid",
44 | "ev_ca" => "ev_ca",
45 | "ev_ac" => "ev_ac",
46 | "ev_la" => "ev_la",
47 | "ev_pr" => "ev_pr",
48 | "ev_va" => "ev_va",
49 | "se_ca" => "se_ca",
50 | "se_ac" => "se_ac",
51 | "se_la" => "se_la",
52 | "se_pr" => "se_pr",
53 | "se_va" => "se_va",
54 | "tr_id" => "tr_id",
55 | "tr_af" => "tr_af",
56 | "tr_tt" => "tr_tt",
57 | "tr_tx" => "tr_tx",
58 | "tr_sh" => "tr_sh",
59 | "tr_ci" => "tr_ci",
60 | "tr_st" => "tr_st",
61 | "tr_co" => "tr_co",
62 | "ti_id" => "ti_id",
63 | "ti_sk" => "ti_sk",
64 | "ti_na" => "ti_na",
65 | "ti_nm" => "ti_nm",
66 | "ti_ca" => "ti_ca",
67 | "ti_pr" => "ti_pr",
68 | "ti_qu" => "ti_qu",
69 | "pp_mix" => "pp_mix",
70 | "pp_max" => "pp_max",
71 | "pp_miy" => "pp_miy",
72 | "pp_may" => "pp_may",
73 | "tr_cu" => "tr_cu",
74 | "ti_cu" => "ti_cu"
75 | }
76 | end
77 | let(:unencoded) do
78 | source.merge('co' => { name:'co' }.to_json, "ue_pr" => { name:'ue_pr' }.to_json)
79 | end
80 | let(:encoded) do
81 | source.merge('cx' => Base64.encode64({ name:'cx' }.to_json), "ue_px" => Base64.encode64({ name:'ue_px' }.to_json))
82 | end
83 | let(:translated) do
84 | {
85 | "event" => "e",
86 | "user_ipaddress" => "ip",
87 | "app_id" => "aid",
88 | "platform" => "p",
89 | "txn_id" => "tid",
90 | "user_id" => "uid",
91 | "domain_userid" => "duid",
92 | "network_userid" => "nuid",
93 | "useragent" => "ua",
94 | "user_fingerprint" => "fp",
95 | "domain_sessionidx" => "vid",
96 | "domain_sessionid" => "sid",
97 | "dvce_created_tstamp" => "dtm",
98 | "true_tstamp" => "ttm",
99 | "dvce_sent_tstamp" => "stm",
100 | "name_tracker" => "tna",
101 | "v_tracker" => "tv",
102 | "v_collector" => "cv",
103 | "br_lang" => "lang",
104 | "br_features_pdf" => "f_pdf",
105 | "br_features_flash" => "f_fla",
106 | "br_features_java" => "f_java",
107 | "br_features_director" => "f_dir",
108 | "br_features_quicktime" => "f_qt",
109 | "br_features_realplayer" => "f_realp",
110 | "br_features_windowsmedia" => "f_wma",
111 | "br_features_gears" => "f_gears",
112 | "br_features_silverlight" => "f_ag",
113 | "br_cookies" => "cookie",
114 | "screen_res_width_x_height" => "res",
115 | "br_colordepth" => "cd",
116 | "os_timezone" => "tz",
117 | "page_referrer" => "refr",
118 | "page_url" => "url",
119 | "page_title" => "page",
120 | "doc_charset" => "cs",
121 | "doc_width_x_height" => "ds",
122 | "browser_viewport_width_x_height" => "vp",
123 | "event_id" => "eid",
124 | "se_category" => "ev_ca",
125 | "se_action" => "ev_ac",
126 | "se_label" => "ev_la",
127 | "se_property" => "ev_pr",
128 | "se_value" => "ev_va",
129 | "se_category" => "se_ca",
130 | "se_action" => "se_ac",
131 | "se_label" => "se_la",
132 | "se_property" => "se_pr",
133 | "se_value" => "se_va",
134 | "tr_orderid" => "tr_id",
135 | "tr_affiliation" => "tr_af",
136 | "tr_total" => "tr_tt",
137 | "tr_tax" => "tr_tx",
138 | "tr_shipping" => "tr_sh",
139 | "tr_city" => "tr_ci",
140 | "tr_state" => "tr_st",
141 | "tr_country" => "tr_co",
142 | "ti_orderid" => "ti_id",
143 | "ti_sku" => "ti_sk",
144 | "ti_name" => "ti_na",
145 | "ti_name" => "ti_nm",
146 | "ti_category" => "ti_ca",
147 | "ti_price" => "ti_pr",
148 | "ti_quantity" => "ti_qu",
149 | "pp_xoffset_min" => "pp_mix",
150 | "pp_xoffset_max" => "pp_max",
151 | "pp_yoffset_min" => "pp_miy",
152 | "pp_yoffset_max" => "pp_may",
153 | "tr_currency" => "tr_cu",
154 | "ti_currency" => "ti_cu"
155 | }
156 | end
157 | let(:tunencoded) do
158 | translated.merge("contexts" => {'name' => 'co'}, "unstruct_event" => {'name' => 'ue_pr'})
159 | end
160 | let(:tencoded) do
161 | translated.merge("contexts" => {'name' => 'cx'}, "unstruct_event" => {'name' => 'ue_px'})
162 | end
163 |
164 | describe '.translate' do
165 | it 'maps query string keys to snowplow fields' do
166 | expect(subject.transform(source)).to eq translated
167 | end
168 | context 'with unencoded context and unstructured event' do
169 | it 'maps query string keys to unencoded contexts and ue' do
170 | expect(subject.transform(unencoded)).to eq tunencoded
171 | end
172 | end
173 | context 'with encoded context and unstructured event' do
174 | it 'maps query string keys to encoded contexts and ue' do
175 | expect(subject.transform(encoded)).to eq tencoded
176 | end
177 | end
178 |
179 | context 'with unknown keys' do
180 | let(:source_with_unknown) { source.merge("unknown" => "unknown")}
181 | it 'ignores unknown keys' do
182 | expect(subject.transform(source_with_unknown)).to eq translated
183 | end
184 | end
185 | end
186 | describe '.convert' do
187 | context 'with string type' do
188 | let(:result) { subject.convert('valid', 'string') }
189 | it 'returns string' do
190 | expect(result).to be_a String
191 | end
192 | end
193 | context 'with integer type' do
194 | context 'and value is integer' do
195 | let(:result) { subject.convert('1', 'integer') }
196 | it 'returns integer' do
197 | expect(result).to eq 1
198 | end
199 | end
200 | context 'and value is not integer' do
201 | let(:result) { subject.convert('abc', 'integer') }
202 | it 'returns string' do
203 | expect(result).to eq 'abc'
204 | end
205 | end
206 | end
207 | context 'with float type' do
208 | let(:result) { subject.convert('1.99', 'number') }
209 | context 'and value is float' do
210 | it 'returns float' do
211 | expect(result).to eq 1.99
212 | end
213 | end
214 | context 'and value is not float' do
215 | let(:result) { subject.convert('abc', 'number') }
216 | it 'returns string' do
217 | expect(result).to eq 'abc'
218 | end
219 | end
220 | end
221 | end
222 | end
223 |
--------------------------------------------------------------------------------
/spec/snowly/unit/validator_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Snowly::Validator do
4 | let(:invalid_request) { { "e"=>"pv", "page"=>"Root README", "url"=>"http://github.com/snowplow/snowplow", "aid"=>"snowplow", "p"=>"web", "tv"=>"no-js-0.1.0" } }
5 | let(:valid_request) { { "e"=>"pv", "page"=>"Root README", "url"=>"http://github.com/snowplow/snowplow", "aid"=>"snowplow", "p"=>"web", "tv"=>"no-js-0.1.0", "ua"=>"firefox", "eid"=>"u2i3" } }
6 | let(:valid_params) { {"data" => [valid_request, valid_request] } }
7 | let(:invalid_params) { {"data" => [valid_request, invalid_request] } }
8 | describe '#validate' do
9 | it 'calls validate for each request' do
10 | batch = Snowly::Validator.new(valid_params, batch: true)
11 | batch.validators.each do |validator|
12 | expect(validator).to receive(:validate)
13 | end
14 | batch.validate
15 | end
16 | it 'returns whether all requests are valid' do
17 | expect(Snowly::Validator.new(valid_params, batch: true).validate).to be true
18 | end
19 | end
20 | describe 'valid?' do
21 | context 'when all requests are valid' do
22 | it 'returns true' do
23 | batch = Snowly::Validator.new(valid_params, batch: true)
24 | batch.validate
25 | expect(batch).to be_valid
26 | end
27 | end
28 | context 'when one of the requests is invalid' do
29 | it 'returns false' do
30 | batch = Snowly::Validator.new(invalid_params, batch: true)
31 | batch.validate
32 | expect(batch).not_to be_valid
33 | end
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/spec/snowly_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Snowly do
4 | it 'has a version number' do
5 | expect(Snowly::VERSION).not_to be nil
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # Snowplow uses questionable regex to validate the iglu location as it doesn't scape -
2 | # Setting verbose to nil supresses a lot of warnings emmited by the Regexp class.
3 | $VERBOSE = nil
4 |
5 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
6 | require 'snowly'
7 | require 'snowly/app/collector'
8 | require 'snowplow-tracker'
9 | require 'pry'
10 | require 'webmock/rspec'
11 | require 'rack/test'
12 |
13 | Dir[File.join(Dir.pwd,"spec/support/**/*.rb")].each {|f| require f}
14 | WebMock.allow_net_connect!
15 | TestServer.new.start_sinatra_server
16 |
17 | module RSpecMixin
18 | include Rack::Test::Methods
19 | def app() Snowly::App::Collector end
20 | end
21 |
22 | RSpec.configure do |config|
23 | config.include RSpecMixin
24 | config.extend EventAssignments
25 | end
--------------------------------------------------------------------------------
/spec/support/emitter.rb:
--------------------------------------------------------------------------------
1 | module Snowly
2 | class EmitterError < StandardError
3 | attr_reader :failed_events
4 |
5 | def initialize(failed_events)
6 | @failed_events = failed_events
7 | end
8 | end
9 | class Emitter
10 | delegate :responses, :reset_responses!, to: :emitter
11 | # include Singleton
12 | COLLECTOR_URL = 'localhost:4567'
13 |
14 | attr_reader :emitter
15 |
16 | def initialize(emitter_type: 'cloudfront', buffer_size: 2, thread_count: 1)
17 | @emitter_type = emitter_type
18 | @buffer_size = buffer_size
19 | @thread_count = thread_count
20 | @emitter = emitter_klass.new(COLLECTOR_URL, emitter_options)
21 | end
22 |
23 | def emitter_options
24 | {
25 | protocol: 'http',
26 | method: http_method,
27 | buffer_size: buffer_size,
28 | thread_count: thread_count,
29 | on_success: lambda { |success_count|
30 | handle_on_success(success_count)
31 | },
32 | on_failure: lambda { |success_count, failed_events|
33 | handle_on_failure(success_count, failed_events)
34 | }
35 | }
36 | end
37 |
38 | def self.should_handle_failure=(value)
39 | @@should_handle_failure = value
40 | end
41 |
42 | def emitter_klass
43 | cloudfront? ? SnowplowTracker::Emitter : SnowplowTracker::AsyncEmitter
44 | end
45 |
46 | def cloudfront?
47 | @emitter_type == 'cloudfront'
48 | end
49 |
50 | def buffer_size
51 | cloudfront? ? 0 : @buffer_size
52 | end
53 |
54 | def thread_count
55 | cloudfront? ? 1 : @thread_count
56 | end
57 |
58 | def http_method
59 | cloudfront? ? 'get' : 'post'
60 | end
61 |
62 | private
63 |
64 | def handle_on_failure(success_count, failed_events)
65 | puts 'failed'
66 | end
67 |
68 | def handle_on_success(success_count)
69 | puts 'success'
70 | end
71 | end
72 | end
73 |
74 | SnowplowTracker::LOGGER.level = Logger::DEBUG if Snowly.debug_mode
--------------------------------------------------------------------------------
/spec/support/event_assignments.rb:
--------------------------------------------------------------------------------
1 | module EventAssignments
2 | def with_event_definitions(&block)
3 | context "importing event definitions" do
4 |
5 | let(:context_url) { "http://iglucentral.com/schemas/com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0" }
6 | let(:ue_url) { "http://iglucentral.com/schemas/com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0" }
7 | let(:context_location) { 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0'}
8 | let(:ue_location) { 'iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0'}
9 | let(:context_content) { File.read(File.expand_path('../../fixtures/snowplow_context.json', __FILE__)) }
10 | let(:ue_content) { File.read(File.expand_path('../../fixtures/snowplow_ue.json', __FILE__)) }
11 | let(:alternative_protocol_schema) { File.expand_path('../../protocol_resolver/snowplow_protocol.json', __FILE__) }
12 |
13 | let(:valid_root) do
14 | {
15 | uid: 1,
16 | aid: 'app',
17 | tna: '1.0',
18 | dtm: Time.now.to_i,
19 | e: 'se',
20 | ua: 'user agent',
21 | p: 'mob',
22 | eid: 'eventid',
23 | tv: 'tracker-1'
24 | }
25 | end
26 |
27 | let(:valid_se) do
28 | {
29 | 'se_ca' => 'web',
30 | 'se_ac' => 'click',
31 | 'se_la' => 'label',
32 | 'se_pr' => 'property',
33 | 'se_va' => 1
34 | }
35 | end
36 |
37 | let(:invalid_se) do
38 | valid_se.merge('se_ca' => '', 'se_ac' => '')
39 | end
40 |
41 | let(:valid_co) do
42 | {
43 | schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0',
44 | data: [ valid_co_data ]
45 | }.to_json
46 | end
47 |
48 |
49 |
50 | let(:invalid_co) do
51 | {
52 | schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0',
53 | data: [ valid_co_data.deep_merge(data: {age: 1000}) ]
54 | }.to_json
55 | end
56 |
57 | let(:not_array_co) do
58 | {
59 | schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0',
60 | data: valid_co_data
61 | }.to_json
62 | end
63 |
64 | let(:valid_co_data) do
65 | {
66 | schema: 'iglu:snowly/context_test_0/jsonschema/1-0-0',
67 | data: {
68 | name: 'name',
69 | age: 10
70 | }
71 | }
72 | end
73 |
74 | let(:valid_co_object) do
75 | SnowplowTracker::SelfDescribingJson.new(valid_co_data[:schema],valid_co_data[:data])
76 | end
77 |
78 | let(:invalid_co_object) do
79 | SnowplowTracker::SelfDescribingJson.new(valid_co_data[:schema], {age: 1000})
80 | end
81 |
82 | let(:valid_co_data_1) do
83 | {
84 | schema: 'iglu:snowly/context_test_1/jsonschema/1-0-0',
85 | data: {
86 | street: 'street'
87 | }
88 | }
89 | end
90 |
91 | let(:valid_co_multiple) do
92 | {
93 | schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0',
94 | data: [ valid_co_data, valid_co_data_1]
95 | }.to_json
96 | end
97 |
98 | let(:invalid_co_multiple) do
99 | {
100 | schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0',
101 | data: [ valid_co_data, valid_co_data_1.deep_merge(data: {street: 'home'}) ]
102 | }.to_json
103 | end
104 |
105 | let(:non_array_cx) { Base64.strict_encode64(not_array_co) }
106 | let(:valid_base64_co) { Base64.strict_encode64(valid_co) }
107 | let(:valid_urlsafe_base64_co) { Base64.urlsafe_encode64(valid_co) }
108 | let(:valid_ue_data) do
109 | {
110 | schema: "iglu:snowly/event_test/jsonschema/1-0-0",
111 | data: {
112 | category: 'reading',
113 | name: 'view',
114 | elapsed_time: 10,
115 | object_id: "oid",
116 | number_property_name: 'nprop',
117 | number_property_value: 1,
118 | string_property_name: 'sprop',
119 | string_property_value: 'sval'
120 | }
121 | }
122 | end
123 |
124 | let(:valid_ue_object) do
125 | SnowplowTracker::SelfDescribingJson.new(valid_ue_data[:schema], valid_ue_data[:data])
126 | end
127 | let(:invalid_ue_object) do
128 | SnowplowTracker::SelfDescribingJson.new(valid_ue_data[:schema], valid_ue_data[:data].merge(elapsed_time: 'none'))
129 | end
130 |
131 | let(:valid_ue) do
132 | {
133 | schema: 'iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0',
134 | data: valid_ue_data
135 | }.to_json
136 | end
137 |
138 | let(:invalid_ue) do
139 | {
140 | schema: 'iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0',
141 | data: valid_ue_data.deep_merge(data: { elapsed_time: 'none'} )
142 | }.to_json
143 | end
144 |
145 | let(:valid_base64_ue) { Base64.strict_encode64(valid_ue) }
146 |
147 | instance_eval &block
148 | end
149 |
150 | end
151 |
152 | end
--------------------------------------------------------------------------------
/spec/support/server_starter.rb:
--------------------------------------------------------------------------------
1 | class TestServer
2 |
3 | def initialize(collector_url = nil)
4 | @collector_url = collector_url || "http://localhost:4567"
5 | end
6 |
7 | def sinatra_startup_timeout
8 | @sinatra_startup_timeout || 15
9 | end
10 |
11 | def wait_until_sinatra_starts
12 | (sinatra_startup_timeout * 10).times do
13 | break if sinatra_running?
14 | sleep(0.1)
15 | end
16 | raise Timeout::Error, "Sinatra failed to start after #{sinatra_startup_timeout} seconds" unless sinatra_running?
17 | end
18 |
19 | def sinatra_running?
20 | begin
21 | ping_uri = URI.parse(@collector_url)
22 | Net::HTTP.get(ping_uri)
23 | true
24 | rescue
25 | false
26 | end
27 | end
28 |
29 | def start_sinatra_server
30 | Snowly.development_iglu_resolver_path = File.expand_path("../../fixtures", __FILE__)+"/"
31 | WebMock.allow_net_connect!
32 | unless sinatra_running?
33 | pid = fork do
34 | Snowly.debug_mode = true
35 | Snowly::App::Collector.run!
36 | end
37 |
38 | at_exit do
39 | WebMock.disable_net_connect!
40 | Process.kill("TERM", pid)
41 | end
42 |
43 | wait_until_sinatra_starts
44 | end
45 | end
46 | end
--------------------------------------------------------------------------------
/spec/support/tracker_patch.rb:
--------------------------------------------------------------------------------
1 | SnowplowTracker::Emitter.class_eval do
2 | attr_reader :responses
3 | alias_method :original_http_get, :http_get
4 | alias_method :original_http_post, :http_post
5 |
6 | def reset_responses!
7 | @responses = []
8 | end
9 |
10 | def http_get(*args)
11 | original_http_get(*args).tap do |response|
12 | @responses ||= []
13 | @responses << response
14 | end
15 | end
16 | def http_post(*args)
17 | response = original_http_post(*args).tap do |response|
18 | @responses ||= []
19 | @responses << response
20 | end
21 | end
22 | end
--------------------------------------------------------------------------------