petstore_api_client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.editorconfig +33 -0
- data/.env.example +50 -0
- data/.github/CODEOWNERS +36 -0
- data/.github/workflows/ci.yml +157 -0
- data/.ruby-version +1 -0
- data/CONTRIBUTORS.md +39 -0
- data/LICENSE +21 -0
- data/README.md +684 -0
- data/Rakefile +12 -0
- data/lib/petstore_api_client/api_client.rb +60 -0
- data/lib/petstore_api_client/authentication/api_key.rb +107 -0
- data/lib/petstore_api_client/authentication/base.rb +113 -0
- data/lib/petstore_api_client/authentication/composite.rb +178 -0
- data/lib/petstore_api_client/authentication/none.rb +42 -0
- data/lib/petstore_api_client/authentication/oauth2.rb +305 -0
- data/lib/petstore_api_client/client.rb +87 -0
- data/lib/petstore_api_client/clients/concerns/pagination.rb +124 -0
- data/lib/petstore_api_client/clients/concerns/resource_operations.rb +121 -0
- data/lib/petstore_api_client/clients/pet_client.rb +119 -0
- data/lib/petstore_api_client/clients/store_client.rb +37 -0
- data/lib/petstore_api_client/configuration.rb +318 -0
- data/lib/petstore_api_client/connection.rb +55 -0
- data/lib/petstore_api_client/errors.rb +70 -0
- data/lib/petstore_api_client/middleware/authentication.rb +44 -0
- data/lib/petstore_api_client/models/api_response.rb +31 -0
- data/lib/petstore_api_client/models/base.rb +60 -0
- data/lib/petstore_api_client/models/category.rb +17 -0
- data/lib/petstore_api_client/models/named_entity.rb +36 -0
- data/lib/petstore_api_client/models/order.rb +55 -0
- data/lib/petstore_api_client/models/pet.rb +225 -0
- data/lib/petstore_api_client/models/tag.rb +20 -0
- data/lib/petstore_api_client/paginated_collection.rb +133 -0
- data/lib/petstore_api_client/request.rb +225 -0
- data/lib/petstore_api_client/response.rb +193 -0
- data/lib/petstore_api_client/validators/array_presence_validator.rb +15 -0
- data/lib/petstore_api_client/validators/enum_validator.rb +17 -0
- data/lib/petstore_api_client/version.rb +5 -0
- data/lib/petstore_api_client.rb +55 -0
- metadata +252 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "oauth2"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module PetstoreApiClient
|
|
7
|
+
module Authentication
|
|
8
|
+
# OAuth2 authentication strategy for Petstore API
|
|
9
|
+
#
|
|
10
|
+
# Implements OAuth2 Client Credentials flow for server-to-server authentication.
|
|
11
|
+
# Automatically handles token fetching, caching, and refresh on expiration.
|
|
12
|
+
#
|
|
13
|
+
# This implementation follows the same security best practices as the ApiKey strategy:
|
|
14
|
+
# - HTTPS enforcement with warnings for insecure connections
|
|
15
|
+
# - Token masking in logs and debug output
|
|
16
|
+
# - Environment variable support for secure credential storage
|
|
17
|
+
# - Thread-safe token management
|
|
18
|
+
#
|
|
19
|
+
# @example Basic usage with explicit credentials
|
|
20
|
+
# auth = OAuth2.new(
|
|
21
|
+
# client_id: "my-client-id",
|
|
22
|
+
# client_secret: "my-secret",
|
|
23
|
+
# token_url: "https://petstore.swagger.io/oauth/token"
|
|
24
|
+
# )
|
|
25
|
+
# auth.configured? # => true
|
|
26
|
+
#
|
|
27
|
+
# @example Loading from environment variables
|
|
28
|
+
# ENV['PETSTORE_OAUTH2_CLIENT_ID'] = 'my-client-id'
|
|
29
|
+
# ENV['PETSTORE_OAUTH2_CLIENT_SECRET'] = 'my-secret'
|
|
30
|
+
# ENV['PETSTORE_OAUTH2_TOKEN_URL'] = 'https://petstore.swagger.io/oauth/token'
|
|
31
|
+
# auth = OAuth2.from_env
|
|
32
|
+
#
|
|
33
|
+
# @example With custom scope
|
|
34
|
+
# auth = OAuth2.new(
|
|
35
|
+
# client_id: "my-client-id",
|
|
36
|
+
# client_secret: "my-secret",
|
|
37
|
+
# token_url: "https://petstore.swagger.io/oauth/token",
|
|
38
|
+
# scope: "read:pets write:pets"
|
|
39
|
+
# )
|
|
40
|
+
#
|
|
41
|
+
# @see https://oauth.net/2/ OAuth 2.0 Specification
|
|
42
|
+
# @see https://tools.ietf.org/html/rfc6749#section-4.4 Client Credentials Grant
|
|
43
|
+
# @since 0.2.0
|
|
44
|
+
class OAuth2 < Base
|
|
45
|
+
# Default token URL for Petstore API
|
|
46
|
+
DEFAULT_TOKEN_URL = "https://petstore.swagger.io/oauth/token"
|
|
47
|
+
|
|
48
|
+
# Default OAuth2 scope for Petstore API (supports both read and write)
|
|
49
|
+
DEFAULT_SCOPE = "read:pets write:pets"
|
|
50
|
+
|
|
51
|
+
# Environment variable names for OAuth2 credentials
|
|
52
|
+
ENV_CLIENT_ID = "PETSTORE_OAUTH2_CLIENT_ID"
|
|
53
|
+
ENV_CLIENT_SECRET = "PETSTORE_OAUTH2_CLIENT_SECRET"
|
|
54
|
+
ENV_TOKEN_URL = "PETSTORE_OAUTH2_TOKEN_URL"
|
|
55
|
+
ENV_SCOPE = "PETSTORE_OAUTH2_SCOPE"
|
|
56
|
+
|
|
57
|
+
# Minimum seconds before expiration to trigger token refresh
|
|
58
|
+
# If token expires in less than this time, fetch a new one
|
|
59
|
+
TOKEN_REFRESH_BUFFER = 60
|
|
60
|
+
|
|
61
|
+
# Minimum length requirements for credentials (security validation)
|
|
62
|
+
MIN_ID_LENGTH = 3
|
|
63
|
+
MIN_SECRET_LENGTH = 3
|
|
64
|
+
|
|
65
|
+
# @!attribute [r] client_id
|
|
66
|
+
# @return [String, nil] OAuth2 client ID
|
|
67
|
+
attr_reader :client_id
|
|
68
|
+
|
|
69
|
+
# @!attribute [r] client_secret
|
|
70
|
+
# @return [String, nil] OAuth2 client secret (masked in output)
|
|
71
|
+
attr_reader :client_secret
|
|
72
|
+
|
|
73
|
+
# @!attribute [r] token_url
|
|
74
|
+
# @return [String] OAuth2 token endpoint URL
|
|
75
|
+
attr_reader :token_url
|
|
76
|
+
|
|
77
|
+
# @!attribute [r] scope
|
|
78
|
+
# @return [String, nil] OAuth2 scope (space-separated permissions)
|
|
79
|
+
attr_reader :scope
|
|
80
|
+
|
|
81
|
+
# Initialize OAuth2 authenticator with client credentials
|
|
82
|
+
#
|
|
83
|
+
# @param client_id [String, nil] OAuth2 client ID
|
|
84
|
+
# @param client_secret [String, nil] OAuth2 client secret
|
|
85
|
+
# @param token_url [String] OAuth2 token endpoint URL
|
|
86
|
+
# @param scope [String, nil] OAuth2 scope (space-separated permissions)
|
|
87
|
+
#
|
|
88
|
+
# @raise [ValidationError] if credentials are invalid format
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# auth = OAuth2.new(
|
|
92
|
+
# client_id: "my-app",
|
|
93
|
+
# client_secret: "secret123",
|
|
94
|
+
# token_url: "https://api.example.com/oauth/token",
|
|
95
|
+
# scope: "read write"
|
|
96
|
+
# )
|
|
97
|
+
#
|
|
98
|
+
# rubocop:disable Lint/MissingSuper
|
|
99
|
+
def initialize(client_id: nil, client_secret: nil, token_url: DEFAULT_TOKEN_URL, scope: nil)
|
|
100
|
+
@client_id = client_id&.to_s&.strip
|
|
101
|
+
@client_secret = client_secret&.to_s&.strip
|
|
102
|
+
@token_url = token_url || DEFAULT_TOKEN_URL
|
|
103
|
+
@scope = scope&.to_s&.strip
|
|
104
|
+
@access_token = nil
|
|
105
|
+
@token_mutex = Mutex.new # Thread-safe token access
|
|
106
|
+
|
|
107
|
+
validate! if configured?
|
|
108
|
+
end
|
|
109
|
+
# rubocop:enable Lint/MissingSuper
|
|
110
|
+
|
|
111
|
+
# Create OAuth2 authenticator from environment variables
|
|
112
|
+
#
|
|
113
|
+
# Loads credentials from:
|
|
114
|
+
# - PETSTORE_OAUTH2_CLIENT_ID
|
|
115
|
+
# - PETSTORE_OAUTH2_CLIENT_SECRET
|
|
116
|
+
# - PETSTORE_OAUTH2_TOKEN_URL (optional, defaults to Petstore API)
|
|
117
|
+
# - PETSTORE_OAUTH2_SCOPE (optional)
|
|
118
|
+
#
|
|
119
|
+
# @return [OAuth2] New authenticator instance
|
|
120
|
+
#
|
|
121
|
+
# @example
|
|
122
|
+
# ENV['PETSTORE_OAUTH2_CLIENT_ID'] = 'my-id'
|
|
123
|
+
# ENV['PETSTORE_OAUTH2_CLIENT_SECRET'] = 'my-secret'
|
|
124
|
+
# auth = OAuth2.from_env
|
|
125
|
+
# auth.configured? # => true
|
|
126
|
+
#
|
|
127
|
+
def self.from_env
|
|
128
|
+
new(
|
|
129
|
+
client_id: ENV.fetch(ENV_CLIENT_ID, nil),
|
|
130
|
+
client_secret: ENV.fetch(ENV_CLIENT_SECRET, nil),
|
|
131
|
+
token_url: ENV.fetch(ENV_TOKEN_URL, DEFAULT_TOKEN_URL),
|
|
132
|
+
scope: ENV.fetch(ENV_SCOPE, nil)
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Apply OAuth2 authentication to Faraday request
|
|
137
|
+
#
|
|
138
|
+
# Adds Authorization header with Bearer token.
|
|
139
|
+
# Automatically fetches or refreshes token if needed.
|
|
140
|
+
#
|
|
141
|
+
# @param env [Faraday::Env] The Faraday request environment
|
|
142
|
+
# @return [void]
|
|
143
|
+
#
|
|
144
|
+
# @raise [AuthenticationError] if token fetch fails
|
|
145
|
+
#
|
|
146
|
+
# @example
|
|
147
|
+
# auth = OAuth2.new(client_id: "id", client_secret: "secret")
|
|
148
|
+
# auth.apply(faraday_env)
|
|
149
|
+
# # Request now has: Authorization: Bearer <access_token>
|
|
150
|
+
#
|
|
151
|
+
def apply(env)
|
|
152
|
+
return unless configured?
|
|
153
|
+
|
|
154
|
+
# Warn if sending credentials over insecure connection
|
|
155
|
+
warn_if_insecure!(env)
|
|
156
|
+
|
|
157
|
+
# Ensure we have a valid token
|
|
158
|
+
ensure_valid_token!
|
|
159
|
+
|
|
160
|
+
# Add Authorization header with Bearer token
|
|
161
|
+
env.request_headers["Authorization"] = "Bearer #{@access_token.token}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Check if OAuth2 credentials are configured
|
|
165
|
+
#
|
|
166
|
+
# @return [Boolean] true if client_id and client_secret are present
|
|
167
|
+
#
|
|
168
|
+
# @example
|
|
169
|
+
# auth = OAuth2.new(client_id: "id", client_secret: "secret")
|
|
170
|
+
# auth.configured? # => true
|
|
171
|
+
#
|
|
172
|
+
# auth = OAuth2.new
|
|
173
|
+
# auth.configured? # => false
|
|
174
|
+
#
|
|
175
|
+
def configured?
|
|
176
|
+
!@client_id.nil? && !@client_id.empty? &&
|
|
177
|
+
!@client_secret.nil? && !@client_secret.empty?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Fetch a new access token from OAuth2 server
|
|
181
|
+
#
|
|
182
|
+
# Uses Client Credentials flow to obtain an access token.
|
|
183
|
+
# Token is cached and reused until expiration.
|
|
184
|
+
#
|
|
185
|
+
# @return [OAuth2::AccessToken] The access token object
|
|
186
|
+
#
|
|
187
|
+
# @raise [AuthenticationError] if token fetch fails
|
|
188
|
+
#
|
|
189
|
+
# @example
|
|
190
|
+
# auth = OAuth2.new(client_id: "id", client_secret: "secret")
|
|
191
|
+
# token = auth.fetch_token!
|
|
192
|
+
# token.token # => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
193
|
+
#
|
|
194
|
+
def fetch_token!
|
|
195
|
+
@token_mutex.synchronize do
|
|
196
|
+
client = build_oauth2_client
|
|
197
|
+
@access_token = client.client_credentials.get_token(scope: @scope)
|
|
198
|
+
rescue ::OAuth2::Error => e
|
|
199
|
+
raise AuthenticationError,
|
|
200
|
+
"OAuth2 token fetch failed: #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Check if current access token is expired or will expire soon
|
|
205
|
+
#
|
|
206
|
+
# Returns true if token is nil, already expired, or expires within
|
|
207
|
+
# TOKEN_REFRESH_BUFFER seconds.
|
|
208
|
+
#
|
|
209
|
+
# @return [Boolean] true if token needs refresh
|
|
210
|
+
#
|
|
211
|
+
# @example
|
|
212
|
+
# auth.token_expired? # => true (no token yet)
|
|
213
|
+
# auth.fetch_token!
|
|
214
|
+
# auth.token_expired? # => false (fresh token)
|
|
215
|
+
#
|
|
216
|
+
def token_expired?
|
|
217
|
+
return true if @access_token.nil?
|
|
218
|
+
return true if @access_token.expired?
|
|
219
|
+
|
|
220
|
+
# Refresh if expiring soon (within buffer window)
|
|
221
|
+
return false if @access_token.expires_at.nil?
|
|
222
|
+
|
|
223
|
+
Time.now.to_i >= (@access_token.expires_at - TOKEN_REFRESH_BUFFER)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# String representation (masks client secret for security)
|
|
227
|
+
#
|
|
228
|
+
# @return [String] Masked representation of OAuth2 config
|
|
229
|
+
#
|
|
230
|
+
# @example
|
|
231
|
+
# auth = OAuth2.new(client_id: "my-app", client_secret: "secret123")
|
|
232
|
+
# auth.inspect # => "#<OAuth2 client_id=my-app secret=sec*******>"
|
|
233
|
+
#
|
|
234
|
+
def inspect
|
|
235
|
+
return unconfigured_inspect unless configured?
|
|
236
|
+
|
|
237
|
+
# Use base class method to mask credentials
|
|
238
|
+
masked_secret = mask_credential(@client_secret, 3)
|
|
239
|
+
|
|
240
|
+
token_status = if @access_token.nil?
|
|
241
|
+
"no token"
|
|
242
|
+
elsif token_expired?
|
|
243
|
+
"token expired"
|
|
244
|
+
else
|
|
245
|
+
"token valid"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
"#<#{self.class.name} client_id=#{@client_id} secret=#{masked_secret} (#{token_status})>"
|
|
249
|
+
end
|
|
250
|
+
alias to_s inspect
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
# Validate OAuth2 credentials format
|
|
255
|
+
#
|
|
256
|
+
# @return [void]
|
|
257
|
+
# @raise [ValidationError] if credentials are invalid
|
|
258
|
+
#
|
|
259
|
+
def validate!
|
|
260
|
+
return unless configured?
|
|
261
|
+
|
|
262
|
+
# Use base class validation methods for DRY principle
|
|
263
|
+
validate_credential_length(@client_id, "OAuth2 client_id", MIN_ID_LENGTH)
|
|
264
|
+
validate_credential_length(@client_secret, "OAuth2 client_secret", MIN_SECRET_LENGTH)
|
|
265
|
+
validate_no_newlines(@client_id, "OAuth2 client_id")
|
|
266
|
+
validate_no_newlines(@client_secret, "OAuth2 client_secret")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Build OAuth2 client for token operations
|
|
270
|
+
#
|
|
271
|
+
# @return [::OAuth2::Client] OAuth2 client instance
|
|
272
|
+
#
|
|
273
|
+
def build_oauth2_client
|
|
274
|
+
::OAuth2::Client.new(
|
|
275
|
+
@client_id,
|
|
276
|
+
@client_secret,
|
|
277
|
+
site: token_site,
|
|
278
|
+
token_url: @token_url
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Extract site URL from token_url
|
|
283
|
+
# OAuth2 gem requires separate site and token_url
|
|
284
|
+
#
|
|
285
|
+
# @return [String] Base site URL
|
|
286
|
+
#
|
|
287
|
+
def token_site
|
|
288
|
+
uri = URI.parse(@token_url)
|
|
289
|
+
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != 80 && uri.port != 443}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Ensure we have a valid access token
|
|
293
|
+
# Fetches new token if needed
|
|
294
|
+
#
|
|
295
|
+
# @return [void]
|
|
296
|
+
# @raise [AuthenticationError] if token fetch fails
|
|
297
|
+
#
|
|
298
|
+
def ensure_valid_token!
|
|
299
|
+
fetch_token! if token_expired?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Note: warn_if_insecure! method inherited from Base class
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetstoreApiClient
|
|
4
|
+
# Base HTTP client for Petstore API
|
|
5
|
+
#
|
|
6
|
+
# Provides low-level HTTP communication with the Petstore API. This class
|
|
7
|
+
# includes Connection and Request modules to handle connection management
|
|
8
|
+
# and HTTP request execution.
|
|
9
|
+
#
|
|
10
|
+
# Most users should use the higher-level PetClient or StoreClient instead
|
|
11
|
+
# of using this class directly.
|
|
12
|
+
#
|
|
13
|
+
# @example Creating a client with default configuration
|
|
14
|
+
# client = PetstoreApiClient::Client.new
|
|
15
|
+
#
|
|
16
|
+
# @example Creating a client with custom configuration
|
|
17
|
+
# config = PetstoreApiClient::Configuration.new
|
|
18
|
+
# config.api_key = "special-key"
|
|
19
|
+
# config.timeout = 60
|
|
20
|
+
# client = PetstoreApiClient::Client.new(config)
|
|
21
|
+
#
|
|
22
|
+
# @example Configuring an existing client
|
|
23
|
+
# client = PetstoreApiClient::Client.new
|
|
24
|
+
# client.configure do |c|
|
|
25
|
+
# c.api_key = "special-key"
|
|
26
|
+
# c.timeout = 60
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @see Connection Connection management
|
|
30
|
+
# @see Request HTTP request methods
|
|
31
|
+
# @since 0.1.0
|
|
32
|
+
class Client
|
|
33
|
+
include Connection
|
|
34
|
+
include Request
|
|
35
|
+
|
|
36
|
+
# @!attribute [r] configuration
|
|
37
|
+
# @return [Configuration] The configuration object for this client
|
|
38
|
+
attr_reader :configuration
|
|
39
|
+
|
|
40
|
+
# Initialize a new HTTP client
|
|
41
|
+
#
|
|
42
|
+
# Creates a new client instance with the provided configuration.
|
|
43
|
+
# If no configuration is provided, uses default configuration.
|
|
44
|
+
# Validates configuration before creating the client.
|
|
45
|
+
#
|
|
46
|
+
# @param config [Configuration, nil] Optional configuration object.
|
|
47
|
+
# If nil, creates a new Configuration with defaults.
|
|
48
|
+
#
|
|
49
|
+
# @raise [ValidationError] if configuration is invalid
|
|
50
|
+
#
|
|
51
|
+
# @example With default configuration
|
|
52
|
+
# client = Client.new
|
|
53
|
+
#
|
|
54
|
+
# @example With custom configuration
|
|
55
|
+
# config = Configuration.new
|
|
56
|
+
# config.api_key = "special-key"
|
|
57
|
+
# client = Client.new(config)
|
|
58
|
+
#
|
|
59
|
+
def initialize(config = nil)
|
|
60
|
+
@configuration = config || Configuration.new
|
|
61
|
+
@configuration.validate!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Configure the client via block
|
|
65
|
+
#
|
|
66
|
+
# Yields the configuration object to the block for modification.
|
|
67
|
+
# Automatically resets the connection after configuration changes
|
|
68
|
+
# to ensure new settings are applied.
|
|
69
|
+
#
|
|
70
|
+
# @yieldparam [Configuration] configuration The configuration object
|
|
71
|
+
# @return [Client] self for method chaining
|
|
72
|
+
#
|
|
73
|
+
# @example
|
|
74
|
+
# client = Client.new
|
|
75
|
+
# client.configure do |config|
|
|
76
|
+
# config.api_key = "special-key"
|
|
77
|
+
# config.timeout = 60
|
|
78
|
+
# config.retry_enabled = false
|
|
79
|
+
# end
|
|
80
|
+
#
|
|
81
|
+
def configure
|
|
82
|
+
yield(configuration) if block_given?
|
|
83
|
+
reset_connection! # Need to reset when config changes
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetstoreApiClient
|
|
4
|
+
module Clients
|
|
5
|
+
module Concerns
|
|
6
|
+
# Pagination support for list endpoints
|
|
7
|
+
# Provides utilities for handling pagination parameters and responses
|
|
8
|
+
#
|
|
9
|
+
# This module follows the Strategy pattern - it encapsulates
|
|
10
|
+
# different pagination strategies (offset-based, page-based)
|
|
11
|
+
module Pagination
|
|
12
|
+
# Default pagination settings
|
|
13
|
+
DEFAULT_PAGE = 1
|
|
14
|
+
DEFAULT_PER_PAGE = 25
|
|
15
|
+
MAX_PER_PAGE = 100
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Normalize pagination options from various input formats
|
|
20
|
+
# Supports both page-based (page, per_page) and offset-based (offset, limit)
|
|
21
|
+
#
|
|
22
|
+
# @param options [Hash] Input options
|
|
23
|
+
# @option options [Integer] :page Page number (1-indexed)
|
|
24
|
+
# @option options [Integer] :per_page Items per page
|
|
25
|
+
# @option options [Integer] :offset Offset for results (0-indexed)
|
|
26
|
+
# @option options [Integer] :limit Maximum number of results
|
|
27
|
+
# @return [Hash] Normalized pagination params with :page and :per_page
|
|
28
|
+
# rubocop:disable Metrics/MethodLength
|
|
29
|
+
def normalize_pagination_options(options = {})
|
|
30
|
+
# Handle offset-based pagination
|
|
31
|
+
if options.key?(:offset) || options.key?(:limit)
|
|
32
|
+
offset = (options[:offset] || 0).to_i
|
|
33
|
+
limit = (options[:limit] || DEFAULT_PER_PAGE).to_i
|
|
34
|
+
|
|
35
|
+
# Convert offset/limit to page/per_page
|
|
36
|
+
page = (offset / limit) + 1
|
|
37
|
+
per_page = limit
|
|
38
|
+
else
|
|
39
|
+
# Handle page-based pagination (default)
|
|
40
|
+
page = (options[:page] || DEFAULT_PAGE).to_i
|
|
41
|
+
per_page = (options[:per_page] || DEFAULT_PER_PAGE).to_i
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate and constrain values
|
|
45
|
+
page = 1 if page < 1
|
|
46
|
+
per_page = DEFAULT_PER_PAGE if per_page < 1
|
|
47
|
+
per_page = MAX_PER_PAGE if per_page > MAX_PER_PAGE
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
page: page,
|
|
51
|
+
per_page: per_page,
|
|
52
|
+
offset: (page - 1) * per_page,
|
|
53
|
+
limit: per_page
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
# rubocop:enable Metrics/MethodLength
|
|
57
|
+
|
|
58
|
+
# Create a PaginatedCollection from array data
|
|
59
|
+
#
|
|
60
|
+
# @param data [Array] Array of items
|
|
61
|
+
# @param pagination_opts [Hash] Pagination options
|
|
62
|
+
# @option pagination_opts [Integer] :page Current page number
|
|
63
|
+
# @option pagination_opts [Integer] :per_page Items per page
|
|
64
|
+
# @option pagination_opts [Integer] :total_count Total items (if known)
|
|
65
|
+
# @return [PaginatedCollection] Wrapped collection with pagination metadata
|
|
66
|
+
def paginated_collection(data, pagination_opts = {})
|
|
67
|
+
PaginatedCollection.new(
|
|
68
|
+
data: data,
|
|
69
|
+
page: pagination_opts[:page] || DEFAULT_PAGE,
|
|
70
|
+
per_page: pagination_opts[:per_page] || DEFAULT_PER_PAGE,
|
|
71
|
+
total_count: pagination_opts[:total_count]
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Apply client-side pagination to an array
|
|
76
|
+
# Used when API returns all results and we need to paginate locally
|
|
77
|
+
#
|
|
78
|
+
# @param items [Array] Full array of items
|
|
79
|
+
# @param options [Hash] Pagination options
|
|
80
|
+
# @return [PaginatedCollection] Paginated subset of items
|
|
81
|
+
def apply_client_side_pagination(items, options = {})
|
|
82
|
+
pagination_opts = normalize_pagination_options(options)
|
|
83
|
+
offset = pagination_opts[:offset]
|
|
84
|
+
limit = pagination_opts[:per_page]
|
|
85
|
+
|
|
86
|
+
# Slice the array based on offset and limit
|
|
87
|
+
page_data = items[offset, limit] || []
|
|
88
|
+
|
|
89
|
+
paginated_collection(
|
|
90
|
+
page_data,
|
|
91
|
+
page: pagination_opts[:page],
|
|
92
|
+
per_page: pagination_opts[:per_page],
|
|
93
|
+
total_count: items.length
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build query parameters for API requests with pagination
|
|
98
|
+
# Converts internal pagination format to API-specific format
|
|
99
|
+
#
|
|
100
|
+
# @param options [Hash] Pagination options
|
|
101
|
+
# @param api_format [Symbol] API pagination format (:offset_limit or :page_per_page)
|
|
102
|
+
# @return [Hash] Query parameters for API request
|
|
103
|
+
def build_pagination_params(options = {}, api_format: :offset_limit)
|
|
104
|
+
pagination_opts = normalize_pagination_options(options)
|
|
105
|
+
|
|
106
|
+
case api_format
|
|
107
|
+
when :offset_limit
|
|
108
|
+
{
|
|
109
|
+
offset: pagination_opts[:offset],
|
|
110
|
+
limit: pagination_opts[:limit]
|
|
111
|
+
}
|
|
112
|
+
when :page_per_page
|
|
113
|
+
{
|
|
114
|
+
page: pagination_opts[:page],
|
|
115
|
+
per_page: pagination_opts[:per_page]
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
raise ArgumentError, "Unknown API format: #{api_format}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetstoreApiClient
|
|
4
|
+
module Clients
|
|
5
|
+
module Concerns
|
|
6
|
+
# Shared resource operations using Template Method pattern
|
|
7
|
+
# Eliminates duplication between PetClient and StoreClient
|
|
8
|
+
#
|
|
9
|
+
# This module provides generic CRUD operations that work for any resource type.
|
|
10
|
+
# Subclasses just need to define:
|
|
11
|
+
# - model_class: The model class (e.g., Models::Pet)
|
|
12
|
+
# - resource_name: The API path segment (e.g., "pet", "store/order")
|
|
13
|
+
# - id_field_name: Human-readable name for validation errors (e.g., "Pet ID")
|
|
14
|
+
module ResourceOperations
|
|
15
|
+
# Template method for creating a resource
|
|
16
|
+
# Pattern: build → validate → POST → parse response
|
|
17
|
+
def create_resource(resource_data)
|
|
18
|
+
resource = build_resource(resource_data)
|
|
19
|
+
validate_resource!(resource)
|
|
20
|
+
|
|
21
|
+
resp = post(create_endpoint, body: resource.to_h)
|
|
22
|
+
model_class.from_response(resp.body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Template method for updating a resource
|
|
26
|
+
# Pattern: build → validate → PUT → parse response
|
|
27
|
+
def update_resource(resource_data)
|
|
28
|
+
resource = build_resource(resource_data)
|
|
29
|
+
validate_resource!(resource)
|
|
30
|
+
|
|
31
|
+
resp = put(update_endpoint, body: resource.to_h)
|
|
32
|
+
model_class.from_response(resp.body)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Template method for getting a resource by ID
|
|
36
|
+
# Pattern: validate ID → GET → parse response
|
|
37
|
+
def get_resource(resource_id)
|
|
38
|
+
validate_id!(resource_id, id_field_name)
|
|
39
|
+
|
|
40
|
+
resp = get("#{resource_name}/#{resource_id}")
|
|
41
|
+
model_class.from_response(resp.body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Template method for deleting a resource
|
|
45
|
+
# Pattern: validate ID → DELETE → return success
|
|
46
|
+
def delete_resource(resource_id)
|
|
47
|
+
validate_id!(resource_id, id_field_name)
|
|
48
|
+
|
|
49
|
+
delete("#{resource_name}/#{resource_id}")
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Build a resource object from various input types
|
|
56
|
+
# Accepts either a model instance or hash of attributes
|
|
57
|
+
def build_resource(resource_data)
|
|
58
|
+
return resource_data if resource_data.is_a?(model_class)
|
|
59
|
+
|
|
60
|
+
model_class.new(resource_data)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Validate resource data before sending to API
|
|
64
|
+
# Raises ValidationError if the model has validation errors
|
|
65
|
+
def validate_resource!(resource)
|
|
66
|
+
return if resource.valid?
|
|
67
|
+
|
|
68
|
+
raise ValidationError, "Invalid #{resource_type_name} data: #{resource.errors.full_messages.join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Validate that an ID is present and numeric
|
|
72
|
+
# Accepts integers or numeric strings like "123"
|
|
73
|
+
def validate_id!(id, field_name = "ID")
|
|
74
|
+
raise ValidationError, "#{field_name} can't be nil" if id.nil?
|
|
75
|
+
|
|
76
|
+
# String IDs are fine as long as they're numeric
|
|
77
|
+
return if id.is_a?(Integer) || id.to_s.match?(/^\d+$/)
|
|
78
|
+
|
|
79
|
+
raise ValidationError, "#{field_name} must be an integer, got #{id.class}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Abstract method: Subclasses must define the model class
|
|
83
|
+
# Example: Models::Pet or Models::Order
|
|
84
|
+
def model_class
|
|
85
|
+
raise NotImplementedError, "#{self.class} must implement #model_class"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Abstract method: Subclasses must define the resource name
|
|
89
|
+
# Example: "pet" or "store/order"
|
|
90
|
+
def resource_name
|
|
91
|
+
raise NotImplementedError, "#{self.class} must implement #resource_name"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Abstract method: Subclasses must define the endpoint for creating
|
|
95
|
+
# Default implementation uses resource_name, but can be overridden
|
|
96
|
+
def create_endpoint
|
|
97
|
+
resource_name
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Abstract method: Subclasses must define the endpoint for updating
|
|
101
|
+
# Default implementation uses resource_name, but can be overridden
|
|
102
|
+
def update_endpoint
|
|
103
|
+
resource_name
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Human-readable field name for ID validation errors
|
|
107
|
+
# Default implementation capitalizes the resource name
|
|
108
|
+
# Can be overridden for custom formatting
|
|
109
|
+
def id_field_name
|
|
110
|
+
"#{resource_type_name} ID"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Extract resource type name from model class for error messages
|
|
114
|
+
# Example: Models::Pet → "pet", Models::Order → "order"
|
|
115
|
+
def resource_type_name
|
|
116
|
+
model_class.name.split("::").last.downcase
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|