seekmodo-sdk 0.5.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +52 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +32 -0
  6. data/Gemfile +11 -0
  7. data/README.md +135 -0
  8. data/docs/tool-catalog.README.md +12 -0
  9. data/lib/seekmodo/sdk/admin/client.rb +120 -0
  10. data/lib/seekmodo/sdk/auto_promoter.rb +124 -0
  11. data/lib/seekmodo/sdk/browser_token.rb +62 -0
  12. data/lib/seekmodo/sdk/circuit_breaker.rb +123 -0
  13. data/lib/seekmodo/sdk/connector/client.rb +250 -0
  14. data/lib/seekmodo/sdk/events/click_beacon.rb +58 -0
  15. data/lib/seekmodo/sdk/events/events_queue.rb +50 -0
  16. data/lib/seekmodo/sdk/exceptions/breaker_open_error.rb +10 -0
  17. data/lib/seekmodo/sdk/exceptions/client_error.rb +66 -0
  18. data/lib/seekmodo/sdk/exceptions/over_quota_error.rb +10 -0
  19. data/lib/seekmodo/sdk/exceptions/seekmodo_error.rb +8 -0
  20. data/lib/seekmodo/sdk/exceptions/signature_mismatch_error.rb +10 -0
  21. data/lib/seekmodo/sdk/exceptions/tenant_unavailable_error.rb +10 -0
  22. data/lib/seekmodo/sdk/hmac_signer.rb +43 -0
  23. data/lib/seekmodo/sdk/mcp/client.rb +105 -0
  24. data/lib/seekmodo/sdk/mode.rb +41 -0
  25. data/lib/seekmodo/sdk/mode_fsm.rb +52 -0
  26. data/lib/seekmodo/sdk/pairing.rb +114 -0
  27. data/lib/seekmodo/sdk/signature_mismatch_tracker.rb +52 -0
  28. data/lib/seekmodo/sdk/storage/memory/stores.rb +100 -0
  29. data/lib/seekmodo/sdk/storage/protocols.rb +47 -0
  30. data/lib/seekmodo/sdk/storefront/client.rb +71 -0
  31. data/lib/seekmodo/sdk/storefront/transport.rb +198 -0
  32. data/lib/seekmodo/sdk/tenant_snapshot.rb +65 -0
  33. data/lib/seekmodo/sdk/tools/registry.rb +88 -0
  34. data/lib/seekmodo/sdk/version.rb +7 -0
  35. data/lib/seekmodo/sdk.rb +33 -0
  36. data/lib/seekmodo-sdk.rb +3 -0
  37. metadata +109 -0
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storage/protocols"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class CircuitBreaker
8
+ STATE_CLOSED = "closed"
9
+ STATE_OPEN = "open"
10
+ STATE_HALFOPEN = "half_open"
11
+
12
+ def initialize(
13
+ store,
14
+ key: "numinix.seekmodo.circuit",
15
+ failure_threshold: 5,
16
+ failure_window_seconds: 60,
17
+ open_cooldown_seconds: 30,
18
+ clock: nil
19
+ )
20
+ @store = store
21
+ @key = key
22
+ @failure_threshold = failure_threshold
23
+ @failure_window_seconds = failure_window_seconds
24
+ @open_cooldown_seconds = open_cooldown_seconds
25
+ @clock = clock || -> { Time.now.to_i }
26
+ end
27
+
28
+ def allow_request?
29
+ state = load_state
30
+ now = @clock.call
31
+
32
+ if state["state"] == STATE_OPEN
33
+ if now - state["opened_at"].to_i >= @open_cooldown_seconds
34
+ state["state"] = STATE_HALFOPEN
35
+ state["probe_in_flight"] = true
36
+ save_state(state)
37
+ return true
38
+ end
39
+ return false
40
+ end
41
+
42
+ if state["state"] == STATE_HALFOPEN
43
+ return false if state["probe_in_flight"]
44
+
45
+ state["probe_in_flight"] = true
46
+ save_state(state)
47
+ return true
48
+ end
49
+
50
+ true
51
+ end
52
+
53
+ def record_success
54
+ state = load_state
55
+ if [STATE_HALFOPEN, STATE_OPEN].include?(state["state"])
56
+ save_state(
57
+ "state" => STATE_CLOSED,
58
+ "failures" => [],
59
+ "opened_at" => 0,
60
+ "probe_in_flight" => false
61
+ )
62
+ return
63
+ end
64
+
65
+ if state["failures"].any?
66
+ state["failures"] = []
67
+ save_state(state)
68
+ end
69
+ end
70
+
71
+ def record_failure
72
+ state = load_state
73
+ now = @clock.call
74
+
75
+ if state["state"] == STATE_HALFOPEN
76
+ save_state(
77
+ "state" => STATE_OPEN,
78
+ "failures" => [],
79
+ "opened_at" => now,
80
+ "probe_in_flight" => false
81
+ )
82
+ return
83
+ end
84
+
85
+ failures = state["failures"] + [now]
86
+ cutoff = now - @failure_window_seconds
87
+ failures = failures.select { |ts| ts >= cutoff }
88
+
89
+ if failures.length >= @failure_threshold
90
+ save_state(
91
+ "state" => STATE_OPEN,
92
+ "failures" => [],
93
+ "opened_at" => now,
94
+ "probe_in_flight" => false
95
+ )
96
+ return
97
+ end
98
+
99
+ state["failures"] = failures
100
+ save_state(state)
101
+ end
102
+
103
+ def state
104
+ load_state["state"]
105
+ end
106
+
107
+ def snapshot
108
+ load_state
109
+ end
110
+
111
+ private
112
+
113
+ def load_state
114
+ @store.load(@key)
115
+ end
116
+
117
+ def save_state(state)
118
+ ttl = [@open_cooldown_seconds * 2, @failure_window_seconds * 4].max
119
+ @store.save(@key, state, ttl)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ require_relative "../exceptions/client_error"
7
+ require_relative "../exceptions/over_quota_error"
8
+ require_relative "../exceptions/signature_mismatch_error"
9
+ require_relative "../exceptions/tenant_unavailable_error"
10
+ require_relative "../hmac_signer"
11
+ require_relative "../circuit_breaker"
12
+
13
+ module Seekmodo
14
+ module Sdk
15
+ module Connector
16
+ class Client
17
+ DEFAULT_GATEWAY_URL = "https://mcp.seekmodo.com"
18
+ INDEX_CHUNK_SIZE = 500
19
+ INDEX_HARD_CAP_PER_CALL = 1000
20
+ DEFAULT_REQUEST_TIMEOUT_MS = 1500
21
+ DEFAULT_CONNECT_TIMEOUT_MS = 250
22
+
23
+ attr_reader :signer, :gateway_url
24
+
25
+ def initialize(
26
+ signer,
27
+ gateway_url: DEFAULT_GATEWAY_URL,
28
+ breaker: nil,
29
+ user_agent: "seekmodo-ruby-sdk/0.5.0",
30
+ storefront_host: "",
31
+ connection: nil,
32
+ timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
33
+ connect_timeout_ms: DEFAULT_CONNECT_TIMEOUT_MS
34
+ )
35
+ @signer = signer
36
+ @gateway_url = gateway_url.to_s.delete_suffix("/")
37
+ @breaker = breaker
38
+ @user_agent = user_agent
39
+ @storefront_host = storefront_host.to_s.downcase.strip
40
+ @owns_connection = connection.nil?
41
+ @connection = connection || build_connection(timeout_ms, connect_timeout_ms)
42
+ end
43
+
44
+ def close
45
+ @connection.close if @owns_connection && @connection.respond_to?(:close)
46
+ end
47
+
48
+ def search(params)
49
+ post_json("/v1/search", params)
50
+ end
51
+
52
+ def index(documents, action: "upsert")
53
+ if documents.length > INDEX_HARD_CAP_PER_CALL
54
+ merged = { "ok" => true, "imported" => 0, "errors" => [] }
55
+ documents.each_slice(INDEX_CHUNK_SIZE) do |chunk|
56
+ res = post_json("/v1/index", { "documents" => chunk, "action" => action })
57
+ merged["imported"] += res.fetch("imported", 0).to_i
58
+ errors = res["errors"]
59
+ merged["errors"] = merged["errors"] + errors if errors.is_a?(Array)
60
+ merged["ok"] = false unless res.fetch("ok", true)
61
+ end
62
+ return merged
63
+ end
64
+
65
+ post_json("/v1/index", { "documents" => documents, "action" => action })
66
+ end
67
+
68
+ def events(events)
69
+ post_json("/v1/events", { "events" => events })
70
+ end
71
+
72
+ def tenant_handshake
73
+ post_json("/v1/tenant/handshake", {})
74
+ end
75
+
76
+ def tenant_snapshot
77
+ post_json("/v1/tenant.snapshot", {})
78
+ end
79
+
80
+ def browser_token(audience = nil)
81
+ body = audience.nil? ? {} : { "audience" => audience }
82
+ post_json("/v1/tenants/token", body)
83
+ end
84
+
85
+ def tools
86
+ get_json("/v1/tools")
87
+ end
88
+
89
+ def health
90
+ get_json("/v1/health", signed: false)
91
+ end
92
+
93
+ def post_json(path, body, extra_headers = {})
94
+ raw = encode_body(body)
95
+ execute("POST", path, raw, extra_headers, signed: true)
96
+ end
97
+
98
+ def get_json(path, extra_headers = {}, signed: true)
99
+ execute("GET", path, "", extra_headers, signed: signed)
100
+ end
101
+
102
+ private
103
+
104
+ def build_connection(timeout_ms, connect_timeout_ms)
105
+ Faraday.new do |f|
106
+ f.options.timeout = timeout_ms / 1000.0
107
+ f.options.open_timeout = connect_timeout_ms / 1000.0
108
+ f.adapter Faraday.default_adapter
109
+ end
110
+ end
111
+
112
+ def execute(method, path, body, extra_headers, signed:)
113
+ if signed && !@signer.configured?
114
+ raise ClientError.new(
115
+ "Seekmodo client is missing tenant_id or shared_secret; cannot sign request.",
116
+ ClientError::KIND_NOT_CONFIGURED
117
+ )
118
+ end
119
+ if @breaker && !@breaker.allow_request?
120
+ raise ClientError.new(
121
+ "Seekmodo circuit breaker is open; refusing to call gateway.",
122
+ ClientError::KIND_BREAKER_OPEN
123
+ )
124
+ end
125
+
126
+ headers = {
127
+ "Accept" => "application/json",
128
+ "User-Agent" => @user_agent
129
+ }
130
+ headers["Content-Type"] = "application/json" if body && !body.empty?
131
+ if signed
132
+ headers.merge!(@signer.headers(body))
133
+ if !@storefront_host.empty?
134
+ headers[HmacSigner::HEADER_STOREFRONT_HOST] = @storefront_host
135
+ end
136
+ end
137
+ headers.merge!(extra_headers)
138
+
139
+ begin
140
+ response = @connection.run_request(
141
+ method.downcase.to_sym,
142
+ @gateway_url + path,
143
+ body.empty? ? nil : body,
144
+ headers
145
+ )
146
+ rescue Faraday::TimeoutError => e
147
+ on_failure
148
+ raise ClientError.new(
149
+ "Network failure calling Seekmodo gateway: #{e.message}",
150
+ ClientError::KIND_TIMEOUT,
151
+ cause: e
152
+ )
153
+ rescue Faraday::Error => e
154
+ on_failure
155
+ raise ClientError.new(
156
+ "Network failure calling Seekmodo gateway: #{e.message}",
157
+ ClientError::KIND_NETWORK,
158
+ cause: e
159
+ )
160
+ end
161
+
162
+ classify(response)
163
+ end
164
+
165
+ def classify(response)
166
+ status = response.status
167
+ body = {}
168
+ if response.body && !response.body.to_s.empty?
169
+ decoded = JSON.parse(response.body)
170
+ body = decoded if decoded.is_a?(Hash)
171
+ end
172
+
173
+ if status >= 200 && status < 300
174
+ on_success
175
+ return body
176
+ end
177
+
178
+ error_code = body["error"].is_a?(String) ? body["error"] : nil
179
+ kind = ClientError.classify_error_code(error_code)
180
+
181
+ if status >= 500
182
+ on_failure
183
+ raise ClientError.new(
184
+ "Gateway returned HTTP #{status}",
185
+ ClientError::KIND_HTTP_5XX,
186
+ status,
187
+ body: body
188
+ )
189
+ end
190
+
191
+ if kind == ClientError::KIND_TENANT_UNAVAILABLE
192
+ on_success
193
+ raise TenantUnavailableError.new(
194
+ "Tenant unavailable (gateway HTTP #{status}, code=#{error_code})",
195
+ kind,
196
+ status,
197
+ body: body
198
+ )
199
+ end
200
+
201
+ if kind == ClientError::KIND_OVER_QUOTA || status == 402
202
+ on_success
203
+ raise OverQuotaError.new(
204
+ "Over plan quota (gateway HTTP #{status}, code=#{error_code || 'over_quota'})",
205
+ ClientError::KIND_OVER_QUOTA,
206
+ status,
207
+ body: body
208
+ )
209
+ end
210
+
211
+ if kind == ClientError::KIND_SIGNATURE_MISMATCH || status == 401
212
+ on_failure
213
+ raise SignatureMismatchError.new(
214
+ "Gateway rejected HMAC (HTTP #{status}, code=#{error_code || 'signature_mismatch'})",
215
+ ClientError::KIND_SIGNATURE_MISMATCH,
216
+ status,
217
+ body: body
218
+ )
219
+ end
220
+
221
+ on_failure
222
+ raise ClientError.new(
223
+ "Gateway returned HTTP #{status}",
224
+ ClientError::KIND_HTTP_4XX,
225
+ status,
226
+ body: body
227
+ )
228
+ end
229
+
230
+ def on_success
231
+ @breaker&.record_success
232
+ end
233
+
234
+ def on_failure
235
+ @breaker&.record_failure
236
+ end
237
+
238
+ def encode_body(body)
239
+ JSON.generate(body)
240
+ rescue JSON::GeneratorError, TypeError => e
241
+ raise ClientError.new(
242
+ "Failed to JSON-encode request body: #{e.message}",
243
+ ClientError::KIND_BAD_RESPONSE,
244
+ cause: e
245
+ )
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seekmodo
4
+ module Sdk
5
+ module Events
6
+ module ClickBeacon
7
+ SURFACE_SERP = "serp"
8
+ SURFACE_TYPEAHEAD = "typeahead"
9
+ SURFACE_RECOMMENDATIONS = "recommendations"
10
+
11
+ module_function
12
+
13
+ def click(query, doc_id, position, is_bot, surface: SURFACE_SERP, shopper_context: nil, extra: nil)
14
+ event = {
15
+ "type" => "click",
16
+ "q" => query,
17
+ "doc_id" => doc_id,
18
+ "position" => position,
19
+ "is_bot" => is_bot,
20
+ "surface" => surface,
21
+ "ts" => Time.now.to_i
22
+ }
23
+ event["shopper"] = shopper_context if shopper_context
24
+ event.merge!(extra) if extra
25
+ event
26
+ end
27
+
28
+ def impression(query, doc_ids, is_bot, surface: SURFACE_SERP, shopper_context: nil, extra: nil)
29
+ event = {
30
+ "type" => "impression",
31
+ "q" => query,
32
+ "doc_ids" => doc_ids.dup,
33
+ "is_bot" => is_bot,
34
+ "surface" => surface,
35
+ "ts" => Time.now.to_i
36
+ }
37
+ event["shopper"] = shopper_context if shopper_context
38
+ event.merge!(extra) if extra
39
+ event
40
+ end
41
+
42
+ def search(query, hits, is_bot, shopper_context: nil, extra: nil)
43
+ event = {
44
+ "type" => "search",
45
+ "q" => query,
46
+ "hits" => hits,
47
+ "is_bot" => is_bot,
48
+ "surface" => SURFACE_SERP,
49
+ "ts" => Time.now.to_i
50
+ }
51
+ event["shopper"] = shopper_context if shopper_context
52
+ event.merge!(extra) if extra
53
+ event
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../connector/client"
4
+ require_relative "../storage/protocols"
5
+
6
+ module Seekmodo
7
+ module Sdk
8
+ module Events
9
+ class EventsQueue
10
+ DEFAULT_AUTO_FLUSH_THRESHOLD = 50
11
+ DEFAULT_MAX_PER_FLUSH = 200
12
+
13
+ def initialize(
14
+ client,
15
+ store,
16
+ auto_flush_threshold: DEFAULT_AUTO_FLUSH_THRESHOLD,
17
+ max_per_flush: DEFAULT_MAX_PER_FLUSH
18
+ )
19
+ @client = client
20
+ @store = store
21
+ @auto_flush_threshold = auto_flush_threshold
22
+ @max_per_flush = max_per_flush
23
+ end
24
+
25
+ def push(event)
26
+ @store.push(event)
27
+ flush if @store.count >= @auto_flush_threshold
28
+ end
29
+
30
+ def flush
31
+ batch = @store.drain(@max_per_flush)
32
+ return 0 if batch.empty?
33
+
34
+ begin
35
+ @client.events(batch)
36
+ rescue StandardError
37
+ batch.each { |event| @store.push(event) }
38
+ return 0
39
+ end
40
+
41
+ batch.length
42
+ end
43
+
44
+ def pending_count
45
+ @store.count
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "seekmodo_error"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class BreakerOpenError < SeekmodoError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "seekmodo_error"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class ClientError < SeekmodoError
8
+ KIND_NOT_CONFIGURED = "not_configured"
9
+ KIND_BREAKER_OPEN = "breaker_open"
10
+ KIND_NETWORK = "network"
11
+ KIND_TIMEOUT = "timeout"
12
+ KIND_HTTP_4XX = "http_4xx"
13
+ KIND_HTTP_5XX = "http_5xx"
14
+ KIND_BAD_RESPONSE = "bad_response"
15
+ KIND_SIGNATURE_MISMATCH = "signature_mismatch"
16
+ KIND_RATE_LIMITED = "rate_limited"
17
+ KIND_OVER_QUOTA = "over_quota"
18
+ KIND_TENANT_UNAVAILABLE = "tenant_unavailable"
19
+
20
+ TENANT_UNAVAILABLE_ERROR_CODES = %w[
21
+ tenant_paused tenant_not_found tenant_unknown tenant_suspended tenant_disabled
22
+ ].freeze
23
+
24
+ attr_reader :kind, :status_code, :body
25
+
26
+ def initialize(message, kind, status_code = 0, body: nil, cause: nil)
27
+ super(message)
28
+ @kind = kind
29
+ @status_code = status_code
30
+ @body = body || {}
31
+ set_backtrace(cause&.backtrace) if cause
32
+ end
33
+
34
+ def is_transient?
35
+ [
36
+ KIND_NETWORK,
37
+ KIND_TIMEOUT,
38
+ KIND_HTTP_5XX,
39
+ KIND_TENANT_UNAVAILABLE
40
+ ].include?(kind)
41
+ end
42
+
43
+ def should_fallback?
44
+ kind != KIND_HTTP_4XX
45
+ end
46
+
47
+ def error_code
48
+ err = body["error"]
49
+ err.is_a?(String) ? err : nil
50
+ end
51
+
52
+ def self.classify_error_code(error_code)
53
+ return nil if error_code.nil? || error_code.empty?
54
+
55
+ if TENANT_UNAVAILABLE_ERROR_CODES.include?(error_code)
56
+ return KIND_TENANT_UNAVAILABLE
57
+ end
58
+ return KIND_SIGNATURE_MISMATCH if error_code == "signature_mismatch"
59
+ return KIND_RATE_LIMITED if error_code == "rate_limited"
60
+ return KIND_OVER_QUOTA if %w[over_quota feature_not_in_plan].include?(error_code)
61
+
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class OverQuotaError < ClientError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seekmodo
4
+ module Sdk
5
+ class SeekmodoError < StandardError
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class SignatureMismatchError < ClientError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_error"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class TenantUnavailableError < ClientError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class HmacSigner
8
+ HEADER_TENANT = "X-Seekmodo-Tenant"
9
+ HEADER_SIGNATURE = "X-Seekmodo-Signature"
10
+ HEADER_TIMESTAMP = "X-Seekmodo-Timestamp"
11
+ HEADER_SESSION = "X-Seekmodo-Session"
12
+ HEADER_STOREFRONT_HOST = "X-Seekmodo-Storefront-Host"
13
+
14
+ attr_reader :tenant_id
15
+
16
+ def initialize(tenant_id, shared_secret)
17
+ @tenant_id = tenant_id
18
+ @shared_secret = shared_secret
19
+ end
20
+
21
+ def headers(raw_body, timestamp = nil)
22
+ ts = (timestamp || Time.now.to_i).to_s
23
+ signature = OpenSSL::HMAC.hexdigest("SHA256", @shared_secret, raw_body)
24
+ {
25
+ HEADER_TENANT => @tenant_id,
26
+ HEADER_SIGNATURE => signature,
27
+ HEADER_TIMESTAMP => ts
28
+ }
29
+ end
30
+
31
+ def verify(raw_body, signature)
32
+ expected = OpenSSL::HMAC.hexdigest("SHA256", @shared_secret, raw_body)
33
+ return false if signature.nil? || signature.empty?
34
+
35
+ OpenSSL.secure_compare(expected, signature)
36
+ end
37
+
38
+ def configured?
39
+ !@tenant_id.to_s.empty? && !@shared_secret.to_s.empty?
40
+ end
41
+ end
42
+ end
43
+ end