diff --git a/lib/heap/client.rb b/lib/heap/client.rb index e1d16ba..4c03775 100644 --- a/lib/heap/client.rb +++ b/lib/heap/client.rb @@ -106,7 +106,8 @@ def add_user_properties(identity, properties) # each value must be a Number or String with fewer than 1024 characters # @return [HeapAPI::Client] self # @see https://heapanalytics.com/docs/server-side#track - def track(event, identity, properties = nil) + def track(event, identity, properties = nil, options = {}) + options ||= {} ensure_valid_app_id! event_name = event.to_s @@ -123,6 +124,14 @@ def track(event, identity, properties = nil) ensure_valid_properties! properties end + unless options[:timestamp].nil? + body[:timestamp] = ensure_valid_timestamp!(options[:timestamp]) + end + + unless options[:idempotency_key].nil? + body[:idempotency_key] = ensure_valid_idempotency_key!(options[:idempotency_key]) + end + response = connection.post '/api/track', body, 'User-Agent' => user_agent raise HeapAPI::ApiError.new(response) unless response.success? diff --git a/lib/heap/validations.rb b/lib/heap/validations.rb index e409641..0c6d7a9 100644 --- a/lib/heap/validations.rb +++ b/lib/heap/validations.rb @@ -1,4 +1,5 @@ # Internal methods used to validate API input. +require 'date' class HeapAPI::Client # Makes sure that the client's app_id property is set. @@ -43,6 +44,45 @@ def ensure_valid_identity!(identity) end private :ensure_valid_identity! + # Validates timestamp, making sure it's a valid iso8061 timestamp or + # number of milliseconds since epoch + # + # @param [String|Integer|DateTime|Time] timestamp + # @raise ArgumentError if timestamp is of an invalid type + # @return [String] unix epoch milliseconds or iso8061 + def ensure_valid_timestamp!(timestamp) + if timestamp.kind_of?(Time) + timestamp = timestamp.to_datetime + end + if timestamp.kind_of?(DateTime) + timestamp = timestamp.strftime('%Q').to_i + end + if timestamp.kind_of?(String) && iso8601?(timestamp) + timestamp + elsif timestamp.kind_of?(Integer) + timestamp.to_s + else + raise ArgumentError, + "Unsupported timestamp format #{timestamp}. " + + "Must be iso8601 or unix epoch milliseconds." + end + end + private :ensure_valid_timestamp! + + # Validate idempotency_key, making sure it's a string + # + # @param [String|Integer] idempotency_key + # @raise ArgumentError if identity is of an invalid type or too long. + # @return [String] stringified idempotency_key + def ensure_valid_idempotency_key!(idempotency_key) + unless idempotency_key.kind_of?(String) || idempotency_key.kind_of?(Integer) + raise ArgumentError, "Unsupported idempotency key format for " + + "#{idempotency_key}. Must be string or integer" + end + idempotency_key.to_s + end + private :ensure_valid_idempotency_key! + # Validates a bag of properties sent to a Heap server-side API. # # @param [Hash] properties key-value property bag; @@ -74,4 +114,12 @@ def ensure_valid_properties!(properties) end end private :ensure_valid_properties! + + def iso8601?(string) + Time.iso8601(string) + true + rescue ArgumentError + false + end + private :iso8601? end diff --git a/test/client_track_test.rb b/test/client_track_test.rb index 79a6aae..cae4f5f 100644 --- a/test/client_track_test.rb +++ b/test/client_track_test.rb @@ -121,6 +121,33 @@ def test_track_with_array_property_value exception.message end + def test_track_with_non_date_timestamp + exception = assert_raises ArgumentError do + @heap.track 'test_track_with_array_property_value', 'test-identity', {}, :timestamp => 'foobar' + end + assert_equal ArgumentError, exception.class + assert_equal 'Unsupported timestamp format foobar. Must be iso8601 or unix epoch milliseconds.', + exception.message + end + + def test_track_with_array_timestamp + exception = assert_raises ArgumentError do + @heap.track 'test_track_with_array_property_value', 'test-identity', {}, :timestamp => [] + end + assert_equal ArgumentError, exception.class + assert_equal 'Unsupported timestamp format []. Must be iso8601 or unix epoch milliseconds.', + exception.message + end + + def test_track_with_array_idempotency_key + exception = assert_raises ArgumentError do + @heap.track 'test_track_with_array_property_value', 'test-identity', {}, :idempotency_key => [] + end + assert_equal ArgumentError, exception.class + assert_equal 'Unsupported idempotency key format for []. Must be string or integer', + exception.message + end + def test_track @stubs.post '/api/track' do |env| golden_body = { @@ -174,6 +201,67 @@ def test_track_with_properties 'test-identity','foo' => 'bar', :heap => :hurray) end + def test_track_with_timestamp + @stubs.post '/api/track' do |env| + golden_body = { + 'app_id' => 'test-app-id', + 'identity' => 'test-identity', + 'event' => 'test_track_with_timestamp', + 'properties' => {}, + 'timestamp' => '1524038400000' + } + assert_equal 'application/json', env[:request_headers]['Content-Type'] + assert_equal @heap.user_agent, env[:request_headers]['User-Agent'] + assert_equal golden_body, JSON.parse(env[:body]) + + [200, { 'Content-Type' => 'text/plain; encoding=utf8' }, ''] + end + + assert_equal @heap, @heap.track('test_track_with_timestamp', + 'test-identity', {}, :timestamp => Time.parse("2018-04-18 08:00:00 UTC")) + end + + def test_track_with_iso8601_timestamp + timestamp = "2018-04-18T22:42:38+03:00" + @stubs.post '/api/track' do |env| + golden_body = { + 'app_id' => 'test-app-id', + 'identity' => 'test-identity', + 'event' => 'test_track_with_iso8601_timestamp', + 'properties' => {}, + 'timestamp' => timestamp + } + assert_equal 'application/json', env[:request_headers]['Content-Type'] + assert_equal @heap.user_agent, env[:request_headers]['User-Agent'] + assert_equal golden_body, JSON.parse(env[:body]) + + [200, { 'Content-Type' => 'text/plain; encoding=utf8' }, ''] + end + + assert_equal @heap, @heap.track('test_track_with_iso8601_timestamp', + 'test-identity', {}, :timestamp => timestamp) + end + + def test_track_with_idempotency_key + @stubs.post '/api/track' do |env| + golden_body = { + 'app_id' => 'test-app-id', + 'identity' => 'test-identity', + 'event' => 'test_track_with_idempotency_key', + 'properties' => {}, + 'idempotency_key' => 'foobar35214532512' + } + assert_equal 'application/json', env[:request_headers]['Content-Type'] + assert_equal @heap.user_agent, env[:request_headers]['User-Agent'] + assert_equal golden_body, JSON.parse(env[:body]) + + [200, { 'Content-Type' => 'text/plain; encoding=utf8' }, ''] + end + + assert_equal @heap, @heap.track('test_track_with_idempotency_key', + 'test-identity', {}, :idempotency_key => 'foobar35214532512') + end + def test_track_error @stubs.post '/api/track' do |env| [400, { 'Content-Type' => 'text/plain; encoding=utf8' }, 'Bad request']