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 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,13 @@
1
+ module Dotypos
2
+ class Configuration
3
+ attr_accessor :timeout, :open_timeout, :logger
4
+
5
+ API_BASE_URL = "https://api.dotykacka.cz/v2".freeze
6
+
7
+ def initialize
8
+ @timeout = 30
9
+ @open_timeout = 5
10
+ @logger = nil
11
+ end
12
+ end
13
+ 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
@@ -0,0 +1,3 @@
1
+ module Dotypos
2
+ VERSION = "0.1.0".freeze
3
+ 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: []