shelfwatch 0.1.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 +137 -0
- data/lib/shelfwatch/client.rb +197 -0
- data/lib/shelfwatch/errors.rb +41 -0
- data/lib/shelfwatch/http.rb +124 -0
- data/lib/shelfwatch/resources/mdm.rb +166 -0
- data/lib/shelfwatch/resources/reports.rb +35 -0
- data/lib/shelfwatch/resources/visits.rb +67 -0
- data/lib/shelfwatch/version.rb +5 -0
- data/lib/shelfwatch.rb +22 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c88743581650ab50a89f0fb2b35a3fba38f1b674b7649ff672666d28f66f5a59
|
|
4
|
+
data.tar.gz: d5ac216d35acf1949856d1546dbf5733482e39d69fc67204cb6a18d5660dd8ba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9d514a01d9b052a35d41544b6e79793341c7f23115110cc4d9d1ceca0a6ad36f7d709d4ed02cf2fa0a5f966075a1663d1cb170eca615acdb09d35e773459073d
|
|
7
|
+
data.tar.gz: 1c6e882c03f0a381e127b633ed38dd8f30681e3ef0660b2293bccb0b05083d8ac3b6e67c78e783e8647b18ec860d506f94864e880b1dd413f02fb645b4f8ff2d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShelfWatch / ParallelDots
|
|
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,137 @@
|
|
|
1
|
+
# ShelfWatch Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for [ShelfWatch APIs v2](https://api.shelfwatch.io).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install shelfwatch
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Ruby 2.6 or newer.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Create credentials in ShelfWatch Console → **Integrations**, then:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "shelfwatch"
|
|
17
|
+
|
|
18
|
+
client = ShelfWatch.new(
|
|
19
|
+
api_key: "swpk_…",
|
|
20
|
+
project_id: "PROJECT_UUID",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
visits = client.visits.list(
|
|
24
|
+
start_date: "2026-07-01",
|
|
25
|
+
end_date: "2026-07-31",
|
|
26
|
+
)
|
|
27
|
+
puts visits["data"]
|
|
28
|
+
|
|
29
|
+
detail = client.visits.get(
|
|
30
|
+
visits["data"][0]["visit_uuid"],
|
|
31
|
+
include_kpis: true,
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### OAuth client credentials
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
client = ShelfWatch.new(
|
|
39
|
+
client_id: "swoc_…",
|
|
40
|
+
client_secret: "swocs_…",
|
|
41
|
+
project_id: "PROJECT_UUID",
|
|
42
|
+
)
|
|
43
|
+
# Access tokens are fetched and refreshed automatically.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API coverage
|
|
47
|
+
|
|
48
|
+
| Resource | Methods |
|
|
49
|
+
|----------|---------|
|
|
50
|
+
| `client.visits` | `list`, `get` |
|
|
51
|
+
| `client.mdm` | `stores`, `users`, `categories`, `brands`, `skus`, `schedules` |
|
|
52
|
+
| `client.reports` | `list`, `generate` |
|
|
53
|
+
|
|
54
|
+
Filters that accept multiple values can be passed as a comma-separated string or an array:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
client.visits.list(
|
|
58
|
+
start_date: "2026-07-01",
|
|
59
|
+
end_date: "2026-07-31",
|
|
60
|
+
visit_status: ["completed"],
|
|
61
|
+
store_code: ["S001", "S002"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
stores = client.mdm.stores(q: "delhi")
|
|
65
|
+
reports = client.reports.list
|
|
66
|
+
rows = client.reports.generate(
|
|
67
|
+
"visit-level",
|
|
68
|
+
start_date: "2026-07-01",
|
|
69
|
+
end_date: "2026-07-07",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Full HTTP reference: ShelfWatch Console → **Help and Support**, or the [apis-v2 docs](https://github.com/ParallelDots/apis-v2).
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
Typed exceptions map to HTTP status codes:
|
|
78
|
+
|
|
79
|
+
| Exception | Status |
|
|
80
|
+
|-----------|--------|
|
|
81
|
+
| `ShelfWatch::ValidationError` | 400 |
|
|
82
|
+
| `ShelfWatch::AuthenticationError` | 401 |
|
|
83
|
+
| `ShelfWatch::ForbiddenError` | 403 |
|
|
84
|
+
| `ShelfWatch::NotFoundError` | 404 |
|
|
85
|
+
| `ShelfWatch::RateLimitError` | 429 |
|
|
86
|
+
| `ShelfWatch::ServerError` | 5xx |
|
|
87
|
+
| `ShelfWatch::Error` | other |
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
require "shelfwatch"
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
client.visits.get("missing-uuid")
|
|
94
|
+
rescue ShelfWatch::NotFoundError => e
|
|
95
|
+
puts "#{e.status_code} #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bundle install
|
|
103
|
+
bundle exec rake test
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Publish to RubyGems
|
|
107
|
+
|
|
108
|
+
Create an API key at [rubygems.org/profile/api_keys](https://rubygems.org/profile/api_keys) with the **Push rubygem** scope (see [API key scopes](https://guides.rubygems.org/api-key-scopes/)). Then either:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# credentials file (~/.gem/credentials or ~/.local/share/gem/credentials)
|
|
112
|
+
mkdir -p ~/.gem
|
|
113
|
+
chmod 0700 ~/.gem
|
|
114
|
+
printf -- "---\n:rubygems_api_key: rubygems_YOUR_KEY\n" > ~/.gem/credentials
|
|
115
|
+
chmod 0600 ~/.gem/credentials
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
or set `GEM_HOST_API_KEY` for a one-off push:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
export GEM_HOST_API_KEY=rubygems_YOUR_KEY
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Publish:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
./scripts/publish.sh
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Or manually:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
gem build shelfwatch.gemspec
|
|
134
|
+
gem push shelfwatch-*.gem
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Bump `VERSION` in `lib/shelfwatch/version.rb` (User-Agent follows automatically) before each release.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module ShelfWatch
|
|
8
|
+
# Synchronous client for ShelfWatch APIs v2.
|
|
9
|
+
#
|
|
10
|
+
# Authenticate with either an API key or OAuth client credentials.
|
|
11
|
+
# Credentials are project-scoped (create them in Console → Integrations).
|
|
12
|
+
#
|
|
13
|
+
# Example (API key):
|
|
14
|
+
#
|
|
15
|
+
# client = ShelfWatch::Client.new(
|
|
16
|
+
# api_key: "swpk_…",
|
|
17
|
+
# project_id: "PROJECT_UUID",
|
|
18
|
+
# )
|
|
19
|
+
# visits = client.visits.list(
|
|
20
|
+
# start_date: "2026-07-01",
|
|
21
|
+
# end_date: "2026-07-31",
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# Example (OAuth):
|
|
25
|
+
#
|
|
26
|
+
# client = ShelfWatch::Client.new(
|
|
27
|
+
# client_id: "swoc_…",
|
|
28
|
+
# client_secret: "swocs_…",
|
|
29
|
+
# project_id: "PROJECT_UUID",
|
|
30
|
+
# )
|
|
31
|
+
class Client
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.shelfwatch.io"
|
|
33
|
+
DEFAULT_TIMEOUT = 60.0
|
|
34
|
+
# Refresh OAuth tokens this many seconds before expires_in.
|
|
35
|
+
TOKEN_SKEW_SECONDS = 60
|
|
36
|
+
|
|
37
|
+
attr_reader :project_id, :base_url, :visits, :mdm, :reports
|
|
38
|
+
|
|
39
|
+
def initialize(
|
|
40
|
+
api_key: nil,
|
|
41
|
+
client_id: nil,
|
|
42
|
+
client_secret: nil,
|
|
43
|
+
project_id: nil,
|
|
44
|
+
base_url: DEFAULT_BASE_URL,
|
|
45
|
+
timeout: DEFAULT_TIMEOUT,
|
|
46
|
+
connection: nil
|
|
47
|
+
)
|
|
48
|
+
has_key = !api_key.nil? && api_key != ""
|
|
49
|
+
has_oauth = (!client_id.nil? && client_id != "") ||
|
|
50
|
+
(!client_secret.nil? && client_secret != "")
|
|
51
|
+
|
|
52
|
+
if has_key && has_oauth
|
|
53
|
+
raise ValidationError, "Provide either api_key or client_id/client_secret, not both"
|
|
54
|
+
end
|
|
55
|
+
if has_oauth && (client_id.nil? || client_id == "" || client_secret.nil? || client_secret == "")
|
|
56
|
+
raise ValidationError, "Both client_id and client_secret are required"
|
|
57
|
+
end
|
|
58
|
+
unless has_key || has_oauth
|
|
59
|
+
raise ValidationError, "Provide api_key or client_id and client_secret"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@project_id = project_id
|
|
63
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
64
|
+
@api_key = has_key ? api_key : nil
|
|
65
|
+
@client_id = has_oauth ? client_id : nil
|
|
66
|
+
@client_secret = has_oauth ? client_secret : nil
|
|
67
|
+
@access_token = nil
|
|
68
|
+
@token_expires_at = 0.0
|
|
69
|
+
|
|
70
|
+
@connection = connection || Faraday.new(url: @base_url) do |f|
|
|
71
|
+
f.options.timeout = timeout
|
|
72
|
+
f.options.open_timeout = timeout
|
|
73
|
+
f.headers["Accept"] = "application/json"
|
|
74
|
+
f.headers["User-Agent"] = "shelfwatch-ruby/#{VERSION}"
|
|
75
|
+
f.adapter Faraday.default_adapter
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@visits = Resources::Visits.new(self)
|
|
79
|
+
@mdm = Resources::Mdm.new(self)
|
|
80
|
+
@reports = Resources::Reports.new(self)
|
|
81
|
+
|
|
82
|
+
return unless block_given?
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
yield self
|
|
86
|
+
ensure
|
|
87
|
+
close
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Resolve project_id from the call or the client default.
|
|
92
|
+
def project(project_id = nil)
|
|
93
|
+
value = project_id || @project_id
|
|
94
|
+
if value.nil? || value == ""
|
|
95
|
+
raise ValidationError,
|
|
96
|
+
"project_id is required (pass it to ShelfWatch::Client.new(...) or to this method)"
|
|
97
|
+
end
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Send an authenticated request and return the JSON body.
|
|
102
|
+
def request(method, path, params: nil, data: nil, json: nil)
|
|
103
|
+
token = ensure_token
|
|
104
|
+
response = @connection.run_request(method.downcase.to_sym, path, nil, {}) do |req|
|
|
105
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
106
|
+
req.params.update(stringify_keys(params)) if params
|
|
107
|
+
if data
|
|
108
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
109
|
+
req.body = URI.encode_www_form(stringify_keys(data))
|
|
110
|
+
elsif !json.nil?
|
|
111
|
+
req.headers["Content-Type"] = "application/json"
|
|
112
|
+
req.body = JSON.generate(json)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Http.raise_for_status(response.status, response.body, response.reason_phrase)
|
|
117
|
+
|
|
118
|
+
return {} if response.status == 204 || response.body.nil? || response.body.empty?
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
payload = JSON.parse(response.body)
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
return { "data" => response.body }
|
|
124
|
+
end
|
|
125
|
+
return { "data" => payload } unless payload.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
payload
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Call GET /health (no auth).
|
|
131
|
+
def health
|
|
132
|
+
response = @connection.get("/health")
|
|
133
|
+
Http.raise_for_status(response.status, response.body, response.reason_phrase)
|
|
134
|
+
payload = JSON.parse(response.body)
|
|
135
|
+
return { "data" => payload } unless payload.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
payload
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Expose the underlying Faraday connection (useful for tests).
|
|
141
|
+
def connection
|
|
142
|
+
@connection
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Close the underlying HTTP connection.
|
|
146
|
+
def close
|
|
147
|
+
@connection.close if @connection.respond_to?(:close)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def stringify_keys(hash)
|
|
153
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
154
|
+
out[key.to_s] = value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def ensure_token
|
|
159
|
+
return @api_key if @api_key
|
|
160
|
+
|
|
161
|
+
now = Time.now.to_f
|
|
162
|
+
if @access_token && now < (@token_expires_at - TOKEN_SKEW_SECONDS)
|
|
163
|
+
return @access_token
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
response = @connection.post("/v2/oauth/token") do |req|
|
|
167
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
168
|
+
req.body = URI.encode_www_form(
|
|
169
|
+
"grant_type" => "client_credentials",
|
|
170
|
+
"client_id" => @client_id,
|
|
171
|
+
"client_secret" => @client_secret
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
Http.raise_for_status(response.status, response.body, response.reason_phrase)
|
|
175
|
+
|
|
176
|
+
payload = begin
|
|
177
|
+
JSON.parse(response.body)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
{}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
token = payload.is_a?(Hash) ? payload["access_token"] : nil
|
|
183
|
+
if token.nil? || token == ""
|
|
184
|
+
raise AuthenticationError.new(
|
|
185
|
+
"OAuth token response missing access_token",
|
|
186
|
+
status_code: response.status,
|
|
187
|
+
body: payload
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
expires_in = (payload.is_a?(Hash) ? (payload["expires_in"] || 3600) : 3600).to_i
|
|
192
|
+
@access_token = token
|
|
193
|
+
@token_expires_at = now + expires_in
|
|
194
|
+
token
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShelfWatch
|
|
4
|
+
# Base exception for all ShelfWatch SDK errors.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :message, :status_code, :body
|
|
7
|
+
|
|
8
|
+
def initialize(message, status_code: nil, body: nil)
|
|
9
|
+
@message = message
|
|
10
|
+
@status_code = status_code
|
|
11
|
+
@body = body
|
|
12
|
+
super(to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
if status_code
|
|
17
|
+
"[#{status_code}] #{message}"
|
|
18
|
+
else
|
|
19
|
+
message.to_s
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raised on 401 responses (missing/invalid credential or token).
|
|
25
|
+
class AuthenticationError < Error; end
|
|
26
|
+
|
|
27
|
+
# Raised on 403 responses (wrong project or missing scope).
|
|
28
|
+
class ForbiddenError < Error; end
|
|
29
|
+
|
|
30
|
+
# Raised on 404 responses.
|
|
31
|
+
class NotFoundError < Error; end
|
|
32
|
+
|
|
33
|
+
# Raised on 400 responses (invalid parameters) and client-side validation.
|
|
34
|
+
class ValidationError < Error; end
|
|
35
|
+
|
|
36
|
+
# Raised on 429 responses.
|
|
37
|
+
class RateLimitError < Error; end
|
|
38
|
+
|
|
39
|
+
# Raised on 5xx responses.
|
|
40
|
+
class ServerError < Error; end
|
|
41
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ShelfWatch
|
|
7
|
+
# HTTP helpers and response handling.
|
|
8
|
+
module Http
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Map HTTP errors to typed SDK exceptions.
|
|
12
|
+
def raise_for_status(status, body, reason_phrase = nil)
|
|
13
|
+
return if status >= 200 && status < 300
|
|
14
|
+
|
|
15
|
+
parsed = decode_body(body)
|
|
16
|
+
fallback = (reason_phrase && !reason_phrase.empty?) ? reason_phrase : "Request failed"
|
|
17
|
+
message = detail_message(parsed, fallback)
|
|
18
|
+
|
|
19
|
+
case status
|
|
20
|
+
when 400 then raise ValidationError.new(message, status_code: status, body: parsed)
|
|
21
|
+
when 401 then raise AuthenticationError.new(message, status_code: status, body: parsed)
|
|
22
|
+
when 403 then raise ForbiddenError.new(message, status_code: status, body: parsed)
|
|
23
|
+
when 404 then raise NotFoundError.new(message, status_code: status, body: parsed)
|
|
24
|
+
when 429 then raise RateLimitError.new(message, status_code: status, body: parsed)
|
|
25
|
+
else
|
|
26
|
+
if status >= 500
|
|
27
|
+
raise ServerError.new(message, status_code: status, body: parsed)
|
|
28
|
+
end
|
|
29
|
+
raise Error.new(message, status_code: status, body: parsed)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Omit nil query params so the API receives only set values.
|
|
34
|
+
# Booleans are serialized as "true" / "false" strings.
|
|
35
|
+
def drop_none(params)
|
|
36
|
+
out = {}
|
|
37
|
+
params.each do |key, value|
|
|
38
|
+
next if value.nil?
|
|
39
|
+
|
|
40
|
+
out[key] = if value == true || value == false
|
|
41
|
+
value ? "true" : "false"
|
|
42
|
+
else
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Accept a string or sequence and serialize as comma-separated.
|
|
50
|
+
def join_csv(value)
|
|
51
|
+
return nil if value.nil?
|
|
52
|
+
return value if value.is_a?(String)
|
|
53
|
+
return value.map(&:to_s).join(",") if value.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
value.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Format a date-like value for visit/schedule endpoints.
|
|
59
|
+
# Strings pass through. Time/Date/DateTime → ISO-8601.
|
|
60
|
+
def format_date(value)
|
|
61
|
+
case value
|
|
62
|
+
when String
|
|
63
|
+
value
|
|
64
|
+
when Time
|
|
65
|
+
value.iso8601
|
|
66
|
+
when DateTime
|
|
67
|
+
value.iso8601
|
|
68
|
+
when Date
|
|
69
|
+
value.iso8601
|
|
70
|
+
else
|
|
71
|
+
value.to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Format a calendar day as YYYY-MM-DD for report endpoints.
|
|
76
|
+
def format_day(value)
|
|
77
|
+
case value
|
|
78
|
+
when String
|
|
79
|
+
value
|
|
80
|
+
when Time
|
|
81
|
+
value.to_date.iso8601
|
|
82
|
+
when DateTime
|
|
83
|
+
value.to_date.iso8601
|
|
84
|
+
when Date
|
|
85
|
+
value.iso8601
|
|
86
|
+
else
|
|
87
|
+
value.to_s
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_body(raw)
|
|
92
|
+
return nil if raw.nil? || raw == ""
|
|
93
|
+
|
|
94
|
+
JSON.parse(raw)
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
raw
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def detail_message(body, fallback)
|
|
100
|
+
if body.is_a?(Hash)
|
|
101
|
+
detail = body["detail"]
|
|
102
|
+
if detail.is_a?(String) && !detail.empty?
|
|
103
|
+
return detail
|
|
104
|
+
end
|
|
105
|
+
if detail.is_a?(Array) && !detail.empty?
|
|
106
|
+
parts = detail.map do |item|
|
|
107
|
+
if item.is_a?(Hash)
|
|
108
|
+
msg = item["msg"] || item["message"]
|
|
109
|
+
msg ? msg.to_s : nil
|
|
110
|
+
else
|
|
111
|
+
item.to_s
|
|
112
|
+
end
|
|
113
|
+
end.compact
|
|
114
|
+
return parts.join("; ") unless parts.empty?
|
|
115
|
+
end
|
|
116
|
+
message = body["message"]
|
|
117
|
+
return message if message.is_a?(String) && !message.empty?
|
|
118
|
+
end
|
|
119
|
+
return body if body.is_a?(String) && !body.empty?
|
|
120
|
+
|
|
121
|
+
fallback
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShelfWatch
|
|
4
|
+
module Resources
|
|
5
|
+
# Master data management endpoints.
|
|
6
|
+
class Mdm
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# List stores for the project.
|
|
12
|
+
def stores(
|
|
13
|
+
project_id: nil,
|
|
14
|
+
limit: 100,
|
|
15
|
+
offset: 0,
|
|
16
|
+
store_code: nil,
|
|
17
|
+
q: nil,
|
|
18
|
+
include_total: true
|
|
19
|
+
)
|
|
20
|
+
@client.request(
|
|
21
|
+
"GET",
|
|
22
|
+
"/v2/mdm/stores",
|
|
23
|
+
params: Http.drop_none(
|
|
24
|
+
project_id: @client.project(project_id),
|
|
25
|
+
limit: limit,
|
|
26
|
+
offset: offset,
|
|
27
|
+
store_code: Http.join_csv(store_code),
|
|
28
|
+
q: q,
|
|
29
|
+
include_total: include_total
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# List users for the project.
|
|
35
|
+
def users(
|
|
36
|
+
project_id: nil,
|
|
37
|
+
limit: 100,
|
|
38
|
+
offset: 0,
|
|
39
|
+
user_uuid: nil,
|
|
40
|
+
q: nil,
|
|
41
|
+
include_total: true
|
|
42
|
+
)
|
|
43
|
+
@client.request(
|
|
44
|
+
"GET",
|
|
45
|
+
"/v2/mdm/users",
|
|
46
|
+
params: Http.drop_none(
|
|
47
|
+
project_id: @client.project(project_id),
|
|
48
|
+
limit: limit,
|
|
49
|
+
offset: offset,
|
|
50
|
+
user_uuid: Http.join_csv(user_uuid),
|
|
51
|
+
q: q,
|
|
52
|
+
include_total: include_total
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# List MDM categories for the project.
|
|
58
|
+
def categories(
|
|
59
|
+
project_id: nil,
|
|
60
|
+
limit: 100,
|
|
61
|
+
offset: 0,
|
|
62
|
+
q: nil,
|
|
63
|
+
include_inactive: false,
|
|
64
|
+
include_total: true
|
|
65
|
+
)
|
|
66
|
+
@client.request(
|
|
67
|
+
"GET",
|
|
68
|
+
"/v2/mdm/categories",
|
|
69
|
+
params: Http.drop_none(
|
|
70
|
+
project_id: @client.project(project_id),
|
|
71
|
+
limit: limit,
|
|
72
|
+
offset: offset,
|
|
73
|
+
q: q,
|
|
74
|
+
include_inactive: include_inactive,
|
|
75
|
+
include_total: include_total
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# List MDM brands for the project.
|
|
81
|
+
def brands(
|
|
82
|
+
project_id: nil,
|
|
83
|
+
limit: 100,
|
|
84
|
+
offset: 0,
|
|
85
|
+
mdm_category_id: nil,
|
|
86
|
+
brand_id: nil,
|
|
87
|
+
q: nil,
|
|
88
|
+
include_inactive: false,
|
|
89
|
+
include_total: true
|
|
90
|
+
)
|
|
91
|
+
@client.request(
|
|
92
|
+
"GET",
|
|
93
|
+
"/v2/mdm/brands",
|
|
94
|
+
params: Http.drop_none(
|
|
95
|
+
project_id: @client.project(project_id),
|
|
96
|
+
limit: limit,
|
|
97
|
+
offset: offset,
|
|
98
|
+
mdm_category_id: Http.join_csv(mdm_category_id),
|
|
99
|
+
brand_id: Http.join_csv(brand_id),
|
|
100
|
+
q: q,
|
|
101
|
+
include_inactive: include_inactive,
|
|
102
|
+
include_total: include_total
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# List MDM SKUs for the project.
|
|
108
|
+
def skus(
|
|
109
|
+
project_id: nil,
|
|
110
|
+
limit: 100,
|
|
111
|
+
offset: 0,
|
|
112
|
+
mdm_category_id: nil,
|
|
113
|
+
brand_id: nil,
|
|
114
|
+
sku_id: nil,
|
|
115
|
+
q: nil,
|
|
116
|
+
include_inactive: false,
|
|
117
|
+
include_total: true
|
|
118
|
+
)
|
|
119
|
+
@client.request(
|
|
120
|
+
"GET",
|
|
121
|
+
"/v2/mdm/skus",
|
|
122
|
+
params: Http.drop_none(
|
|
123
|
+
project_id: @client.project(project_id),
|
|
124
|
+
limit: limit,
|
|
125
|
+
offset: offset,
|
|
126
|
+
mdm_category_id: Http.join_csv(mdm_category_id),
|
|
127
|
+
brand_id: Http.join_csv(brand_id),
|
|
128
|
+
sku_id: Http.join_csv(sku_id),
|
|
129
|
+
q: q,
|
|
130
|
+
include_inactive: include_inactive,
|
|
131
|
+
include_total: include_total
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# List schedules (max 31-day range on start_datetime).
|
|
137
|
+
def schedules(
|
|
138
|
+
start_date:,
|
|
139
|
+
end_date:,
|
|
140
|
+
project_id: nil,
|
|
141
|
+
limit: 100,
|
|
142
|
+
offset: 0,
|
|
143
|
+
store_code: nil,
|
|
144
|
+
user_uuid: nil,
|
|
145
|
+
frequency: nil,
|
|
146
|
+
include_total: true
|
|
147
|
+
)
|
|
148
|
+
@client.request(
|
|
149
|
+
"GET",
|
|
150
|
+
"/v2/mdm/schedules",
|
|
151
|
+
params: Http.drop_none(
|
|
152
|
+
project_id: @client.project(project_id),
|
|
153
|
+
start_date: Http.format_date(start_date),
|
|
154
|
+
end_date: Http.format_date(end_date),
|
|
155
|
+
limit: limit,
|
|
156
|
+
offset: offset,
|
|
157
|
+
store_code: Http.join_csv(store_code),
|
|
158
|
+
user_uuid: Http.join_csv(user_uuid),
|
|
159
|
+
frequency: frequency,
|
|
160
|
+
include_total: include_total
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShelfWatch
|
|
4
|
+
module Resources
|
|
5
|
+
# Report list and generate endpoints.
|
|
6
|
+
class Reports
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# List available reports for the project.
|
|
12
|
+
def list(project_id: nil)
|
|
13
|
+
@client.request(
|
|
14
|
+
"GET",
|
|
15
|
+
"/v2/reports",
|
|
16
|
+
params: Http.drop_none(project_id: @client.project(project_id))
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Generate a report by report_id (see #list).
|
|
21
|
+
def generate(report_id, start_date:, end_date:, project_id: nil)
|
|
22
|
+
@client.request(
|
|
23
|
+
"GET",
|
|
24
|
+
"/v2/reports/generate",
|
|
25
|
+
params: Http.drop_none(
|
|
26
|
+
project_id: @client.project(project_id),
|
|
27
|
+
report_id: report_id,
|
|
28
|
+
start_date: Http.format_day(start_date),
|
|
29
|
+
end_date: Http.format_day(end_date)
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShelfWatch
|
|
4
|
+
module Resources
|
|
5
|
+
# Visit list and detail endpoints.
|
|
6
|
+
class Visits
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# List visits for a project (max 31-day range).
|
|
12
|
+
def list(
|
|
13
|
+
start_date:,
|
|
14
|
+
end_date:,
|
|
15
|
+
project_id: nil,
|
|
16
|
+
limit: 100,
|
|
17
|
+
offset: 0,
|
|
18
|
+
user_uuid: nil,
|
|
19
|
+
store_code: nil,
|
|
20
|
+
visit_status: nil,
|
|
21
|
+
has_images: nil,
|
|
22
|
+
has_survey: nil,
|
|
23
|
+
include_total: true
|
|
24
|
+
)
|
|
25
|
+
@client.request(
|
|
26
|
+
"GET",
|
|
27
|
+
"/v2/visits",
|
|
28
|
+
params: Http.drop_none(
|
|
29
|
+
project_id: @client.project(project_id),
|
|
30
|
+
start_date: Http.format_date(start_date),
|
|
31
|
+
end_date: Http.format_date(end_date),
|
|
32
|
+
limit: limit,
|
|
33
|
+
offset: offset,
|
|
34
|
+
user_uuid: Http.join_csv(user_uuid),
|
|
35
|
+
store_code: Http.join_csv(store_code),
|
|
36
|
+
visit_status: Http.join_csv(visit_status),
|
|
37
|
+
has_images: has_images,
|
|
38
|
+
has_survey: has_survey,
|
|
39
|
+
include_total: include_total
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get visit details (images, survey; optionally KPIs or a template).
|
|
45
|
+
def get(
|
|
46
|
+
visit_uuid,
|
|
47
|
+
project_id: nil,
|
|
48
|
+
include_kpis: false,
|
|
49
|
+
kpi_level: "topline",
|
|
50
|
+
template_id: nil,
|
|
51
|
+
category: nil
|
|
52
|
+
)
|
|
53
|
+
@client.request(
|
|
54
|
+
"GET",
|
|
55
|
+
"/v2/visits/#{visit_uuid}",
|
|
56
|
+
params: Http.drop_none(
|
|
57
|
+
project_id: @client.project(project_id),
|
|
58
|
+
include_kpis: include_kpis,
|
|
59
|
+
kpi_level: kpi_level,
|
|
60
|
+
template_id: template_id,
|
|
61
|
+
category: category
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/shelfwatch.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "shelfwatch/version"
|
|
5
|
+
require "shelfwatch/errors"
|
|
6
|
+
require "shelfwatch/http"
|
|
7
|
+
require "shelfwatch/resources/visits"
|
|
8
|
+
require "shelfwatch/resources/mdm"
|
|
9
|
+
require "shelfwatch/resources/reports"
|
|
10
|
+
require "shelfwatch/client"
|
|
11
|
+
|
|
12
|
+
# Official Ruby SDK for ShelfWatch APIs v2.
|
|
13
|
+
#
|
|
14
|
+
# client = ShelfWatch.new(api_key: "swpk_…", project_id: "PROJECT_UUID")
|
|
15
|
+
# visits = client.visits.list(start_date: "2026-07-01", end_date: "2026-07-31")
|
|
16
|
+
#
|
|
17
|
+
module ShelfWatch
|
|
18
|
+
# Construct a {Client} (mirrors the Python ``ShelfWatch(...)`` entrypoint).
|
|
19
|
+
def self.new(**kwargs)
|
|
20
|
+
Client.new(**kwargs)
|
|
21
|
+
end
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shelfwatch
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ShelfWatch / ParallelDots
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-03 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.10'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '3'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '1.10'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: minitest
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
type: :development
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rake
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: webmock
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.18'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.18'
|
|
75
|
+
description: Synchronous Ruby SDK for ShelfWatch visits, MDM, and reports APIs.
|
|
76
|
+
email:
|
|
77
|
+
- support@shelfwatch.io
|
|
78
|
+
executables: []
|
|
79
|
+
extensions: []
|
|
80
|
+
extra_rdoc_files: []
|
|
81
|
+
files:
|
|
82
|
+
- LICENSE
|
|
83
|
+
- README.md
|
|
84
|
+
- lib/shelfwatch.rb
|
|
85
|
+
- lib/shelfwatch/client.rb
|
|
86
|
+
- lib/shelfwatch/errors.rb
|
|
87
|
+
- lib/shelfwatch/http.rb
|
|
88
|
+
- lib/shelfwatch/resources/mdm.rb
|
|
89
|
+
- lib/shelfwatch/resources/reports.rb
|
|
90
|
+
- lib/shelfwatch/resources/visits.rb
|
|
91
|
+
- lib/shelfwatch/version.rb
|
|
92
|
+
homepage: https://github.com/ParallelDots/apis-ruby-sdk
|
|
93
|
+
licenses:
|
|
94
|
+
- MIT
|
|
95
|
+
metadata:
|
|
96
|
+
homepage_uri: https://shelfwatch.io
|
|
97
|
+
source_code_uri: https://github.com/ParallelDots/apis-ruby-sdk
|
|
98
|
+
documentation_uri: https://api.shelfwatch.io
|
|
99
|
+
rubygems_mfa_required: 'true'
|
|
100
|
+
post_install_message:
|
|
101
|
+
rdoc_options: []
|
|
102
|
+
require_paths:
|
|
103
|
+
- lib
|
|
104
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: 2.6.0
|
|
109
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '0'
|
|
114
|
+
requirements: []
|
|
115
|
+
rubygems_version: 3.0.3.1
|
|
116
|
+
signing_key:
|
|
117
|
+
specification_version: 4
|
|
118
|
+
summary: Official Ruby client for ShelfWatch APIs v2
|
|
119
|
+
test_files: []
|