strix_ruby 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: d3c540b7a6ce319f180344ab79f747935cd377d50815ba1f303cb6b4b95c8612
4
+ data.tar.gz: 93485bbae481f783eb2eafc08581f73d028369ec627dfa4965c51107dde33b0e
5
+ SHA512:
6
+ metadata.gz: 56e5fae1c16a06017a9738e92e2f6f42c39bbdc3cd76f6be326282b2e0adb9db74d9e7f948b11659f0ebfc4deb5d0cdc629e810ae8a9300e760b374a43e7d86f
7
+ data.tar.gz: 4f0e164b09856ae83a82c5d1719efe06df56cd02288bdf996ebf8183891f9c72229beb98742f7f0e8c088a0d1db60e05b542e18824bdea4359e0b34929d1dde7
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,46 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "bin/**/*"
7
+ - "vendor/**/*"
8
+
9
+ Style/StringLiterals:
10
+ EnforcedStyle: double_quotes
11
+
12
+ Style/StringLiteralsInInterpolation:
13
+ EnforcedStyle: double_quotes
14
+
15
+ # validate! is intentionally named with bang to indicate it raises exceptions
16
+ Naming/PredicateMethod:
17
+ AllowedMethods:
18
+ - validate!
19
+
20
+ # Gem classes can be longer
21
+ Metrics/ClassLength:
22
+ Max: 150
23
+
24
+ # Allow longer methods for configuration and HTTP handling
25
+ Metrics/MethodLength:
26
+ Max: 15
27
+ Exclude:
28
+ - "spec/**/*"
29
+
30
+ Metrics/AbcSize:
31
+ Max: 20
32
+
33
+ Metrics/CyclomaticComplexity:
34
+ Max: 10
35
+
36
+ Metrics/PerceivedComplexity:
37
+ Max: 10
38
+
39
+ Metrics/ParameterLists:
40
+ Max: 6
41
+
42
+ # Exclude spec files from block length - RSpec blocks are naturally long
43
+ Metrics/BlockLength:
44
+ Exclude:
45
+ - "spec/**/*"
46
+ - "*.gemspec"
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2024-12-31
9
+
10
+ ### Added
11
+
12
+ - Initial release of StrixRuby client
13
+ - OAuth token management with automatic refresh
14
+ - Global and per-instance configuration
15
+ - `account_id` configurable globally with per-method override
16
+ - API endpoints:
17
+ - `list_things` - List all things in an account
18
+ - `list_things_filtered` - List things with type filter
19
+ - `get_trail_locations` - Get trail locations for a thing
20
+ - `get_trail_summarized` - Get summarized trail data
21
+ - DateTime/Time parameter support with automatic UTC milliseconds conversion
22
+ - Custom error classes:
23
+ - `AuthenticationError` - Invalid credentials
24
+ - `APIError` - API request failures
25
+ - `ConfigurationError` - Missing configuration
26
+ - `TokenExpiredError` - Token expiration issues
27
+ - Comprehensive test suite with WebMock
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 TechSed
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # StrixRuby
2
+
3
+ A Ruby client library for the Strix Integration API. Provides OAuth token management, things listing, and trail location/summary endpoints with automatic token refresh.
4
+
5
+ ## Features
6
+
7
+ - 🔐 **Automatic OAuth Token Management** - Tokens are obtained automatically and refreshed when expired
8
+ - 📍 **Trail Locations & Summaries** - Query vehicle/thing trail data with date ranges
9
+ - 🕐 **DateTime Support** - Pass `DateTime` or `Time` objects, automatically converted to UTC milliseconds
10
+ - ⚙️ **Flexible Configuration** - Global or per-instance configuration
11
+ - 🧪 **Well Tested** - Comprehensive test suite with mocked API responses
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'strix_ruby'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ Or install it yourself as:
28
+
29
+ ```bash
30
+ gem install strix_ruby
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ ### Global Configuration
36
+
37
+ Configure the gem globally (recommended for Rails applications):
38
+
39
+ ```ruby
40
+ StrixRuby.configure do |config|
41
+ config.base_url = "https://api.strix.example.com"
42
+ config.username = "your_username"
43
+ config.password = "your_password"
44
+ config.account_id = "your_default_account_id" # optional
45
+ end
46
+ ```
47
+
48
+ ### Rails Initializer
49
+
50
+ Create a file `config/initializers/strix_ruby.rb`:
51
+
52
+ ```ruby
53
+ StrixRuby.configure do |config|
54
+ config.base_url = ENV["STRIX_API_URL"]
55
+ config.username = ENV["STRIX_USERNAME"]
56
+ config.password = ENV["STRIX_PASSWORD"]
57
+ config.account_id = ENV["STRIX_ACCOUNT_ID"] # optional
58
+ end
59
+ ```
60
+
61
+ ### Per-Instance Configuration
62
+
63
+ You can also configure each client instance separately:
64
+
65
+ ```ruby
66
+ client = StrixRuby::Client.new(
67
+ base_url: "https://api.strix.example.com",
68
+ username: "your_username",
69
+ password: "your_password",
70
+ account_id: "your_account_id" # optional
71
+ )
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ### Create a Client
77
+
78
+ ```ruby
79
+ # Using global configuration
80
+ client = StrixRuby::Client.new
81
+
82
+ # Or with instance configuration
83
+ client = StrixRuby::Client.new(
84
+ base_url: "https://api.strix.example.com",
85
+ username: "your_username",
86
+ password: "your_password"
87
+ )
88
+ ```
89
+
90
+ ### List Things
91
+
92
+ The `account_id` is resolved in this priority order:
93
+ 1. Method parameter (if provided)
94
+ 2. Configured `account_id` (global or instance)
95
+ 3. Token's `meta.business_account_id` (extracted from JWT)
96
+
97
+ ```ruby
98
+ # Uses account_id from configuration or token
99
+ things = client.list_things
100
+
101
+ # Override with specific account_id
102
+ things = client.list_things(account_id: "12345")
103
+
104
+ # Access the account_id from the token
105
+ client.token_account_id # => "mrn:account:business:..."
106
+ ```
107
+
108
+ ### List Things with Filter
109
+
110
+ ```ruby
111
+ # Get only vehicles
112
+ vehicles = client.list_things_filtered(types: "mrn:thing:vehicle")
113
+
114
+ # Specify account_id
115
+ vehicles = client.list_things_filtered(
116
+ account_id: "12345",
117
+ types: "mrn:thing:vehicle"
118
+ )
119
+ ```
120
+
121
+ ### Get Trail Locations
122
+
123
+ Query trail locations for a thing within a date range:
124
+
125
+ ```ruby
126
+ locations = client.get_trail_locations(
127
+ thing_id: "67890",
128
+ date_from: DateTime.now - 1, # 1 day ago
129
+ date_to: DateTime.now,
130
+ limit: 100, # optional, default: 10
131
+ page: 1, # optional, default: 1
132
+ type: "valid" # optional, default: "valid"
133
+ )
134
+ ```
135
+
136
+ ### Get Trail Summary
137
+
138
+ Get a summarized view of trail data:
139
+
140
+ ```ruby
141
+ summary = client.get_trail_summarized(
142
+ thing_id: "67890",
143
+ date_from: DateTime.new(2024, 1, 1, 0, 0, 0, "-03:00"),
144
+ date_to: DateTime.new(2024, 1, 31, 23, 59, 59, "-03:00"),
145
+ type: "_valid" # optional, default: "_valid"
146
+ )
147
+
148
+ # Response includes:
149
+ # - max_speed: Maximum speed in Km/h
150
+ # - distance: Distance in meters
151
+ # - time_with_contact_off: Time without contact (seconds)
152
+ # - time_in_movement_and_contact_on: Time moving with contact (seconds)
153
+ # - time_stopped_and_contact_on: Time stopped with contact (seconds)
154
+ ```
155
+
156
+ ### Date/Time Handling
157
+
158
+ The API expects timestamps in UTC milliseconds. The client accepts:
159
+
160
+ - **DateTime objects**: Converted to UTC milliseconds automatically
161
+ - **Time objects**: Converted to UTC milliseconds automatically
162
+ - **Integer timestamps**: Used as-is (must be in milliseconds)
163
+
164
+ ```ruby
165
+ # All of these work:
166
+ client.get_trail_locations(
167
+ thing_id: "67890",
168
+ date_from: DateTime.now - 1, # DateTime
169
+ date_to: Time.now # Time
170
+ )
171
+
172
+ client.get_trail_locations(
173
+ thing_id: "67890",
174
+ date_from: 1704067200000, # Integer (milliseconds)
175
+ date_to: 1704153600000
176
+ )
177
+ ```
178
+
179
+ ### Token Management
180
+
181
+ Tokens are managed automatically, but you can control them manually if needed:
182
+
183
+ ```ruby
184
+ # Force token refresh
185
+ client.refresh_token!
186
+
187
+ # Clear cached token (next request will fetch new token)
188
+ client.clear_token!
189
+ ```
190
+
191
+ ## Error Handling
192
+
193
+ The gem raises specific errors for different failure cases:
194
+
195
+ ```ruby
196
+ begin
197
+ client.list_things
198
+ rescue StrixRuby::AuthenticationError => e
199
+ # Invalid credentials (401)
200
+ puts "Authentication failed: #{e.message}"
201
+ rescue StrixRuby::APIError => e
202
+ # Other API errors
203
+ puts "API error (#{e.status}): #{e.message}"
204
+ puts "Response body: #{e.body}"
205
+ rescue StrixRuby::ConfigurationError => e
206
+ # Missing configuration
207
+ puts "Configuration error: #{e.message}"
208
+ end
209
+ ```
210
+
211
+ ## Development
212
+
213
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
214
+
215
+ ```bash
216
+ bundle install
217
+ bundle exec rspec
218
+ ```
219
+
220
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
221
+
222
+ To install this gem onto your local machine, run `bundle exec rake install`.
223
+
224
+ ## Contributing
225
+
226
+ Bug reports and pull requests are welcome on GitHub at https://github.com/tech-sed/strix_ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
227
+
228
+ ## License
229
+
230
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
231
+
232
+ ## Code of Conduct
233
+
234
+ Everyone interacting in the StrixRuby project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrixRuby
4
+ # Main client for interacting with the Strix Integration API
5
+ class Client
6
+ attr_reader :base_url, :account_id
7
+
8
+ # Returns the account_id extracted from the JWT token
9
+ # @return [String, nil] the account_id from token's meta.business_account_id
10
+ def token_account_id
11
+ # Ensure token is fetched first
12
+ @token_manager.access_token
13
+ @token_manager.token_account_id
14
+ end
15
+
16
+ # Initialize a new client
17
+ # @param base_url [String] the base URL of the API (optional if globally configured)
18
+ # @param username [String] the API username (optional if globally configured)
19
+ # @param password [String] the API password (optional if globally configured)
20
+ # @param account_id [String, Integer] the default account ID (optional)
21
+ def initialize(base_url: nil, username: nil, password: nil, account_id: nil)
22
+ @base_url = base_url || StrixRuby.configuration&.base_url
23
+ @username = username || StrixRuby.configuration&.username
24
+ @password = password || StrixRuby.configuration&.password
25
+ @account_id = account_id || StrixRuby.configuration&.account_id
26
+
27
+ validate_configuration!
28
+
29
+ @connection = Connection.new(base_url: @base_url)
30
+ @token_manager = TokenManager.new(
31
+ connection: @connection,
32
+ username: @username,
33
+ password: @password
34
+ )
35
+ end
36
+
37
+ # List all things in an account
38
+ # @param account_id [String, Integer, nil] the account ID (uses configured account_id if not provided)
39
+ # @return [Hash] the API response with things list
40
+ def list_things(account_id: nil)
41
+ resolved_account_id = resolve_account_id(account_id)
42
+ authenticated_get("/v1.5/accounts/#{resolved_account_id}/things")
43
+ end
44
+
45
+ # List things with filters
46
+ # @param account_id [String, Integer, nil] the account ID (uses configured account_id if not provided)
47
+ # @param types [String] thing type filter (e.g., 'mrn:thing:vehicle')
48
+ # @return [Hash] the API response with filtered things list
49
+ def list_things_filtered(types:, account_id: nil)
50
+ resolved_account_id = resolve_account_id(account_id)
51
+ authenticated_get(
52
+ "/v1.5/things",
53
+ params: {
54
+ account_id: resolved_account_id,
55
+ types: types
56
+ }
57
+ )
58
+ end
59
+
60
+ # Get trail locations for a thing
61
+ # @param thing_id [String, Integer] the thing ID
62
+ # @param date_from [DateTime, Time] start date (will be converted to UTC milliseconds)
63
+ # @param date_to [DateTime, Time] end date (will be converted to UTC milliseconds)
64
+ # @param limit [Integer] number of results per page (default: 100)
65
+ # @param page [Integer] page number (default: 1)
66
+ # @param type [String] location type filter (default: 'valid')
67
+ # @return [Hash] the API response with trail locations
68
+ def get_trail_locations(thing_id:, date_from:, date_to:, limit: 100, page: 1, type: "valid")
69
+ authenticated_get(
70
+ "/v1.5/things/#{thing_id}/trail_locations",
71
+ params: {
72
+ _from: datetime_to_ms(date_from),
73
+ _to: datetime_to_ms(date_to),
74
+ _limit: limit,
75
+ _page: page,
76
+ _type: type
77
+ }
78
+ )
79
+ end
80
+
81
+ # Get summarized trail for a thing
82
+ # @param thing_id [String, Integer] the thing ID
83
+ # @param date_from [DateTime, Time] start date (will be converted to UTC milliseconds)
84
+ # @param date_to [DateTime, Time] end date (will be converted to UTC milliseconds)
85
+ # @param type [String] summary type filter (default: '_valid')
86
+ # @return [Hash] the API response with trail summary
87
+ def get_trail_summarized(thing_id:, date_from:, date_to:, type: "_valid")
88
+ authenticated_get(
89
+ "/v1.5/things/#{thing_id}/trail_summarized",
90
+ params: {
91
+ _from: datetime_to_ms(date_from),
92
+ _to: datetime_to_ms(date_to),
93
+ _type: type
94
+ }
95
+ )
96
+ end
97
+
98
+ # Force refresh of the access token
99
+ # @return [String] the new access token
100
+ def refresh_token!
101
+ @token_manager.refresh_token
102
+ end
103
+
104
+ # Clear the cached token
105
+ def clear_token!
106
+ @token_manager.clear!
107
+ end
108
+
109
+ private
110
+
111
+ def validate_configuration!
112
+ missing = []
113
+ missing << "base_url" if @base_url.nil? || @base_url.empty?
114
+ missing << "username" if @username.nil? || @username.empty?
115
+ missing << "password" if @password.nil? || @password.empty?
116
+
117
+ return if missing.empty?
118
+
119
+ raise ConfigurationError, "Missing configuration: #{missing.join(", ")}"
120
+ end
121
+
122
+ def authenticated_get(path, params: {})
123
+ @connection.get(
124
+ path,
125
+ params: params,
126
+ headers: auth_headers
127
+ )
128
+ end
129
+
130
+ def auth_headers
131
+ {
132
+ "Authorization" => "Bearer #{@token_manager.access_token}"
133
+ }
134
+ end
135
+
136
+ # Resolve account_id with fallback priority:
137
+ # 1. Method parameter
138
+ # 2. Configured account_id
139
+ # 3. Token's meta.business_account_id
140
+ def resolve_account_id(provided_account_id)
141
+ resolved = provided_account_id || @account_id || token_account_id
142
+ unless resolved
143
+ raise ArgumentError,
144
+ "account_id is required (provide as argument, configure globally, or it must be present in token)"
145
+ end
146
+
147
+ resolved
148
+ end
149
+
150
+ # Convert a DateTime or Time object to UTC milliseconds
151
+ # @param datetime [DateTime, Time, Integer] the datetime to convert
152
+ # @return [Integer] UTC timestamp in milliseconds
153
+ def datetime_to_ms(datetime)
154
+ case datetime
155
+ when Integer
156
+ datetime
157
+ when DateTime
158
+ (datetime.to_time.utc.to_f * 1000).to_i
159
+ when Time
160
+ (datetime.utc.to_f * 1000).to_i
161
+ else
162
+ raise ArgumentError, "Expected DateTime, Time, or Integer, got #{datetime.class}"
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrixRuby
4
+ # Configuration class for storing API credentials and settings
5
+ class Configuration
6
+ attr_accessor :base_url, :username, :password, :account_id
7
+
8
+ def initialize
9
+ @base_url = nil
10
+ @username = nil
11
+ @password = nil
12
+ @account_id = nil
13
+ end
14
+
15
+ # Validates that all required configuration is present
16
+ # @return [Boolean] true if configuration is valid
17
+ # @raise [ConfigurationError] if configuration is invalid
18
+ def validate!
19
+ missing = []
20
+ missing << "base_url" if base_url.nil? || base_url.empty?
21
+ missing << "username" if username.nil? || username.empty?
22
+ missing << "password" if password.nil? || password.empty?
23
+
24
+ raise ConfigurationError, "Missing configuration: #{missing.join(", ")}" unless missing.empty?
25
+
26
+ true
27
+ end
28
+
29
+ # Check if configuration is valid without raising
30
+ # @return [Boolean] true if configuration is valid
31
+ def valid?
32
+ validate!
33
+ true
34
+ rescue ConfigurationError
35
+ false
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module StrixRuby
7
+ # HTTP connection wrapper using Faraday
8
+ class Connection
9
+ attr_reader :base_url
10
+
11
+ def initialize(base_url:)
12
+ @base_url = base_url.chomp("/")
13
+ @connection = build_connection
14
+ end
15
+
16
+ # Perform a GET request
17
+ # @param path [String] the API path
18
+ # @param params [Hash] query parameters
19
+ # @param headers [Hash] additional headers
20
+ # @return [Hash] parsed JSON response
21
+ def get(path, params: {}, headers: {})
22
+ response = @connection.get(path) do |req|
23
+ req.params = params unless params.empty?
24
+ headers.each { |key, value| req.headers[key] = value }
25
+ end
26
+
27
+ handle_response(response)
28
+ rescue Faraday::ClientError => e
29
+ handle_faraday_error(e)
30
+ end
31
+
32
+ # Perform a POST request with form data
33
+ # @param path [String] the API path
34
+ # @param body [Hash] form data
35
+ # @param headers [Hash] additional headers
36
+ # @return [Hash] parsed JSON response
37
+ def post_form(path, body: {}, headers: {})
38
+ response = @connection.post(path) do |req|
39
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
40
+ headers.each { |key, value| req.headers[key] = value }
41
+ req.body = URI.encode_www_form(body)
42
+ end
43
+
44
+ handle_response(response)
45
+ rescue Faraday::ClientError => e
46
+ handle_faraday_error(e)
47
+ end
48
+
49
+ private
50
+
51
+ def build_connection
52
+ Faraday.new(url: @base_url) do |conn|
53
+ conn.request :url_encoded
54
+ conn.response :raise_error, include_request: true
55
+ conn.adapter Faraday.default_adapter
56
+ conn.headers["Accept"] = "application/json; charset=utf-8"
57
+ end
58
+ end
59
+
60
+ def handle_response(response)
61
+ case response.status
62
+ when 200..299
63
+ parse_json(response.body)
64
+ when 401
65
+ raise AuthenticationError, "Authentication failed (401)"
66
+ else
67
+ raise APIError.new(
68
+ "API request failed with status #{response.status}",
69
+ status: response.status,
70
+ body: response.body
71
+ )
72
+ end
73
+ end
74
+
75
+ def handle_faraday_error(error)
76
+ status = error.response&.dig(:status)
77
+
78
+ raise AuthenticationError, "Authentication failed (401)" if status == 401
79
+
80
+ raise APIError.new(
81
+ error.message,
82
+ status: status,
83
+ body: error.response&.dig(:body)
84
+ )
85
+ end
86
+
87
+ def parse_json(body)
88
+ return {} if body.nil? || body.empty?
89
+
90
+ JSON.parse(body)
91
+ rescue JSON::ParserError
92
+ { "raw" => body }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrixRuby
4
+ # Custom error classes for the Strix API client
5
+ class Error < StandardError; end
6
+
7
+ # Raised when authentication fails (invalid credentials)
8
+ class AuthenticationError < Error
9
+ def initialize(message = "Authentication failed. Please check your credentials.")
10
+ super
11
+ end
12
+ end
13
+
14
+ # Raised when the API returns an error response
15
+ class APIError < Error
16
+ attr_reader :status, :body
17
+
18
+ def initialize(message = nil, status: nil, body: nil)
19
+ @status = status
20
+ @body = body
21
+ super(message || "API request failed with status #{status}")
22
+ end
23
+ end
24
+
25
+ # Raised when the token has expired and cannot be refreshed
26
+ class TokenExpiredError < Error
27
+ def initialize(message = "Token has expired and could not be refreshed.")
28
+ super
29
+ end
30
+ end
31
+
32
+ # Raised when configuration is missing or invalid
33
+ class ConfigurationError < Error
34
+ def initialize(message = "Invalid configuration. Please ensure base_url, username, and password are set.")
35
+ super
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "jwt"
5
+
6
+ module StrixRuby
7
+ # Manages OAuth token lifecycle: obtains, caches, and refreshes tokens
8
+ class TokenManager
9
+ # Buffer time (in seconds) before actual expiration to refresh token
10
+ EXPIRATION_BUFFER = 60
11
+
12
+ # The account_id extracted from the token's meta.business_account_id
13
+ attr_reader :token_account_id
14
+
15
+ def initialize(connection:, username:, password:)
16
+ @connection = connection
17
+ @username = username
18
+ @password = password
19
+ @current_token = nil
20
+ @expires_at = nil
21
+ @token_account_id = nil
22
+ end
23
+
24
+ # Get a valid access token, refreshing if necessary
25
+ # @return [String] the access token
26
+ def access_token
27
+ refresh_token if token_expired?
28
+ @current_token
29
+ end
30
+
31
+ # Force a token refresh
32
+ # @return [String] the new access token
33
+ def refresh_token
34
+ response = request_new_token
35
+ @current_token = response["access_token"]
36
+ extract_token_data(@current_token)
37
+ @current_token
38
+ end
39
+
40
+ # Check if the current token is expired or not present
41
+ # @return [Boolean] true if token needs refresh
42
+ def token_expired?
43
+ return true if @current_token.nil? || @expires_at.nil?
44
+
45
+ Time.now.to_i >= (@expires_at - EXPIRATION_BUFFER)
46
+ end
47
+
48
+ # Clear the cached token
49
+ def clear!
50
+ @current_token = nil
51
+ @expires_at = nil
52
+ @token_account_id = nil
53
+ end
54
+
55
+ private
56
+
57
+ def request_new_token
58
+ auth_header = "Basic #{Base64.strict_encode64("#{@username}:#{@password}")}"
59
+
60
+ @connection.post_form(
61
+ "/oauth/token",
62
+ body: {
63
+ grant_type: "password",
64
+ user: @username,
65
+ password: @password,
66
+ scope: "all"
67
+ },
68
+ headers: {
69
+ "Authorization" => auth_header
70
+ }
71
+ )
72
+ end
73
+
74
+ def extract_token_data(token)
75
+ # Decode JWT without verification to extract claims
76
+ # The token signature is verified by the server
77
+ decoded = JWT.decode(token, nil, false)
78
+ payload = decoded.first
79
+
80
+ @expires_at = extract_expiration(payload)
81
+ @token_account_id = extract_account_id(payload)
82
+ rescue JWT::DecodeError
83
+ # If we can't decode the token, assume it expires in 1 hour
84
+ @expires_at = Time.now.to_i + 3600
85
+ @token_account_id = nil
86
+ end
87
+
88
+ def extract_expiration(payload)
89
+ # exp can be relative (seconds) or absolute (timestamp)
90
+ exp_value = payload["exp"]
91
+
92
+ if exp_value < 100_000_000 # Relative expiration (seconds from iat)
93
+ iat = payload["iat"] || Time.now.to_i
94
+ iat + exp_value
95
+ else
96
+ exp_value
97
+ end
98
+ end
99
+
100
+ def extract_account_id(payload)
101
+ # Extract account_id from meta.business_account_id
102
+ payload.dig("meta", "business_account_id")
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrixRuby
4
+ VERSION = "0.1.0"
5
+ end
data/lib/strix_ruby.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strix_ruby/version"
4
+ require_relative "strix_ruby/errors"
5
+ require_relative "strix_ruby/configuration"
6
+ require_relative "strix_ruby/connection"
7
+ require_relative "strix_ruby/token_manager"
8
+ require_relative "strix_ruby/client"
9
+
10
+ # StrixRuby is a Ruby client for the Strix Integration API
11
+ #
12
+ # @example Global configuration
13
+ # StrixRuby.configure do |config|
14
+ # config.base_url = "https://api.example.com"
15
+ # config.username = "your_username"
16
+ # config.password = "your_password"
17
+ # end
18
+ #
19
+ # client = StrixRuby::Client.new
20
+ # things = client.list_things(account_id: 123)
21
+ #
22
+ # @example Instance configuration
23
+ # client = StrixRuby::Client.new(
24
+ # base_url: "https://api.example.com",
25
+ # username: "your_username",
26
+ # password: "your_password"
27
+ # )
28
+ # things = client.list_things(account_id: 123)
29
+ #
30
+ module StrixRuby
31
+ class << self
32
+ attr_accessor :configuration
33
+
34
+ # Configure StrixRuby globally
35
+ # @yield [Configuration] the configuration object
36
+ # @example
37
+ # StrixRuby.configure do |config|
38
+ # config.base_url = "https://api.example.com"
39
+ # config.username = "your_username"
40
+ # config.password = "your_password"
41
+ # end
42
+ def configure
43
+ self.configuration ||= Configuration.new
44
+ yield(configuration)
45
+ end
46
+
47
+ # Reset the configuration to defaults
48
+ def reset_configuration!
49
+ self.configuration = Configuration.new
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,4 @@
1
+ module StrixRuby
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: strix_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nacho Althabe
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-12-31 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.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ description: A Ruby client library for the Strix Integration API. Provides OAuth token
42
+ management, things listing, and trail location/summary endpoints with automatic
43
+ token refresh.
44
+ email:
45
+ - nacho@tech-sed.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - ".rspec"
51
+ - ".rubocop.yml"
52
+ - CHANGELOG.md
53
+ - CODE_OF_CONDUCT.md
54
+ - LICENSE.txt
55
+ - README.md
56
+ - Rakefile
57
+ - lib/strix_ruby.rb
58
+ - lib/strix_ruby/client.rb
59
+ - lib/strix_ruby/configuration.rb
60
+ - lib/strix_ruby/connection.rb
61
+ - lib/strix_ruby/errors.rb
62
+ - lib/strix_ruby/token_manager.rb
63
+ - lib/strix_ruby/version.rb
64
+ - sig/strix_ruby.rbs
65
+ homepage: https://github.com/tech-sed/strix_ruby
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/tech-sed/strix_ruby
70
+ source_code_uri: https://github.com/tech-sed/strix_ruby
71
+ changelog_uri: https://github.com/tech-sed/strix_ruby/blob/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.0.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.22
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Ruby client for the Strix Integration API
92
+ test_files: []