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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +33 -0
  3. data/.env.example +50 -0
  4. data/.github/CODEOWNERS +36 -0
  5. data/.github/workflows/ci.yml +157 -0
  6. data/.ruby-version +1 -0
  7. data/CONTRIBUTORS.md +39 -0
  8. data/LICENSE +21 -0
  9. data/README.md +684 -0
  10. data/Rakefile +12 -0
  11. data/lib/petstore_api_client/api_client.rb +60 -0
  12. data/lib/petstore_api_client/authentication/api_key.rb +107 -0
  13. data/lib/petstore_api_client/authentication/base.rb +113 -0
  14. data/lib/petstore_api_client/authentication/composite.rb +178 -0
  15. data/lib/petstore_api_client/authentication/none.rb +42 -0
  16. data/lib/petstore_api_client/authentication/oauth2.rb +305 -0
  17. data/lib/petstore_api_client/client.rb +87 -0
  18. data/lib/petstore_api_client/clients/concerns/pagination.rb +124 -0
  19. data/lib/petstore_api_client/clients/concerns/resource_operations.rb +121 -0
  20. data/lib/petstore_api_client/clients/pet_client.rb +119 -0
  21. data/lib/petstore_api_client/clients/store_client.rb +37 -0
  22. data/lib/petstore_api_client/configuration.rb +318 -0
  23. data/lib/petstore_api_client/connection.rb +55 -0
  24. data/lib/petstore_api_client/errors.rb +70 -0
  25. data/lib/petstore_api_client/middleware/authentication.rb +44 -0
  26. data/lib/petstore_api_client/models/api_response.rb +31 -0
  27. data/lib/petstore_api_client/models/base.rb +60 -0
  28. data/lib/petstore_api_client/models/category.rb +17 -0
  29. data/lib/petstore_api_client/models/named_entity.rb +36 -0
  30. data/lib/petstore_api_client/models/order.rb +55 -0
  31. data/lib/petstore_api_client/models/pet.rb +225 -0
  32. data/lib/petstore_api_client/models/tag.rb +20 -0
  33. data/lib/petstore_api_client/paginated_collection.rb +133 -0
  34. data/lib/petstore_api_client/request.rb +225 -0
  35. data/lib/petstore_api_client/response.rb +193 -0
  36. data/lib/petstore_api_client/validators/array_presence_validator.rb +15 -0
  37. data/lib/petstore_api_client/validators/enum_validator.rb +17 -0
  38. data/lib/petstore_api_client/version.rb +5 -0
  39. data/lib/petstore_api_client.rb +55 -0
  40. 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