dotypos 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.md +21 -0
- data/README.md +232 -0
- data/lib/dotypos/client.rb +189 -0
- data/lib/dotypos/configuration.rb +13 -0
- data/lib/dotypos/errors.rb +41 -0
- data/lib/dotypos/filter_builder.rb +77 -0
- data/lib/dotypos/key_transformer.rb +64 -0
- data/lib/dotypos/paged_result.rb +83 -0
- data/lib/dotypos/resource.rb +63 -0
- data/lib/dotypos/resource_collection.rb +144 -0
- data/lib/dotypos/token_manager.rb +98 -0
- data/lib/dotypos/version.rb +3 -0
- data/lib/dotypos.rb +26 -0
- metadata +159 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2a64ec820b14432a4f7f5846c0a27688e081eedd352be2e881173a27a9a1fbba
|
|
4
|
+
data.tar.gz: 8e3d6f7d7e4cddc4d9256ef7b40aef688ac63376807d92c35d86ee47da9907c8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48174bfed7b8afc5932c3ec2a88b608dc5734e583d86f0524d744582fc395f435bbe5628b68837ad70161905f9b834924a48a8d4598b1836460aa6d1d41f7b00
|
|
7
|
+
data.tar.gz: 7c0bc07e13990cd02c5c92d6ee1ec8db3c347bc55c37dd0fa1a28329f6ceb970dbc7f77fa6f28b483b3fd5b291a6f32fcb2fbff09a274eaf24e99e2e51a4cf04
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stockbird Team
|
|
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,232 @@
|
|
|
1
|
+
# dotypos
|
|
2
|
+
|
|
3
|
+
Ruby API client for the [Dotypos (Dotykačka) API v2](https://docs.api.dotypos.com/).
|
|
4
|
+
|
|
5
|
+
Handles OAuth token management, automatic token refresh, pagination, and full CRUD for all API resources. Built by [Stockbird](https://stockbird.app).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "dotypos"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install directly:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
gem install dotypos
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Ruby >= 3.3.0
|
|
24
|
+
- A Dotypos `refresh_token` and `cloud_id` obtained via the [Dotypos OAuth flow](https://docs.api.dotypos.com/)
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
client = Dotypos::Client.new(
|
|
30
|
+
refresh_token: ENV["DOTYPOS_REFRESH_TOKEN"],
|
|
31
|
+
cloud_id: ENV["DOTYPOS_CLOUD_ID"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# List orders (returns a PagedResult)
|
|
35
|
+
result = client.orders.list(limit: 25)
|
|
36
|
+
result.data.each { |order| puts "#{order.id}: #{order.note}" }
|
|
37
|
+
|
|
38
|
+
# Paginate
|
|
39
|
+
while result.next_page?
|
|
40
|
+
result = result.next_page
|
|
41
|
+
result.data.each { |order| puts order.id }
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Authentication
|
|
46
|
+
|
|
47
|
+
The gem handles authentication automatically. You only need to supply a `refresh_token` and `cloud_id` once — the gem obtains a short-lived access token and refreshes it transparently before it expires (~1 hour). The access token is kept in memory; no persistence is needed.
|
|
48
|
+
|
|
49
|
+
## Resources
|
|
50
|
+
|
|
51
|
+
The following resource accessors are available on the client:
|
|
52
|
+
|
|
53
|
+
| Method | API path |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `client.branches` | `branch` |
|
|
56
|
+
| `client.categories` | `category` |
|
|
57
|
+
| `client.courses` | `course` |
|
|
58
|
+
| `client.customers` | `customer` |
|
|
59
|
+
| `client.employees` | `employee` |
|
|
60
|
+
| `client.order_items` | `order-item` |
|
|
61
|
+
| `client.orders` | `order` |
|
|
62
|
+
| `client.points_logs` | `pointslog` |
|
|
63
|
+
| `client.printers` | `printer` |
|
|
64
|
+
| `client.products` | `product` |
|
|
65
|
+
| `client.reservations` | `reservation` |
|
|
66
|
+
| `client.stock_logs` | `stocklog` |
|
|
67
|
+
| `client.suppliers` | `supplier` |
|
|
68
|
+
| `client.tags` | `tag` |
|
|
69
|
+
| `client.warehouses` | `warehouse` |
|
|
70
|
+
| `client.webhooks` | `webhook` |
|
|
71
|
+
|
|
72
|
+
For any path not in the list above:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
client.resource("custom-entity").list
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## CRUD operations
|
|
79
|
+
|
|
80
|
+
### List
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
result = client.products.list(page: 1, limit: 50, sort: "-versionDate")
|
|
84
|
+
result.data # => [Dotypos::Resource, ...]
|
|
85
|
+
result.current_page # => 1
|
|
86
|
+
result.next_page? # => true
|
|
87
|
+
result.next_page # => PagedResult for page 2
|
|
88
|
+
result.prev_page? # => false
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Filter DSL
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
filter = Dotypos::FilterBuilder.build do |f|
|
|
95
|
+
f.where :deleted, :eq, false
|
|
96
|
+
f.where :total_price, :gteq, 100
|
|
97
|
+
f.where :name, :like, "coffee"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result = client.products.list(filter: filter, sort: "-version_date")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Supported operators: `eq`, `ne`, `gt`, `gteq`, `lt`, `lteq`, `like`, `in`, `notin`, `bin`, `bex`.
|
|
104
|
+
|
|
105
|
+
### Get single resource
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
product = client.products.get("123456789")
|
|
109
|
+
product.name # => "Espresso"
|
|
110
|
+
product.total_price # => "3.50"
|
|
111
|
+
product.etag # => '"5C6FEF0BAD91914172B353E157219626"' (for updates)
|
|
112
|
+
product[:name] # hash-style access
|
|
113
|
+
product.to_h # plain snake_case symbol-keyed hash
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Create
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
product = client.products.create(
|
|
120
|
+
name: "Cappuccino",
|
|
121
|
+
total_price: "4.20"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Batch create
|
|
125
|
+
products = client.products.create([
|
|
126
|
+
{ name: "Espresso", total_price: "3.50" },
|
|
127
|
+
{ name: "Latte", total_price: "4.80" }
|
|
128
|
+
])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Update (PATCH)
|
|
132
|
+
|
|
133
|
+
Always fetch first to get the ETag, then update:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
product = client.products.get("123456789")
|
|
137
|
+
updated = client.products.update(product, name: "Double Espresso")
|
|
138
|
+
|
|
139
|
+
# Or with explicit ID + ETag:
|
|
140
|
+
updated = client.products.update("123456789", { name: "Double Espresso" }, etag: product.etag)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Replace (PUT)
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
product = client.products.get("123456789")
|
|
147
|
+
replaced = client.products.replace(product, product.to_h.merge(name: "New Name"))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Delete
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
client.products.delete("123456789") # => true
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Response objects
|
|
157
|
+
|
|
158
|
+
All returned data is a `Dotypos::Resource` — a generic object backed by a snake_case symbol-keyed hash. Access attributes via dot notation, hash notation, or `to_h`:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
order.total_price # dot notation
|
|
162
|
+
order[:total_price] # symbol key
|
|
163
|
+
order["totalPrice"] # camelCase string key (also works)
|
|
164
|
+
order.to_h # plain hash
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
API keys are transformed as follows:
|
|
168
|
+
- `currentPage` → `:current_page`
|
|
169
|
+
- `_cloudId` → `:cloud_id` (leading underscore stripped)
|
|
170
|
+
- `totalItemsCount` → `:total_items_count`
|
|
171
|
+
|
|
172
|
+
## Error handling
|
|
173
|
+
|
|
174
|
+
All errors inherit from `Dotypos::Error` and carry `http_status`, `http_body`, and `http_headers`:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
begin
|
|
178
|
+
client.orders.get("nonexistent")
|
|
179
|
+
rescue Dotypos::NotFoundError => e
|
|
180
|
+
puts e.http_status # 404
|
|
181
|
+
rescue Dotypos::AuthenticationError
|
|
182
|
+
# refresh_token invalid or revoked
|
|
183
|
+
rescue Dotypos::PreconditionError
|
|
184
|
+
# ETag mismatch — resource was modified since last GET
|
|
185
|
+
rescue Dotypos::RateLimitError
|
|
186
|
+
# 429 — back off and retry
|
|
187
|
+
rescue Dotypos::ServerError
|
|
188
|
+
# 5xx
|
|
189
|
+
rescue Dotypos::Error => e
|
|
190
|
+
# catch-all
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Full error hierarchy:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Dotypos::Error
|
|
198
|
+
├── Dotypos::ConnectionError
|
|
199
|
+
├── Dotypos::TimeoutError
|
|
200
|
+
└── Dotypos::ClientError
|
|
201
|
+
├── Dotypos::AuthenticationError (401)
|
|
202
|
+
├── Dotypos::ForbiddenError (403)
|
|
203
|
+
├── Dotypos::NotFoundError (404)
|
|
204
|
+
├── Dotypos::ConflictError (409)
|
|
205
|
+
├── Dotypos::PreconditionError (412)
|
|
206
|
+
├── Dotypos::UnprocessableError (422)
|
|
207
|
+
└── Dotypos::RateLimitError (429)
|
|
208
|
+
└── Dotypos::ServerError (5xx)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Configuration
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
client = Dotypos::Client.new(
|
|
215
|
+
refresh_token: "...",
|
|
216
|
+
cloud_id: "...",
|
|
217
|
+
timeout: 60, # read timeout in seconds (default: 30)
|
|
218
|
+
open_timeout: 10, # connection timeout in seconds (default: 5)
|
|
219
|
+
logger: Logger.new($stdout)
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
bundle install
|
|
227
|
+
bundle exec rspec
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT — see [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Dotypos
|
|
5
|
+
# Entry point for all API interactions.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# client = Dotypos::Client.new(
|
|
9
|
+
# refresh_token: "your_refresh_token",
|
|
10
|
+
# cloud_id: "123456"
|
|
11
|
+
# )
|
|
12
|
+
#
|
|
13
|
+
# # List resources
|
|
14
|
+
# result = client.orders.list(page: 1, limit: 25)
|
|
15
|
+
# result.data.each { |order| puts order.id }
|
|
16
|
+
#
|
|
17
|
+
# # Paginate
|
|
18
|
+
# next_result = result.next_page if result.next_page?
|
|
19
|
+
#
|
|
20
|
+
# # Filter with DSL
|
|
21
|
+
# filter = Dotypos::FilterBuilder.build { |f| f.where(:deleted, :eq, false) }
|
|
22
|
+
# client.products.list(filter: filter, sort: "-version_date")
|
|
23
|
+
#
|
|
24
|
+
# # Full CRUD
|
|
25
|
+
# customer = client.customers.get("789")
|
|
26
|
+
# client.customers.update(customer, name: "New Name")
|
|
27
|
+
class Client
|
|
28
|
+
API_BASE_URL = "https://api.dotykacka.cz/v2/".freeze
|
|
29
|
+
|
|
30
|
+
# All supported resource types.
|
|
31
|
+
# key = Ruby method name (snake_case, plural)
|
|
32
|
+
# value = API path segment (as used in /v2/clouds/:cloudId/<segment>)
|
|
33
|
+
RESOURCES = {
|
|
34
|
+
branches: "branch",
|
|
35
|
+
categories: "category",
|
|
36
|
+
courses: "course",
|
|
37
|
+
customers: "customer",
|
|
38
|
+
employees: "employee",
|
|
39
|
+
order_items: "order-item",
|
|
40
|
+
orders: "order",
|
|
41
|
+
points_logs: "pointslog",
|
|
42
|
+
printers: "printer",
|
|
43
|
+
products: "product",
|
|
44
|
+
reservations: "reservation",
|
|
45
|
+
stock_logs: "stocklog",
|
|
46
|
+
suppliers: "supplier",
|
|
47
|
+
tags: "tag",
|
|
48
|
+
warehouses: "warehouse",
|
|
49
|
+
webhooks: "webhook",
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Maps HTTP error status codes to [ErrorClass, default_message] pairs.
|
|
53
|
+
ERROR_MAP = {
|
|
54
|
+
401 => [AuthenticationError, "Authentication failed"],
|
|
55
|
+
403 => [ForbiddenError, "Forbidden"],
|
|
56
|
+
404 => [NotFoundError, "Resource not found"],
|
|
57
|
+
409 => [ConflictError, "Conflict — versionDate mismatch"],
|
|
58
|
+
412 => [PreconditionError, "ETag mismatch — resource was modified since last read"],
|
|
59
|
+
422 => [UnprocessableError, "Unprocessable entity"],
|
|
60
|
+
429 => [RateLimitError, "Rate limit exceeded"],
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
attr_reader :cloud_id
|
|
64
|
+
|
|
65
|
+
# @param refresh_token [String] long-lived token obtained via the Dotypos OAuth flow
|
|
66
|
+
# @param cloud_id [String] cloud identifier for this installation
|
|
67
|
+
# @param timeout [Integer] read timeout in seconds (default: 30)
|
|
68
|
+
# @param open_timeout [Integer] connection timeout in seconds (default: 5)
|
|
69
|
+
# @param logger [Logger, nil] optional logger; receives request/response details
|
|
70
|
+
def initialize(refresh_token:, cloud_id:, timeout: 30, open_timeout: 5, logger: nil)
|
|
71
|
+
@cloud_id = cloud_id.to_s
|
|
72
|
+
@timeout = timeout
|
|
73
|
+
@open_timeout = open_timeout
|
|
74
|
+
@logger = logger
|
|
75
|
+
@token_manager = build_token_manager(refresh_token, timeout, open_timeout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Dynamically define accessor methods for each resource type.
|
|
79
|
+
RESOURCES.each do |method_name, path|
|
|
80
|
+
define_method(method_name) { ResourceCollection.new(self, path) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Allows calling any arbitrary API path not in the RESOURCES list.
|
|
84
|
+
# client.resource("custom-entity").list
|
|
85
|
+
def resource(path)
|
|
86
|
+
ResourceCollection.new(self, path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Makes an authenticated HTTP request. Used internally by ResourceCollection.
|
|
90
|
+
#
|
|
91
|
+
# @param method [Symbol] :get, :post, :patch, :put, :delete
|
|
92
|
+
# @param path [String] path relative to API_BASE_URL (e.g. "clouds/123/order")
|
|
93
|
+
# @param params [Hash] query parameters
|
|
94
|
+
# @param body [Hash, nil] request body (will be JSON-encoded)
|
|
95
|
+
# @param headers [Hash] additional request headers
|
|
96
|
+
# @return [Hash] { body: parsed_response, etag: "..." }
|
|
97
|
+
def request(method, path, params: {}, body: nil, headers: {})
|
|
98
|
+
response = execute_with_token_refresh(method, path, params: params, body: body, headers: headers)
|
|
99
|
+
handle_response(response)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_token_manager(refresh_token, timeout, open_timeout)
|
|
105
|
+
TokenManager.new(
|
|
106
|
+
refresh_token: refresh_token,
|
|
107
|
+
cloud_id: @cloud_id,
|
|
108
|
+
base_url: API_BASE_URL,
|
|
109
|
+
timeout: timeout,
|
|
110
|
+
open_timeout: open_timeout
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def execute_with_token_refresh(method, path, params:, body:, headers:)
|
|
115
|
+
token = @token_manager.access_token
|
|
116
|
+
response = execute_request(method, path, params: params, body: body, headers: headers, token: token)
|
|
117
|
+
return response unless response.status == 401
|
|
118
|
+
|
|
119
|
+
# Transparently retry once (token may have been invalidated server-side)
|
|
120
|
+
execute_request(method, path, params: params, body: body, headers: headers,
|
|
121
|
+
token: @token_manager.force_refresh!)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def execute_request(method, path, params:, body:, headers:, token:) # rubocop:disable Metrics/ParameterLists
|
|
125
|
+
connection.run_request(method, path, body&.to_json, request_headers(token, headers)) do |req|
|
|
126
|
+
req.params = params unless params.empty?
|
|
127
|
+
end
|
|
128
|
+
rescue Faraday::ConnectionFailed => e
|
|
129
|
+
raise Dotypos::ConnectionError, e.message
|
|
130
|
+
rescue Faraday::TimeoutError => e
|
|
131
|
+
raise Dotypos::TimeoutError, e.message
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_response(response)
|
|
135
|
+
body = parse_body(response.body)
|
|
136
|
+
etag = response.headers["etag"] || response.headers["ETag"]
|
|
137
|
+
|
|
138
|
+
return { body: body, etag: etag } if (200..299).cover?(response.status)
|
|
139
|
+
return { body: nil, etag: etag } if response.status == 304
|
|
140
|
+
|
|
141
|
+
raise_error_for(response, body)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def raise_error_for(response, body)
|
|
145
|
+
klass, default_msg = ERROR_MAP[response.status]
|
|
146
|
+
klass ||= response.status >= 500 ? ServerError : Error
|
|
147
|
+
default_msg ||= response.status >= 500 ? "Server error" : "Unexpected status #{response.status}"
|
|
148
|
+
|
|
149
|
+
raise klass.new(
|
|
150
|
+
error_message(body, default_msg),
|
|
151
|
+
http_status: response.status,
|
|
152
|
+
http_body: response.body,
|
|
153
|
+
http_headers: response.headers
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def connection
|
|
158
|
+
@connection ||= Faraday.new(url: API_BASE_URL) do |f|
|
|
159
|
+
f.options.timeout = @timeout
|
|
160
|
+
f.options.open_timeout = @open_timeout
|
|
161
|
+
f.request :logger, @logger, headers: false, bodies: false if @logger
|
|
162
|
+
f.adapter Faraday.default_adapter
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def request_headers(token, extra = {})
|
|
167
|
+
{
|
|
168
|
+
"Authorization" => "Bearer #{token}",
|
|
169
|
+
"Content-Type" => "application/json",
|
|
170
|
+
"Accept" => "application/json",
|
|
171
|
+
"User-Agent" => "dotypos-ruby/#{Dotypos::VERSION} ruby/#{RUBY_VERSION}",
|
|
172
|
+
}.merge(extra)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_body(body)
|
|
176
|
+
return nil if body.nil? || body.empty?
|
|
177
|
+
|
|
178
|
+
JSON.parse(body, symbolize_names: false)
|
|
179
|
+
rescue JSON::ParserError
|
|
180
|
+
body
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def error_message(parsed_body, fallback)
|
|
184
|
+
return fallback unless parsed_body.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
parsed_body["message"] || parsed_body["error"] || fallback
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Dotypos
|
|
2
|
+
# Base error — callers can rescue Dotypos::Error to catch everything
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
attr_reader :http_status, :http_body, :http_headers
|
|
5
|
+
|
|
6
|
+
def initialize(msg = nil, http_status: nil, http_body: nil, http_headers: nil)
|
|
7
|
+
super(msg)
|
|
8
|
+
@http_status = http_status
|
|
9
|
+
@http_body = http_body
|
|
10
|
+
@http_headers = http_headers
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
http_status ? "(HTTP #{http_status}) #{super}" : super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Network-level errors
|
|
19
|
+
class ConnectionError < Error; end
|
|
20
|
+
class TimeoutError < Error; end
|
|
21
|
+
|
|
22
|
+
# 4xx client errors
|
|
23
|
+
class ClientError < Error; end
|
|
24
|
+
# 401
|
|
25
|
+
class AuthenticationError < ClientError; end
|
|
26
|
+
# 403
|
|
27
|
+
class ForbiddenError < ClientError; end
|
|
28
|
+
# 404
|
|
29
|
+
class NotFoundError < ClientError; end
|
|
30
|
+
# 409 — versionDate mismatch
|
|
31
|
+
class ConflictError < ClientError; end
|
|
32
|
+
# 412 — ETag mismatch on PUT/PATCH
|
|
33
|
+
class PreconditionError < ClientError; end
|
|
34
|
+
# 422
|
|
35
|
+
class UnprocessableError < ClientError; end
|
|
36
|
+
# 429
|
|
37
|
+
class RateLimitError < ClientError; end
|
|
38
|
+
|
|
39
|
+
# 5xx server errors
|
|
40
|
+
class ServerError < Error; end
|
|
41
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Dotypos
|
|
2
|
+
# Builds the filter query string expected by the Dotypos API.
|
|
3
|
+
#
|
|
4
|
+
# API format: "attribute|operator|value;attribute2|operator2|value2"
|
|
5
|
+
# Supported operators: eq, ne, gt, gteq, lt, lteq, like, in, notin, bin, bex
|
|
6
|
+
#
|
|
7
|
+
# Usage (block DSL):
|
|
8
|
+
# filter = Dotypos::FilterBuilder.build do |f|
|
|
9
|
+
# f.where :price, :gteq, 500
|
|
10
|
+
# f.where :deleted, :eq, false
|
|
11
|
+
# f.where :name, :like, "John"
|
|
12
|
+
# end
|
|
13
|
+
# # => "price|gteq|500;deleted|eq|0;name|like|John"
|
|
14
|
+
#
|
|
15
|
+
# Usage (chainable):
|
|
16
|
+
# filter = Dotypos::FilterBuilder.new
|
|
17
|
+
# .where(:price, :gteq, 500)
|
|
18
|
+
# .where(:deleted, :eq, false)
|
|
19
|
+
# .to_s
|
|
20
|
+
#
|
|
21
|
+
# Pass the result to any list call:
|
|
22
|
+
# client.orders.list(filter: filter, sort: "-created")
|
|
23
|
+
class FilterBuilder
|
|
24
|
+
VALID_OPERATORS = %w[eq ne gt gteq lt lteq like in notin bin bex].freeze
|
|
25
|
+
|
|
26
|
+
def self.build(&block)
|
|
27
|
+
builder = new
|
|
28
|
+
block.call(builder)
|
|
29
|
+
builder.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@conditions = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Adds a filter condition.
|
|
37
|
+
#
|
|
38
|
+
# @param attribute [Symbol, String] the API attribute name (snake_case is converted to camelCase)
|
|
39
|
+
# @param operator [Symbol, String] one of the supported operators
|
|
40
|
+
# @param value the filter value (booleans are converted to 1/0)
|
|
41
|
+
# @return [self] for chaining
|
|
42
|
+
def where(attribute, operator, value)
|
|
43
|
+
op = operator.to_s.downcase
|
|
44
|
+
unless VALID_OPERATORS.include?(op)
|
|
45
|
+
raise ArgumentError, "Invalid filter operator '#{op}'. " \
|
|
46
|
+
"Valid operators: #{VALID_OPERATORS.join(', ')}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
api_attribute = KeyTransformer.camel_key(attribute)
|
|
50
|
+
api_value = serialize_value(value)
|
|
51
|
+
|
|
52
|
+
@conditions << "#{api_attribute}|#{op}|#{api_value}"
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the encoded filter string or nil if no conditions were added.
|
|
57
|
+
def to_s
|
|
58
|
+
@conditions.empty? ? nil : @conditions.join(";")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def empty?
|
|
62
|
+
@conditions.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def serialize_value(value)
|
|
68
|
+
case value
|
|
69
|
+
when true then "1"
|
|
70
|
+
when false then "0"
|
|
71
|
+
when nil then "null"
|
|
72
|
+
when Array then value.map { |v| serialize_value(v) }.join(",")
|
|
73
|
+
else value.to_s
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Dotypos
|
|
2
|
+
# Bidirectional key transformation between the API's lowerCamelCase / _prefixed
|
|
3
|
+
# format and Ruby's conventional snake_case.
|
|
4
|
+
#
|
|
5
|
+
# API → Ruby (responses):
|
|
6
|
+
# "currentPage" → :current_page
|
|
7
|
+
# "_cloudId" → :cloud_id (leading underscore stripped)
|
|
8
|
+
# "totalItemsCount" → :total_items_count
|
|
9
|
+
#
|
|
10
|
+
# Ruby → API (request bodies):
|
|
11
|
+
# :current_page → "currentPage"
|
|
12
|
+
# :cloud_id → "cloudId" (no underscore prefix re-added; see note below)
|
|
13
|
+
#
|
|
14
|
+
# Note: The API's _-prefixed keys (like _cloudId) appear in entity bodies as
|
|
15
|
+
# read-only metadata fields. When writing, cloudId is supplied via the URL path,
|
|
16
|
+
# not the request body, so the round-trip loss of the leading _ is harmless.
|
|
17
|
+
module KeyTransformer
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Recursively transform all keys in a Hash (or Array of Hashes) from the API
|
|
21
|
+
# format to snake_case symbols.
|
|
22
|
+
def to_snake(obj)
|
|
23
|
+
case obj
|
|
24
|
+
when Hash
|
|
25
|
+
obj.transform_keys { |k| snake_key(k) }
|
|
26
|
+
.transform_values { |v| to_snake(v) }
|
|
27
|
+
when Array
|
|
28
|
+
obj.map { |v| to_snake(v) }
|
|
29
|
+
else
|
|
30
|
+
obj
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Recursively transform all keys in a Hash (or Array of Hashes) from
|
|
35
|
+
# snake_case symbols/strings to lowerCamelCase strings for API requests.
|
|
36
|
+
def to_camel(obj)
|
|
37
|
+
case obj
|
|
38
|
+
when Hash
|
|
39
|
+
obj.transform_keys { |k| camel_key(k) }
|
|
40
|
+
.transform_values { |v| to_camel(v) }
|
|
41
|
+
when Array
|
|
42
|
+
obj.map { |v| to_camel(v) }
|
|
43
|
+
else
|
|
44
|
+
obj
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Single key: API string → snake_case symbol
|
|
49
|
+
def snake_key(key)
|
|
50
|
+
key.to_s
|
|
51
|
+
.delete_prefix("_") # strip leading underscore (_cloudId → cloudId)
|
|
52
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # ABCDef → ABC_def
|
|
53
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2') # camelCase → camel_case
|
|
54
|
+
.downcase
|
|
55
|
+
.to_sym
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Single key: snake_case symbol/string → lowerCamelCase string
|
|
59
|
+
def camel_key(key)
|
|
60
|
+
parts = key.to_s.split("_")
|
|
61
|
+
parts[0] + parts[1..].map(&:capitalize).join
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Dotypos
|
|
2
|
+
# Wraps a paginated list response from the API.
|
|
3
|
+
#
|
|
4
|
+
# The underlying API envelope (after snake_case conversion):
|
|
5
|
+
# current_page, per_page, total_items_on_page, total_items_count,
|
|
6
|
+
# first_page, last_page, next_page (number), prev_page (number)
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# result = client.orders.list(page: 1, limit: 50)
|
|
10
|
+
# result.data # => [Resource, Resource, ...]
|
|
11
|
+
# result.next_page? # => true / false
|
|
12
|
+
# result.next_page # => PagedResult (fetches page 2)
|
|
13
|
+
# result.prev_page? # => true / false
|
|
14
|
+
# result.prev_page # => PagedResult (fetches page 1)
|
|
15
|
+
class PagedResult
|
|
16
|
+
attr_reader :data,
|
|
17
|
+
:current_page,
|
|
18
|
+
:per_page,
|
|
19
|
+
:total_items_on_page,
|
|
20
|
+
:total_items_count,
|
|
21
|
+
:first_page,
|
|
22
|
+
:last_page
|
|
23
|
+
|
|
24
|
+
def initialize(collection, envelope, request_params = {})
|
|
25
|
+
@collection = collection
|
|
26
|
+
@request_params = request_params
|
|
27
|
+
assign_envelope(envelope)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# True when the API reports a next page exists.
|
|
31
|
+
# For high-volume entities (orders, order-items) where next_page may be null,
|
|
32
|
+
# we also check whether the current page is full.
|
|
33
|
+
def next_page?
|
|
34
|
+
if @next_page_number
|
|
35
|
+
true
|
|
36
|
+
elsif @per_page && @total_items_on_page
|
|
37
|
+
@total_items_on_page >= @per_page
|
|
38
|
+
else
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True when there is a previous page.
|
|
44
|
+
def prev_page?
|
|
45
|
+
@prev_page_number ? @prev_page_number >= 1 : (@current_page && @current_page > 1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fetches and returns the next PagedResult, or nil if on the last page.
|
|
49
|
+
def next_page
|
|
50
|
+
return nil unless next_page?
|
|
51
|
+
|
|
52
|
+
page_number = @next_page_number || (@current_page + 1)
|
|
53
|
+
@collection.list(@request_params.merge(page: page_number))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fetches and returns the previous PagedResult, or nil if on the first page.
|
|
57
|
+
def prev_page
|
|
58
|
+
return nil unless prev_page?
|
|
59
|
+
|
|
60
|
+
page_number = @prev_page_number || (@current_page - 1)
|
|
61
|
+
@collection.list(@request_params.merge(page: page_number))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<#{self.class.name} page=#{current_page} items=#{data.size} " \
|
|
66
|
+
"next=#{next_page?} prev=#{prev_page?}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def assign_envelope(envelope)
|
|
72
|
+
@data = Array(envelope[:data]).map { |item| Resource.new(item) }
|
|
73
|
+
@current_page = envelope[:current_page]
|
|
74
|
+
@per_page = envelope[:per_page]
|
|
75
|
+
@total_items_on_page = envelope[:total_items_on_page]
|
|
76
|
+
@total_items_count = envelope[:total_items_count]
|
|
77
|
+
@first_page = envelope[:first_page]
|
|
78
|
+
@last_page = envelope[:last_page]
|
|
79
|
+
@next_page_number = envelope[:next_page]
|
|
80
|
+
@prev_page_number = envelope[:prev_page]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Dotypos
|
|
2
|
+
# Generic response object representing any API entity (order, product, customer, …).
|
|
3
|
+
#
|
|
4
|
+
# All keys are snake_case symbols. Attribute access is available via:
|
|
5
|
+
# - Dot notation: resource.total_price
|
|
6
|
+
# - Hash notation: resource[:total_price]
|
|
7
|
+
# - Plain hash: resource.to_h
|
|
8
|
+
#
|
|
9
|
+
# The ETag received from a GET response is stored on the object and is
|
|
10
|
+
# automatically used by ResourceCollection#update and #replace.
|
|
11
|
+
class Resource
|
|
12
|
+
attr_accessor :etag
|
|
13
|
+
|
|
14
|
+
def initialize(attributes, etag: nil)
|
|
15
|
+
@attributes = KeyTransformer.to_snake(attributes)
|
|
16
|
+
@etag = etag
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Hash-style access with either symbol or string key.
|
|
20
|
+
def [](key)
|
|
21
|
+
@attributes[KeyTransformer.snake_key(key)]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns a plain snake_case-keyed hash (deep copy).
|
|
25
|
+
def to_h
|
|
26
|
+
deep_dup(@attributes)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def inspect
|
|
30
|
+
"#<#{self.class.name} #{@attributes.inspect}>"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s
|
|
34
|
+
inspect
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ==(other)
|
|
38
|
+
other.is_a?(Resource) && other.to_h == to_h
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def respond_to_missing?(name, include_private = false)
|
|
42
|
+
@attributes.key?(name) || super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def method_missing(name, *args)
|
|
46
|
+
if @attributes.key?(name)
|
|
47
|
+
@attributes[name]
|
|
48
|
+
else
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def deep_dup(obj)
|
|
56
|
+
case obj
|
|
57
|
+
when Hash then obj.transform_values { |v| deep_dup(v) }
|
|
58
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
59
|
+
else obj
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module Dotypos
|
|
2
|
+
# Provides CRUD operations for a single API resource type.
|
|
3
|
+
#
|
|
4
|
+
# All methods are reached via the client accessors:
|
|
5
|
+
# client.orders # => ResourceCollection scoped to "order"
|
|
6
|
+
# client.products # => ResourceCollection scoped to "product"
|
|
7
|
+
#
|
|
8
|
+
# List
|
|
9
|
+
# result = client.orders.list(page: 1, limit: 50, filter: "...", sort: "-created")
|
|
10
|
+
# # => PagedResult
|
|
11
|
+
#
|
|
12
|
+
# Get single
|
|
13
|
+
# order = client.orders.get("123456789")
|
|
14
|
+
# # => Resource (with ETag set)
|
|
15
|
+
#
|
|
16
|
+
# Create
|
|
17
|
+
# order = client.orders.create(note: "Table 4", table_id: "987")
|
|
18
|
+
# # => Resource
|
|
19
|
+
#
|
|
20
|
+
# Update (PATCH — partial, requires ETag)
|
|
21
|
+
# # Pass the Resource object — ETag is used automatically:
|
|
22
|
+
# updated = client.orders.update(order, note: "Table 5")
|
|
23
|
+
# # Or pass id + attrs + explicit etag:
|
|
24
|
+
# updated = client.orders.update("123", { note: "Table 5" }, etag: "abc123")
|
|
25
|
+
#
|
|
26
|
+
# Replace (PUT — full replace, requires ETag)
|
|
27
|
+
# replaced = client.orders.replace(order, full_attributes_hash)
|
|
28
|
+
# replaced = client.orders.replace("123", full_attributes_hash, etag: "abc123")
|
|
29
|
+
#
|
|
30
|
+
# Delete
|
|
31
|
+
# client.orders.delete("123456789") # => true
|
|
32
|
+
class ResourceCollection
|
|
33
|
+
def initialize(client, path)
|
|
34
|
+
@client = client
|
|
35
|
+
@path = path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns a PagedResult.
|
|
39
|
+
#
|
|
40
|
+
# @param params [Hash] query parameters: page:, limit:, filter:, sort:
|
|
41
|
+
# filter can be a String (raw API filter) or a FilterBuilder instance.
|
|
42
|
+
def list(params = {})
|
|
43
|
+
params = normalize_list_params(params)
|
|
44
|
+
response = @client.request(:get, collection_path, params: params)
|
|
45
|
+
envelope = KeyTransformer.to_snake(response.fetch(:body))
|
|
46
|
+
PagedResult.new(self, envelope, params)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns a single Resource with its ETag populated.
|
|
50
|
+
def get(id)
|
|
51
|
+
response = @client.request(:get, member_path(id))
|
|
52
|
+
Resource.new(response.fetch(:body), etag: response[:etag])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Creates one or more resources. Pass a Hash for a single resource or an
|
|
56
|
+
# Array of Hashes for batch creation.
|
|
57
|
+
# Returns a Resource (single) or Array<Resource> (batch).
|
|
58
|
+
def create(attributes)
|
|
59
|
+
body = KeyTransformer.to_camel(attributes)
|
|
60
|
+
response = @client.request(:post, collection_path, body: body)
|
|
61
|
+
|
|
62
|
+
if response[:body].is_a?(Array)
|
|
63
|
+
response[:body].map { |item| Resource.new(item) }
|
|
64
|
+
else
|
|
65
|
+
Resource.new(response.fetch(:body), etag: response[:etag])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Partial update (PATCH). Requires the current ETag.
|
|
70
|
+
#
|
|
71
|
+
# @overload update(resource, attributes = {})
|
|
72
|
+
# @param resource [Resource] existing resource (ETag extracted automatically)
|
|
73
|
+
# @param attributes [Hash] fields to update; merged with resource data when empty
|
|
74
|
+
#
|
|
75
|
+
# @overload update(id, attributes, etag: "...")
|
|
76
|
+
# @param id [String] entity ID
|
|
77
|
+
# @param attributes [Hash] fields to update
|
|
78
|
+
# @param options [Hash] accepts :etag — current ETag from a prior GET
|
|
79
|
+
def update(resource_or_id, attributes = {}, options = {})
|
|
80
|
+
id, attrs, tag = resolve_mutation_args(resource_or_id, attributes, options[:etag])
|
|
81
|
+
body = KeyTransformer.to_camel(attrs.merge(id: id))
|
|
82
|
+
response = @client.request(:patch, member_path(id), body: body,
|
|
83
|
+
headers: { "If-Match" => tag })
|
|
84
|
+
Resource.new(response.fetch(:body), etag: response[:etag])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Full replace (PUT). Requires the current ETag.
|
|
88
|
+
#
|
|
89
|
+
# @overload replace(resource, attributes = {})
|
|
90
|
+
# @overload replace(id, attributes, etag: "...")
|
|
91
|
+
def replace(resource_or_id, attributes = {}, options = {})
|
|
92
|
+
id, attrs, tag = resolve_mutation_args(resource_or_id, attributes, options[:etag])
|
|
93
|
+
body = KeyTransformer.to_camel(attrs.merge(id: id))
|
|
94
|
+
response = @client.request(:put, member_path(id), body: body,
|
|
95
|
+
headers: { "If-Match" => tag })
|
|
96
|
+
Resource.new(response.fetch(:body), etag: response[:etag])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Deletes the resource with the given id. Returns true on success.
|
|
100
|
+
def delete(id)
|
|
101
|
+
@client.request(:delete, member_path(id))
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def collection_path
|
|
108
|
+
"clouds/#{@client.cloud_id}/#{@path}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def member_path(id)
|
|
112
|
+
"clouds/#{@client.cloud_id}/#{@path}/#{id}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_mutation_args(resource_or_id, attributes, explicit_etag)
|
|
116
|
+
id, attrs, tag = if resource_or_id.is_a?(Resource)
|
|
117
|
+
args_from_resource(resource_or_id, attributes, explicit_etag)
|
|
118
|
+
else
|
|
119
|
+
[resource_or_id.to_s, attributes, explicit_etag]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
raise ArgumentError, etag_required_message if tag.nil?
|
|
123
|
+
|
|
124
|
+
[id, attrs, tag]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def args_from_resource(resource, attributes, explicit_etag)
|
|
128
|
+
id = resource[:id].to_s
|
|
129
|
+
attrs = attributes.empty? ? resource.to_h : attributes
|
|
130
|
+
tag = explicit_etag || resource.etag
|
|
131
|
+
[id, attrs, tag]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def etag_required_message
|
|
135
|
+
"An ETag is required for PUT/PATCH. Obtain one via #get first, " \
|
|
136
|
+
"then pass the Resource object or supply etag: explicitly."
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def normalize_list_params(params)
|
|
140
|
+
params = params.merge(filter: params[:filter].to_s) if params[:filter].is_a?(FilterBuilder)
|
|
141
|
+
params.compact
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Dotypos
|
|
5
|
+
# Manages obtaining and refreshing the short-lived access token.
|
|
6
|
+
#
|
|
7
|
+
# The Dotypos auth flow (Step 2 of the documented OAuth model):
|
|
8
|
+
# POST /v2/signin/token
|
|
9
|
+
# Authorization: User {refreshToken}
|
|
10
|
+
# Body: { "_cloudId": "123" }
|
|
11
|
+
# Response: { "accessToken": "eyJ0..." }
|
|
12
|
+
#
|
|
13
|
+
# Access tokens expire in ~1 hour. This class keeps the token in memory,
|
|
14
|
+
# checks expiry before each use, and refreshes proactively (60 s buffer).
|
|
15
|
+
# A Mutex ensures thread safety.
|
|
16
|
+
class TokenManager
|
|
17
|
+
TOKEN_EXPIRY_SECONDS = 3600
|
|
18
|
+
EXPIRY_BUFFER_SECONDS = 60
|
|
19
|
+
AUTH_ENDPOINT = "signin/token".freeze
|
|
20
|
+
|
|
21
|
+
def initialize(refresh_token:, cloud_id:, base_url:, open_timeout: 5, timeout: 30)
|
|
22
|
+
@refresh_token = refresh_token
|
|
23
|
+
@cloud_id = cloud_id.to_s
|
|
24
|
+
@base_url = base_url
|
|
25
|
+
@open_timeout = open_timeout
|
|
26
|
+
@timeout = timeout
|
|
27
|
+
@access_token = nil
|
|
28
|
+
@expires_at = nil
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns a valid access token, refreshing if necessary.
|
|
33
|
+
def access_token
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
refresh! if token_expired?
|
|
36
|
+
@access_token
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Forces a token refresh regardless of expiry. Used when the server
|
|
41
|
+
# returns 401 mid-session (e.g. token invalidated server-side).
|
|
42
|
+
# Returns the new access token.
|
|
43
|
+
def force_refresh!
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
refresh!
|
|
46
|
+
@access_token
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def token_expired?
|
|
53
|
+
@access_token.nil? ||
|
|
54
|
+
@expires_at.nil? ||
|
|
55
|
+
Time.now >= @expires_at - EXPIRY_BUFFER_SECONDS
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def refresh!
|
|
59
|
+
response = post_token_request
|
|
60
|
+
validate_token_response!(response)
|
|
61
|
+
store_access_token(response.body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def post_token_request
|
|
65
|
+
auth_connection.post(AUTH_ENDPOINT) do |req|
|
|
66
|
+
req.headers["Authorization"] = "User #{@refresh_token}"
|
|
67
|
+
req.headers["Content-Type"] = "application/json"
|
|
68
|
+
req.body = JSON.generate("_cloudId" => @cloud_id)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_token_response!(response)
|
|
73
|
+
return if response.status == 200
|
|
74
|
+
|
|
75
|
+
raise Dotypos::AuthenticationError.new(
|
|
76
|
+
"Failed to obtain access token",
|
|
77
|
+
http_status: response.status,
|
|
78
|
+
http_body: response.body
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def store_access_token(body)
|
|
83
|
+
parsed = JSON.parse(body)
|
|
84
|
+
@access_token = parsed["accessToken"] || parsed["access_token"]
|
|
85
|
+
raise Dotypos::AuthenticationError, "No accessToken in auth response: #{body}" if @access_token.nil?
|
|
86
|
+
|
|
87
|
+
@expires_at = Time.now + TOKEN_EXPIRY_SECONDS
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def auth_connection
|
|
91
|
+
@auth_connection ||= Faraday.new(url: @base_url) do |f|
|
|
92
|
+
f.options.open_timeout = @open_timeout
|
|
93
|
+
f.options.timeout = @timeout
|
|
94
|
+
f.adapter Faraday.default_adapter
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/dotypos.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require_relative "dotypos/version"
|
|
2
|
+
require_relative "dotypos/errors"
|
|
3
|
+
require_relative "dotypos/configuration"
|
|
4
|
+
require_relative "dotypos/key_transformer"
|
|
5
|
+
require_relative "dotypos/token_manager"
|
|
6
|
+
require_relative "dotypos/resource"
|
|
7
|
+
require_relative "dotypos/paged_result"
|
|
8
|
+
require_relative "dotypos/filter_builder"
|
|
9
|
+
require_relative "dotypos/resource_collection"
|
|
10
|
+
require_relative "dotypos/client"
|
|
11
|
+
|
|
12
|
+
module Dotypos
|
|
13
|
+
class << self
|
|
14
|
+
def configuration
|
|
15
|
+
@configuration ||= Configuration.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configure
|
|
19
|
+
yield(configuration)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset!
|
|
23
|
+
@configuration = Configuration.new
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dotypos
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stockbird Team
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-06 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: '2.7'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.7'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday-retry
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rspec
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.13'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.13'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.68'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.68'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rubocop-rspec
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.2'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.2'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: webmock
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '3.23'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '3.23'
|
|
111
|
+
description: A Ruby gem for interacting with the Dotypos API v2. Handles authentication,
|
|
112
|
+
token refresh, pagination, and provides a clean interface to all API resources.
|
|
113
|
+
email:
|
|
114
|
+
- info@stockbird.app
|
|
115
|
+
executables: []
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- LICENSE.md
|
|
120
|
+
- README.md
|
|
121
|
+
- lib/dotypos.rb
|
|
122
|
+
- lib/dotypos/client.rb
|
|
123
|
+
- lib/dotypos/configuration.rb
|
|
124
|
+
- lib/dotypos/errors.rb
|
|
125
|
+
- lib/dotypos/filter_builder.rb
|
|
126
|
+
- lib/dotypos/key_transformer.rb
|
|
127
|
+
- lib/dotypos/paged_result.rb
|
|
128
|
+
- lib/dotypos/resource.rb
|
|
129
|
+
- lib/dotypos/resource_collection.rb
|
|
130
|
+
- lib/dotypos/token_manager.rb
|
|
131
|
+
- lib/dotypos/version.rb
|
|
132
|
+
homepage: https://github.com/stockbird-app/dotypos
|
|
133
|
+
licenses:
|
|
134
|
+
- MIT
|
|
135
|
+
metadata:
|
|
136
|
+
bug_tracker_uri: https://github.com/stockbird-app/dotypos/issues
|
|
137
|
+
changelog_uri: https://github.com/stockbird-app/dotypos/blob/main/CHANGELOG.md
|
|
138
|
+
rubygems_mfa_required: 'true'
|
|
139
|
+
source_code_uri: https://github.com/stockbird-app/dotypos
|
|
140
|
+
post_install_message:
|
|
141
|
+
rdoc_options: []
|
|
142
|
+
require_paths:
|
|
143
|
+
- lib
|
|
144
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
145
|
+
requirements:
|
|
146
|
+
- - ">="
|
|
147
|
+
- !ruby/object:Gem::Version
|
|
148
|
+
version: 3.3.0
|
|
149
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
150
|
+
requirements:
|
|
151
|
+
- - ">="
|
|
152
|
+
- !ruby/object:Gem::Version
|
|
153
|
+
version: '0'
|
|
154
|
+
requirements: []
|
|
155
|
+
rubygems_version: 3.5.16
|
|
156
|
+
signing_key:
|
|
157
|
+
specification_version: 4
|
|
158
|
+
summary: Ruby API client for Dotypos (Dotykačka)
|
|
159
|
+
test_files: []
|