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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "concerns/resource_operations"
|
|
4
|
+
require_relative "concerns/pagination"
|
|
5
|
+
|
|
6
|
+
module PetstoreApiClient
|
|
7
|
+
module Clients
|
|
8
|
+
# Client for Pet-related API endpoints
|
|
9
|
+
# Handles creation, retrieval, updating, and deletion of pets
|
|
10
|
+
#
|
|
11
|
+
# Refactored to use ResourceOperations concern (Template Method pattern)
|
|
12
|
+
# which eliminates ~50 lines of duplication with StoreClient
|
|
13
|
+
class PetClient < Client
|
|
14
|
+
include Concerns::ResourceOperations
|
|
15
|
+
include Concerns::Pagination
|
|
16
|
+
|
|
17
|
+
# Public API methods - delegate to generic resource operations
|
|
18
|
+
alias create_pet create_resource
|
|
19
|
+
alias update_pet update_resource
|
|
20
|
+
alias get_pet get_resource
|
|
21
|
+
alias delete_pet delete_resource
|
|
22
|
+
|
|
23
|
+
# Find pets by status with optional pagination
|
|
24
|
+
#
|
|
25
|
+
# @param status [String, Array<String>] Status value(s) to filter by
|
|
26
|
+
# Valid values: "available", "pending", "sold"
|
|
27
|
+
# @param options [Hash] Optional pagination and filter options
|
|
28
|
+
# @option options [Integer] :page Page number (default: 1)
|
|
29
|
+
# @option options [Integer] :per_page Items per page (default: 25)
|
|
30
|
+
# @option options [Integer] :offset Alternative: offset for results
|
|
31
|
+
# @option options [Integer] :limit Alternative: limit for results
|
|
32
|
+
# @return [PaginatedCollection<Pet>] Paginated collection of pets
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# # Get first page of available pets
|
|
36
|
+
# pets = client.pets.find_by_status("available")
|
|
37
|
+
#
|
|
38
|
+
# # Get second page with custom page size
|
|
39
|
+
# pets = client.pets.find_by_status("available", page: 2, per_page: 50)
|
|
40
|
+
#
|
|
41
|
+
# # Search multiple statuses
|
|
42
|
+
# pets = client.pets.find_by_status(["available", "pending"])
|
|
43
|
+
def find_by_status(status, options = {})
|
|
44
|
+
# Validate status values
|
|
45
|
+
statuses = Array(status)
|
|
46
|
+
validate_statuses!(statuses)
|
|
47
|
+
|
|
48
|
+
# Make API request (returns all matching pets)
|
|
49
|
+
params = { status: statuses.join(",") }
|
|
50
|
+
resp = get("pet/findByStatus", params: params)
|
|
51
|
+
|
|
52
|
+
# Parse response into Pet objects
|
|
53
|
+
pets = Array(resp.body).map { |pet_data| Models::Pet.from_response(pet_data) }
|
|
54
|
+
|
|
55
|
+
# Apply client-side pagination (API doesn't support server-side pagination)
|
|
56
|
+
apply_client_side_pagination(pets, options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Find pets by tags with optional pagination
|
|
60
|
+
#
|
|
61
|
+
# @param tags [String, Array<String>] Tag value(s) to filter by
|
|
62
|
+
# @param options [Hash] Optional pagination options
|
|
63
|
+
# @option options [Integer] :page Page number (default: 1)
|
|
64
|
+
# @option options [Integer] :per_page Items per page (default: 25)
|
|
65
|
+
# @return [PaginatedCollection<Pet>] Paginated collection of pets
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# # Find pets with specific tag
|
|
69
|
+
# pets = client.pets.find_by_tags("friendly")
|
|
70
|
+
#
|
|
71
|
+
# # Find pets with multiple tags
|
|
72
|
+
# pets = client.pets.find_by_tags(["friendly", "vaccinated"], page: 1, per_page: 10)
|
|
73
|
+
def find_by_tags(tags, options = {})
|
|
74
|
+
# Ensure tags is an array
|
|
75
|
+
tag_array = Array(tags)
|
|
76
|
+
raise ValidationError, "Tags cannot be empty" if tag_array.empty?
|
|
77
|
+
|
|
78
|
+
# Make API request
|
|
79
|
+
params = { tags: tag_array.join(",") }
|
|
80
|
+
resp = get("pet/findByTags", params: params)
|
|
81
|
+
|
|
82
|
+
# Parse response into Pet objects
|
|
83
|
+
pets = Array(resp.body).map { |pet_data| Models::Pet.from_response(pet_data) }
|
|
84
|
+
|
|
85
|
+
# Apply client-side pagination
|
|
86
|
+
apply_client_side_pagination(pets, options)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Template method implementation: Define the model class
|
|
92
|
+
def model_class
|
|
93
|
+
Models::Pet
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Template method implementation: Define the resource name for API paths
|
|
97
|
+
def resource_name
|
|
98
|
+
"pet"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Template method implementation: Human-readable name for validation errors
|
|
102
|
+
def id_field_name
|
|
103
|
+
"Pet ID"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validate status values against allowed values
|
|
107
|
+
def validate_statuses!(statuses)
|
|
108
|
+
# Performance optimization: use constant instead of creating new array
|
|
109
|
+
invalid = statuses.reject { |s| Models::Pet::VALID_STATUSES.include?(s.to_s) }
|
|
110
|
+
|
|
111
|
+
return if invalid.empty?
|
|
112
|
+
|
|
113
|
+
raise ValidationError,
|
|
114
|
+
"Invalid status value(s): #{invalid.join(", ")}. " \
|
|
115
|
+
"Must be one of: #{Models::Pet::VALID_STATUSES.join(", ")}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "concerns/resource_operations"
|
|
4
|
+
|
|
5
|
+
module PetstoreApiClient
|
|
6
|
+
module Clients
|
|
7
|
+
# Store client - handles order operations
|
|
8
|
+
#
|
|
9
|
+
# Refactored to use ResourceOperations concern (Template Method pattern)
|
|
10
|
+
# which eliminates ~40 lines of duplication with PetClient
|
|
11
|
+
class StoreClient < Client
|
|
12
|
+
include Concerns::ResourceOperations
|
|
13
|
+
|
|
14
|
+
# Public API methods - delegate to generic resource operations
|
|
15
|
+
alias create_order create_resource
|
|
16
|
+
alias get_order get_resource
|
|
17
|
+
alias delete_order delete_resource
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Template method implementation: Define the model class
|
|
22
|
+
def model_class
|
|
23
|
+
Models::Order
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Template method implementation: Define the resource name for API paths
|
|
27
|
+
def resource_name
|
|
28
|
+
"store/order"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Template method implementation: Human-readable name for validation errors
|
|
32
|
+
def id_field_name
|
|
33
|
+
"Order ID"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "authentication/api_key"
|
|
4
|
+
require_relative "authentication/oauth2"
|
|
5
|
+
require_relative "authentication/composite"
|
|
6
|
+
require_relative "authentication/none"
|
|
7
|
+
|
|
8
|
+
module PetstoreApiClient
|
|
9
|
+
# Configuration management for Petstore API client
|
|
10
|
+
#
|
|
11
|
+
# Centralized configuration for all client settings including authentication,
|
|
12
|
+
# timeouts, retries, and pagination. Supports multiple authentication strategies
|
|
13
|
+
# via feature flags.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic configuration
|
|
16
|
+
# config = Configuration.new
|
|
17
|
+
# config.api_key = "special-key"
|
|
18
|
+
# config.timeout = 60
|
|
19
|
+
#
|
|
20
|
+
# @example Block-based configuration
|
|
21
|
+
# config = Configuration.new
|
|
22
|
+
# config.configure do |c|
|
|
23
|
+
# c.auth_strategy = :oauth2
|
|
24
|
+
# c.oauth2_client_id = "my-id"
|
|
25
|
+
# c.oauth2_client_secret = "my-secret"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @since 0.1.0
|
|
29
|
+
# rubocop:disable Metrics/ClassLength
|
|
30
|
+
class Configuration
|
|
31
|
+
# Default API base URL for Petstore API
|
|
32
|
+
DEFAULT_BASE_URL = "https://petstore.swagger.io/v2"
|
|
33
|
+
|
|
34
|
+
# Default number of items per page for paginated endpoints
|
|
35
|
+
DEFAULT_PAGE_SIZE = 25
|
|
36
|
+
|
|
37
|
+
# Maximum allowed page size to prevent abuse
|
|
38
|
+
MAX_PAGE_SIZE = 100
|
|
39
|
+
|
|
40
|
+
# Valid authentication strategy options
|
|
41
|
+
VALID_AUTH_STRATEGIES = %i[none api_key oauth2 both].freeze
|
|
42
|
+
|
|
43
|
+
# @!attribute [rw] base_url
|
|
44
|
+
# @return [String] Base URL for API endpoints
|
|
45
|
+
# @!attribute [rw] timeout
|
|
46
|
+
# @return [Integer] Request timeout in seconds
|
|
47
|
+
# @!attribute [rw] open_timeout
|
|
48
|
+
# @return [Integer] Connection open timeout in seconds
|
|
49
|
+
# @!attribute [rw] retry_enabled
|
|
50
|
+
# @return [Boolean] Enable automatic retry for transient failures
|
|
51
|
+
# @!attribute [rw] max_retries
|
|
52
|
+
# @return [Integer] Maximum number of retry attempts
|
|
53
|
+
# @!attribute [rw] default_page_size
|
|
54
|
+
# @return [Integer] Default items per page for pagination
|
|
55
|
+
# @!attribute [rw] max_page_size
|
|
56
|
+
# @return [Integer] Maximum items per page for pagination
|
|
57
|
+
attr_accessor :base_url, :timeout, :open_timeout, :retry_enabled, :max_retries,
|
|
58
|
+
:default_page_size, :max_page_size
|
|
59
|
+
|
|
60
|
+
# @!attribute [r] api_key
|
|
61
|
+
# @return [String, nil] API key for authentication (read-only, use setter)
|
|
62
|
+
attr_reader :api_key
|
|
63
|
+
|
|
64
|
+
# @!attribute [rw] auth_strategy
|
|
65
|
+
# @return [Symbol] Authentication strategy (:none, :api_key, :oauth2, :both)
|
|
66
|
+
attr_accessor :auth_strategy
|
|
67
|
+
|
|
68
|
+
# @!attribute [rw] oauth2_client_id
|
|
69
|
+
# @return [String, nil] OAuth2 client ID
|
|
70
|
+
# @!attribute [rw] oauth2_client_secret
|
|
71
|
+
# @return [String, nil] OAuth2 client secret
|
|
72
|
+
# @!attribute [rw] oauth2_token_url
|
|
73
|
+
# @return [String, nil] OAuth2 token endpoint URL
|
|
74
|
+
# @!attribute [rw] oauth2_scope
|
|
75
|
+
# @return [String, nil] OAuth2 scope (space-separated permissions)
|
|
76
|
+
attr_accessor :oauth2_client_id, :oauth2_client_secret, :oauth2_token_url, :oauth2_scope
|
|
77
|
+
|
|
78
|
+
# Initialize configuration with default values
|
|
79
|
+
#
|
|
80
|
+
# Sets sensible defaults for all configuration options:
|
|
81
|
+
# - base_url: Petstore API endpoint
|
|
82
|
+
# - timeout: 30 seconds
|
|
83
|
+
# - retry_enabled: true
|
|
84
|
+
# - auth_strategy: :api_key (backward compatible)
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# config = Configuration.new
|
|
88
|
+
# config.base_url # => "https://petstore.swagger.io/v2"
|
|
89
|
+
# config.timeout # => 30
|
|
90
|
+
#
|
|
91
|
+
def initialize
|
|
92
|
+
@base_url = DEFAULT_BASE_URL
|
|
93
|
+
@api_key = nil
|
|
94
|
+
@timeout = 30 # seconds
|
|
95
|
+
@open_timeout = 10 # seconds
|
|
96
|
+
@retry_enabled = true # Auto-retry transient failures
|
|
97
|
+
@max_retries = 2 # Number of retries for failed requests
|
|
98
|
+
@default_page_size = DEFAULT_PAGE_SIZE # Default items per page for pagination
|
|
99
|
+
@max_page_size = MAX_PAGE_SIZE # Maximum items per page (prevents abuse)
|
|
100
|
+
@auth_strategy = :api_key # Default strategy for backward compatibility
|
|
101
|
+
|
|
102
|
+
# OAuth2 credentials
|
|
103
|
+
@oauth2_client_id = nil
|
|
104
|
+
@oauth2_client_secret = nil
|
|
105
|
+
@oauth2_token_url = nil
|
|
106
|
+
@oauth2_scope = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Set API key for authentication
|
|
110
|
+
#
|
|
111
|
+
# Supports loading from environment variable using :from_env symbol.
|
|
112
|
+
#
|
|
113
|
+
# @param value [String, Symbol, nil] The API key or :from_env
|
|
114
|
+
#
|
|
115
|
+
# @example Direct setting
|
|
116
|
+
# config.api_key = "special-key"
|
|
117
|
+
#
|
|
118
|
+
# @example From environment variable
|
|
119
|
+
# ENV['PETSTORE_API_KEY'] = "special-key"
|
|
120
|
+
# config.api_key = :from_env
|
|
121
|
+
#
|
|
122
|
+
def api_key=(value)
|
|
123
|
+
@api_key = if value == :from_env
|
|
124
|
+
ENV.fetch(Authentication::ApiKey::ENV_VAR_NAME, nil)
|
|
125
|
+
else
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Build authenticator instance based on auth_strategy
|
|
131
|
+
#
|
|
132
|
+
# Returns appropriate authentication strategy based on auth_strategy setting:
|
|
133
|
+
# - :none - No authentication
|
|
134
|
+
# - :api_key - API Key authentication only
|
|
135
|
+
# - :oauth2 - OAuth2 authentication only
|
|
136
|
+
# - :both - Both API Key AND OAuth2 (composite)
|
|
137
|
+
#
|
|
138
|
+
# The authenticator is memoized and reused until reset_authenticator! is called.
|
|
139
|
+
#
|
|
140
|
+
# @return [Authentication::Base] Authentication strategy instance
|
|
141
|
+
#
|
|
142
|
+
# @raise [ConfigurationError] if auth_strategy is invalid
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# config.auth_strategy = :oauth2
|
|
146
|
+
# config.authenticator # => #<OAuth2 client_id=...>
|
|
147
|
+
#
|
|
148
|
+
def authenticator
|
|
149
|
+
@authenticator ||= build_authenticator
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Reset memoized authenticator
|
|
153
|
+
#
|
|
154
|
+
# Call this when configuration changes to rebuild the authenticator
|
|
155
|
+
# with new settings.
|
|
156
|
+
#
|
|
157
|
+
# @return [void]
|
|
158
|
+
#
|
|
159
|
+
# @example
|
|
160
|
+
# config.auth_strategy = :api_key
|
|
161
|
+
# config.authenticator # => ApiKey instance
|
|
162
|
+
# config.auth_strategy = :oauth2
|
|
163
|
+
# config.reset_authenticator!
|
|
164
|
+
# config.authenticator # => OAuth2 instance
|
|
165
|
+
#
|
|
166
|
+
def reset_authenticator!
|
|
167
|
+
@authenticator = nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Configure settings via block
|
|
171
|
+
#
|
|
172
|
+
# Yields self to the block for convenient configuration.
|
|
173
|
+
# Automatically resets authenticator after configuration.
|
|
174
|
+
#
|
|
175
|
+
# @yieldparam [Configuration] self
|
|
176
|
+
# @return [Configuration] self for method chaining
|
|
177
|
+
#
|
|
178
|
+
# @example
|
|
179
|
+
# config.configure do |c|
|
|
180
|
+
# c.api_key = "special-key"
|
|
181
|
+
# c.timeout = 60
|
|
182
|
+
# c.retry_enabled = false
|
|
183
|
+
# end
|
|
184
|
+
#
|
|
185
|
+
def configure
|
|
186
|
+
yield(self) if block_given?
|
|
187
|
+
reset_authenticator! # Rebuild authenticator when config changes
|
|
188
|
+
self
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Validate configuration settings
|
|
192
|
+
#
|
|
193
|
+
# Checks that all required configuration is valid.
|
|
194
|
+
# Currently validates:
|
|
195
|
+
# - base_url is present
|
|
196
|
+
# - authenticator is properly configured (if auth is enabled)
|
|
197
|
+
#
|
|
198
|
+
# @return [Boolean] true if valid
|
|
199
|
+
# @raise [ValidationError] if configuration is invalid
|
|
200
|
+
#
|
|
201
|
+
# @example
|
|
202
|
+
# config = Configuration.new
|
|
203
|
+
# config.validate! # => true
|
|
204
|
+
#
|
|
205
|
+
# config.base_url = nil
|
|
206
|
+
# config.validate! # raises ValidationError
|
|
207
|
+
#
|
|
208
|
+
def validate!
|
|
209
|
+
raise ValidationError, "base_url can't be nil" if base_url.nil? || base_url.empty?
|
|
210
|
+
|
|
211
|
+
# Validate authenticator if configured
|
|
212
|
+
authenticator.is_a?(Authentication::ApiKey) && authenticator.configured?
|
|
213
|
+
|
|
214
|
+
true
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
# Build authentication strategy based on auth_strategy setting
|
|
220
|
+
#
|
|
221
|
+
# @return [Authentication::Base] Authentication strategy instance
|
|
222
|
+
# @raise [ConfigurationError] if auth_strategy is invalid
|
|
223
|
+
#
|
|
224
|
+
def build_authenticator
|
|
225
|
+
case @auth_strategy
|
|
226
|
+
when :none
|
|
227
|
+
build_none_authenticator
|
|
228
|
+
when :api_key
|
|
229
|
+
build_api_key_authenticator
|
|
230
|
+
when :oauth2
|
|
231
|
+
build_oauth2_authenticator
|
|
232
|
+
when :both
|
|
233
|
+
build_composite_authenticator
|
|
234
|
+
else
|
|
235
|
+
raise ConfigurationError,
|
|
236
|
+
"Invalid auth_strategy: #{@auth_strategy.inspect}. " \
|
|
237
|
+
"Must be one of: #{VALID_AUTH_STRATEGIES.map(&:inspect).join(", ")}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Build None authenticator (no authentication)
|
|
242
|
+
#
|
|
243
|
+
# @return [Authentication::None]
|
|
244
|
+
#
|
|
245
|
+
def build_none_authenticator
|
|
246
|
+
Authentication::None.new
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Build API Key authenticator
|
|
250
|
+
#
|
|
251
|
+
# Returns None authenticator if api_key is not configured.
|
|
252
|
+
#
|
|
253
|
+
# @return [Authentication::ApiKey, Authentication::None]
|
|
254
|
+
#
|
|
255
|
+
def build_api_key_authenticator
|
|
256
|
+
if api_key.nil? || api_key.to_s.strip.empty?
|
|
257
|
+
Authentication::None.new
|
|
258
|
+
else
|
|
259
|
+
Authentication::ApiKey.new(api_key)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Build OAuth2 authenticator
|
|
264
|
+
#
|
|
265
|
+
# Returns None authenticator if OAuth2 credentials are not configured.
|
|
266
|
+
#
|
|
267
|
+
# @return [Authentication::OAuth2, Authentication::None]
|
|
268
|
+
#
|
|
269
|
+
def build_oauth2_authenticator
|
|
270
|
+
if oauth2_configured?
|
|
271
|
+
Authentication::OAuth2.new(
|
|
272
|
+
client_id: @oauth2_client_id,
|
|
273
|
+
client_secret: @oauth2_client_secret,
|
|
274
|
+
token_url: @oauth2_token_url || Authentication::OAuth2::DEFAULT_TOKEN_URL,
|
|
275
|
+
scope: @oauth2_scope
|
|
276
|
+
)
|
|
277
|
+
else
|
|
278
|
+
Authentication::None.new
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Build Composite authenticator (both API Key and OAuth2)
|
|
283
|
+
#
|
|
284
|
+
# Creates a composite that applies both authentication methods simultaneously.
|
|
285
|
+
# Only includes strategies that are actually configured.
|
|
286
|
+
#
|
|
287
|
+
# @return [Authentication::Composite]
|
|
288
|
+
#
|
|
289
|
+
def build_composite_authenticator
|
|
290
|
+
strategies = []
|
|
291
|
+
|
|
292
|
+
# Add API Key if configured
|
|
293
|
+
strategies << Authentication::ApiKey.new(api_key) unless api_key.nil? || api_key.to_s.strip.empty?
|
|
294
|
+
|
|
295
|
+
# Add OAuth2 if configured
|
|
296
|
+
if oauth2_configured?
|
|
297
|
+
strategies << Authentication::OAuth2.new(
|
|
298
|
+
client_id: @oauth2_client_id,
|
|
299
|
+
client_secret: @oauth2_client_secret,
|
|
300
|
+
token_url: @oauth2_token_url || Authentication::OAuth2::DEFAULT_TOKEN_URL,
|
|
301
|
+
scope: @oauth2_scope
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
Authentication::Composite.new(strategies)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Check if OAuth2 credentials are configured
|
|
309
|
+
#
|
|
310
|
+
# @return [Boolean] true if client_id and client_secret are present
|
|
311
|
+
#
|
|
312
|
+
def oauth2_configured?
|
|
313
|
+
!@oauth2_client_id.nil? && !@oauth2_client_id.to_s.strip.empty? &&
|
|
314
|
+
!@oauth2_client_secret.nil? && !@oauth2_client_secret.to_s.strip.empty?
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
# rubocop:enable Metrics/ClassLength
|
|
318
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require_relative "middleware/authentication"
|
|
6
|
+
|
|
7
|
+
module PetstoreApiClient
|
|
8
|
+
# Connection setup for HTTP requests
|
|
9
|
+
# Tried HTTParty first but Faraday's middleware is cleaner for this use case
|
|
10
|
+
module Connection
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Creates and memoizes a Faraday connection (reuses same connection for better performance)
|
|
14
|
+
def connection
|
|
15
|
+
@connection ||= Faraday.new(url: configuration.base_url) do |conn|
|
|
16
|
+
conn.request :json
|
|
17
|
+
conn.response :json, content_type: /\bjson$/
|
|
18
|
+
|
|
19
|
+
# Add authentication middleware (Strategy pattern + Interceptor pattern)
|
|
20
|
+
# This uses the authentication strategy from configuration
|
|
21
|
+
conn.use Middleware::Authentication, authenticator: configuration.authenticator
|
|
22
|
+
|
|
23
|
+
# Add retry middleware for transient failures (finally got around to implementing this!)
|
|
24
|
+
setup_retry_middleware(conn) if configuration.retry_enabled
|
|
25
|
+
|
|
26
|
+
# Standard headers for JSON API
|
|
27
|
+
conn.headers["Content-Type"] = "application/json"
|
|
28
|
+
conn.headers["Accept"] = "application/json"
|
|
29
|
+
|
|
30
|
+
# Timeouts to prevent hanging requests
|
|
31
|
+
conn.options.timeout = configuration.timeout
|
|
32
|
+
conn.options.open_timeout = configuration.open_timeout
|
|
33
|
+
|
|
34
|
+
conn.adapter Faraday.default_adapter
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Configure retry middleware with sensible defaults
|
|
39
|
+
def setup_retry_middleware(conn)
|
|
40
|
+
conn.request :retry,
|
|
41
|
+
max: configuration.max_retries,
|
|
42
|
+
interval: 0.5,
|
|
43
|
+
interval_randomness: 0.5,
|
|
44
|
+
backoff_factor: 2,
|
|
45
|
+
retry_statuses: [429, 500, 502, 503, 504], # Retry on these HTTP codes
|
|
46
|
+
methods: %i[get post put delete], # Retry all methods
|
|
47
|
+
exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reset connection when config changes
|
|
51
|
+
def reset_connection!
|
|
52
|
+
@connection = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetstoreApiClient
|
|
4
|
+
# Base error class - all our exceptions inherit from this
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :status_code, :error_type
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, status_code: nil, error_type: nil)
|
|
9
|
+
@status_code = status_code
|
|
10
|
+
@error_type = error_type
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Validation errors - thrown before we even hit the API
|
|
16
|
+
class ValidationError < Error; end
|
|
17
|
+
|
|
18
|
+
# Configuration errors - thrown when configuration is invalid
|
|
19
|
+
# @since 0.2.0
|
|
20
|
+
class ConfigurationError < Error; end
|
|
21
|
+
|
|
22
|
+
# Authentication errors - thrown when authentication fails
|
|
23
|
+
# @since 0.2.0
|
|
24
|
+
class AuthenticationError < Error
|
|
25
|
+
def initialize(message = "Authentication failed")
|
|
26
|
+
super(message, status_code: 401, error_type: "Authentication")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 404 errors
|
|
31
|
+
class NotFoundError < Error
|
|
32
|
+
def initialize(message = "Resource not found")
|
|
33
|
+
super(message, status_code: 404, error_type: "NotFound")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 405 or 400 errors for bad input
|
|
38
|
+
class InvalidInputError < Error
|
|
39
|
+
def initialize(message = "Invalid input provided")
|
|
40
|
+
super(message, status_code: 405, error_type: "InvalidInput")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 400 errors specific to orders
|
|
45
|
+
class InvalidOrderError < Error
|
|
46
|
+
def initialize(message = "Invalid order supplied")
|
|
47
|
+
super(message, status_code: 400, error_type: "InvalidOrder")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Network/connection issues
|
|
52
|
+
class ConnectionError < Error
|
|
53
|
+
def initialize(message = "Failed to connect to the API")
|
|
54
|
+
super(message, status_code: nil, error_type: "Connection")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Rate limiting errors - too many requests
|
|
59
|
+
class RateLimitError < Error
|
|
60
|
+
attr_reader :retry_after
|
|
61
|
+
|
|
62
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil)
|
|
63
|
+
@retry_after = retry_after
|
|
64
|
+
super(message, status_code: 429, error_type: "RateLimit")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Catch-all for other API errors
|
|
69
|
+
class ApiError < Error; end
|
|
70
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetstoreApiClient
|
|
4
|
+
module Middleware
|
|
5
|
+
# Faraday middleware for authentication
|
|
6
|
+
# Applies authentication strategy to each request
|
|
7
|
+
#
|
|
8
|
+
# This middleware follows the Interceptor pattern - it intercepts
|
|
9
|
+
# outgoing requests and adds authentication headers before they're sent
|
|
10
|
+
#
|
|
11
|
+
# This is the same pattern used by industry-standard gems:
|
|
12
|
+
# - Octokit (GitHub API)
|
|
13
|
+
# - Slack-ruby-client
|
|
14
|
+
# - Stripe Ruby
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # In Faraday connection setup
|
|
18
|
+
# conn.use PetstoreApiClient::Middleware::Authentication,
|
|
19
|
+
# authenticator: ApiKey.new("special-key")
|
|
20
|
+
class Authentication < Faraday::Middleware
|
|
21
|
+
# Initialize middleware with authentication strategy
|
|
22
|
+
#
|
|
23
|
+
# @param app [#call] The next middleware in the stack
|
|
24
|
+
# @param options [Hash] Middleware options
|
|
25
|
+
# @option options [Authentication::Base] :authenticator The auth strategy
|
|
26
|
+
def initialize(app, options = {})
|
|
27
|
+
super(app)
|
|
28
|
+
@authenticator = options[:authenticator]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Process request - apply authentication before sending
|
|
32
|
+
#
|
|
33
|
+
# @param env [Faraday::Env] The request environment
|
|
34
|
+
# @return [Faraday::Response] The response from the next middleware
|
|
35
|
+
def call(env)
|
|
36
|
+
# Apply authentication if configured
|
|
37
|
+
@authenticator&.apply(env) if @authenticator&.configured?
|
|
38
|
+
|
|
39
|
+
# Continue to next middleware
|
|
40
|
+
@app.call(env)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PetstoreApiClient
|
|
4
|
+
module Models
|
|
5
|
+
# ApiResponse model for structured error responses
|
|
6
|
+
# Not used much since we raise exceptions instead of returning error objects
|
|
7
|
+
class ApiResponse < Base
|
|
8
|
+
attribute :code, :integer
|
|
9
|
+
attribute :type, :string
|
|
10
|
+
attribute :message, :string
|
|
11
|
+
|
|
12
|
+
def to_h
|
|
13
|
+
{
|
|
14
|
+
code: code,
|
|
15
|
+
type: type,
|
|
16
|
+
message: message
|
|
17
|
+
}.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_response(data)
|
|
21
|
+
return nil if data.nil?
|
|
22
|
+
|
|
23
|
+
new(
|
|
24
|
+
code: data["code"] || data[:code],
|
|
25
|
+
type: data["type"] || data[:type],
|
|
26
|
+
message: data["message"] || data[:message]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|