likyly 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: daf5efd48e779542b6bf7d497ec6ec8462e2b2a061eeb85e46dc4966dffeb2dc
4
+ data.tar.gz: d2bdb88e7c64b614c0c2f98e65dbcca4cc8e04db43c040f14c6b569f16ea45fc
5
+ SHA512:
6
+ metadata.gz: 60e458c1036fa1e5c31999b62d5689eb9ed48a1f83af1b3a36c112eebe780861fc98a1d3189378dc5bba80c48d663abf03a0e030548179e64187b9f8ba7a248d
7
+ data.tar.gz: 4026a30c33621ce76e57c2d2abe36cfd025173fc583aa793563bfa65fd6953110fe9d77c27ccfb133e4d300e3d54b87af8c80d2b12f6bd57f0879ab45edfc91c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LIKYLY
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # LIKYLY SDK for Ruby
2
+
3
+ [LIKYLY](https://likyly.com) shows each visitor of your site the products they are most likely to want:
4
+ "You may also like" on a product page, "Recommended for you" on the home page, "Complete your order" in
5
+ the cart. It learns from what visitors actually do, and gets better as they do it.
6
+
7
+ This is the official Ruby client for the LIKYLY API. Your code uses it to:
8
+
9
+ 1. **Send your catalog**: the products, articles or films you want recommended.
10
+ 2. **Report what visitors do**: they view a product, add it to their cart, sometimes buy it. Browsing
11
+ alone is useful: a visitor who never buys still teaches LIKYLY what goes together, and still gets
12
+ recommendations.
13
+ 3. **Ask what to show**: the products to recommend on a page, for a visitor, or both.
14
+ 4. **Report what happened to the recommendations**: which ones were shown and which were clicked. LIKYLY
15
+ learns which recommendations work, and you can measure what they bring in.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ gem install likyly
21
+ # or in your Gemfile:
22
+ gem "likyly"
23
+ ```
24
+
25
+ Ruby 3.0 or later. No dependency (standard library `net/http` and `json`), no Rails required. Thread-safe.
26
+
27
+ ## Quick start
28
+
29
+ ### Initialize
30
+
31
+ Create one client and reuse it. Use your **secret** key on your server (see [Which key?](#which-key)).
32
+
33
+ ```ruby
34
+ require "likyly"
35
+
36
+ # Backend only: the secret key gives access to your catalog and your users.
37
+ likyly = Likyly::Client.new(
38
+ api_key: ENV.fetch("LIKYLY_SECRET_KEY")
39
+ )
40
+ ```
41
+
42
+ ### Send your catalog
43
+
44
+ ```ruby
45
+ # Send your catalog once (and whenever it changes). Ids are YOUR ids: SKUs, UUIDs, Shopify gids...
46
+ likyly.items.upsert_many([
47
+ { item_id: "SKU-1", title: "Nike Air Max", description: "Running shoe with air cushioning",
48
+ properties: { "category" => "shoes", "brand" => "Nike", "price" => 129.9 } },
49
+ { item_id: "SKU-2", title: "Adidas Ultraboost", description: "Responsive running shoe",
50
+ properties: { "category" => "shoes", "brand" => "Adidas", "price" => 149 } },
51
+ { item_id: "SKU-3", title: "Nike Pegasus", description: "Everyday running shoe",
52
+ properties: { "category" => "shoes", "brand" => "Nike", "price" => 119 } }
53
+ ])
54
+
55
+ # Users are optional: describe them if you want the profile to travel with their events.
56
+ likyly.users.upsert("user_123", properties: { "country" => "FR", "segment" => "premium" })
57
+ ```
58
+
59
+ Ids are your own: any string (`SKU-123`, a UUID, `gid://shopify/Product/123`). `properties` is free-form; `category` and `description` improve similarity, everything else is stored as is.
60
+
61
+ ### Track events
62
+
63
+ ```ruby
64
+ # Tell LIKYLY what your visitors do.
65
+ likyly.events.view(user_id: "user_123", item_id: "SKU-1")
66
+
67
+ # Purchases carry an event_id: replaying the call can never count the sale twice.
68
+ likyly.events.purchase(
69
+ event_id: "purchase_order_9281_SKU-1",
70
+ user_id: "user_123",
71
+ item_id: "SKU-1",
72
+ quantity: 1,
73
+ properties: { "price" => 129.9, "currency" => "EUR", "orderId" => "order_9281" }
74
+ )
75
+ ```
76
+
77
+ ### Get recommendations
78
+
79
+ ```ruby
80
+ # Ask what to show. You never pick an algorithm: LIKYLY chooses the best strategy for what it knows.
81
+ recs = likyly.recommendations.get(user_id: "user_123", placement: "homepage", limit: 3)
82
+
83
+ puts "strategy: #{recs.strategy}"
84
+ recs.items.each { |item| puts "#{item.item_id} #{item.title}" }
85
+ ```
86
+
87
+ You never choose an algorithm: LIKYLY picks the best strategy for what it knows about the visitor and tells you which one it used (`strategy`: `hybrid`, `content`, `collaborative`, `session` or `popular`). With no user, no session and no item you still get a useful answer: what is popular.
88
+
89
+ ### Placements: the recommended way
90
+
91
+ ```ruby
92
+ # The recommended way: a placement ("pdp-related"...) is configured once in LIKYLY - strategy,
93
+ # fallback, filters - and your page only says where it is and what it knows.
94
+ related = likyly.placements.recommend(placement: "pdp-related", context: { item_id: "SKU-123", session_id: "sess_abc" }, limit: 4)
95
+ puts "strategy used: #{related.strategy_used}"
96
+
97
+ # When an anonymous visitor signs in, attach their session's history to their account.
98
+ linked = likyly.events.identify(user_id: "user_123", session_id: "sess_abc")
99
+ puts "linked: #{linked.linked_interactions}"
100
+ ```
101
+
102
+ A placement (`pdp-related`, `home-for-you`, `cart-cross-sell`...) is configured once in LIKYLY, by your coding assistant through the MCP server or in the console: strategy, fallback, filters, how many items. Your code only names it and passes what it knows (`itemId`, `userId`, `sessionId`, ...); change the configuration later without redeploying. `identify` attaches an anonymous visitor's history to their account the moment they sign in, so their recommendations stay personal.
103
+
104
+ ### Close the loop
105
+
106
+ ```ruby
107
+ # Send the recommendation_id back: LIKYLY measures which recommendations get seen, clicked and bought.
108
+ first = recs.items.first
109
+ likyly.events.impression(user_id: "user_123", item_id: first.item_id, recommendation_id: recs.recommendation_id, placement: "homepage")
110
+ likyly.events.click(user_id: "user_123", item_id: first.item_id, recommendation_id: recs.recommendation_id, placement: "homepage")
111
+ ```
112
+
113
+ Send the recommendation id back on the events that follow (`impression` when it is displayed, `click`, `add_to_cart`, `purchase`) and LIKYLY can measure which recommendations actually convert.
114
+
115
+ ## Which key?
116
+
117
+ | | Secret key | Public key |
118
+ |---|---|---|
119
+ | Where | your server only | anywhere, including a web page |
120
+ | Recommendations | yes | yes |
121
+ | Record events | yes | yes |
122
+ | Items and users (create, read, delete) | yes | no (`Likyly::PermissionDeniedError`) |
123
+ | `debug` explanations | yes | no |
124
+
125
+ The public key can only read recommendations and write events, so it is safe to expose. **Never put the secret key in code that ships to a browser or a mobile app.**
126
+
127
+ ```ruby
128
+ # Where the code is exposed to visitors, use the PUBLIC key: it can only read recommendations and record events.
129
+ front = Likyly::Client.new(
130
+ api_key: ENV.fetch("LIKYLY_PUBLIC_KEY")
131
+ )
132
+
133
+ # A visitor who is not logged in: use an anonymous session id (any string you generate and keep in a cookie).
134
+ front.events.view(session_id: "sess_abc", item_id: "SKU-2")
135
+ for_visitor = front.recommendations.get(session_id: "sess_abc", viewed_item_ids: ["SKU-2"], placement: "product_page", limit: 3)
136
+ ```
137
+
138
+ ## Events
139
+
140
+ `track` is the one mechanism; `impression`, `view`, `click`, `add_to_cart`, `remove_from_cart` and `purchase` are shortcuts that call it with the matching type. Event types are **open strings**: use your own (`favorite`, `share`, `video_played`, ...) as freely as the official ones.
141
+
142
+ ```ruby
143
+ # Any string is a valid event type: track what matters to your business.
144
+ likyly.events.track("favorite", user_id: "user_123", item_id: "SKU-2", properties: { "list" => "wishlist" })
145
+
146
+ # Up to 1000 events per call, each with its own type.
147
+ likyly.events.track_many([
148
+ { type: "view", user_id: "user_123", item_id: "SKU-3" },
149
+ { type: "add_to_cart", user_id: "user_123", item_id: "SKU-3", quantity: 1 }
150
+ ])
151
+ ```
152
+
153
+ - An event needs an item and a user, a session, or both (both ties the anonymous session to the user once they log in).
154
+ - Anonymous visitors: generate a session id (any string) and keep it in a cookie; no login needed.
155
+ - `properties` is a free-form object (price, currency, order id, ...). Its keys are stored exactly as you send them and are never renamed by the SDK.
156
+ - `event_id` (idempotency key): replaying an event with the same id records nothing and returns `duplicate: true`. Set it on purchases, for example `purchase_<orderId>_<itemId>`.
157
+ - `placement` is a free-form label of where something happened or will be displayed (`homepage`, `product_page`, `cart`, ...).
158
+
159
+ ## Advanced recommendations
160
+
161
+ `recommendations.get` is the normal way. When you want to pick the strategy yourself:
162
+
163
+ ```ruby
164
+ # Advanced Recommendations: one strategy at a time, when you want to choose.
165
+ similar = likyly.recommendations.similar(item_id: "SKU-1", limit: 3)
166
+ hybrid = likyly.recommendations.hybrid(user_id: "user_123", item_id: "SKU-1", alpha: 0.7, limit: 3)
167
+ session = likyly.recommendations.session(viewed_item_ids: %w[SKU-1 SKU-2], limit: 3)
168
+ ```
169
+
170
+ | Strategy | What it does |
171
+ |---|---|
172
+ | `popular` | The most popular items: the fallback for a visitor with no history |
173
+ | `similar` | Items similar to one item (content similarity), no user needed |
174
+ | `collaborative` | What users with similar histories liked. Needs a trained model, otherwise a not-found error |
175
+ | `hybrid` | Similar items personalised for a user; `alpha` (0 to 1) weighs collaborative against content |
176
+ | `session` | Recency-weighted, from `viewed item ids` or from a user's own view history (exactly one) |
177
+
178
+ These endpoints carry ids in the URL path, so ids containing `/` (such as `gid://shopify/Product/1`) or, for the session list, `,` are refused with a validation error: use `recommendations.get`, which takes ids in the request body.
179
+
180
+ ## Errors
181
+
182
+ Errors are typed (every one is a `Likyly::Error`). Catch the specific ones you care about:
183
+
184
+ | Error | When |
185
+ |---|---|
186
+ | `Likyly::ValidationError` | The request is invalid: caught by the SDK before anything is sent, or a 422 from the API (the message names the offending fields) |
187
+ | `Likyly::AuthenticationError` | 401: missing, invalid or revoked API key |
188
+ | `Likyly::PermissionDeniedError` | 403: for example a public key used for a secret-key operation, or a plan limit |
189
+ | `Likyly::NotFoundError` | 404 |
190
+ | `Likyly::RateLimitError` | 429: see the retry-after value |
191
+ | `Likyly::ApiError` | Any other error status from the API (the four rows above are its subtypes) |
192
+ | `Likyly::NetworkError` | No HTTP response: DNS, connection reset, TLS |
193
+ | `Likyly::TimeoutError` | The request timed out |
194
+
195
+ Errors that come from the API carry `#status_code`, `#request_id`, `#retry_after` (seconds), `#body`. Quote the request id when you contact support.
196
+
197
+ ```ruby
198
+ begin
199
+ likyly.items.get("does-not-exist")
200
+ rescue Likyly::NotFoundError => e
201
+ puts "no such item, request #{e.request_id}"
202
+ rescue Likyly::RateLimitError => e
203
+ puts "slow down, retry in #{e.retry_after} s"
204
+ end
205
+ ```
206
+
207
+ ## Retries and timeouts
208
+
209
+ The SDK retries transient failures for you (2 retries by default, exponential backoff from 0.5 s capped at 8 s, with full jitter):
210
+
211
+ - **429**: always retried, honouring `Retry-After`. A `Retry-After` longer than 60 s is not waited for: the rate-limit error is returned instead.
212
+ - **502, 503, 504, dropped connections and timeouts**: retried only when replaying the call is harmless: reads, upserts, deletes, events that carry an `event_id`, and batches where every event has one.
213
+ - An event **without** an `event_id` is never retried after an ambiguous failure, so a flaky network can never record a purchase twice. Set `event_id` on events you care about and they become safe to retry.
214
+ - Other 4xx errors are never retried.
215
+
216
+ ## Configuration
217
+
218
+ | Setting | Option | Default |
219
+ |---|---|---|
220
+ | API key | `api_key:` | required |
221
+ | Base URL | `base_url:` | `https://api.likyly.com` |
222
+ | Catalog | `catalog:` | your only catalog |
223
+ | Timeout | `timeout:` (seconds) | 10 |
224
+ | Retries | `max_retries:` | 2 |
225
+ | User-Agent suffix | `user_agent:` | none |
226
+ | Custom HTTP | `transport:` (anything answering `call(method, url, headers, body, timeout)`) | `Net::HTTP` |
227
+
228
+ ```ruby
229
+ tuned = Likyly::Client.new(
230
+ api_key: ENV.fetch("LIKYLY_SECRET_KEY"),
231
+ timeout: 5, # seconds per socket operation (default 10)
232
+ max_retries: 3, # automatic retries on 429 / 502 / 503 / 504 / network errors (default 2, 0 disables)
233
+ user_agent: "my-shop/1.4", # appended to the SDK's User-Agent
234
+ )
235
+ ```
236
+
237
+ Every method also accepts `timeout:` and `max_retries:` keywords.
238
+
239
+ ## API reference
240
+
241
+ `key` says which API key may call it: **S** = secret key only, **P** = public or secret key.
242
+
243
+ | Method | What it does | Key |
244
+ |---|---|---|
245
+ | `likyly.items.get` | One item by your own id | S |
246
+ | `likyly.items.list` | One page of the catalog (`limit` / `offset`), with the catalog's total | S |
247
+ | `likyly.items.upsert` | Create or replace an item (idempotent) | S |
248
+ | `likyly.items.delete` | Remove an item (events already recorded for it are kept) | S |
249
+ | `likyly.items.upsert_many` | Batch upsert, 1 to 1000 items | S |
250
+ | `likyly.items.import` | Alias of the batch upsert above (JSON batch) | S |
251
+ | `likyly.items.delete_many` | Batch delete, 1 to 1000 ids; unknown ids are reported, not raised | S |
252
+ | `likyly.users.get` | One user profile | S |
253
+ | `likyly.users.list` | One page of users (`limit` / `offset`) | S |
254
+ | `likyly.users.upsert` | Create or replace a profile (free-form `properties`) | S |
255
+ | `likyly.users.delete` | Erase the profile **and every event recorded for that user** | S |
256
+ | `likyly.users.import` | Batch upsert of users, 1 to 1000 | S |
257
+ | `likyly.events.track` | Record an event of any type (open string) | P |
258
+ | `likyly.events.track_many` | Up to 1000 events in one call, each with its own type | P |
259
+ | `likyly.events.impression` | The item was displayed to the visitor | P |
260
+ | `likyly.events.view` | The visitor looked at the item | P |
261
+ | `likyly.events.click` | The visitor clicked the item | P |
262
+ | `likyly.events.add_to_cart` | The visitor added the item to their cart | P |
263
+ | `likyly.events.remove_from_cart` | The visitor removed the item from their cart | P |
264
+ | `likyly.events.purchase` | The visitor bought the item (set an event id) | P |
265
+ | `likyly.events.identify` | An anonymous visitor signed in: attach their session's history to their user id | P |
266
+ | `likyly.placements.recommend` | Recommendations for a named placement configured in LIKYLY (the recommended way) | P |
267
+ | `likyly.recommendations.get` | Recommendations with the automatic strategy: user, session, item or viewed items | P |
268
+ | `likyly.recommendations.popular` | Advanced: the most popular items | P |
269
+ | `likyly.recommendations.similar` | Advanced: items similar to one item | P |
270
+ | `likyly.recommendations.collaborative` | Advanced: what similar users liked (needs a trained model) | P |
271
+ | `likyly.recommendations.hybrid` | Advanced: similar items personalised for a user (`alpha`) | P |
272
+ | `likyly.recommendations.session` | Advanced: from viewed items or a user's view history | P |
273
+
274
+ Lists are paginated with `limit` and `offset` (the API's default page is 100 items); the total is reported next to the page. Users have a batch `import` but no batch delete: the API has no such endpoint. `import` on items is an alias of the batch upsert (the CSV import is a dashboard feature).
275
+
276
+ ## Notes for Ruby
277
+
278
+ - Rails: create one client in an initializer (`LIKYLY = Likyly::Client.new(api_key: Rails.application.credentials.likyly_secret_key)`); nothing in the gem depends on Rails.
279
+ - Fields are `snake_case` keywords (`item_id:`, `recommendation_id:`); the keys inside `properties` are yours and are never renamed. Results are `Struct`s (`recs.items.first.item_id`).
280
+
281
+ ## Documentation and support
282
+
283
+ - Documentation: <https://likyly.com/docs/sdk/ruby>
284
+ - REST API reference (OpenAPI): <https://api.likyly.com/docs>
285
+ - Package: likyly on RubyGems
286
+
287
+ MIT licensed.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # The LIKYLY client. Five things to know:
5
+ #
6
+ # client.items your catalog
7
+ # client.users your users (optional)
8
+ # client.events what visitors do
9
+ # client.placements what to show them, where (the recommended way)
10
+ # client.recommendations what to show them, one call or one strategy at a time
11
+ #
12
+ # A client holds no connection state: it is safe to share between threads.
13
+ class Client
14
+ attr_reader :items, :users, :events, :recommendations, :placements
15
+
16
+ # @param api_key [String] Two kinds: the **secret** key (backend only: items, users, everything) and the
17
+ # **public** key (safe in a web page: recommendations and event tracking only). Never put the secret key
18
+ # in code that ships to a browser.
19
+ # @param base_url [String] defaults to https://api.likyly.com
20
+ # @param catalog [String, nil] which of your catalogs to use, if your account has several. Omit it if you have
21
+ # one: LIKYLY uses your only catalog.
22
+ # @param timeout [Numeric] per-request timeout in seconds (applied to connecting, writing and each read). Default 10.
23
+ # @param max_retries [Integer] automatic retries on transient failures (429, 502, 503, 504, dropped
24
+ # connections). Default 2. Use 0 to disable.
25
+ # @param user_agent [String, nil] appended to the SDK's User-Agent, e.g. "my-shop/1.4"
26
+ # @param transport [#call, nil] replaces Net::HTTP: +call(method, url, headers, body, timeout)+ returning
27
+ # +[status, headers, body]+ with lower-cased header names
28
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, catalog: nil, timeout: 10, max_retries: 2, user_agent: nil,
29
+ transport: nil, sleeper: nil, random: nil)
30
+ unless api_key.is_a?(String) && !api_key.strip.empty?
31
+ raise ValidationError, "api_key is required (create one in your LIKYLY account)"
32
+ end
33
+
34
+ agent = "likyly-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
35
+ agent = "#{agent} #{user_agent}" if user_agent
36
+ http = HttpCore.new(
37
+ api_key: api_key, base_url: base_url, catalog: catalog, timeout: timeout, max_retries: max_retries,
38
+ user_agent: agent, transport: transport || NetHttpTransport.new,
39
+ sleeper: sleeper || ->(seconds) { sleep(seconds) }, random: random || -> { rand }
40
+ )
41
+ @items = Items.new(http)
42
+ @users = Users.new(http)
43
+ @events = Events.new(http)
44
+ @recommendations = Recommendations.new(http)
45
+ @placements = Placements.new(http)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # Every error the SDK raises is a Likyly::Error.
5
+ #
6
+ # Likyly::Error
7
+ # ├── ValidationError invalid request - caught by the SDK before sending, or a 422 from the API
8
+ # ├── NetworkError no HTTP response (DNS, connection reset, ...)
9
+ # ├── TimeoutError the request timed out
10
+ # └── ApiError the API answered with an error status
11
+ # ├── AuthenticationError 401 - missing / invalid / revoked API key
12
+ # ├── PermissionDeniedError 403 - e.g. a public key used for a secret-key operation, or a plan limit
13
+ # ├── NotFoundError 404
14
+ # └── RateLimitError 429 - see #retry_after
15
+ class Error < StandardError
16
+ # @return [Integer, nil] the HTTP status code, when the API answered
17
+ attr_reader :status_code
18
+ # @return [String, nil] the request_id of the failed call (also the X-Request-ID header) - quote it when reporting a problem
19
+ attr_reader :request_id
20
+ # @return [Numeric, nil] the Retry-After header in seconds (429/503), when present
21
+ attr_reader :retry_after
22
+ # @return [Object, nil] the parsed response body (Hash, Array or String), when there was one
23
+ attr_reader :body
24
+
25
+ def initialize(message = nil, status_code: nil, request_id: nil, retry_after: nil, body: nil, cause: nil)
26
+ super(message)
27
+ @status_code = status_code
28
+ @request_id = request_id
29
+ @retry_after = retry_after
30
+ @body = body
31
+ @cause_override = cause
32
+ end
33
+
34
+ def message
35
+ base = super
36
+ request_id ? "#{base} (request_id=#{request_id})" : base
37
+ end
38
+
39
+ def cause
40
+ @cause_override || super
41
+ end
42
+ end
43
+
44
+ # Invalid request: caught by the SDK before anything is sent (status_code is nil), or a 422 from the API.
45
+ class ValidationError < Error; end
46
+
47
+ # No HTTP response: DNS failure, connection refused or reset, TLS error, ...
48
+ class NetworkError < Error; end
49
+
50
+ # The request timed out (the client's `timeout`).
51
+ class TimeoutError < Error; end
52
+
53
+ # The API answered with an error status.
54
+ class ApiError < Error; end
55
+
56
+ # 401: missing, invalid or revoked API key.
57
+ class AuthenticationError < ApiError; end
58
+
59
+ # 403: e.g. a public key used for a secret-key operation, or a plan limit.
60
+ class PermissionDeniedError < ApiError; end
61
+
62
+ # 404.
63
+ class NotFoundError < ApiError; end
64
+
65
+ # 429: see #retry_after.
66
+ class RateLimitError < ApiError; end
67
+
68
+ # @api private
69
+ module Errors
70
+ module_function
71
+
72
+ def from_response(status, body, request_id, retry_after)
73
+ klass = case status
74
+ when 401 then AuthenticationError
75
+ when 403 then PermissionDeniedError
76
+ when 404 then NotFoundError
77
+ when 422 then ValidationError
78
+ when 429 then RateLimitError
79
+ else ApiError
80
+ end
81
+ message = message_of(body) || "API error (HTTP #{status})"
82
+ klass.new(message, status_code: status, request_id: request_id, retry_after: retry_after, body: body)
83
+ end
84
+
85
+ def message_of(body)
86
+ return nil unless body.is_a?(Hash)
87
+
88
+ detail = body["detail"]
89
+ case detail
90
+ when String then detail
91
+ when Array then detail.map { |d| d.is_a?(Hash) && d["msg"] ? d["msg"].to_s : d.to_s }.join("; ")
92
+ when nil then nil
93
+ else detail.to_s
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+
9
+ module Likyly
10
+ # The default transport: one Net::HTTP connection per request (thread-safe, no shared state). Anything that
11
+ # responds to +call(method, url, headers, body, timeout)+ and returns +[status, headers, body]+ (header names
12
+ # lower-cased) can replace it - see the +transport:+ client option.
13
+ class NetHttpTransport
14
+ def call(method, url, headers, body, timeout)
15
+ uri = URI.parse(url)
16
+ http = Net::HTTP.new(uri.host, uri.port)
17
+ http.use_ssl = uri.scheme == "https"
18
+ http.open_timeout = http.read_timeout = http.write_timeout = timeout
19
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
20
+ request.body = body if body
21
+ response = http.request(request)
22
+ [response.code.to_i, response.each_header.to_h { |k, v| [k.downcase, v] }, response.body.to_s.dup.force_encoding(Encoding::UTF_8)]
23
+ end
24
+ end
25
+
26
+ # The only place that talks HTTP. Every resource goes through #request: auth header, catalog parameter,
27
+ # timeout, retries with exponential backoff + jitter, and the error mapping.
28
+ # @api private
29
+ class HttpCore
30
+ RETRY_BASE = 0.5
31
+ RETRY_CAP = 8.0
32
+ # A Retry-After longer than this is not waited for: the RateLimitError is raised instead.
33
+ MAX_RETRY_AFTER = 60
34
+
35
+ Response = Struct.new(:status, :headers, :body) do
36
+ def json
37
+ JSON.parse(body)
38
+ rescue JSON::ParserError => e
39
+ raise ApiError.new("the API answered with a body that is not the expected JSON: #{e.message}", status_code: status)
40
+ end
41
+ end
42
+
43
+ def initialize(api_key:, base_url:, catalog:, timeout:, max_retries:, user_agent:, transport:, sleeper:, random:)
44
+ @api_key = api_key
45
+ @base_url = base_url.sub(%r{/+\z}, "")
46
+ @catalog = catalog
47
+ @timeout = timeout
48
+ @max_retries = max_retries
49
+ @user_agent = user_agent
50
+ @transport = transport
51
+ @sleeper = sleeper
52
+ @random = random
53
+ end
54
+
55
+ # +idempotent+ says whether the call is safe to send again if its outcome is unknown (a timeout, a 5xx, a
56
+ # dropped connection): false for events without an event_id, where a blind retry could record the event twice.
57
+ def request(method, path, idempotent:, query: {}, body: nil, timeout: nil, max_retries: nil)
58
+ timeout ||= @timeout
59
+ max_retries ||= @max_retries
60
+ url = build_url(path, query)
61
+ payload = body.nil? ? nil : JSON.generate(body)
62
+ attempt = 0
63
+ begin
64
+ perform(method, url, payload, timeout)
65
+ rescue Error => e
66
+ delay = retry_delay(e, attempt, idempotent)
67
+ raise if delay.nil? || attempt >= max_retries
68
+
69
+ @sleeper.call(delay)
70
+ attempt += 1
71
+ retry
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def build_url(path, query)
78
+ params = query.compact.map { |k, v| "#{Support.encode(k)}=#{Support.encode(v)}" }
79
+ params << "data_product_type=#{Support.encode(@catalog)}" if @catalog
80
+ "#{@base_url}#{path}#{params.empty? ? "" : "?#{params.join("&")}"}"
81
+ end
82
+
83
+ def perform(method, url, payload, timeout)
84
+ headers = { "X-API-Key" => @api_key, "Accept" => "application/json", "User-Agent" => @user_agent }
85
+ headers["Content-Type"] = "application/json" if payload
86
+ begin
87
+ status, response_headers, text = @transport.call(method, url, headers, payload, timeout)
88
+ rescue ::Timeout::Error => e
89
+ raise TimeoutError.new("request timed out after #{timeout}s", cause: e)
90
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
91
+ raise NetworkError.new("could not reach the API: #{e.message}", cause: e)
92
+ end
93
+
94
+ return Response.new(status, response_headers, text) if status < 400
95
+
96
+ body = begin
97
+ text.to_s.empty? ? nil : JSON.parse(text)
98
+ rescue JSON::ParserError
99
+ text # e.g. an HTML 502 page from a proxy
100
+ end
101
+ request_id = response_headers["x-request-id"] || (body.is_a?(Hash) ? body["request_id"]&.to_s : nil)
102
+ raise Errors.from_response(status, body, request_id, parse_retry_after(response_headers["retry-after"]))
103
+ end
104
+
105
+ def retry_delay(error, attempt, idempotent)
106
+ backoff = @random.call * [RETRY_CAP, RETRY_BASE * (2**attempt)].min # full jitter
107
+ case error
108
+ when ApiError, ValidationError
109
+ status = error.status_code
110
+ return nil if status.nil?
111
+
112
+ if status == 429
113
+ # Rejected by the rate limiter before reaching the application: nothing was processed, so retrying is safe for every request.
114
+ return after_header(error, backoff)
115
+ end
116
+ return nil unless idempotent # the outcome is unknown - never risk a duplicate
117
+
118
+ [502, 503, 504].include?(status) ? after_header(error, backoff) : nil
119
+ when NetworkError, TimeoutError
120
+ idempotent ? backoff : nil
121
+ end
122
+ end
123
+
124
+ def after_header(error, backoff)
125
+ return backoff if error.retry_after.nil?
126
+
127
+ error.retry_after <= MAX_RETRY_AFTER ? error.retry_after : nil
128
+ end
129
+
130
+ def parse_retry_after(value)
131
+ return nil if value.nil? || value.strip.empty?
132
+ return Float(value) if value.match?(/\A\s*\d+(\.\d+)?\s*\z/)
133
+
134
+ [Time.httpdate(value) - Time.now, 0].max
135
+ rescue ArgumentError
136
+ nil
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # Free-form properties (price, currency, orderId, category, ...) are plain Hashes whose keys are yours: the SDK
5
+ # never renames them. Hashes you read back have String keys, exactly as stored.
6
+
7
+ # A catalog entry. +item_id+ is your own identifier - any string (SKU-123, a UUID, gid://shopify/Product/123).
8
+ Item = Struct.new(:item_id, :title, :description, :properties, keyword_init: true)
9
+
10
+ # One page of the catalog. +total+ is the whole catalog's size (X-Total-Count), nil if the API did not report
11
+ # it; +limit+ is the page size you asked for (nil = the API's default).
12
+ ItemList = Struct.new(:items, :total, :limit, :offset, keyword_init: true)
13
+
14
+ # An optional user profile.
15
+ User = Struct.new(:user_id, :properties, keyword_init: true)
16
+
17
+ # One page of users.
18
+ UserList = Struct.new(:users, :total, :limit, :offset, keyword_init: true)
19
+
20
+ # One failed entry of a batch call.
21
+ BatchError = Struct.new(:index, :id, :message, keyword_init: true)
22
+
23
+ # Result of a batch call. A failing entry is reported in +errors+, it does not raise.
24
+ BatchResult = Struct.new(:received, :succeeded, :failed, :errors, keyword_init: true)
25
+
26
+ # Answer to a recorded event. +duplicate+ is true if this +event_id+ was already recorded - nothing was written.
27
+ EventResult = Struct.new(:message, :event_id, :duplicate, keyword_init: true) do
28
+ def duplicate?
29
+ duplicate == true
30
+ end
31
+ end
32
+
33
+ # Answer to events.track_many. +duplicates+ are events skipped because their +event_id+ was already recorded.
34
+ EventBatchResult = Struct.new(:received, :accepted, :duplicates, keyword_init: true)
35
+
36
+ # A user whose history contributed to a recommendation (debug, secret key only).
37
+ SimilarUser = Struct.new(:user_id, :shared_item_ids, keyword_init: true)
38
+
39
+ # Why an item was recommended.
40
+ Explanation = Struct.new(
41
+ :reason, :content_similarity, :semantic_similarity, :popularity_score, :interaction_count,
42
+ :interaction_label, :collaborative_score, :source_item_ids, :similar_users,
43
+ keyword_init: true
44
+ )
45
+
46
+ # One recommended item.
47
+ RecommendedItem = Struct.new(:item_id, :score, :title, :description, :properties, :explanation, keyword_init: true)
48
+
49
+ # What every recommendation call returns. Send +recommendation_id+ back on the impression / click /
50
+ # add_to_cart / purchase events for these items. +strategy+ is what LIKYLY used: hybrid, content,
51
+ # collaborative, session or popular.
52
+ RecommendationResponse = Struct.new(:recommendation_id, :strategy, :placement, :items, keyword_init: true)
53
+
54
+ # What placements.recommend returns. Send +recommendation_id+ back on the events that follow, like any
55
+ # recommendation. +strategy_used+ is what the placement actually used: hybrid, content, collaborative, session
56
+ # or popular.
57
+ PlacementRecommendation = Struct.new(:recommendation_id, :placement, :strategy_used, :items, keyword_init: true)
58
+
59
+ # Answer to events.identify: how many past interactions of the session are now attributed to the user.
60
+ IdentifyResult = Struct.new(:linked_interactions, keyword_init: true)
61
+
62
+ # @api private
63
+ module Wire
64
+ module_function
65
+
66
+ def item(w)
67
+ Item.new(item_id: w["item_id"], title: w["title"], description: w["description"], properties: w["properties"] || {})
68
+ end
69
+
70
+ def user(w)
71
+ User.new(user_id: w["user_id"], properties: w["properties"] || {})
72
+ end
73
+
74
+ def batch(w)
75
+ errors = (w["errors"] || []).map { |e| BatchError.new(index: e["index"], id: e["id"], message: e["message"]) }
76
+ BatchResult.new(received: w["received"], succeeded: w["succeeded"], failed: w["failed"], errors: errors)
77
+ end
78
+
79
+ def explanation(w)
80
+ Explanation.new(
81
+ reason: w["reason"], content_similarity: w["content_similarity"], semantic_similarity: w["semantic_similarity"],
82
+ popularity_score: w["popularity_score"], interaction_count: w["interaction_count"],
83
+ interaction_label: w["interaction_label"], collaborative_score: w["collaborative_score"],
84
+ source_item_ids: w["source_item_ids"],
85
+ similar_users: w["similar_users"]&.map { |u| SimilarUser.new(user_id: u["user_id"], shared_item_ids: u["shared_item_ids"] || []) }
86
+ )
87
+ end
88
+
89
+ def recommended_item(i)
90
+ RecommendedItem.new(
91
+ item_id: i["item_id"], score: i["score"], title: i["title"], description: i["description"],
92
+ properties: i["properties"] || {}, explanation: i["explanation"] ? explanation(i["explanation"]) : nil
93
+ )
94
+ end
95
+
96
+ def recommendation(w)
97
+ items = w["items"].map { |i| recommended_item(i) }
98
+ RecommendationResponse.new(recommendation_id: w["recommendation_id"], strategy: w["strategy"], placement: w["placement"], items: items)
99
+ end
100
+
101
+ def placement_recommendation(w)
102
+ PlacementRecommendation.new(
103
+ recommendation_id: w["recommendation_id"], placement: w["placement"], strategy_used: w["strategy_used"],
104
+ items: (w["items"] || []).map { |i| recommended_item(i) }
105
+ )
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # +client.events+ - what your visitors do. Works with the **public** key from a browser, or the secret key from
5
+ # your backend. +track+ is the one mechanism; +view+, +click+, ... are shortcuts that call it with the matching
6
+ # event type.
7
+ #
8
+ # An event needs an +item_id+ and a +user_id+ or a +session_id+ (or both: that ties an anonymous session to the
9
+ # user). Every event method also accepts +timeout:+ and +max_retries:+.
10
+ class Events
11
+ EVENT_TYPE = /\A[A-Za-z0-9_-]{1,64}\z/
12
+
13
+ # @api private
14
+ def initialize(http)
15
+ @http = http
16
+ end
17
+
18
+ # Records one event of any type: "view", "click", ... or your own ("favorite", "share", ...). Event types are
19
+ # open strings - the six helpers below are conveniences, not a closed list.
20
+ #
21
+ # @param type [String]
22
+ # @param item_id [String]
23
+ # @param user_id [String, nil]
24
+ # @param session_id [String, nil] anonymous visitor / browsing session - no login needed
25
+ # @param recommendation_id [String, nil] the +recommendation_id+ of the recommendation that surfaced this item - enables attribution
26
+ # @param placement [String, nil] free-form label of where this happened: "homepage", "product_page", "cart", ...
27
+ # @param quantity [Integer, nil]
28
+ # @param occurred_at [Time, String, nil] defaults to the server's time
29
+ # @param properties [Hash, nil] free-form; keys are yours
30
+ # @param event_id [String, nil] idempotency key, unique per account: replaying an event with the same +event_id+
31
+ # records nothing and returns +duplicate: true+. Set it on purchases - it makes retries safe.
32
+ # @return [EventResult]
33
+ #
34
+ # Retries: a failed call is only retried automatically when it carries an +event_id+ (then a replay is
35
+ # harmless); without one, an ambiguous failure is raised rather than risking a duplicate.
36
+ def track(type, item_id: nil, user_id: nil, session_id: nil, recommendation_id: nil, placement: nil,
37
+ quantity: nil, occurred_at: nil, properties: nil, event_id: nil, timeout: nil, max_retries: nil)
38
+ body = event_body(item_id: item_id, user_id: user_id, session_id: session_id, recommendation_id: recommendation_id,
39
+ placement: placement, quantity: quantity, occurred_at: occurred_at, properties: properties, event_id: event_id)
40
+ response = @http.request("POST", "/events/#{Support.encode(require_type(type))}", body: body, idempotent: !event_id.nil?,
41
+ timeout: timeout, max_retries: max_retries).json
42
+ EventResult.new(message: response["message"], event_id: response["event_id"], duplicate: response["duplicate"] == true)
43
+ end
44
+
45
+ # Up to 1000 events in one call, each a Hash with its own +type:+. All-or-nothing validation.
46
+ # @param events [Array<Hash>] +{ type: "view", user_id:, item_id:, ... }+
47
+ # @return [EventBatchResult]
48
+ def track_many(events, timeout: nil, max_retries: nil)
49
+ raise ValidationError, "events must be a non-empty Array" unless events.is_a?(Array) && !events.empty?
50
+
51
+ entries = events.map do |e|
52
+ raise ValidationError, "each event must be a Hash" unless e.is_a?(Hash)
53
+
54
+ unknown = e.keys - BODY_KEYS - [:type]
55
+ raise ValidationError, "unknown event field#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
56
+
57
+ event_body(**BODY_KEYS.to_h { |k| [k, e[k]] }).merge("event_type" => require_type(e[:type]))
58
+ end
59
+ idempotent = events.all? { |e| !e[:event_id].nil? }
60
+ r = @http.request("POST", "/events/batch", body: { "events" => entries }, idempotent: idempotent, timeout: timeout, max_retries: max_retries).json
61
+ EventBatchResult.new(received: r["received"], accepted: r["accepted"], duplicates: r["duplicates"])
62
+ end
63
+
64
+ # The item was shown to the visitor (send the +recommendation_id+ it came with).
65
+ def impression(**event) = track("impression", **event)
66
+
67
+ # The visitor looked at the item (a product page, an article).
68
+ def view(**event) = track("view", **event)
69
+
70
+ # The visitor clicked the item.
71
+ def click(**event) = track("click", **event)
72
+
73
+ # The visitor added the item to their cart.
74
+ def add_to_cart(**event) = track("add_to_cart", **event)
75
+
76
+ # The visitor removed the item from their cart.
77
+ def remove_from_cart(**event) = track("remove_from_cart", **event)
78
+
79
+ # The visitor bought the item. Set +event_id+ (e.g. +"purchase_#{order_id}_#{item_id}"+) so a retry can never
80
+ # count the purchase twice.
81
+ def purchase(**event) = track("purchase", **event)
82
+
83
+ # Call right after login or signup, once you know +user_id+, with the +session_id+ the visitor was anonymous
84
+ # under: every past interaction of that session not yet attributed to a user is reassigned to +user_id+, so it
85
+ # counts as that user's history from then on. Safe to call more than once.
86
+ #
87
+ # @param user_id [String]
88
+ # @param session_id [String]
89
+ # @return [IdentifyResult]
90
+ def identify(user_id:, session_id:, timeout: nil, max_retries: nil)
91
+ body = { "user_id" => Support.require_id(user_id, "user_id"), "session_id" => Support.require_id(session_id, "session_id") }
92
+ # Only ever reassigns not-yet-attributed interactions: replaying it is harmless, so it is retried.
93
+ r = @http.request("POST", "/events/identify", body: body, idempotent: true, timeout: timeout, max_retries: max_retries).json
94
+ IdentifyResult.new(linked_interactions: r["linked_interactions"])
95
+ end
96
+
97
+ BODY_KEYS = %i[item_id user_id session_id recommendation_id placement quantity occurred_at properties event_id].freeze
98
+ private_constant :BODY_KEYS
99
+
100
+ private
101
+
102
+ def require_type(type)
103
+ return type if type.is_a?(String) && type.match?(EVENT_TYPE)
104
+
105
+ raise ValidationError, 'event type must be 1-64 characters of letters, digits, "_" or "-" (e.g. "view", "add_to_cart", "favorite")'
106
+ end
107
+
108
+ # +properties+ is passed through untouched: its keys are yours.
109
+ def event_body(item_id:, user_id:, session_id:, recommendation_id:, placement:, quantity:, occurred_at:, properties:, event_id:)
110
+ Support.require_id(item_id, "item_id")
111
+ raise ValidationError, "an event needs a user_id or a session_id (or both)" if user_id.nil? && session_id.nil?
112
+
113
+ Support.require_id(user_id, "user_id") unless user_id.nil?
114
+ Support.require_id(session_id, "session_id") unless session_id.nil?
115
+ Support.compact(
116
+ "event_id" => event_id, "user_id" => user_id, "session_id" => session_id, "item_id" => item_id,
117
+ "recommendation_id" => recommendation_id, "placement" => placement, "quantity" => quantity,
118
+ "occurred_at" => occurred_at.is_a?(Time) ? occurred_at.getutc.iso8601(3) : occurred_at,
119
+ "properties" => properties
120
+ )
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # +client.items+ - your catalog. Needs the **secret** API key.
5
+ #
6
+ # Every method also accepts +timeout:+ (seconds) and +max_retries:+ to override the client's for that call.
7
+ class Items
8
+ # @api private
9
+ def initialize(http)
10
+ @http = http
11
+ end
12
+
13
+ # One item by your own id.
14
+ # @return [Item]
15
+ def get(item_id, timeout: nil, max_retries: nil)
16
+ id = Support.require_id(item_id, "item_id")
17
+ Wire.item(@http.request("GET", "/items/#{Support.encode(id)}", idempotent: true, timeout: timeout, max_retries: max_retries).json)
18
+ end
19
+
20
+ # One page of the catalog. Pagination is +limit+ + +offset+ (the API's default is 100 per page); +total+ is
21
+ # the whole catalog's size.
22
+ # @return [ItemList]
23
+ def list(limit: nil, offset: nil, timeout: nil, max_retries: nil)
24
+ response = @http.request("GET", "/items", query: { "limit" => limit, "offset" => offset }, idempotent: true, timeout: timeout, max_retries: max_retries)
25
+ ItemList.new(items: response.json.map { |w| Wire.item(w) }, total: total_of(response), limit: limit, offset: offset || 0)
26
+ end
27
+
28
+ # Creates the item, or replaces it if it exists - idempotent, safe to call as often as you like. The body is
29
+ # the whole item: fields you leave out are cleared.
30
+ # @param properties [Hash, nil] +category+ and +description+ feed content similarity; everything else is stored as is
31
+ # @return [Item]
32
+ def upsert(item_id, title: nil, description: nil, properties: nil, timeout: nil, max_retries: nil)
33
+ id = Support.require_id(item_id, "item_id")
34
+ body = item_body(title, description, properties)
35
+ Wire.item(@http.request("PUT", "/items/#{Support.encode(id)}", body: body, idempotent: true, timeout: timeout, max_retries: max_retries).json)
36
+ end
37
+
38
+ # Removes the item from the catalog. Events already recorded for it are kept.
39
+ # @return [nil]
40
+ def delete(item_id, timeout: nil, max_retries: nil)
41
+ id = Support.require_id(item_id, "item_id")
42
+ @http.request("DELETE", "/items/#{Support.encode(id)}", idempotent: true, timeout: timeout, max_retries: max_retries)
43
+ nil
44
+ end
45
+
46
+ # Batch upsert (1-1000 items) - each entry behaves like +upsert+. A failing entry is reported in +errors+.
47
+ # @param items [Array<Hash>] +{ item_id:, title:, description:, properties: }+
48
+ # @return [BatchResult]
49
+ def upsert_many(items, timeout: nil, max_retries: nil)
50
+ raise ValidationError, "items must be a non-empty Array" unless items.is_a?(Array) && !items.empty?
51
+
52
+ entries = items.map do |i|
53
+ raise ValidationError, "each item must be a Hash" unless i.is_a?(Hash)
54
+
55
+ item_body(i[:title], i[:description], i[:properties]).merge("item_id" => Support.require_id(i[:item_id], "item_id"))
56
+ end
57
+ # An upsert: replaying it changes nothing, so it is retried.
58
+ Wire.batch(@http.request("POST", "/items/import", body: { "items" => entries }, idempotent: true, timeout: timeout, max_retries: max_retries).json)
59
+ end
60
+
61
+ # Alias of {#upsert_many}: the API's +POST /items/import+ is a JSON batch upsert. (A CSV import exists only in
62
+ # the LIKYLY dashboard.)
63
+ def import(items, **options)
64
+ upsert_many(items, **options)
65
+ end
66
+
67
+ # Batch delete (1-1000 ids). Ids that don't exist are reported in +errors+.
68
+ # @return [BatchResult]
69
+ def delete_many(item_ids, timeout: nil, max_retries: nil)
70
+ raise ValidationError, "item_ids must be a non-empty Array" unless item_ids.is_a?(Array) && !item_ids.empty?
71
+
72
+ ids = item_ids.map { |id| Support.require_id(id, "item_id") }
73
+ Wire.batch(@http.request("POST", "/items/delete", body: { "item_ids" => ids }, idempotent: true, timeout: timeout, max_retries: max_retries).json)
74
+ end
75
+
76
+ private
77
+
78
+ def item_body(title, description, properties)
79
+ raise ValidationError, "an item needs a non-empty title" unless title.is_a?(String) && !title.empty?
80
+
81
+ Support.compact("title" => title, "description" => description, "properties" => properties)
82
+ end
83
+
84
+ def total_of(response)
85
+ value = response.headers["x-total-count"]
86
+ value && Integer(value, exception: false)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # +client.placements+ - the recommended way to ask for recommendations. A placement ("what to recommend, where,
5
+ # with what fallback and filters") is configured once, in LIKYLY (dashboard or MCP admin tools) - never from this
6
+ # SDK - and called by its slug from here on. +client.recommendations+ remains available for per-call use with no
7
+ # stored configuration.
8
+ #
9
+ # Works with the **public** key from a browser, or the secret key from your backend. Every method also accepts
10
+ # +timeout:+ and +max_retries:+ to override the client's for that call.
11
+ class Placements
12
+ # @api private
13
+ def initialize(http)
14
+ @http = http
15
+ end
16
+
17
+ # Recommendations for one placement. Send whatever context you have - the placement's own configuration
18
+ # decides the strategy, fallback and filters, not you. Send +recommendation_id+ back on the events that follow.
19
+ #
20
+ # client.placements.recommend(placement: "pdp-related", context: { item_id: "SKU-456", user_id: "user_123" })
21
+ #
22
+ # @param placement [String] the placement's slug, e.g. "pdp-related"
23
+ # @param context [Hash, nil] what you know about this page view, all optional:
24
+ # +item_id+ (the item being looked at), +category_id+, +collection_id+, +user_id+, +anonymous_id+ (stable
25
+ # anonymous visitor id, used like +session_id+ when that is omitted), +session_id+, +cart_item_ids+ (Array),
26
+ # +locale+, +custom_context+ (Hash, passed through untouched: its keys are yours)
27
+ # @param limit [Integer, nil] overrides the placement's own configured limit for this call
28
+ # @return [PlacementRecommendation]
29
+ def recommend(placement:, context: nil, limit: nil, timeout: nil, max_retries: nil)
30
+ slug = Support.require_id(placement, "placement")
31
+ unless limit.nil? || (limit.is_a?(Integer) && limit >= 1)
32
+ raise ValidationError, "limit must be a positive Integer"
33
+ end
34
+
35
+ body = Support.compact("context" => context_body(context), "limit" => limit)
36
+ # Each call mints a fresh recommendation_id: repeating it on a dropped connection is harmless, so it is retried.
37
+ response = @http.request("POST", "/placements/#{Support.encode(slug)}/recommend", body: body, idempotent: true,
38
+ timeout: timeout, max_retries: max_retries)
39
+ Wire.placement_recommendation(response.json)
40
+ end
41
+
42
+ # Context key => wire name.
43
+ CONTEXT_KEYS = {
44
+ item_id: "current_item_id", category_id: "category_id", collection_id: "collection_id", user_id: "user_id",
45
+ anonymous_id: "anonymous_id", session_id: "session_id", cart_item_ids: "cart_item_ids", locale: "locale",
46
+ custom_context: "custom_context"
47
+ }.freeze
48
+ private_constant :CONTEXT_KEYS
49
+
50
+ private
51
+
52
+ def context_body(context)
53
+ return nil if context.nil?
54
+ raise ValidationError, "context must be a Hash" unless context.is_a?(Hash)
55
+
56
+ unknown = context.keys - CONTEXT_KEYS.keys
57
+ raise ValidationError, "unknown context field#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
58
+
59
+ Support.compact(CONTEXT_KEYS.to_h { |key, wire| [wire, context[key]] })
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # +client.recommendations+. Use {#get}: send what you know and LIKYLY picks the best strategy. The other methods
5
+ # are the *Advanced Recommendations* - one strategy at a time.
6
+ #
7
+ # Every method also accepts +timeout:+ and +max_retries:+ to override the client's for that call.
8
+ class Recommendations
9
+ # @api private
10
+ def initialize(http)
11
+ @http = http
12
+ end
13
+
14
+ # Recommendations for a user, an anonymous session, an item being viewed, viewed items - or nothing at all
15
+ # (then you get what is popular). You never choose the algorithm; +strategy+ in the response says what was used.
16
+ # Send +recommendation_id+ back on the events that follow.
17
+ #
18
+ # @param user_id [String, nil]
19
+ # @param session_id [String, nil]
20
+ # @param item_id [String, nil] the item being looked at (e.g. the product page)
21
+ # @param viewed_item_ids [Array<String>, nil] recently viewed items, oldest first
22
+ # @param placement [String, nil] free-form label of where the recommendations will be shown
23
+ # @param limit [Integer, nil] how many items to return (1-100, default 10)
24
+ # @param debug [Boolean] diagnostic detail in +explanation+. Secret key only.
25
+ # @return [RecommendationResponse]
26
+ def get(user_id: nil, session_id: nil, item_id: nil, viewed_item_ids: nil, placement: nil, limit: nil, debug: false,
27
+ timeout: nil, max_retries: nil)
28
+ body = Support.compact(
29
+ "user_id" => user_id.nil? ? nil : Support.require_id(user_id, "user_id"),
30
+ "session_id" => session_id.nil? ? nil : Support.require_id(session_id, "session_id"),
31
+ "item_id" => item_id.nil? ? nil : Support.require_id(item_id, "item_id"),
32
+ "viewed_item_ids" => viewed_item_ids&.map { |id| Support.require_id(id, "viewed_item_ids[]") },
33
+ "placement" => placement, "count" => limit, "debug" => debug ? true : nil
34
+ )
35
+ # A read: repeating it only mints another recommendation_id, so it is retried.
36
+ Wire.recommendation(@http.request("POST", "/getRec", body: body, idempotent: true, timeout: timeout, max_retries: max_retries).json)
37
+ end
38
+
39
+ # ---- Advanced Recommendations --------------------------------------------------------------------------
40
+ # One strategy at a time, for expert use. Ids travel in the URL path here, so they cannot contain "/".
41
+ # All of them also take +placement:+, +session_id:+ (attaches the recommendation to an anonymous session, for
42
+ # attribution) and +limit:+ (default 10).
43
+
44
+ # The most popular items - the fallback for a visitor with no history at all.
45
+ def popular(limit: nil, placement: nil, session_id: nil, timeout: nil, max_retries: nil)
46
+ advanced("/getRec/popular/#{Support.limit_of(limit)}", placement, session_id, {}, timeout, max_retries)
47
+ end
48
+
49
+ # Items similar to one item (content similarity). No user needed.
50
+ def similar(item_id:, limit: nil, placement: nil, session_id: nil, timeout: nil, max_retries: nil)
51
+ item = Support.require_path_safe_id(item_id, "item_id")
52
+ advanced("/getRec/content/#{Support.encode(item)}/#{Support.limit_of(limit)}", placement, session_id, {}, timeout, max_retries)
53
+ end
54
+
55
+ # Collaborative filtering: what users with similar histories liked. Needs a trained model.
56
+ def collaborative(user_id:, limit: nil, placement: nil, session_id: nil, timeout: nil, max_retries: nil)
57
+ user = Support.require_path_safe_id(user_id, "user_id")
58
+ advanced("/getRec/collaborative/#{Support.encode(user)}/#{Support.limit_of(limit)}", placement, session_id, {}, timeout, max_retries)
59
+ end
60
+
61
+ # Similar items, personalized for a user. +alpha+ (0-1) weighs the collaborative signal against content
62
+ # similarity: 0 = pure content, 1 = pure collaborative (the API's default is 0.5).
63
+ def hybrid(user_id:, item_id:, alpha: nil, limit: nil, placement: nil, session_id: nil, timeout: nil, max_retries: nil)
64
+ user = Support.require_path_safe_id(user_id, "user_id")
65
+ item = Support.require_path_safe_id(item_id, "item_id")
66
+ path = "/getRec/hybrid/#{Support.encode(user)}/#{Support.encode(item)}/#{Support.limit_of(limit)}"
67
+ advanced(path, placement, session_id, { "alpha" => alpha }, timeout, max_retries)
68
+ end
69
+
70
+ # Recency-weighted recommendations from what was viewed: pass +viewed_item_ids+ (an explicit list, oldest
71
+ # first) **or** +user_id+ (LIKYLY's own history of that user's views) - exactly one.
72
+ def session(viewed_item_ids: nil, user_id: nil, limit: nil, placement: nil, session_id: nil, timeout: nil, max_retries: nil)
73
+ if viewed_item_ids.nil? == user_id.nil?
74
+ raise ValidationError, "session needs either viewed_item_ids or user_id (exactly one)"
75
+ end
76
+
77
+ count = Support.limit_of(limit)
78
+ unless user_id.nil?
79
+ user = Support.require_path_safe_id(user_id, "user_id")
80
+ return advanced("/getRec/sessionForUser/#{Support.encode(user)}/#{count}", placement, session_id, {}, timeout, max_retries)
81
+ end
82
+
83
+ raise ValidationError, "viewed_item_ids must be a non-empty Array" unless viewed_item_ids.is_a?(Array) && !viewed_item_ids.empty?
84
+
85
+ ids = viewed_item_ids.map { |id| Support.require_id(id, "viewed_item_ids[]") }
86
+ if ids.any? { |id| id.include?(",") }
87
+ raise ValidationError, 'an item id containing "," cannot be sent in this endpoint\'s comma-separated list - use recommendations.get'
88
+ end
89
+
90
+ advanced("/getRec/session", placement, session_id, { "viewed_item_ids" => ids.join(","), "count" => count }, timeout, max_retries)
91
+ end
92
+
93
+ private
94
+
95
+ def advanced(path, placement, session_id, extra, timeout, max_retries)
96
+ # response_format=object: always the same {recommendation_id, strategy, items} envelope
97
+ query = { "response_format" => "object", "placement" => placement, "session_id" => session_id }.merge(extra)
98
+ Wire.recommendation(@http.request("GET", path, query: query, idempotent: true, timeout: timeout, max_retries: max_retries).json)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # +client.users+ - optional user profiles. Needs the **secret** API key (profiles are personal data). You don't
5
+ # have to create a user before sending events for them.
6
+ class Users
7
+ # @api private
8
+ def initialize(http)
9
+ @http = http
10
+ end
11
+
12
+ # @return [User]
13
+ def get(user_id, timeout: nil, max_retries: nil)
14
+ id = Support.require_id(user_id, "user_id")
15
+ Wire.user(@http.request("GET", "/users/#{Support.encode(id)}", idempotent: true, timeout: timeout, max_retries: max_retries).json)
16
+ end
17
+
18
+ # One page of users. Pagination is +limit+ + +offset+; without +limit+ the API returns every user.
19
+ # @return [UserList]
20
+ def list(limit: nil, offset: nil, timeout: nil, max_retries: nil)
21
+ response = @http.request("GET", "/users", query: { "limit" => limit, "offset" => offset }, idempotent: true, timeout: timeout, max_retries: max_retries)
22
+ total = response.headers["x-total-count"]
23
+ UserList.new(users: response.json.map { |w| Wire.user(w) }, total: total && Integer(total, exception: false), limit: limit, offset: offset || 0)
24
+ end
25
+
26
+ # Creates or replaces the profile (idempotent). +properties+ is free-form: country, segment, language, ...
27
+ # @return [User]
28
+ def upsert(user_id, properties: nil, timeout: nil, max_retries: nil)
29
+ id = Support.require_id(user_id, "user_id")
30
+ body = Support.compact("properties" => properties)
31
+ Wire.user(@http.request("PUT", "/users/#{Support.encode(id)}", body: body, idempotent: true, timeout: timeout, max_retries: max_retries).json)
32
+ end
33
+
34
+ # Erases the user: the profile **and every event recorded for them**.
35
+ # @return [nil]
36
+ def delete(user_id, timeout: nil, max_retries: nil)
37
+ id = Support.require_id(user_id, "user_id")
38
+ @http.request("DELETE", "/users/#{Support.encode(id)}", idempotent: true, timeout: timeout, max_retries: max_retries)
39
+ nil
40
+ end
41
+
42
+ # Batch upsert (1-1000 users). A failing entry is reported in +errors+.
43
+ # @param users [Array<Hash>] +{ user_id:, properties: }+
44
+ # @return [BatchResult]
45
+ def import(users, timeout: nil, max_retries: nil)
46
+ raise ValidationError, "users must be a non-empty Array" unless users.is_a?(Array) && !users.empty?
47
+
48
+ entries = users.map do |u|
49
+ raise ValidationError, "each user must be a Hash" unless u.is_a?(Hash)
50
+
51
+ Support.compact("user_id" => Support.require_id(u[:user_id], "user_id"), "properties" => u[:properties])
52
+ end
53
+ Wire.batch(@http.request("POST", "/users/import", body: { "users" => entries }, idempotent: true, timeout: timeout, max_retries: max_retries).json)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ # @api private
5
+ module Support
6
+ UNRESERVED = /[^A-Za-z0-9\-._~]/
7
+
8
+ module_function
9
+
10
+ # Percent-encodes a path segment: everything outside RFC 3986 "unreserved" (A-Z a-z 0-9 - . _ ~) becomes
11
+ # %XX (UTF-8), '/' included - so an id like "gid://shopify/Product/1" is one segment.
12
+ def encode(value)
13
+ value.to_s.dup.force_encoding(Encoding::BINARY).gsub(UNRESERVED) { |c| format("%%%02X", c.ord) }
14
+ end
15
+
16
+ # Ids are opaque, non-empty strings - never coerced (an Integer or a Symbol is refused).
17
+ def require_id(value, name)
18
+ return value if value.is_a?(String) && !value.strip.empty?
19
+
20
+ raise ValidationError, "#{name} must be a non-empty String (your own identifier, e.g. \"SKU-123\")"
21
+ end
22
+
23
+ # Advanced recommendation endpoints carry ids in the URL path, where '/' cannot be used.
24
+ def require_path_safe_id(value, name)
25
+ id = require_id(value, name)
26
+ if id.include?("/")
27
+ raise ValidationError,
28
+ "#{name} #{id.inspect} contains \"/\", which this endpoint cannot carry in its URL path - " \
29
+ "use recommendations.get, which takes ids in the request body"
30
+ end
31
+ id
32
+ end
33
+
34
+ # Drops nil values so optional fields are simply not sent.
35
+ def compact(hash)
36
+ hash.reject { |_, v| v.nil? }
37
+ end
38
+
39
+ def limit_of(limit)
40
+ limit = 10 if limit.nil?
41
+ raise ValidationError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit >= 1
42
+
43
+ limit
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Likyly
4
+ VERSION = "1.0.0"
5
+ DEFAULT_BASE_URL = "https://api.likyly.com"
6
+ end
data/lib/likyly.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Official Ruby client for the LIKYLY recommendations API.
4
+ #
5
+ # client = Likyly::Client.new(api_key: ENV.fetch("LIKYLY_SECRET_KEY"))
6
+ #
7
+ # # Track what a visitor does
8
+ # client.events.view(user_id: "user_123", item_id: "SKU-456")
9
+ #
10
+ # # Ask what to show them
11
+ # recs = client.recommendations.get(user_id: "user_123", placement: "homepage", limit: 8)
12
+ # recs.items.each { |item| puts item.item_id }
13
+ #
14
+ # There are two kinds of API key. The **secret** key is for your backend only (items, users, everything). The
15
+ # **public** key is safe to expose in a web page but can only ask for recommendations and record events. Never
16
+ # ship the secret key in code that runs on a visitor's device.
17
+ module Likyly
18
+ end
19
+
20
+ require_relative "likyly/version"
21
+ require_relative "likyly/errors"
22
+ require_relative "likyly/support"
23
+ require_relative "likyly/models"
24
+ require_relative "likyly/http"
25
+ require_relative "likyly/resources/items"
26
+ require_relative "likyly/resources/users"
27
+ require_relative "likyly/resources/events"
28
+ require_relative "likyly/resources/recommendations"
29
+ require_relative "likyly/resources/placements"
30
+ require_relative "likyly/client"
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: likyly
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - LIKYLY
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: LIKYLY shows each visitor the products they are most likely to want ("You
13
+ may also like", "Recommended for you"). Send your catalog and what visitors do,
14
+ ask what to show. No runtime dependencies, no Rails required.
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/likyly.rb
22
+ - lib/likyly/client.rb
23
+ - lib/likyly/errors.rb
24
+ - lib/likyly/http.rb
25
+ - lib/likyly/models.rb
26
+ - lib/likyly/resources/events.rb
27
+ - lib/likyly/resources/items.rb
28
+ - lib/likyly/resources/placements.rb
29
+ - lib/likyly/resources/recommendations.rb
30
+ - lib/likyly/resources/users.rb
31
+ - lib/likyly/support.rb
32
+ - lib/likyly/version.rb
33
+ homepage: https://www.likyly.com
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ homepage_uri: https://www.likyly.com
38
+ documentation_uri: https://likyly.com/docs/sdk/ruby
39
+ source_code_uri: https://github.com/Likyly/likyly-recsys/tree/main/sdk/ruby
40
+ bug_tracker_uri: https://github.com/Likyly/likyly-recsys/issues
41
+ rubygems_mfa_required: 'true'
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '3.0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.23
57
+ specification_version: 4
58
+ summary: Product recommendations for your site, learned from what your visitors do.
59
+ Official LIKYLY client.
60
+ test_files: []