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 +7 -0
- data/LICENSE +21 -0
- data/README.md +287 -0
- data/lib/likyly/client.rb +48 -0
- data/lib/likyly/errors.rb +97 -0
- data/lib/likyly/http.rb +139 -0
- data/lib/likyly/models.rb +108 -0
- data/lib/likyly/resources/events.rb +123 -0
- data/lib/likyly/resources/items.rb +89 -0
- data/lib/likyly/resources/placements.rb +62 -0
- data/lib/likyly/resources/recommendations.rb +101 -0
- data/lib/likyly/resources/users.rb +56 -0
- data/lib/likyly/support.rb +46 -0
- data/lib/likyly/version.rb +6 -0
- data/lib/likyly.rb +30 -0
- metadata +60 -0
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
|
data/lib/likyly/http.rb
ADDED
|
@@ -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
|
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: []
|