verquest 0.1.0 → 0.2.1

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.
@@ -1,5 +1,212 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verquest
4
- VERSION = "0.1.0"
4
+ # Represents a specific version of an API request schema
5
+ #
6
+ # The Version class manages the properties, schema generation, and mapping
7
+ # for a specific version of an API request. It holds the collection of
8
+ # properties that define the request structure and handles
9
+ # transforming between different property naming conventions.
10
+ #
11
+ # @example
12
+ # version = Verquest::Version.new(name: "2023-01")
13
+ # version.add(Verquest::Properties::Field.new(name: :email, type: :string))
14
+ # version.prepare
15
+ #
16
+ # # Generate schema
17
+ # schema = version.schema
18
+ #
19
+ # # Get mapping
20
+ # mapping = version.mapping
21
+ class Version
22
+ # @!attribute [r] name
23
+ # @return [String] The name/identifier of the version (e.g., "2023-01")
24
+ #
25
+ # @!attribute [r] properties
26
+ # @return [Hash<Symbol, Verquest::Properties::Base>] The properties that define the version's schema
27
+ #
28
+ # @!attribute [r] schema
29
+ # @return [Hash] The generated JSON schema for this version
30
+ #
31
+ # @!attribute [r] validation_schema
32
+ # @return [Hash] The schema used for request validation
33
+ #
34
+ # @!attribute [r] mapping
35
+ # @return [Hash] The mapping from schema property paths to internal paths
36
+ #
37
+ # @!attribute [r] transformer
38
+ # @return [Verquest::Transformer] The transformer that applies the mapping
39
+ attr_reader :name, :properties, :schema, :validation_schema, :mapping, :transformer
40
+
41
+ # @!attribute [rw] schema_options
42
+ # @return [Hash] Additional JSON schema options for this version
43
+ #
44
+ # @!attribute [rw] description
45
+ # @return [String] Description of this version
46
+ attr_accessor :schema_options, :description
47
+
48
+ # Initialize a new Version instance
49
+ #
50
+ # @param name [String] The name/identifier of the version
51
+ # @return [Version] A new Version instance
52
+ def initialize(name:)
53
+ @name = name
54
+ @schema_options = {}
55
+ @properties = {}
56
+ end
57
+
58
+ # Add a property to this version
59
+ #
60
+ # @param property [Verquest::Properties::Base] The property to add
61
+ # @return [Verquest::Properties::Base] The added property
62
+ def add(property)
63
+ properties[property.name] = property
64
+ end
65
+
66
+ # Remove a property from this version by name
67
+ #
68
+ # @param property_name [Symbol, String] The name of the property to remove
69
+ # @return [Verquest::Properties::Base] The removed property
70
+ # @raise [PropertyNotFoundError] If the property doesn't exist
71
+ def remove(property_name)
72
+ properties.delete(property_name) || raise(PropertyNotFoundError.new("Property '#{property_name}' is not defined on '#{name}"))
73
+ end
74
+
75
+ # Check if this version has a property with the given name
76
+ #
77
+ # @param property_name [Symbol, String] The name of the property to check
78
+ # @return [Boolean] true if the property exists, false otherwise
79
+ def has?(property_name)
80
+ properties.key?(property_name)
81
+ end
82
+
83
+ # Copy properties from another version
84
+ #
85
+ # @param version [Verquest::Version] The version to copy properties from
86
+ # @param exclude_properties [Array<Symbol>] Names of properties to not copy
87
+ # @return [void]
88
+ # @raise [ArgumentError] If version is not a Verquest::Version instance
89
+ def copy_from(version, exclude_properties: [])
90
+ raise ArgumentError, "Expected a Verquest::Version instance" unless version.is_a?(Version)
91
+
92
+ version.properties.values.each do |property|
93
+ next if exclude_properties.include?(property.name)
94
+
95
+ add(property)
96
+ end
97
+ end
98
+
99
+ # Prepare this version by generating schema and creating transformer
100
+ #
101
+ # @return [void]
102
+ def prepare
103
+ return if frozen?
104
+
105
+ prepare_schema
106
+ prepare_validation_schema
107
+ prepare_mapping
108
+ @transformer = Transformer.new(mapping: mapping)
109
+
110
+ freeze
111
+ end
112
+
113
+ # Validate the schema against the metaschema
114
+ #
115
+ # @return [Boolean] true if the schema is valid, false otherwise
116
+ def validate_schema
117
+ schema_name = Verquest.configuration.json_schema_version
118
+
119
+ metaschema = JSON::Validator.validator_for_name(schema_name).metaschema
120
+ JSON::Validator.validate(metaschema, validation_schema)
121
+ end
122
+
123
+ # Validate request parameters against the version's validation schema
124
+ #
125
+ # @param params [Hash] The request parameters to validate
126
+ # @param component_reference [String] A reference string for components in the schema
127
+ # @param remove_extra_root_keys [Boolean] Whether to remove extra keys not in the schema
128
+ # @return [Array<Hash>] An array of validation error details, or empty if valid
129
+ def validate_params(params:, component_reference:, remove_extra_root_keys:)
130
+ schema_name = Verquest.configuration.json_schema_version
131
+
132
+ result = JSON::Validator.fully_validate(validation_schema, params, version: schema_name, errors_as_objects: true)
133
+ return result if result.empty?
134
+
135
+ result.map do |error|
136
+ schema = error.delete(:schema)
137
+ error[:message].gsub!(schema.to_s, component_reference)
138
+ end
139
+ end
140
+
141
+ # Get the mapping for a specific property
142
+ #
143
+ # @param property [Symbol, String] The property name to get the mapping for
144
+ # @return [Hash] The mapping for the property
145
+ # @raise [PropertyNotFoundError] If the property doesn't exist
146
+ def mapping_for(property)
147
+ raise PropertyNotFoundError.new("Property '#{property}' is not defined on '#{name}'") unless has?(property)
148
+
149
+ {}.tap do |mapping|
150
+ properties[property].mapping(key_prefix: [], value_prefix: [], mapping: mapping, version: name)
151
+ end
152
+ end
153
+
154
+ # Map request parameters to internal representation using the transformer
155
+ #
156
+ # @param params [Hash] The request parameters to map
157
+ # @return [Hash] The mapped parameters
158
+ def map_params(params)
159
+ transformer.call(params)
160
+ end
161
+
162
+ private
163
+
164
+ # Generates the JSON schema for this version
165
+ #
166
+ # Creates a schema object with type, description, required properties,
167
+ # and property definitions based on the properties in this version.
168
+ # The schema is frozen to prevent modification after preparation.
169
+ #
170
+ # @return [Hash] The frozen schema hash
171
+ def prepare_schema
172
+ @schema = {
173
+ type: :object,
174
+ description: description,
175
+ required: properties.values.select(&:required).map(&:name),
176
+ properties: properties.transform_values { |property| property.to_schema[property.name] }
177
+ }.merge(schema_options).freeze
178
+ end
179
+
180
+ # Generates the validation schema for this version
181
+ #
182
+ # Similar to prepare_schema but specifically for validation purposes.
183
+ # The validation schema will include all referenced components and properties.
184
+ #
185
+ # @return [Hash] The frozen validation schema hash
186
+ def prepare_validation_schema
187
+ @validation_schema = {
188
+ type: :object,
189
+ description: description,
190
+ required: properties.values.select(&:required).map(&:name),
191
+ properties: properties.transform_values { |property| property.to_validation_schema(version: name)[property.name] }
192
+ }.merge(schema_options).freeze
193
+ end
194
+
195
+ # Prepares the parameter mapping for this version
196
+ #
197
+ # Collects mappings from all properties in this version and checks for
198
+ # duplicate mappings, which would cause conflicts during transformation.
199
+ #
200
+ # @return [Hash] The mapping from schema property paths to internal paths
201
+ # @raise [MappingError] If duplicate mappings are detected
202
+ def prepare_mapping
203
+ @mapping = properties.values.each_with_object({}) do |property, mapping|
204
+ property.mapping(key_prefix: [], value_prefix: [], mapping: mapping, version: name)
205
+ end
206
+
207
+ if (duplicates = mapping.keys.select { |k| mapping.values.count(k) > 1 }).any?
208
+ raise MappingError.new("Mapping must be unique. Found duplicates in version '#{name}': #{duplicates.join(", ")}")
209
+ end
210
+ end
211
+ end
5
212
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ # Resolves requested version identifiers to actual version objects
5
+ #
6
+ # The VersionResolver handles version resolution logic, finding the appropriate
7
+ # version to use based on a requested version identifier. It implements a "downgrading"
8
+ # strategy - when an exact version match isn't found, it returns the closest earlier version.
9
+ #
10
+ # @example Resolving a version
11
+ # versions = ["2025-01", "2025-06", "2026-01"]
12
+ #
13
+ # # Exact match
14
+ # Verquest::VersionResolver.call("2025-06", versions) # => "2025-06"
15
+ #
16
+ # # Between versions - returns previous version
17
+ # Verquest::VersionResolver.call("2025-04", versions) # => "2025-01"
18
+ #
19
+ # # After latest version - returns latest
20
+ # Verquest::VersionResolver.call("2026-06", versions) # => "2026-01"
21
+ class VersionResolver
22
+ # Resolves a requested version to the appropriate available version
23
+ #
24
+ # This method implements the version resolution strategy:
25
+ # - If an exact match is found, that version is returned
26
+ # - If the requested version is between two available versions, the earlier one is returned
27
+ # - If the requested version is after all available versions, the latest version is returned
28
+ #
29
+ # @param requested_version [String] The version identifier requested
30
+ # @param versions [Array<String>] List of available version identifiers, sorted by age (oldest first)
31
+ # @return [String, nil] The resolved version, or nil if no versions available
32
+ def self.call(requested_version, versions)
33
+ versions.each_with_index do |version, index|
34
+ return version if version == requested_version
35
+
36
+ if requested_version > version && ((versions[index + 1] && requested_version < versions[index + 1]) || !versions[index + 1])
37
+ return version
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verquest
4
+ # Container for managing multiple API versions
5
+ #
6
+ # The Versions class stores and provides access to all available versions of an
7
+ # API request schema. It handles adding new versions and resolving version identifiers
8
+ # to specific Version objects based on the configured version resolution strategy.
9
+ #
10
+ # @example Adding and resolving versions
11
+ # versions = Verquest::Versions.new
12
+ #
13
+ # # Add versions
14
+ # versions.add(Verquest::Version.new(name: "2022-01"))
15
+ # versions.add(Verquest::Version.new(name: "2023-01"))
16
+ #
17
+ # # Resolve a version
18
+ # version = versions.resolve("2022-06") # Returns "2022-01" version based on resolver
19
+ class Versions
20
+ # @!attribute [rw] description
21
+ # @return [String] Default description for versions that don't specify one
22
+ #
23
+ # @!attribute [rw] schema_options
24
+ # @return [Hash] Default schema options for versions that don't specify any
25
+ attr_accessor :description, :schema_options
26
+
27
+ # Initialize a new Versions container
28
+ #
29
+ # @return [Versions] A new Versions instance
30
+ def initialize
31
+ @versions = {}
32
+ @schema_options = {}
33
+ end
34
+
35
+ # Add a version to the container
36
+ #
37
+ # @param version [Verquest::Version] The version to add
38
+ # @return [Verquest::Version] The added version
39
+ # @raise [ArgumentError] If the provided object is not a Version instance
40
+ def add(version)
41
+ raise ArgumentError, "Expected a Verquest::Version instance" unless version.is_a?(Verquest::Version)
42
+
43
+ versions[version.name] = version
44
+ end
45
+
46
+ # Resolve a version identifier to a specific Version object
47
+ #
48
+ # Uses the configured version resolver to determine which version to use
49
+ # based on the requested version identifier.
50
+ #
51
+ # @param version_name [String] The version identifier to resolve
52
+ # @return [Verquest::Version] The resolved Version object
53
+ def resolve(version_name)
54
+ resolved_version_name = Verquest.configuration.version_resolver.call(version_name, versions.keys.sort)
55
+
56
+ versions[resolved_version_name] || raise(Verquest::VersionNotFoundError, "Version '#{version_name}' not found")
57
+ end
58
+
59
+ private
60
+
61
+ # @!attribute [r] versions
62
+ # @return [Hash<String, Verquest::Version>] The versions stored in this container
63
+ attr_reader :versions
64
+ end
65
+ end
data/lib/verquest.rb CHANGED
@@ -1,10 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zeitwerk"
4
- loader = Zeitwerk::Loader.for_gem
4
+ require "json-schema"
5
+
6
+ loader = Zeitwerk::Loader.new
7
+ loader.tag = File.basename(__FILE__, ".rb")
8
+ loader.push_dir(File.dirname(__FILE__))
5
9
  loader.setup
6
10
 
11
+ # Verquest is a Ruby gem for versioning API requests
12
+ #
13
+ # Verquest allows you to define and manage versioned API request schemas,
14
+ # handle parameter mapping between different versions, validate incoming
15
+ # parameters against schemas, and generate documentation.
16
+ #
17
+ # @example Basic usage
18
+ # class UserCreateRequest < Verquest::Base
19
+ # description "User Create Request"
20
+ # schema_options additional_properties: false
21
+ #
22
+ # version "2025-06" do
23
+ # field :email, type: :string, required: true, format: "email"
24
+ # field :name, type: :string
25
+ #
26
+ # object :address do
27
+ # field :street, type: :string, map: "/address_street"
28
+ # field :city, type: :string, required: true, map: "/address_city"
29
+ # field :zip_code, type: :string, map: "/address_zip_code"
30
+ # end
31
+ # end
32
+ #
33
+ # version "2025-08", exclude_properties: %i[name] do
34
+ # field :name, type: :string, required: true
35
+ # end
36
+ # end
37
+ #
38
+ # # Map and validate parameters for a specific version
39
+ # result = UserCreateRequest.map(params, version: "2025-07", validate: true)
40
+ #
41
+ # if result.success?
42
+ # process_user(result.value)
43
+ # else
44
+ # handle_errors(result.errors)
45
+ # end
46
+ #
47
+ # @see Verquest::Base Base class for creating versioned request schemas
48
+ # @see Verquest::Configuration Configuration options for Verquest
7
49
  module Verquest
8
- class Error < StandardError; end
9
- # Your code goes here...
50
+ # Base error class for all Verquest-related errors
51
+ # @api public
52
+ Error = Class.new(StandardError)
53
+
54
+ # Error raised when a requested version cannot be found
55
+ # @api public
56
+ VersionNotFoundError = Class.new(Verquest::Error)
57
+
58
+ # Error raised when a requested property cannot be found in a version
59
+ # @api public
60
+ PropertyNotFoundError = Class.new(Verquest::Error)
61
+
62
+ # Error raised when there are issues with property mappings
63
+ # @api public
64
+ MappingError = Class.new(Verquest::Error)
65
+
66
+ # Error raised when parameters do not match the expected schema
67
+ # @api public
68
+ InvalidParamsError = Class.new(Verquest::Error) do
69
+ attr_reader :errors
70
+
71
+ # @param message [String] error message
72
+ # @param errors [Array] validation errors
73
+ def initialize(message, errors:)
74
+ super(message)
75
+ @errors = errors
76
+ end
77
+ end
78
+
79
+ class << self
80
+ # Returns the global configuration for Verquest
81
+ #
82
+ # @return [Verquest::Configuration] The configuration instance
83
+ # @see Verquest::Configuration
84
+ def configuration
85
+ @configuration ||= Configuration.new
86
+ end
87
+
88
+ # Configure Verquest with the given block
89
+ #
90
+ # @example
91
+ # Verquest.configure do |config|
92
+ # config.validate_params = true
93
+ # config.current_version = -> { "2023-06" }
94
+ # end
95
+ #
96
+ # @yield [configuration] The configuration instance
97
+ # @yieldparam configuration [Verquest::Configuration] The configuration to modify
98
+ # @return [void]
99
+ def configure
100
+ yield(configuration)
101
+ end
102
+ end
10
103
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verquest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Petr Hlavicka
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-06-22 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: zeitwerk
@@ -23,23 +24,56 @@ dependencies:
23
24
  - - "~>"
24
25
  - !ruby/object:Gem::Version
25
26
  version: '2.7'
26
- description: Verquest is a Ruby gem that helps you version your public API requests,
27
- making it easier to manage changes and maintain backward compatibility with OpenAPI
28
- support.
27
+ - !ruby/object:Gem::Dependency
28
+ name: json-schema
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ description: Verquest helps you version API requests, simplifying the management of
42
+ changes, handling the mapping for internal versus external names and structures,
43
+ validating parameters, and exporting your requests to JSON Schema components for
44
+ OpenAPI.
29
45
  email:
30
- - petr@hlavicka.cz
46
+ - yes@petr.codes
31
47
  executables: []
32
48
  extensions: []
33
49
  extra_rdoc_files: []
34
50
  files:
35
51
  - ".rubocop.yml"
52
+ - ".yardopts"
36
53
  - CHANGELOG.md
37
54
  - CODE_OF_CONDUCT.md
38
55
  - LICENSE.txt
39
56
  - README.md
40
57
  - Rakefile
41
58
  - lib/verquest.rb
59
+ - lib/verquest/base.rb
60
+ - lib/verquest/base/helper_class_methods.rb
61
+ - lib/verquest/base/private_class_methods.rb
62
+ - lib/verquest/base/public_class_methods.rb
63
+ - lib/verquest/configuration.rb
64
+ - lib/verquest/gem_version.rb
65
+ - lib/verquest/properties.rb
66
+ - lib/verquest/properties/array.rb
67
+ - lib/verquest/properties/base.rb
68
+ - lib/verquest/properties/collection.rb
69
+ - lib/verquest/properties/field.rb
70
+ - lib/verquest/properties/object.rb
71
+ - lib/verquest/properties/reference.rb
72
+ - lib/verquest/result.rb
73
+ - lib/verquest/transformer.rb
42
74
  - lib/verquest/version.rb
75
+ - lib/verquest/version_resolver.rb
76
+ - lib/verquest/versions.rb
43
77
  homepage: https://github.com/CiTroNaK/verquest
44
78
  licenses:
45
79
  - MIT
@@ -47,6 +81,7 @@ metadata:
47
81
  homepage_uri: https://github.com/CiTroNaK/verquest
48
82
  source_code_uri: https://github.com/CiTroNaK/verquest
49
83
  changelog_uri: https://github.com/CiTroNaK/verquest/blob/main/CHANGELOG.md
84
+ post_install_message:
50
85
  rdoc_options: []
51
86
  require_paths:
52
87
  - lib
@@ -54,14 +89,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
54
89
  requirements:
55
90
  - - ">="
56
91
  - !ruby/object:Gem::Version
57
- version: 3.1.0
92
+ version: '3.2'
58
93
  required_rubygems_version: !ruby/object:Gem::Requirement
59
94
  requirements:
60
95
  - - ">="
61
96
  - !ruby/object:Gem::Version
62
97
  version: '0'
63
98
  requirements: []
64
- rubygems_version: 3.6.7
99
+ rubygems_version: 3.4.19
100
+ signing_key:
65
101
  specification_version: 4
66
- summary: Version your public API requests with ease
102
+ summary: Verquest is a Ruby gem that offers an elegant solution for versioning API
103
+ requests
67
104
  test_files: []