drawbridge 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +17 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +52 -0
- data/Rakefile +10 -0
- data/drawbridge.gemspec +27 -0
- data/lib/drawbridge.rb +13 -0
- data/lib/drawbridge/adapter.rb +35 -0
- data/lib/drawbridge/config.rb +48 -0
- data/lib/drawbridge/debug.rb +18 -0
- data/lib/drawbridge/mapper.rb +214 -0
- data/lib/drawbridge/refinement_scrubber.rb +26 -0
- data/lib/drawbridge/request.rb +92 -0
- data/lib/drawbridge/result.rb +146 -0
- data/lib/drawbridge/transformer.rb +190 -0
- data/lib/drawbridge/version.rb +3 -0
- data/spec/debug_spec.rb +23 -0
- data/spec/helper.rb +18 -0
- data/spec/refinement_scrubber_spec.rb +68 -0
- data/spec/request_spec.rb +84 -0
- data/spec/result_spec.rb +388 -0
- data/spec/transformer_spec.rb +249 -0
- metadata +175 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
module Drawbridge
|
3
|
+
class RefinementScrubber
|
4
|
+
|
5
|
+
def self.scrub(data)
|
6
|
+
Array(data).each do |ref|
|
7
|
+
add_aggrbins_key(ref) if ref['dimensionvalues'].present?
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def self.add_aggrbins_key(ref)
|
14
|
+
ref['dimensionvalues'].each do |val|
|
15
|
+
next unless has_aggr_bins?(val)
|
16
|
+
val['dimvalueproperties']['aggrbins'] = val['dimvalueproperties']['dgraph.aggrbins']
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.has_aggr_bins?(value)
|
21
|
+
props = value['dimvalueproperties']
|
22
|
+
value.present? && props.present? && props['dgraph.aggrbins'].present?
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-require 'oj'
|
2
|
+
require 'oj'
|
3
|
+
require 'curb'
|
4
|
+
|
5
|
+
module Drawbridge
|
6
|
+
|
7
|
+
class Request
|
8
|
+
class RequestError < StandardError; end
|
9
|
+
|
10
|
+
ERROR_CODE = 0
|
11
|
+
ResultsError = {'status' => ERROR_CODE}
|
12
|
+
|
13
|
+
attr_accessor :uri, :query, :timeout
|
14
|
+
|
15
|
+
def self.perform(uri, query, timeout = (Drawbridge.config.timeout || 5))
|
16
|
+
raise RequestError, "Must provide a uri" unless uri
|
17
|
+
new(uri, query, timeout).perform
|
18
|
+
end
|
19
|
+
|
20
|
+
# Initialize
|
21
|
+
# @params: uri
|
22
|
+
# @params: query
|
23
|
+
# @params: timeout
|
24
|
+
def initialize(uri, query, timeout)
|
25
|
+
@uri = uri
|
26
|
+
@query = query
|
27
|
+
@timeout = timeout
|
28
|
+
end
|
29
|
+
|
30
|
+
# Perform HTTP call
|
31
|
+
# @return: json response
|
32
|
+
def perform
|
33
|
+
@perform ||= handle_response get_response
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Build full qualified uri
|
39
|
+
# @instance variable: query
|
40
|
+
# @instance variable: uri
|
41
|
+
def full_uri
|
42
|
+
@full_uri ||= query ? URI.escape("#{uri}?#{query}") : uri
|
43
|
+
end
|
44
|
+
|
45
|
+
# Make get request to assembler
|
46
|
+
# @instance variable: full_url
|
47
|
+
# @sucess: json data
|
48
|
+
# @failed: raising RequestError
|
49
|
+
def get_response
|
50
|
+
Drawbridge::Debug.log "ENDECA_QUERY", full_uri
|
51
|
+
|
52
|
+
Curl::Easy.perform(full_uri) do |curl|
|
53
|
+
curl.timeout = timeout
|
54
|
+
end
|
55
|
+
rescue Curl::Err::TimeoutError => e
|
56
|
+
raise e, e.message
|
57
|
+
rescue StandardError => e
|
58
|
+
puts "Request to: #{full_uri} failed: #{e.message}"
|
59
|
+
raise RequestError, e.message
|
60
|
+
end
|
61
|
+
|
62
|
+
# Handle response from assembler
|
63
|
+
# @success: Parse data with Oj gem
|
64
|
+
# @failed: defaul json {'status' => 0}
|
65
|
+
def handle_response(response)
|
66
|
+
case response.response_code.to_s
|
67
|
+
when "200"
|
68
|
+
begin
|
69
|
+
Oj.load(clean_bad_json(response.body_str))
|
70
|
+
rescue Oj::ParseError => e
|
71
|
+
raise RequestError, "JSON Parse error: #{e}"
|
72
|
+
end
|
73
|
+
else
|
74
|
+
puts "Unsuccessful response from #{full_uri}: #{response}\nbody:#{response.body_str unless response.nil?}"
|
75
|
+
Request::ResultsError
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def clean_bad_json(str)
|
82
|
+
str.encode!('UTF-16', 'UTF-8', :invalid => :replace, :replace => '')
|
83
|
+
str.encode!('UTF-8', 'UTF-16')
|
84
|
+
str.gsub!("\t",'')
|
85
|
+
str.gsub!("\\ ","\\\\\\ ")
|
86
|
+
str.gsub!(/\'/, "'") unless Drawbridge.config.skip_single_quote_encoding
|
87
|
+
str
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
module Drawbridge
|
2
|
+
class Result
|
3
|
+
|
4
|
+
attr_accessor :data
|
5
|
+
|
6
|
+
def initialize(params)
|
7
|
+
@data = may_be_success_response params
|
8
|
+
end
|
9
|
+
|
10
|
+
def refinements
|
11
|
+
@refinements ||= RefinementScrubber.scrub(downcased_refinements)
|
12
|
+
end
|
13
|
+
|
14
|
+
def breadcrumbs
|
15
|
+
Array(downcase_element(data["Breadcrumbs"]))
|
16
|
+
end
|
17
|
+
|
18
|
+
def meta_info
|
19
|
+
downcase_element(data["MetaInfo"]) || {}
|
20
|
+
end
|
21
|
+
|
22
|
+
def records
|
23
|
+
@records ||= parse_records_array
|
24
|
+
end
|
25
|
+
|
26
|
+
def status
|
27
|
+
data['status'] || 1
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_stats_hash
|
31
|
+
{ meta_info: meta_info, breadcrumbs: breadcrumbs, refinements: refinements }
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def downcased_refinements
|
37
|
+
downcase_array(@data['Refinements'])
|
38
|
+
end
|
39
|
+
|
40
|
+
def parse_records_array
|
41
|
+
is_aggregate? ? parse_aggregate_records : parse_records
|
42
|
+
end
|
43
|
+
|
44
|
+
def parse_aggregate_records
|
45
|
+
aggregate_records.map { |aggregate_record| merge_derived_properties aggregate_record }
|
46
|
+
end
|
47
|
+
|
48
|
+
def merge_derived_properties(aggregate_record)
|
49
|
+
first_parsed_record = parse_records(aggregate_record["Records"]).first
|
50
|
+
derived_properties = aggregate_record["DerivedProperties"]
|
51
|
+
first_parsed_record.merge derived_properties
|
52
|
+
end
|
53
|
+
|
54
|
+
def parse_records(records = unaggregate_records)
|
55
|
+
mod_records = records.map do |record|
|
56
|
+
record["Dimensions"].merge record['Properties']
|
57
|
+
end
|
58
|
+
special_handling mod_records
|
59
|
+
end
|
60
|
+
|
61
|
+
def is_aggregate?
|
62
|
+
data.has_key? 'AggrRecords'
|
63
|
+
end
|
64
|
+
|
65
|
+
def aggregate_records
|
66
|
+
Array(data['AggrRecords'])
|
67
|
+
end
|
68
|
+
|
69
|
+
def unaggregate_records
|
70
|
+
Array(data['Records'])
|
71
|
+
end
|
72
|
+
|
73
|
+
def downcase_hash(subject_hash)
|
74
|
+
return {} if subject_hash.nil?
|
75
|
+
subject_hash.each_with_object({}) { |(k, v), h| h[k.downcase] = downcase_element(v) }
|
76
|
+
end
|
77
|
+
|
78
|
+
def downcase_array(subject_array)
|
79
|
+
Array(subject_array).map { |element| downcase_element(element) }
|
80
|
+
end
|
81
|
+
|
82
|
+
def downcase_element(element)
|
83
|
+
if element.instance_of? Array
|
84
|
+
downcase_array element
|
85
|
+
elsif element.instance_of? Hash
|
86
|
+
downcase_hash element
|
87
|
+
else
|
88
|
+
element
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def special_handling(records)
|
93
|
+
records.each do |record|
|
94
|
+
record['miles_to_geocode'] = parse_geocode record, 'miles'
|
95
|
+
record['kilometers_to_geocode'] = parse_geocode record, 'kilometers'
|
96
|
+
record['matched_on'] = parse_matched_on record
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def parse_geocode(record, units)
|
101
|
+
units_to_geocode = /#{units}_to_geocode/
|
102
|
+
record[record.keys.detect{ |key| key =~ units_to_geocode }]
|
103
|
+
end
|
104
|
+
|
105
|
+
def parse_matched_on(record)
|
106
|
+
whymatch = record['DGraph.WhyDidItMatch']
|
107
|
+
return unless whymatch.to_s.include?(':')
|
108
|
+
|
109
|
+
if whymatch.instance_of? String
|
110
|
+
whymatch_string_to_hash whymatch
|
111
|
+
else
|
112
|
+
whymatch_array_to_hash whymatch
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def whymatch_string_to_hash(str)
|
117
|
+
parts = str.split(':').map(&:strip)
|
118
|
+
{ parts[0] => parts[1] }
|
119
|
+
end
|
120
|
+
|
121
|
+
def whymatch_array_to_hash(arr)
|
122
|
+
arr.each_with_object({}) do |s, hash|
|
123
|
+
arr = s.split(':').collect(&:strip)
|
124
|
+
hash[arr[0]] = arr[1]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# If Error returned by Endeca, return default structure
|
129
|
+
def may_be_success_response(params)
|
130
|
+
return params unless params['methodResponse']
|
131
|
+
|
132
|
+
status = response_params_contain_fault?(params) ? 404 : 500
|
133
|
+
|
134
|
+
{ 'MetaInfo' => {}, 'Refinements' => [], 'Breadcrumbs' => [], 'AggrRecords' => [], 'status' => status }
|
135
|
+
end
|
136
|
+
|
137
|
+
def response_params_contain_fault?(params)
|
138
|
+
fault_string_from_params(params) =~ /HTTP Error 404/i
|
139
|
+
end
|
140
|
+
|
141
|
+
def fault_string_from_params(params)
|
142
|
+
params['methodResponse']['fault']['value']['faultString']
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
module Drawbridge
|
2
|
+
module Transformer
|
3
|
+
KM = 0.62137
|
4
|
+
PLUS = '+'
|
5
|
+
PIPE = '|'
|
6
|
+
|
7
|
+
def transform(adapter)
|
8
|
+
query = []
|
9
|
+
|
10
|
+
clean_adapter_keys(adapter).keys.each do |key|
|
11
|
+
begin
|
12
|
+
query << send(key, adapter[key])
|
13
|
+
rescue NoMethodError => e
|
14
|
+
raise NoMethodError, "Method #{key} is not supported"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
query.compact!
|
18
|
+
|
19
|
+
Drawbridge::Debug.log "ENDECA PARAMS", query
|
20
|
+
|
21
|
+
query.join('&')
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def clean_adapter_keys(adapter)
|
27
|
+
return {} if adapter.nil?
|
28
|
+
|
29
|
+
adapter[:rollup] && adapter[:rollup].blank? ?
|
30
|
+
adapter.delete(:aggregate_offset) :
|
31
|
+
adapter.delete(:offset)
|
32
|
+
|
33
|
+
adapter
|
34
|
+
end
|
35
|
+
|
36
|
+
# N - root navigation request, or one or more dimension value ID
|
37
|
+
# N=123+234
|
38
|
+
# @params Array
|
39
|
+
def root_navigtion(filters)
|
40
|
+
filters = [0] if filters.blank?
|
41
|
+
|
42
|
+
"N=#{filters.join(PLUS)}"
|
43
|
+
end
|
44
|
+
|
45
|
+
# Ns - sort keys
|
46
|
+
# @params - Array of Hashes [{'showapartments' => 1}, {'searchonly' => 1}]
|
47
|
+
# Ns=showapartments|1||searchonly|1
|
48
|
+
def order(sort_order)
|
49
|
+
return nil if sort_order.blank?
|
50
|
+
|
51
|
+
separator = '||'
|
52
|
+
sort_arr = []
|
53
|
+
sort_order.each do |item|
|
54
|
+
item.each_pair{|k,v| sort_arr << "#{k}#{PIPE}#{v}"}
|
55
|
+
end
|
56
|
+
|
57
|
+
"Ns=#{sort_arr.join(separator)}"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Nf - Range filter
|
61
|
+
# key|GCLY latitude,longitude radius in Kilometers
|
62
|
+
# @params - Hash {lat: 123, lng: -12, radius: 10}
|
63
|
+
# Nf=geocode|GCLY 42.365615,-71.075647 10
|
64
|
+
def geo_filter(geo)
|
65
|
+
return nil if geo.blank?
|
66
|
+
|
67
|
+
"Nf=#{geo[:field]}|GCLT #{geo[:lat]},#{geo[:lng]} #{geo[:radius]/KM}"
|
68
|
+
end
|
69
|
+
|
70
|
+
# Nf - Range filter
|
71
|
+
# Sets a range filter with one of these formats:
|
72
|
+
# key|LT+number
|
73
|
+
# key|LTEQ+number
|
74
|
+
# key|GT+number
|
75
|
+
# key|GTEQ+number
|
76
|
+
# key|BTWN+num1+num2
|
77
|
+
# Nf=Price|LT+25
|
78
|
+
# Nf=Price|BTWN+8+15
|
79
|
+
# @params Array of hashes.
|
80
|
+
# Hash structure
|
81
|
+
# {
|
82
|
+
# :field => 'latitude',
|
83
|
+
# :values => ['30.123', '34.456'],
|
84
|
+
# :operator => 'BTWN'
|
85
|
+
# }
|
86
|
+
def range_filter(filters)
|
87
|
+
return nil if filters.blank?
|
88
|
+
|
89
|
+
filters.map! do |filter|
|
90
|
+
values = filter[:values].join(PLUS)
|
91
|
+
"#{filter[:field]}#{PIPE}#{filter[:operator]}#{PLUS}#{values}"
|
92
|
+
end
|
93
|
+
|
94
|
+
"Nf=#{filters.join(PIPE)}"
|
95
|
+
end
|
96
|
+
|
97
|
+
# Ne - indicating which dimension navigation refinements will be exposed
|
98
|
+
# @params - Array [123, 345, 678]
|
99
|
+
# Ne=123+345+678
|
100
|
+
def expand_refinements(refinements)
|
101
|
+
return nil if refinements.blank?
|
102
|
+
|
103
|
+
"Ne=#{refinements.join(PLUS)}"
|
104
|
+
end
|
105
|
+
|
106
|
+
# Ntk - Search filters key
|
107
|
+
# Ntt - Search filters term
|
108
|
+
# Ntk=showapartments|showcollege&Ntt=1|0
|
109
|
+
def where(where)
|
110
|
+
return nil if where.blank?
|
111
|
+
|
112
|
+
ntt = []
|
113
|
+
ntk = []
|
114
|
+
|
115
|
+
where.each_pair do |key, value|
|
116
|
+
ntk << key
|
117
|
+
ntt << value
|
118
|
+
end
|
119
|
+
|
120
|
+
"Ntk=#{ntk.join(PIPE)}&Ntt=#{ntt.join(PIPE)}"
|
121
|
+
end
|
122
|
+
|
123
|
+
# Nr - Record filters
|
124
|
+
# @params - Array: ['NOT(132831)', 'propertyB:valueY']
|
125
|
+
# Nr=AND(132831,propertyA:valueX,OR(propertyB:valueY,propertyC:valueZ))
|
126
|
+
# Nr=NOT(132831)&Nr=propertyB:valueY
|
127
|
+
def record_filters(filters)
|
128
|
+
return nil if filters.blank?
|
129
|
+
|
130
|
+
filters.collect! do |filter|
|
131
|
+
"Nr=#{filter}"
|
132
|
+
end.join('&')
|
133
|
+
end
|
134
|
+
|
135
|
+
# Nao - Aggregate records offset
|
136
|
+
# Nao=20
|
137
|
+
def aggregate_offset(offset)
|
138
|
+
offset.to_i == 0 ? nil : "Nao=#{offset}"
|
139
|
+
end
|
140
|
+
|
141
|
+
# No - Record offset
|
142
|
+
# No=20
|
143
|
+
def offset(offset)
|
144
|
+
offset.to_i == 0 ? nil : "No=#{offset}"
|
145
|
+
end
|
146
|
+
|
147
|
+
# M - Records per page
|
148
|
+
# @parms - number
|
149
|
+
# M=recs_per_page:10
|
150
|
+
def limit(limit)
|
151
|
+
limit.blank? ? nil : "M=recs_per_page:#{limit}"
|
152
|
+
end
|
153
|
+
|
154
|
+
# F - fields to selct
|
155
|
+
# @params - Array
|
156
|
+
# ['listingid', 'propertyname'] => 'F=listingid:1|propertyname:1'
|
157
|
+
def select(fields)
|
158
|
+
return nil if fields.blank?
|
159
|
+
|
160
|
+
field_list = []
|
161
|
+
fields.each do |field|
|
162
|
+
field_list << "#{field}:1" unless field.blank?
|
163
|
+
end
|
164
|
+
|
165
|
+
"F=#{field_list.join(PIPE)}"
|
166
|
+
end
|
167
|
+
|
168
|
+
# Nu - rollup records
|
169
|
+
# @params - String (listingid)
|
170
|
+
# Nu=listingid
|
171
|
+
def rollup(rolled_by)
|
172
|
+
return nil if rolled_by.blank?
|
173
|
+
|
174
|
+
"Nu=#{rolled_by}"
|
175
|
+
end
|
176
|
+
|
177
|
+
# Ntx - match mode
|
178
|
+
# @params - Array
|
179
|
+
# ['matchany', 'matchboolean'] => 'Ntx=mode matchany|mode matchboolean'
|
180
|
+
def match_mode(modes)
|
181
|
+
return nil if modes.blank?
|
182
|
+
mode_string = modes.map do |m|
|
183
|
+
"mode #{m}"
|
184
|
+
end.join(PIPE)
|
185
|
+
|
186
|
+
"Ntx=#{mode_string}"
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
end
|