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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetstoreApiClient
4
+ module Models
5
+ # Base class for all API models
6
+ # Provides common ActiveModel functionality and shared behavior
7
+ #
8
+ # This follows the DRY principle by centralizing all the common
9
+ # ActiveModel includes that every model needs.
10
+ #
11
+ # Benefits:
12
+ # - Single place to add model-wide functionality
13
+ # - Consistent behavior across all models
14
+ # - Reduces boilerplate in model classes
15
+ class Base
16
+ include ActiveModel::Model
17
+ include ActiveModel::Attributes
18
+ include ActiveModel::Validations
19
+ include ActiveModel::Serialization
20
+
21
+ # Convert model to hash for API requests
22
+ # Subclasses can override this for custom serialization
23
+ # Default implementation uses compact to remove nil values
24
+ def to_h
25
+ attributes.compact
26
+ end
27
+
28
+ # Create model instance from API response data
29
+ # This is a template method - subclasses must implement their own
30
+ # field mapping logic since each model has different fields
31
+ #
32
+ # @param data [Hash] Response data from API
33
+ # @return [Base, nil] Model instance or nil if data is nil
34
+ def self.from_response(data)
35
+ raise NotImplementedError, "#{name} must implement .from_response"
36
+ end
37
+
38
+ class << self
39
+ protected
40
+
41
+ # Helper method to extract value from response data
42
+ # Tries multiple key formats: camelCase, snake_case, symbol
43
+ #
44
+ # @param data [Hash] Response data
45
+ # @param field [Symbol] Field name in snake_case
46
+ # @param api_key [String, nil] Optional camelCase API key (if different from field)
47
+ # @return [Object, nil] Field value or nil
48
+ def extract_field(data, field, api_key = nil)
49
+ return nil if data.nil?
50
+
51
+ # Try API key (camelCase) first if provided
52
+ value = data[api_key] if api_key
53
+ # Then try snake_case string and symbol
54
+ value ||= data[field.to_s] || data[field]
55
+ value
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "named_entity"
4
+
5
+ module PetstoreApiClient
6
+ module Models
7
+ # Category model - represents a pet category
8
+ # Inherits all behavior from NamedEntity (id + name)
9
+ #
10
+ # Refactored to eliminate 100% duplication with Tag model
11
+ # by inheriting from shared NamedEntity base class
12
+ class Category < NamedEntity
13
+ # Category-specific logic can be added here if needed
14
+ # For now, it just uses the base NamedEntity behavior
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module PetstoreApiClient
6
+ module Models
7
+ # Base class for simple entities with just ID and name
8
+ # Used by Category and Tag models to eliminate duplication
9
+ #
10
+ # This is an example of the DRY principle - both Category and Tag
11
+ # were 100% identical, so we extracted their common behavior here.
12
+ class NamedEntity < Base
13
+ attribute :id, :integer
14
+ attribute :name, :string
15
+
16
+ # Override to_h to use symbol keys (API expects this format)
17
+ def to_h
18
+ {
19
+ id: id,
20
+ name: name
21
+ }.compact
22
+ end
23
+
24
+ # Create from API response data
25
+ # Handles both string and symbol keys
26
+ def self.from_response(data)
27
+ return nil if data.nil?
28
+
29
+ new(
30
+ id: extract_field(data, :id),
31
+ name: extract_field(data, :name)
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetstoreApiClient
4
+ module Models
5
+ # Order model representing a store order
6
+ # TODO: Should validate quantity > 0 and pet_id presence, but assignment doc didn't specify
7
+ class Order < Base
8
+ VALID_STATUSES = %w[placed approved delivered].freeze
9
+
10
+ attribute :id, :integer
11
+ attribute :pet_id, :integer
12
+ attribute :quantity, :integer
13
+ attribute :ship_date, :datetime
14
+ attribute :status, :string
15
+ attribute :complete, :boolean, default: false
16
+
17
+ validates :status, enum: { in: VALID_STATUSES, allow_nil: true }, if: -> { status.present? }
18
+
19
+ # Note: API expects snake_case to be converted to camelCase
20
+ def to_h
21
+ {
22
+ id: id,
23
+ petId: pet_id,
24
+ quantity: quantity,
25
+ shipDate: ship_date&.iso8601,
26
+ status: status,
27
+ complete: complete
28
+ }.compact
29
+ end
30
+
31
+ def self.from_response(data)
32
+ return nil if data.nil?
33
+
34
+ # Parse ship_date if it's a string
35
+ ship_date = data["shipDate"] || data[:shipDate] || data[:ship_date]
36
+ ship_date = parse_datetime(ship_date) if ship_date.is_a?(String)
37
+
38
+ new(
39
+ id: data["id"] || data[:id],
40
+ pet_id: data["petId"] || data[:petId] || data[:pet_id],
41
+ quantity: data["quantity"] || data[:quantity],
42
+ ship_date: ship_date,
43
+ status: data["status"] || data[:status],
44
+ complete: data["complete"] || data[:complete] || false
45
+ )
46
+ end
47
+
48
+ def self.parse_datetime(datetime_string)
49
+ DateTime.parse(datetime_string)
50
+ rescue ArgumentError
51
+ nil # Silently fail - API shouldn't send invalid dates anyway
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetstoreApiClient
4
+ module Models
5
+ # Pet resource model
6
+ #
7
+ # Represents a pet in the Petstore API with comprehensive validations
8
+ # following the API specification. Uses ActiveModel for validation,
9
+ # attribute management, and serialization.
10
+ #
11
+ # The Pet model supports:
12
+ # - Required and optional attributes with type casting
13
+ # - Nested objects (Category, Tags)
14
+ # - Status enum validation
15
+ # - Bi-directional conversion (to API format and from API responses)
16
+ # - ActiveModel validations
17
+ #
18
+ # @example Creating a new pet
19
+ # pet = Pet.new(
20
+ # name: "Fluffy",
21
+ # status: "available",
22
+ # photo_urls: ["https://example.com/photo1.jpg"]
23
+ # )
24
+ #
25
+ # @example Creating a pet with nested category
26
+ # pet = Pet.new(
27
+ # name: "Fluffy",
28
+ # status: "available",
29
+ # category: { id: 1, name: "Dogs" },
30
+ # tags: [{ id: 1, name: "friendly" }],
31
+ # photo_urls: ["https://example.com/photo1.jpg"]
32
+ # )
33
+ #
34
+ # @example Creating from API response
35
+ # data = { "id" => 123, "name" => "Fluffy", "status" => "available" }
36
+ # pet = Pet.from_response(data)
37
+ #
38
+ # @example Converting to API format
39
+ # pet_hash = pet.to_h
40
+ # # Returns: { id: 123, name: "Fluffy", photoUrls: [...], status: "available" }
41
+ #
42
+ # @see Category
43
+ # @see Tag
44
+ # @since 0.1.0
45
+ class Pet < Base
46
+ # Valid pet status values per API specification
47
+ VALID_STATUSES = %w[available pending sold].freeze
48
+
49
+ # @!attribute [rw] id
50
+ # @return [Integer, nil] Pet ID (assigned by server)
51
+ attribute :id, :integer
52
+
53
+ # @!attribute [rw] name
54
+ # @return [String] Pet name (required)
55
+ attribute :name, :string
56
+
57
+ # @!attribute [rw] photo_urls
58
+ # @return [Array<String>] URLs of pet photos (required, minimum 1)
59
+ attribute :photo_urls, default: -> { [] }
60
+
61
+ # @!attribute [rw] status
62
+ # @return [String, nil] Pet status (available, pending, or sold)
63
+ attribute :status, :string
64
+
65
+ # @!attribute [rw] category
66
+ # @return [Category, nil] Pet category (optional nested object)
67
+ # @!attribute [rw] tags
68
+ # @return [Array<Tag>] Pet tags (optional nested objects)
69
+ attr_accessor :category, :tags
70
+
71
+ # Validations per API specification
72
+ validates :name, presence: true, length: { minimum: 1 }
73
+ validates :photo_urls, array_presence: true
74
+ validates :status, enum: { in: VALID_STATUSES, allow_nil: true }, if: -> { status.present? }
75
+
76
+ # Validate nested category if present
77
+ validate :category_valid, if: -> { category.present? }
78
+
79
+ # Validate nested tags if present
80
+ validate :tags_valid, if: -> { tags.present? && tags.any? }
81
+
82
+ # Initialize a new Pet model
83
+ #
84
+ # Accepts attributes as a hash and builds nested Category and Tag objects
85
+ # if provided. Handles both symbol and string keys.
86
+ #
87
+ # @param attributes [Hash] Pet attributes
88
+ # @option attributes [Integer] :id Pet ID (optional, assigned by server)
89
+ # @option attributes [String] :name Pet name (required)
90
+ # @option attributes [Array<String>] :photo_urls Photo URLs (required)
91
+ # @option attributes [String] :status Pet status (available, pending, sold)
92
+ # @option attributes [Hash, Category] :category Category data or object
93
+ # @option attributes [Array<Hash>, Array<Tag>] :tags Array of tag data or objects
94
+ #
95
+ # @example
96
+ # pet = Pet.new(name: "Fluffy", photo_urls: ["http://example.com/1.jpg"])
97
+ #
98
+ def initialize(attributes = {})
99
+ # Handle category separately since it's a nested object
100
+ category_data = attributes.delete(:category) || attributes.delete("category")
101
+ @category = build_category(category_data) if category_data
102
+
103
+ # Handle tags separately since they're nested objects
104
+ tags_data = attributes.delete(:tags) || attributes.delete("tags")
105
+ @tags = build_tags(tags_data) if tags_data
106
+
107
+ super
108
+ end
109
+
110
+ # Convert pet to hash for API requests
111
+ #
112
+ # Converts the pet object to a hash suitable for API requests.
113
+ # Uses camelCase keys as expected by the Petstore API.
114
+ # Nested objects (category, tags) are also converted to hashes.
115
+ # Nil values are removed from the output.
116
+ #
117
+ # @return [Hash] Pet data in API format with camelCase keys
118
+ #
119
+ # @example
120
+ # pet = Pet.new(name: "Fluffy", photo_urls: ["http://example.com/1.jpg"])
121
+ # pet.to_h
122
+ # # => { name: "Fluffy", photoUrls: ["http://example.com/1.jpg"] }
123
+ #
124
+ def to_h
125
+ # puts "Converting pet to hash: #{name}" if ENV['DEBUG']
126
+ {
127
+ id: id,
128
+ category: category&.to_h,
129
+ name: name,
130
+ photoUrls: photo_urls, # camelCase for API
131
+ tags: tags&.map(&:to_h),
132
+ status: status
133
+ }.compact # Remove nil values
134
+ end
135
+
136
+ # Create Pet instance from API response data
137
+ #
138
+ # Factory method that creates a Pet object from API response data.
139
+ # Handles both string and symbol keys, and converts camelCase API
140
+ # keys (photoUrls) to snake_case Ruby convention (photo_urls).
141
+ #
142
+ # @param data [Hash, nil] API response data
143
+ # @return [Pet, nil] New Pet instance, or nil if data is nil
144
+ #
145
+ # @example
146
+ # response_data = {
147
+ # "id" => 123,
148
+ # "name" => "Fluffy",
149
+ # "photoUrls" => ["http://example.com/1.jpg"],
150
+ # "status" => "available"
151
+ # }
152
+ # pet = Pet.from_response(response_data)
153
+ #
154
+ def self.from_response(data)
155
+ return nil if data.nil?
156
+
157
+ new(
158
+ id: data["id"] || data[:id],
159
+ name: data["name"] || data[:name],
160
+ photo_urls: data["photoUrls"] || data[:photoUrls] || data[:photo_urls],
161
+ category: data["category"] || data[:category],
162
+ tags: data["tags"] || data[:tags],
163
+ status: data["status"] || data[:status]
164
+ )
165
+ end
166
+
167
+ private
168
+
169
+ # Build Category object from data
170
+ #
171
+ # @param category_data [Hash, Category, nil] Category data or object
172
+ # @return [Category, nil] Category instance or nil
173
+ # @api private
174
+ def build_category(category_data)
175
+ return nil if category_data.nil?
176
+ return category_data if category_data.is_a?(Category)
177
+
178
+ Category.new(category_data)
179
+ end
180
+
181
+ # Build array of Tag objects from data
182
+ #
183
+ # @param tags_data [Array<Hash>, Array<Tag>, nil] Array of tag data or objects
184
+ # @return [Array<Tag>] Array of Tag instances
185
+ # @api private
186
+ def build_tags(tags_data)
187
+ return [] if tags_data.nil? || !tags_data.is_a?(Array)
188
+
189
+ tags_data.map do |tag_data|
190
+ tag_data.is_a?(Tag) ? tag_data : Tag.new(tag_data)
191
+ end
192
+ end
193
+
194
+ # Validate nested category object
195
+ #
196
+ # Custom validator that checks if the nested category is valid.
197
+ # Adds error messages from category to this pet's errors.
198
+ #
199
+ # @return [void]
200
+ # @api private
201
+ def category_valid
202
+ return unless category.present?
203
+ return if category.valid?
204
+
205
+ errors.add(:category, "is invalid: #{category.errors.full_messages.join(", ")}")
206
+ end
207
+
208
+ # Validate nested tag objects
209
+ #
210
+ # Custom validator that checks if all tags are valid.
211
+ # Adds error if any tags are invalid.
212
+ #
213
+ # @return [void]
214
+ # @api private
215
+ def tags_valid
216
+ return unless tags.is_a?(Array)
217
+
218
+ invalid_tags = tags.reject(&:valid?)
219
+ return if invalid_tags.empty?
220
+
221
+ errors.add(:tags, "contain invalid entries")
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "named_entity"
4
+
5
+ module PetstoreApiClient
6
+ module Models
7
+ # Tag model - represents a pet tag
8
+ # Inherits all behavior from NamedEntity (id + name)
9
+ #
10
+ # Refactored to eliminate 100% duplication with Category model
11
+ # by inheriting from shared NamedEntity base class
12
+ #
13
+ # Keeping this as a separate class (rather than aliasing to Category)
14
+ # in case we need to add tag-specific logic later
15
+ class Tag < NamedEntity
16
+ # Tag-specific logic can be added here if needed
17
+ # For now, it just uses the base NamedEntity behavior
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PetstoreApiClient
4
+ # Wrapper for paginated API responses
5
+ # Provides metadata about pagination along with the data
6
+ #
7
+ # This follows the Decorator pattern - it wraps an array
8
+ # with additional pagination information
9
+ class PaginatedCollection
10
+ include Enumerable
11
+
12
+ attr_reader :data, :page, :per_page, :total_count
13
+
14
+ # Initialize a paginated collection
15
+ #
16
+ # @param data [Array] The array of items for this page
17
+ # @param page [Integer] Current page number (1-indexed)
18
+ # @param per_page [Integer] Number of items per page
19
+ # @param total_count [Integer, nil] Total number of items across all pages (if known)
20
+ def initialize(data:, page: 1, per_page: 25, total_count: nil)
21
+ @data = Array(data)
22
+ @page = page.to_i
23
+ @per_page = per_page.to_i
24
+ @total_count = total_count&.to_i
25
+ end
26
+
27
+ # Delegate enumerable methods to data array
28
+ def each(&)
29
+ data.each(&)
30
+ end
31
+
32
+ # Number of items in current page
33
+ def count
34
+ data.count
35
+ end
36
+ alias size count
37
+ alias length count
38
+
39
+ # Check if there are more pages available
40
+ # Returns true if we have total_count and current page isn't the last
41
+ # Returns nil if total_count is unknown (can't determine)
42
+ # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
43
+ def next_page?
44
+ return nil if total_count.nil?
45
+
46
+ page < total_pages
47
+ end
48
+ # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
49
+
50
+ # Check if there's a previous page
51
+ def prev_page?
52
+ page > 1
53
+ end
54
+ alias previous_page? prev_page?
55
+
56
+ # Get next page number (nil if on last page or unknown)
57
+ def next_page
58
+ next_page? ? page + 1 : nil
59
+ end
60
+
61
+ # Get previous page number (nil if on first page)
62
+ def prev_page
63
+ prev_page? ? page - 1 : nil
64
+ end
65
+ alias previous_page prev_page
66
+
67
+ # Calculate total number of pages
68
+ # Returns nil if total_count is unknown
69
+ def total_pages
70
+ return nil if total_count.nil?
71
+ return 1 if total_count.zero?
72
+
73
+ (total_count.to_f / per_page).ceil
74
+ end
75
+
76
+ # Check if this is the first page
77
+ def first_page?
78
+ page == 1
79
+ end
80
+
81
+ # Check if this is the last page
82
+ # Returns nil if total_count is unknown
83
+ # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
84
+ def last_page?
85
+ return nil if total_count.nil?
86
+
87
+ page >= total_pages
88
+ end
89
+ # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
90
+
91
+ # Get offset for current page (0-indexed)
92
+ def offset
93
+ (page - 1) * per_page
94
+ end
95
+
96
+ # Check if collection is empty
97
+ def empty?
98
+ data.empty?
99
+ end
100
+
101
+ # Check if collection has any items
102
+ def any?
103
+ !empty?
104
+ end
105
+
106
+ # Convert to array (returns the data)
107
+ def to_a
108
+ data
109
+ end
110
+ alias to_ary to_a
111
+
112
+ # Summary information about pagination
113
+ def pagination_info
114
+ {
115
+ page: page,
116
+ per_page: per_page,
117
+ count: count,
118
+ total_count: total_count,
119
+ total_pages: total_pages,
120
+ next_page: next_page,
121
+ prev_page: prev_page,
122
+ first_page: first_page?,
123
+ last_page: last_page?
124
+ }
125
+ end
126
+
127
+ # Inspect override for better debugging
128
+ def inspect
129
+ "#<PaginatedCollection page=#{page}/#{total_pages || "?"} " \
130
+ "per_page=#{per_page} count=#{count} total=#{total_count || "?"}>"
131
+ end
132
+ end
133
+ end