wenmar 0.8.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 +162 -0
- data/lib/wenmar/auth.rb +98 -0
- data/lib/wenmar/client.rb +222 -0
- data/lib/wenmar/config.rb +34 -0
- data/lib/wenmar/credentials.rb +124 -0
- data/lib/wenmar/error.rb +108 -0
- data/lib/wenmar/oauth.rb +53 -0
- data/lib/wenmar/pagination.rb +90 -0
- data/lib/wenmar/resources.rb +2588 -0
- data/lib/wenmar/token.rb +54 -0
- data/lib/wenmar/version.rb +5 -0
- data/lib/wenmar.rb +15 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 43545722c16579d457083b3ca7c8ce6d5d29c04810f409009aef3d54f4a46cd4
|
|
4
|
+
data.tar.gz: 91d256168a7a1dac6ab545420a3567d28e2bc1a1a99c1dc9d660e33b013aca98
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e433cfa494a87297b498ad3f9586800eb1dabc37b241caafc24f552232d411ad9e1e15104c6a8d5f184ddc2e9c01589671afe4d05fc17f0e544353d8503649f1
|
|
7
|
+
data.tar.gz: f6cb3a1c6187e884a9ef94774ef25873d8351680fe0522c057802f405836c91095e4170e6611a838e9e141bc0783eb57c450a79af2607e722ad991aba66230a7
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wenmar Pro
|
|
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,162 @@
|
|
|
1
|
+
# wenmar — Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the Wenmar Pro API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "wenmar"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
then run `bundle install`. Or install it directly:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem install wenmar
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "wenmar"
|
|
23
|
+
|
|
24
|
+
client = Wenmar::Client.new(token: "YOUR_API_TOKEN")
|
|
25
|
+
|
|
26
|
+
# List customers (paginated)
|
|
27
|
+
customers = client.list_customers
|
|
28
|
+
customers # => [{ "id" => 1, "full_name" => "Jane Doe", ... }]
|
|
29
|
+
|
|
30
|
+
# Show a customer
|
|
31
|
+
customer = client.show_customer(1)
|
|
32
|
+
customer # => { "id" => 1, "full_name" => "Jane Doe", ... }
|
|
33
|
+
|
|
34
|
+
# Create a customer (request body is nested under the resource key)
|
|
35
|
+
created = client.create_customer(customer: { first_name: "Jane", last_name: "Doe" })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
`Wenmar::Client.new` takes a token (required) and an optional base URL:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
client = Wenmar::Client.new(token: "YOUR_API_KEY", base_url: "https://app.wenmarpro.com")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Location scoping
|
|
47
|
+
|
|
48
|
+
Use `for_location` to scope every request to a specific location. The parent
|
|
49
|
+
client is not mutated:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
shop = client.for_location("42")
|
|
53
|
+
shop.list_customers # sends X-Wenmar-Location: 42
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## API coverage
|
|
57
|
+
|
|
58
|
+
The full surface is generated into `resources.rb` — hundreds of methods across
|
|
59
|
+
every tag. See the [generated API reference](../docs/api/api-reference.md) for
|
|
60
|
+
the live list. A representative sample:
|
|
61
|
+
|
|
62
|
+
| Operation | Method |
|
|
63
|
+
|---|---|
|
|
64
|
+
| List customers | `list_customers(customer_tag_id:, has_balance:, has_vehicle:, last_visit_months:, page:, per_page:, q:, status:, type:)` |
|
|
65
|
+
| Create customer | `create_customer(customer:)` |
|
|
66
|
+
| Show customer | `show_customer(id)` |
|
|
67
|
+
| Update customer | `update_customer(id, customer:)` |
|
|
68
|
+
| List vehicles | `list_vehicles(page:, per_page:, q:, ...)` |
|
|
69
|
+
| Create vehicle | `create_vehicle(vehicle:)` |
|
|
70
|
+
| Show vehicle | `show_vehicle(id)` |
|
|
71
|
+
| Update vehicle | `update_vehicle(id, vehicle:)` |
|
|
72
|
+
| Trash vehicle | `trash_vehicle(id)` |
|
|
73
|
+
| Decode VIN | `decode_vin(vin:)` |
|
|
74
|
+
| Check duplicates | `check_vehicle_duplicate(vin:)` |
|
|
75
|
+
| List work orders | `list_work_orders(page:, per_page:, q:, ...)` |
|
|
76
|
+
| Create work order | `create_work_order(work_order:)` |
|
|
77
|
+
| Show work order | `show_work_order(id)` |
|
|
78
|
+
| Update work order | `update_work_order(id, work_order:)` |
|
|
79
|
+
| Void work order | `void_work_order(id, closure_reason:)` |
|
|
80
|
+
| Reopen work order | `reopen_work_order(id)` |
|
|
81
|
+
|
|
82
|
+
Work orders use a domain workflow (`stage`) and are never hard-deleted — use
|
|
83
|
+
`void_work_order`/`reopen_work_order`. Lifecycle-managed resources (customers,
|
|
84
|
+
vehicles, vendors, …) support `trash_*`/`archive_*`/`restore_*` rather than
|
|
85
|
+
`delete`.
|
|
86
|
+
|
|
87
|
+
Every paginated list also has a `get_all_*` variant that auto-paginates with a
|
|
88
|
+
1,000-item safety cap, e.g. `get_all_customers`.
|
|
89
|
+
|
|
90
|
+
## Pagination
|
|
91
|
+
|
|
92
|
+
List endpoints paginate via the RFC 5988 `Link` header. Paginated list methods
|
|
93
|
+
return a `Wenmar::Paginator`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
result = client.list_customers
|
|
97
|
+
result.each { |customer| puts customer["full_name"] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Or collect everything with `get_all_customers`.
|
|
101
|
+
|
|
102
|
+
## Errors
|
|
103
|
+
|
|
104
|
+
All non-2xx responses raise `Wenmar::Error`:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
begin
|
|
108
|
+
client.show_customer(999)
|
|
109
|
+
rescue Wenmar::Error => e
|
|
110
|
+
e.code # => "not_found"
|
|
111
|
+
e.status # => 404
|
|
112
|
+
e.message
|
|
113
|
+
e.field_errors
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
See [docs/errors.md](../docs/api/errors.md) for the full error envelope and code table.
|
|
118
|
+
|
|
119
|
+
## Retry
|
|
120
|
+
|
|
121
|
+
The client retries 429/503/504 with exponential backoff (max 3 retries). It
|
|
122
|
+
respects the `Retry-After` response header. Mutations are only retried on 429 —
|
|
123
|
+
never on transport errors, which could duplicate a write.
|
|
124
|
+
|
|
125
|
+
## OAuth and credential storage
|
|
126
|
+
|
|
127
|
+
For OAuth (the browser `authorization_code` + PKCE flow is a CLI concern; the
|
|
128
|
+
SDK ships the token model, store, and refresh machinery):
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
require "wenmar"
|
|
132
|
+
|
|
133
|
+
# Exchange a refresh token for a new access token.
|
|
134
|
+
token = Wenmar::OAuth.refresh(
|
|
135
|
+
base_url: "https://app.wenmarpro.com",
|
|
136
|
+
refresh_token: refresh_token
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Persist the full token (access + refresh + expiry) with 0600 permissions.
|
|
140
|
+
store = Wenmar::CredentialStore.new
|
|
141
|
+
store.save_token(token)
|
|
142
|
+
|
|
143
|
+
# Auto-refresh when the token is expired or within 5 minutes of expiry.
|
|
144
|
+
manager = Wenmar::AuthManager.new(store: store, oauth: { base_url: "https://app.wenmarpro.com" })
|
|
145
|
+
provider = Wenmar::CredentialStoreProvider.new(store: store, manager: manager)
|
|
146
|
+
client = Wenmar::Client.new(token_provider: provider)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`Wenmar::KeychainStore` is an alternative macOS keychain-backed store (it
|
|
150
|
+
requires the optional `ruby-keychain` gem, which is **not** a runtime
|
|
151
|
+
dependency — install it yourself if you want keychain storage).
|
|
152
|
+
|
|
153
|
+
## Documentation
|
|
154
|
+
|
|
155
|
+
- [API reference](../docs/api/api-reference.md)
|
|
156
|
+
- [Authentication](../docs/api/authentication.md)
|
|
157
|
+
- [Pagination](../docs/api/pagination.md)
|
|
158
|
+
- [Errors](../docs/api/errors.md)
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
data/lib/wenmar/auth.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wenmar
|
|
4
|
+
class TokenError < StandardError; end
|
|
5
|
+
|
|
6
|
+
module TokenProvider
|
|
7
|
+
def token
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class StaticTokenProvider
|
|
13
|
+
include TokenProvider
|
|
14
|
+
|
|
15
|
+
def initialize(token)
|
|
16
|
+
raise ArgumentError, "token is required" if token.nil? || token.empty?
|
|
17
|
+
|
|
18
|
+
@token = token
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :token
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reads a Wenmar::Token from a store, auto-refreshing via an AuthManager when
|
|
25
|
+
# the stored token is expired or within a refresh window of expiry.
|
|
26
|
+
class CredentialStoreProvider
|
|
27
|
+
DEFAULT_REFRESH_WINDOW = 300
|
|
28
|
+
|
|
29
|
+
def initialize(store:, manager: nil, refresh_window: DEFAULT_REFRESH_WINDOW)
|
|
30
|
+
@store = store
|
|
31
|
+
@manager = manager
|
|
32
|
+
@refresh_window = refresh_window
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def token
|
|
36
|
+
token = @store.get_token
|
|
37
|
+
return nil if token.nil? || token.access_token.nil? || token.access_token.empty?
|
|
38
|
+
|
|
39
|
+
if token.expired? || token.will_expire_within?(@refresh_window)
|
|
40
|
+
if @manager
|
|
41
|
+
@manager.refresh
|
|
42
|
+
token = @store.get_token
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
token&.access_token
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Coordinates token storage, retrieval, and refresh. When configured with an
|
|
50
|
+
# oauth hash (base_url and optional client_id), #refresh exchanges the stored
|
|
51
|
+
# refresh token at the Doorkeeper token endpoint.
|
|
52
|
+
class AuthManager
|
|
53
|
+
def initialize(store:, provider: nil, oauth: nil)
|
|
54
|
+
@store = store
|
|
55
|
+
@provider = provider
|
|
56
|
+
@refresh_fn = oauth ? build_oauth_refresh(oauth) : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Overrides the refresh function used by #refresh. Useful for tests and
|
|
60
|
+
# custom refresh flows.
|
|
61
|
+
def set_refresh_fn(fn = nil, &block)
|
|
62
|
+
@refresh_fn = fn || block
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def token
|
|
66
|
+
return @provider.token if @provider
|
|
67
|
+
|
|
68
|
+
token = @store.get_token
|
|
69
|
+
token&.access_token
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def refresh
|
|
73
|
+
token = @store.get_token
|
|
74
|
+
raise TokenError, "no token stored" if token.nil?
|
|
75
|
+
raise TokenError, "no refresh token stored" if token.refresh_token.nil? || token.refresh_token.empty?
|
|
76
|
+
raise TokenError, "OAuth refresh is not configured" if @refresh_fn.nil?
|
|
77
|
+
|
|
78
|
+
new_token = @refresh_fn.call(token.refresh_token)
|
|
79
|
+
@store.save_token(new_token)
|
|
80
|
+
new_token
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def logout
|
|
84
|
+
@store.delete
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def build_oauth_refresh(oauth)
|
|
90
|
+
oauth = {base_url: oauth} if oauth.is_a?(String)
|
|
91
|
+
base_url = oauth[:base_url]
|
|
92
|
+
client_id = oauth[:client_id] || OAuth::DEFAULT_CLIENT_ID
|
|
93
|
+
return nil if base_url.nil? || base_url.empty?
|
|
94
|
+
|
|
95
|
+
->(refresh_token) { OAuth.refresh(base_url: base_url, refresh_token: refresh_token, client_id: client_id) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
require_relative "error"
|
|
9
|
+
require_relative "auth"
|
|
10
|
+
require_relative "config"
|
|
11
|
+
require_relative "credentials"
|
|
12
|
+
require_relative "pagination"
|
|
13
|
+
|
|
14
|
+
module Wenmar
|
|
15
|
+
class Client
|
|
16
|
+
DEFAULT_BASE_URL = "https://app.wenmarpro.com"
|
|
17
|
+
|
|
18
|
+
# Faraday's retry middleware raises RetriableResponse internally when a
|
|
19
|
+
# response status matches retry_statuses. The write connection must match
|
|
20
|
+
# only that synthetic exception so 429s retry while transport errors
|
|
21
|
+
# (Faraday::ConnectionFailed, etc.) do not — mutations are not safe to
|
|
22
|
+
# replay on a lost connection.
|
|
23
|
+
WRITE_RETRY_EXCEPTIONS = [Faraday::RetriableResponse].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :base_url, :location_id, :config
|
|
26
|
+
|
|
27
|
+
def initialize(config = nil, token: nil, base_url: nil, token_provider: nil)
|
|
28
|
+
@config = config.is_a?(Config) ? config : Config.new(config || {})
|
|
29
|
+
@config.access_token = token if token
|
|
30
|
+
@config.token_provider = token_provider if token_provider
|
|
31
|
+
@config.base_url = base_url if base_url
|
|
32
|
+
|
|
33
|
+
@base_url = (@config.base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
|
|
34
|
+
@location_id = @config.location_id
|
|
35
|
+
raise ArgumentError, "base_url must use https (http only allowed for localhost)" unless https_or_localhost?(@base_url)
|
|
36
|
+
|
|
37
|
+
@read_connection = build_connection(retry_statuses: [429, 500, 502, 503, 504])
|
|
38
|
+
@write_connection = build_connection(retry_statuses: [429], methods: %i[post patch delete], exceptions: WRITE_RETRY_EXCEPTIONS)
|
|
39
|
+
@cache = {}
|
|
40
|
+
@cache_mutex = Mutex.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def https_or_localhost?(url)
|
|
44
|
+
parsed = URI.parse(url)
|
|
45
|
+
return true if parsed.scheme == "https"
|
|
46
|
+
|
|
47
|
+
%w[localhost 127.0.0.1].include?(parsed.host)
|
|
48
|
+
rescue URI::InvalidURIError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
private :https_or_localhost?
|
|
52
|
+
|
|
53
|
+
def token_provider
|
|
54
|
+
@config.token_provider || StaticTokenProvider.new(@config.access_token)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def for_location(location_id)
|
|
58
|
+
scoped = dup
|
|
59
|
+
scoped.instance_variable_set(:@location_id, location_id)
|
|
60
|
+
scoped.instance_variable_set(:@cache, {})
|
|
61
|
+
scoped.instance_variable_set(:@cache_mutex, Mutex.new)
|
|
62
|
+
scoped.instance_variable_set(:@read_connection, build_connection(retry_statuses: [429, 500, 502, 503, 504], location_id: location_id))
|
|
63
|
+
scoped.instance_variable_set(:@write_connection, build_connection(retry_statuses: [429], methods: %i[post patch delete], exceptions: WRITE_RETRY_EXCEPTIONS, location_id: location_id))
|
|
64
|
+
scoped
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def get(path, params = {})
|
|
68
|
+
cache_key = cache_key(path, params)
|
|
69
|
+
cached = if @config.cache_enabled
|
|
70
|
+
@cache_mutex.synchronize { @cache[cache_key] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
response = @read_connection.get(path, params) do |req|
|
|
74
|
+
if cached
|
|
75
|
+
req.headers["If-None-Match"] = cached[:etag] if cached[:etag]
|
|
76
|
+
req.headers["If-Modified-Since"] = cached[:last_modified] if cached[:last_modified]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if response.status == 304 && cached
|
|
81
|
+
response = Faraday::Response.new(
|
|
82
|
+
status: 200,
|
|
83
|
+
response_headers: cached[:headers],
|
|
84
|
+
body: cached[:body]
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if @config.cache_enabled && response.status == 200 && (response.headers["ETag"] || response.headers["Last-Modified"])
|
|
89
|
+
@cache_mutex.synchronize do
|
|
90
|
+
@cache[cache_key] = {
|
|
91
|
+
etag: response.headers["ETag"],
|
|
92
|
+
last_modified: response.headers["Last-Modified"],
|
|
93
|
+
body: response.body,
|
|
94
|
+
headers: response.headers
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
handle_response(response)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def get_raw(url)
|
|
103
|
+
response = @read_connection.get(url) do |req|
|
|
104
|
+
req.headers["Accept"] = "application/json"
|
|
105
|
+
end
|
|
106
|
+
raise Wenmar::Error.from_response(response) if response.status >= 400
|
|
107
|
+
|
|
108
|
+
response
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def post(path, body = nil, params = {})
|
|
112
|
+
response = @write_connection.post(path) do |req|
|
|
113
|
+
req.params.merge!(params) unless params.empty?
|
|
114
|
+
req.body = body.to_json if body
|
|
115
|
+
end
|
|
116
|
+
handle_response(response)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def patch(path, body = nil, params = {})
|
|
120
|
+
response = @write_connection.patch(path) do |req|
|
|
121
|
+
req.params.merge!(params) unless params.empty?
|
|
122
|
+
req.body = body.to_json if body
|
|
123
|
+
end
|
|
124
|
+
handle_response(response)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def delete(path, params = {})
|
|
128
|
+
response = @write_connection.delete(path) do |req|
|
|
129
|
+
req.params.merge!(params) unless params.empty?
|
|
130
|
+
end
|
|
131
|
+
handle_response(response)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# paginator_to_a collects all pages from a paginated list result, up to
|
|
135
|
+
# max items (default 1000). The returned array includes the initial page
|
|
136
|
+
# (the response body) plus every subsequent page fetched via the Link
|
|
137
|
+
# header. We intentionally do NOT delegate to Paginator#to_a: that method
|
|
138
|
+
# starts from the paginator's own (empty) data and would drop the initial
|
|
139
|
+
# page.
|
|
140
|
+
def paginator_to_a(result, max = 1000)
|
|
141
|
+
return result unless result.respond_to?(:paginator)
|
|
142
|
+
|
|
143
|
+
paginator = result.paginator
|
|
144
|
+
return result unless paginator
|
|
145
|
+
|
|
146
|
+
items = result.dup
|
|
147
|
+
while paginator.has_next? && items.size < max
|
|
148
|
+
page = paginator.next_page
|
|
149
|
+
break if page.nil?
|
|
150
|
+
|
|
151
|
+
items.concat(page)
|
|
152
|
+
end
|
|
153
|
+
items.first(max)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def build_connection(retry_statuses:, methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS, exceptions: [Faraday::Error], location_id: @location_id)
|
|
159
|
+
Faraday.new(url: @base_url) do |conn|
|
|
160
|
+
conn.headers["Accept"] = "application/json"
|
|
161
|
+
conn.headers["Content-Type"] = "application/json"
|
|
162
|
+
conn.headers["User-Agent"] = "wenmar-sdk-ruby/#{Wenmar::VERSION}"
|
|
163
|
+
conn.headers["X-Wenmar-Location"] = location_id if location_id
|
|
164
|
+
conn.options.timeout = @config.timeout
|
|
165
|
+
conn.options.open_timeout = @config.timeout
|
|
166
|
+
conn.request :authorization, "Bearer", -> { resolve_token }
|
|
167
|
+
conn.request :retry, retry_options.merge(
|
|
168
|
+
retry_statuses: retry_statuses,
|
|
169
|
+
methods: methods,
|
|
170
|
+
exceptions: exceptions
|
|
171
|
+
)
|
|
172
|
+
conn.adapter Faraday.default_adapter
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds the Faraday retry middleware options from @config.retry_options,
|
|
177
|
+
# defaulting max to @config.max_retries when not present. These may be
|
|
178
|
+
# overridden per-connection (retry_statuses/methods/exceptions) by callers
|
|
179
|
+
# of build_connection, which always win.
|
|
180
|
+
def retry_options
|
|
181
|
+
@config.retry_options.dup.tap do |opts|
|
|
182
|
+
opts[:max] = @config.max_retries unless opts.key?(:max) && opts[:max]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def resolve_token
|
|
187
|
+
token_provider.token
|
|
188
|
+
rescue TokenError => e
|
|
189
|
+
raise Error.new(code: "auth_failed", message: e.message)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cache_key(path, params)
|
|
193
|
+
"#{location_id}|#{path_with_query(path, params)}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def path_with_query(path, params)
|
|
197
|
+
return path if params.nil? || params.empty?
|
|
198
|
+
|
|
199
|
+
"#{path}?#{URI.encode_www_form(params)}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_response(response)
|
|
203
|
+
if response.status >= 400
|
|
204
|
+
raise Wenmar::Error.from_response(response)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
body = response.body
|
|
208
|
+
return nil if body.nil? || body == ""
|
|
209
|
+
|
|
210
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
211
|
+
|
|
212
|
+
# Attach a paginator to list responses that carry a Link header.
|
|
213
|
+
if body.is_a?(Array) && response.headers["Link"]
|
|
214
|
+
client = self
|
|
215
|
+
body.define_singleton_method(:paginator) do
|
|
216
|
+
Paginator.from_response(response, client)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
body
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wenmar
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor :base_url, :access_token, :token_provider, :location_id,
|
|
6
|
+
:timeout, :max_retries, :cache_enabled, :retry_options
|
|
7
|
+
|
|
8
|
+
def initialize(attrs = {})
|
|
9
|
+
@base_url = attrs[:base_url] || "https://app.wenmarpro.com"
|
|
10
|
+
@access_token = attrs[:access_token]
|
|
11
|
+
@token_provider = attrs[:token_provider]
|
|
12
|
+
@location_id = attrs[:location_id]
|
|
13
|
+
@timeout = attrs[:timeout] || 30
|
|
14
|
+
@max_retries = attrs[:max_retries] || 3
|
|
15
|
+
@cache_enabled = attrs.fetch(:cache_enabled, true)
|
|
16
|
+
@retry_options = attrs[:retry_options] || {
|
|
17
|
+
max: @max_retries,
|
|
18
|
+
interval: 0.1,
|
|
19
|
+
interval_randomness: 0.5,
|
|
20
|
+
backoff_factor: 2,
|
|
21
|
+
max_interval: 30
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_env
|
|
26
|
+
new(
|
|
27
|
+
base_url: ENV.fetch("WENMAR_BASE_URL", "https://app.wenmarpro.com"),
|
|
28
|
+
timeout: ENV["WENMAR_TIMEOUT"]&.to_i || 30,
|
|
29
|
+
max_retries: ENV["WENMAR_MAX_RETRIES"]&.to_i || 3,
|
|
30
|
+
cache_enabled: ENV["WENMAR_CACHE"] != "false"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Wenmar
|
|
7
|
+
class CredentialStore
|
|
8
|
+
TOKEN_KEY = "access_token"
|
|
9
|
+
LEGACY_TOKEN_KEY = "token"
|
|
10
|
+
|
|
11
|
+
def initialize(path = default_path)
|
|
12
|
+
@path = path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load
|
|
16
|
+
return {} unless File.exist?(@path)
|
|
17
|
+
|
|
18
|
+
JSON.parse(File.read(@path))
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
{}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Legacy: returns the access token string or nil. Prefer get_token.
|
|
24
|
+
def token
|
|
25
|
+
data = load
|
|
26
|
+
data[TOKEN_KEY] || data[LEGACY_TOKEN_KEY]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Legacy: wraps a token string in a Wenmar::Token and persists it.
|
|
30
|
+
def save(token)
|
|
31
|
+
save_token(Token.new(access_token: token))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_token
|
|
35
|
+
data = load
|
|
36
|
+
return nil if data.empty?
|
|
37
|
+
|
|
38
|
+
return Token.new(access_token: data[LEGACY_TOKEN_KEY]) if data[LEGACY_TOKEN_KEY] && !data[TOKEN_KEY]
|
|
39
|
+
|
|
40
|
+
Token.from_h(data)
|
|
41
|
+
rescue TokenError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def save_token(token)
|
|
46
|
+
token = Token.from_h(token) if token.is_a?(Hash)
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
48
|
+
File.write(@path, JSON.pretty_generate(token.to_h), perm: 0o600)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete
|
|
52
|
+
FileUtils.rm_f(@path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def default_path
|
|
58
|
+
File.join(Dir.home, ".config", "wenmar", "credentials.json")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class KeychainStore
|
|
63
|
+
SERVICE = "wenmar"
|
|
64
|
+
LEGACY_SERVICE = "wenmar-cli"
|
|
65
|
+
ACCOUNT = "token"
|
|
66
|
+
|
|
67
|
+
# The ruby-keychain gem is macOS-only and optional. It must not be a hard
|
|
68
|
+
# runtime dependency, so we require it lazily and only when a keychain
|
|
69
|
+
# operation is attempted. Keychain users must `gem install ruby-keychain`
|
|
70
|
+
# themselves.
|
|
71
|
+
def initialize
|
|
72
|
+
require_keychain
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def get_token
|
|
76
|
+
password = read_password
|
|
77
|
+
return nil unless password
|
|
78
|
+
|
|
79
|
+
Token.from_h(JSON.parse(password))
|
|
80
|
+
rescue TokenError, JSON::ParserError
|
|
81
|
+
# A legacy plain-token entry has no JSON envelope; treat it as a bare
|
|
82
|
+
# access token.
|
|
83
|
+
bare = read_password
|
|
84
|
+
bare ? Token.new(access_token: bare) : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def save_token(token)
|
|
88
|
+
token = Token.from_h(token) if token.is_a?(Hash)
|
|
89
|
+
write_password(JSON.generate(token.to_h))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def delete
|
|
93
|
+
require_keychain
|
|
94
|
+
Keychain.generic_passwords.where(service: SERVICE, account: ACCOUNT).first&.destroy
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def require_keychain
|
|
100
|
+
require "keychain"
|
|
101
|
+
rescue LoadError
|
|
102
|
+
raise TokenError, "The 'ruby-keychain' gem is required to use KeychainStore. Run `gem install ruby-keychain`."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_password
|
|
106
|
+
require_keychain
|
|
107
|
+
item = Keychain.generic_passwords.where(service: SERVICE, account: ACCOUNT).first
|
|
108
|
+
return item.password if item
|
|
109
|
+
|
|
110
|
+
legacy = Keychain.generic_passwords.where(service: LEGACY_SERVICE, account: ACCOUNT).first
|
|
111
|
+
return nil unless legacy
|
|
112
|
+
|
|
113
|
+
# Migrate a legacy entry into the current service.
|
|
114
|
+
write_password(legacy.password)
|
|
115
|
+
legacy.password
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_password(password)
|
|
119
|
+
require_keychain
|
|
120
|
+
Keychain.generic_passwords.where(service: SERVICE, account: ACCOUNT).first&.destroy
|
|
121
|
+
Keychain.generic_passwords.create(service: SERVICE, account: ACCOUNT, password: password)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|