treaty 0.8.0 → 0.10.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 +4 -4
- data/README.md +6 -6
- data/config/locales/en.yml +6 -1
- data/lib/treaty/attribute/option/registry_initializer.rb +2 -0
- data/lib/treaty/attribute/option/validators/format_validator.rb +220 -0
- data/lib/treaty/exceptions/base.rb +2 -0
- data/lib/treaty/exceptions/specified_version_not_found.rb +117 -0
- data/lib/treaty/exceptions/version_not_found.rb +160 -0
- data/lib/treaty/version.rb +1 -1
- data/lib/treaty/versions/resolver.rb +13 -13
- data/lib/treaty/versions/workspace.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3115e472c191d30a9c5c8bea6bceb1ed6674437c4e82053817da35e4e94ed4f
|
|
4
|
+
data.tar.gz: e43b55d9b75c5d2ded71686319a773a814863c10e7d6c25a030151d45883ceeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f83bdae2ce72bb4f47773ccfa3911be30fb55a5710d211778ce725d487a6b09044c838d18fa23299607ae2ade4cbe671a189ed7d0969b57f1ab7ceaf912c8680
|
|
7
|
+
data.tar.gz: 01b3769426d4b04b1fe18441c3d943aa36ad8ccca077e7f0d4bf7c5ab1179ec88ae3343c4cd09a9274a87bcf97a88707479458bf7f9fc64fac6936770f0fce06
|
data/README.md
CHANGED
|
@@ -13,18 +13,18 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
15
|
> [!WARNING]
|
|
16
|
-
> **Development Status**: Treaty is currently under active development in the 0.x version series. Breaking changes may occur between minor versions (0.x) as we refine the API and add new features. The library will stabilize with the 1.0 release. We recommend pinning to specific patch versions in your Gemfile (e.g., `gem "treaty", "~> 0.
|
|
16
|
+
> **Development Status**: Treaty is currently under active development in the 0.x version series. Breaking changes may occur between minor versions (0.x) as we refine the API and add new features. The library will stabilize with the 1.0 release. We recommend pinning to specific patch versions in your Gemfile (e.g., `gem "treaty", "~> 0.9.0"`) until the 1.0 release.
|
|
17
17
|
|
|
18
18
|
## 📚 Documentation
|
|
19
19
|
|
|
20
20
|
Explore comprehensive guides and documentation at [docs](./docs):
|
|
21
21
|
|
|
22
|
-
- [Getting Started](./docs/getting-started.md) -
|
|
23
|
-
- [Core Concepts](./docs/core-concepts.md) -
|
|
24
|
-
- [API Reference](./docs/api-reference.md) -
|
|
25
|
-
- [Examples](./docs/examples.md) -
|
|
22
|
+
- [Getting Started](./docs/getting-started.md) - Installation and basic setup
|
|
23
|
+
- [Core Concepts](./docs/core-concepts.md) - Fundamental concepts and architecture
|
|
24
|
+
- [API Reference](./docs/api-reference.md) - Complete API documentation
|
|
25
|
+
- [Examples](./docs/examples.md) - Real-world usage examples
|
|
26
26
|
- [Internationalization](./docs/internationalization.md) - I18n and multilingual support
|
|
27
|
-
- [Full Documentation Index](./docs/README.md) -
|
|
27
|
+
- [Full Documentation Index](./docs/README.md) - Complete documentation index
|
|
28
28
|
|
|
29
29
|
## 💡 Why Treaty?
|
|
30
30
|
|
data/config/locales/en.yml
CHANGED
|
@@ -23,6 +23,11 @@ en:
|
|
|
23
23
|
invalid_schema: "Option 'inclusion' for attribute '%{attribute}' must have a non-empty array of allowed values"
|
|
24
24
|
not_included: "Attribute '%{attribute}' must be one of: %{allowed}. Got: '%{value}'"
|
|
25
25
|
|
|
26
|
+
format:
|
|
27
|
+
type_mismatch: "Option 'format' for attribute '%{attribute}' can only be used with String type. Current type: %{type}"
|
|
28
|
+
unknown_format: "Unknown format '%{format_name}' for attribute '%{attribute}'. Allowed formats: %{allowed}"
|
|
29
|
+
mismatch: "Attribute '%{attribute}' has invalid %{format_name} format: '%{value}'"
|
|
30
|
+
|
|
26
31
|
# Nested structures validation
|
|
27
32
|
nested:
|
|
28
33
|
# Orchestrator errors
|
|
@@ -79,7 +84,7 @@ en:
|
|
|
79
84
|
versioning:
|
|
80
85
|
# Version resolver
|
|
81
86
|
resolver:
|
|
82
|
-
|
|
87
|
+
specified_version_required: "Specified version is required for validation"
|
|
83
88
|
version_not_found: "Version %{version} not found in treaty definition"
|
|
84
89
|
version_deprecated: "Version %{version} is deprecated and cannot be used"
|
|
85
90
|
|
|
@@ -21,6 +21,7 @@ module Treaty
|
|
|
21
21
|
# - `:required` → RequiredValidator - Validates required/optional attributes
|
|
22
22
|
# - `:type` → TypeValidator - Validates value types
|
|
23
23
|
# - `:inclusion` → InclusionValidator - Validates value is in allowed set
|
|
24
|
+
# - `:format` → FormatValidator - Validates string values match specific formats
|
|
24
25
|
#
|
|
25
26
|
# ## Built-in Modifiers
|
|
26
27
|
#
|
|
@@ -71,6 +72,7 @@ module Treaty
|
|
|
71
72
|
Registry.register(:required, Validators::RequiredValidator, category: :validator)
|
|
72
73
|
Registry.register(:type, Validators::TypeValidator, category: :validator)
|
|
73
74
|
Registry.register(:inclusion, Validators::InclusionValidator, category: :validator)
|
|
75
|
+
Registry.register(:format, Validators::FormatValidator, category: :validator)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
# Registers all built-in modifiers
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Attribute
|
|
5
|
+
module Option
|
|
6
|
+
module Validators
|
|
7
|
+
# Validates that string attribute value matches a specific format.
|
|
8
|
+
#
|
|
9
|
+
# ## Supported Formats
|
|
10
|
+
#
|
|
11
|
+
# - `:uuid` - Universally unique identifier
|
|
12
|
+
# - `:email` - Email address (RFC 2822 compliant)
|
|
13
|
+
# - `:password` - Password (8-16 chars, must contain digit, lowercase, and uppercase)
|
|
14
|
+
# - `:duration` - ActiveSupport::Duration compatible string (e.g., "1 day", "2 hours")
|
|
15
|
+
# - `:date` - ISO 8601 date string (e.g., "2025-01-15")
|
|
16
|
+
# - `:datetime` - ISO 8601 datetime string (e.g., "2025-01-15T10:30:00Z")
|
|
17
|
+
# - `:time` - Time string (e.g., "10:30:00", "10:30 AM")
|
|
18
|
+
# - `:boolean` - Boolean string ("true", "false", "0", "1")
|
|
19
|
+
#
|
|
20
|
+
# ## Usage Examples
|
|
21
|
+
#
|
|
22
|
+
# Simple mode:
|
|
23
|
+
# string :email, format: :email
|
|
24
|
+
# string :started_on, format: :date
|
|
25
|
+
#
|
|
26
|
+
# Advanced mode:
|
|
27
|
+
# string :email, format: { is: :email }
|
|
28
|
+
# string :started_on, format: { is: :date, message: "Invalid date format" }
|
|
29
|
+
# string :started_on, format: { is: :date, message: ->(attribute:, value:, **) { "#{attribute} has invalid date: #{value}" } } # rubocop:disable Layout/LineLength
|
|
30
|
+
#
|
|
31
|
+
# ## Validation Rules
|
|
32
|
+
#
|
|
33
|
+
# - Only works with `:string` type attributes
|
|
34
|
+
# - Raises Treaty::Exceptions::Validation if used with non-string types
|
|
35
|
+
# - Skips validation for nil values (handled by RequiredValidator)
|
|
36
|
+
# - Each format has a pattern and/or validator for flexible validation
|
|
37
|
+
#
|
|
38
|
+
# ## Extensibility
|
|
39
|
+
#
|
|
40
|
+
# To add new formats, extend DEFAULT_FORMATS hash with format definition:
|
|
41
|
+
# DEFAULT_FORMATS[:custom_format] = {
|
|
42
|
+
# pattern: /regex/,
|
|
43
|
+
# validator: ->(value) { custom_validation_logic }
|
|
44
|
+
# }
|
|
45
|
+
class FormatValidator < Treaty::Attribute::Option::Base # rubocop:disable Metrics/ClassLength
|
|
46
|
+
# UUID format regex (8-4-4-4-12 hexadecimal pattern)
|
|
47
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
48
|
+
|
|
49
|
+
# Password format regex (8-16 chars, at least one digit, lowercase, and uppercase)
|
|
50
|
+
PASSWORD_PATTERN = /\A(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,16}\z/
|
|
51
|
+
|
|
52
|
+
# Boolean string format regex (accepts "true", "false", "0", "1" case-insensitive)
|
|
53
|
+
BOOLEAN_PATTERN = /\A(true|false|0|1)\z/i
|
|
54
|
+
|
|
55
|
+
# Default format definitions with patterns and validators
|
|
56
|
+
# Each format can have:
|
|
57
|
+
# - pattern: Regex for pattern matching
|
|
58
|
+
# - validator: Lambda for custom validation logic
|
|
59
|
+
DEFAULT_FORMATS = {
|
|
60
|
+
uuid: {
|
|
61
|
+
pattern: UUID_PATTERN,
|
|
62
|
+
validator: nil
|
|
63
|
+
},
|
|
64
|
+
email: {
|
|
65
|
+
pattern: URI::MailTo::EMAIL_REGEXP,
|
|
66
|
+
validator: nil
|
|
67
|
+
},
|
|
68
|
+
password: {
|
|
69
|
+
pattern: PASSWORD_PATTERN,
|
|
70
|
+
validator: nil
|
|
71
|
+
},
|
|
72
|
+
duration: {
|
|
73
|
+
pattern: nil,
|
|
74
|
+
validator: lambda do |value|
|
|
75
|
+
ActiveSupport::Duration.parse(value)
|
|
76
|
+
true
|
|
77
|
+
rescue StandardError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
},
|
|
81
|
+
date: {
|
|
82
|
+
pattern: nil,
|
|
83
|
+
validator: lambda do |value|
|
|
84
|
+
Date.parse(value)
|
|
85
|
+
true
|
|
86
|
+
rescue ArgumentError, TypeError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
},
|
|
90
|
+
datetime: {
|
|
91
|
+
pattern: nil,
|
|
92
|
+
validator: lambda do |value|
|
|
93
|
+
DateTime.parse(value)
|
|
94
|
+
true
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
},
|
|
99
|
+
time: {
|
|
100
|
+
pattern: nil,
|
|
101
|
+
validator: lambda do |value|
|
|
102
|
+
Time.parse(value)
|
|
103
|
+
true
|
|
104
|
+
rescue ArgumentError, TypeError
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
},
|
|
108
|
+
boolean: {
|
|
109
|
+
pattern: BOOLEAN_PATTERN,
|
|
110
|
+
validator: nil
|
|
111
|
+
}
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
# Validates that format is only used with string type attributes
|
|
115
|
+
# and that the format name is valid
|
|
116
|
+
#
|
|
117
|
+
# @raise [Treaty::Exceptions::Validation] If format is used with non-string type
|
|
118
|
+
# @raise [Treaty::Exceptions::Validation] If format name is unknown
|
|
119
|
+
# @return [void]
|
|
120
|
+
def validate_schema! # rubocop:disable Metrics/MethodLength
|
|
121
|
+
# Format option only works with string types
|
|
122
|
+
unless @attribute_type == :string
|
|
123
|
+
raise Treaty::Exceptions::Validation,
|
|
124
|
+
I18n.t(
|
|
125
|
+
"treaty.attributes.validators.format.type_mismatch",
|
|
126
|
+
attribute: @attribute_name,
|
|
127
|
+
type: @attribute_type
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
format_name = option_value
|
|
132
|
+
|
|
133
|
+
# Validate that format name exists
|
|
134
|
+
return if formats.key?(format_name)
|
|
135
|
+
|
|
136
|
+
raise Treaty::Exceptions::Validation,
|
|
137
|
+
I18n.t(
|
|
138
|
+
"treaty.attributes.validators.format.unknown_format",
|
|
139
|
+
attribute: @attribute_name,
|
|
140
|
+
format_name:,
|
|
141
|
+
allowed: formats.keys.join(", ")
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Validates that the value matches the specified format
|
|
146
|
+
# Skips validation for nil values (handled by RequiredValidator)
|
|
147
|
+
#
|
|
148
|
+
# @param value [String] The value to validate
|
|
149
|
+
# @raise [Treaty::Exceptions::Validation] If value doesn't match format
|
|
150
|
+
# @return [void]
|
|
151
|
+
def validate_value!(value) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
152
|
+
return if value.nil? # Format validation doesn't check for nil, required does.
|
|
153
|
+
|
|
154
|
+
format_name = option_value
|
|
155
|
+
format_definition = formats[format_name]
|
|
156
|
+
|
|
157
|
+
# Allow blank values (empty strings should be caught by required validator)
|
|
158
|
+
return if value.to_s.strip.empty?
|
|
159
|
+
|
|
160
|
+
# Apply pattern matching if defined
|
|
161
|
+
if format_definition.fetch(:pattern)
|
|
162
|
+
return if value.match?(format_definition.fetch(:pattern))
|
|
163
|
+
|
|
164
|
+
# Pattern failed, and no validator - raise error
|
|
165
|
+
unless format_definition.fetch(:validator)
|
|
166
|
+
attributes = {
|
|
167
|
+
attribute: @attribute_name,
|
|
168
|
+
value:,
|
|
169
|
+
format_name:
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
message = resolve_custom_message(**attributes) || default_message(**attributes)
|
|
173
|
+
|
|
174
|
+
raise Treaty::Exceptions::Validation, message
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Apply validator if defined
|
|
179
|
+
return unless format_definition.fetch(:validator)
|
|
180
|
+
return if format_definition.fetch(:validator).call(value)
|
|
181
|
+
|
|
182
|
+
attributes = {
|
|
183
|
+
attribute: @attribute_name,
|
|
184
|
+
value:,
|
|
185
|
+
format_name:
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
message = resolve_custom_message(**attributes) || default_message(**attributes)
|
|
189
|
+
|
|
190
|
+
raise Treaty::Exceptions::Validation, message
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Returns the available formats (allows for extension)
|
|
196
|
+
#
|
|
197
|
+
# @return [Hash] Hash of available formats with their definitions
|
|
198
|
+
def formats
|
|
199
|
+
DEFAULT_FORMATS
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Generates default error message for format validation failure using I18n
|
|
203
|
+
#
|
|
204
|
+
# @param attribute [Symbol] The attribute name
|
|
205
|
+
# @param value [Object] The actual value that failed validation
|
|
206
|
+
# @param format_name [Symbol] The format name
|
|
207
|
+
# @return [String] Default error message
|
|
208
|
+
def default_message(attribute:, value:, format_name:)
|
|
209
|
+
I18n.t(
|
|
210
|
+
"treaty.attributes.validators.format.mismatch",
|
|
211
|
+
attribute:,
|
|
212
|
+
value:,
|
|
213
|
+
format_name:
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -35,6 +35,8 @@ module Treaty
|
|
|
35
35
|
# - Validation - Attribute validation errors
|
|
36
36
|
# - Execution - Service execution errors
|
|
37
37
|
# - Deprecated - API version deprecation
|
|
38
|
+
# - SpecifiedVersionNotFound - No version specified and no default configured
|
|
39
|
+
# - VersionNotFound - Requested version doesn't exist
|
|
38
40
|
# - Strategy - Invalid strategy specification
|
|
39
41
|
# - ClassName - Treaty class not found
|
|
40
42
|
# - MethodName - Unknown method in DSL
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Exceptions
|
|
5
|
+
# Raised when no API version is specified and no default version is configured
|
|
6
|
+
#
|
|
7
|
+
# ## Purpose
|
|
8
|
+
#
|
|
9
|
+
# Prevents treaty execution when the client doesn't specify a version
|
|
10
|
+
# and the treaty hasn't defined a default version to fall back to.
|
|
11
|
+
# Enforces explicit version selection for API contracts.
|
|
12
|
+
#
|
|
13
|
+
# ## Usage
|
|
14
|
+
#
|
|
15
|
+
# Raised automatically during version resolution in two scenarios:
|
|
16
|
+
#
|
|
17
|
+
# ### Scenario 1: No Version Specified, No Default Configured
|
|
18
|
+
# ```ruby
|
|
19
|
+
# class PostsTreaty < ApplicationTreaty
|
|
20
|
+
# version 1 do
|
|
21
|
+
# # No default: true specified
|
|
22
|
+
# request { string :title }
|
|
23
|
+
# response(200) { object :post }
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# version 2 do
|
|
27
|
+
# request { string :title }
|
|
28
|
+
# response(200) { object :post }
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # Client request without version header
|
|
33
|
+
# PostsTreaty.call!(version: nil, params: { title: "Test" })
|
|
34
|
+
# # => Raises Treaty::Exceptions::SpecifiedVersionNotFound
|
|
35
|
+
# # => "Specified version is required for validation"
|
|
36
|
+
# ```
|
|
37
|
+
#
|
|
38
|
+
# ### Scenario 2: Empty Version String
|
|
39
|
+
# ```ruby
|
|
40
|
+
# PostsTreaty.call!(version: "", params: { title: "Test" })
|
|
41
|
+
# # => Raises Treaty::Exceptions::SpecifiedVersionNotFound
|
|
42
|
+
# ```
|
|
43
|
+
#
|
|
44
|
+
# ### Prevention: Define Default Version
|
|
45
|
+
# ```ruby
|
|
46
|
+
# class PostsTreaty < ApplicationTreaty
|
|
47
|
+
# version 1 do
|
|
48
|
+
# request { string :title }
|
|
49
|
+
# response(200) { object :post }
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# version 2, default: true do # Marks version 2 as default
|
|
53
|
+
# request { string :title }
|
|
54
|
+
# response(200) { object :post }
|
|
55
|
+
# end
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# # Now works without explicit version
|
|
59
|
+
# PostsTreaty.call!(version: nil, params: { title: "Test" })
|
|
60
|
+
# # => Uses version 2 by default
|
|
61
|
+
# ```
|
|
62
|
+
#
|
|
63
|
+
# ## Integration
|
|
64
|
+
#
|
|
65
|
+
# Can be rescued by application controllers to return appropriate HTTP status:
|
|
66
|
+
#
|
|
67
|
+
# ```ruby
|
|
68
|
+
# rescue_from Treaty::Exceptions::SpecifiedVersionNotFound, with: :render_version_required
|
|
69
|
+
#
|
|
70
|
+
# def render_version_required(exception)
|
|
71
|
+
# render json: {
|
|
72
|
+
# error: exception.message,
|
|
73
|
+
# hint: "Please specify an API version in the request header"
|
|
74
|
+
# }, status: :bad_request # HTTP 400
|
|
75
|
+
# end
|
|
76
|
+
# ```
|
|
77
|
+
#
|
|
78
|
+
# ## HTTP Status
|
|
79
|
+
#
|
|
80
|
+
# Typically returns HTTP 400 Bad Request, indicating that the client
|
|
81
|
+
# failed to provide required version information.
|
|
82
|
+
#
|
|
83
|
+
# ## Best Practices
|
|
84
|
+
#
|
|
85
|
+
# ### For API Providers
|
|
86
|
+
#
|
|
87
|
+
# 1. **Always define a default version** for backward compatibility:
|
|
88
|
+
# ```ruby
|
|
89
|
+
# version 1, default: true do
|
|
90
|
+
# # ...
|
|
91
|
+
# end
|
|
92
|
+
# ```
|
|
93
|
+
#
|
|
94
|
+
# 2. **Document version requirements** in API documentation
|
|
95
|
+
#
|
|
96
|
+
# 3. **Provide helpful error messages** in rescue handlers
|
|
97
|
+
#
|
|
98
|
+
# ### For API Clients
|
|
99
|
+
#
|
|
100
|
+
# 1. **Always specify version explicitly** in production code
|
|
101
|
+
# 2. **Don't rely on default versions** for critical applications
|
|
102
|
+
# 3. **Handle this exception** with version selection logic
|
|
103
|
+
#
|
|
104
|
+
# ## Difference from VersionNotFound
|
|
105
|
+
#
|
|
106
|
+
# - **SpecifiedVersionNotFound**: No version specified (nil/blank)
|
|
107
|
+
# - **VersionNotFound**: Specific version specified but doesn't exist
|
|
108
|
+
#
|
|
109
|
+
# ## Version Selection Flow
|
|
110
|
+
#
|
|
111
|
+
# 1. Client provides version → Use specified version
|
|
112
|
+
# 2. Client provides no version → Look for default version
|
|
113
|
+
# 3. No default version configured → Raise SpecifiedVersionNotFound
|
|
114
|
+
class SpecifiedVersionNotFound < Base
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Treaty
|
|
4
|
+
module Exceptions
|
|
5
|
+
# Raised when a specific API version is requested but doesn't exist in the treaty
|
|
6
|
+
#
|
|
7
|
+
# ## Purpose
|
|
8
|
+
#
|
|
9
|
+
# Prevents execution with non-existent API versions. Helps clients
|
|
10
|
+
# discover available versions and prevents errors from version mismatches.
|
|
11
|
+
# Ensures API versioning integrity.
|
|
12
|
+
#
|
|
13
|
+
# ## Usage
|
|
14
|
+
#
|
|
15
|
+
# Raised automatically during version resolution when a requested version
|
|
16
|
+
# is not defined in the treaty:
|
|
17
|
+
#
|
|
18
|
+
# ### Example: Requesting Non-Existent Version
|
|
19
|
+
# ```ruby
|
|
20
|
+
# class PostsTreaty < ApplicationTreaty
|
|
21
|
+
# version 1 do
|
|
22
|
+
# request { string :title }
|
|
23
|
+
# response(200) { object :post }
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# version 2, default: true do
|
|
27
|
+
# request { string :title, :summary }
|
|
28
|
+
# response(200) { object :post }
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # Client requests version 3 (doesn't exist)
|
|
33
|
+
# PostsTreaty.call!(version: "3", params: { title: "Test" })
|
|
34
|
+
# # => Raises Treaty::Exceptions::VersionNotFound
|
|
35
|
+
# # => "Version 3 not found in treaty definition"
|
|
36
|
+
# ```
|
|
37
|
+
#
|
|
38
|
+
# ### Example: Version Format Mismatch
|
|
39
|
+
# ```ruby
|
|
40
|
+
# # Treaty defines version 1
|
|
41
|
+
# version 1 do
|
|
42
|
+
# # ...
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # Client requests "1.0.0" (treated as different from "1")
|
|
46
|
+
# PostsTreaty.call!(version: "1.0.0", params: {})
|
|
47
|
+
# # => Raises Treaty::Exceptions::VersionNotFound if exact match not found
|
|
48
|
+
# ```
|
|
49
|
+
#
|
|
50
|
+
# ### Example: Typo in Version Number
|
|
51
|
+
# ```ruby
|
|
52
|
+
# PostsTreaty.call!(version: "v2", params: {}) # Should be "2"
|
|
53
|
+
# # => Raises Treaty::Exceptions::VersionNotFound
|
|
54
|
+
# ```
|
|
55
|
+
#
|
|
56
|
+
# ## Integration
|
|
57
|
+
#
|
|
58
|
+
# Can be rescued by application controllers to return appropriate HTTP status:
|
|
59
|
+
#
|
|
60
|
+
# ```ruby
|
|
61
|
+
# rescue_from Treaty::Exceptions::VersionNotFound, with: :render_version_not_found
|
|
62
|
+
#
|
|
63
|
+
# def render_version_not_found(exception)
|
|
64
|
+
# available_versions = extract_available_versions(exception)
|
|
65
|
+
#
|
|
66
|
+
# render json: {
|
|
67
|
+
# error: exception.message,
|
|
68
|
+
# available_versions: available_versions,
|
|
69
|
+
# hint: "Please use one of the available API versions"
|
|
70
|
+
# }, status: :not_found # HTTP 404
|
|
71
|
+
# end
|
|
72
|
+
# ```
|
|
73
|
+
#
|
|
74
|
+
# ## HTTP Status
|
|
75
|
+
#
|
|
76
|
+
# Typically returns HTTP 404 Not Found, indicating that the requested
|
|
77
|
+
# resource (API version) does not exist on the server.
|
|
78
|
+
#
|
|
79
|
+
# ## Common Scenarios
|
|
80
|
+
#
|
|
81
|
+
# ### 1. Client Using Outdated Version Number
|
|
82
|
+
# ```ruby
|
|
83
|
+
# # Version 1 was removed, only version 2 and 3 exist
|
|
84
|
+
# PostsTreaty.call!(version: "1", params: {})
|
|
85
|
+
# # => VersionNotFound
|
|
86
|
+
# ```
|
|
87
|
+
#
|
|
88
|
+
# ### 2. Client Using Future Version
|
|
89
|
+
# ```ruby
|
|
90
|
+
# # Client expects version 5 but only version 1-3 deployed
|
|
91
|
+
# PostsTreaty.call!(version: "5", params: {})
|
|
92
|
+
# # => VersionNotFound
|
|
93
|
+
# ```
|
|
94
|
+
#
|
|
95
|
+
# ### 3. Version Format Inconsistency
|
|
96
|
+
# ```ruby
|
|
97
|
+
# # Treaty uses integers, client uses semantic versioning
|
|
98
|
+
# version 1 do ... end
|
|
99
|
+
# version 2 do ... end
|
|
100
|
+
#
|
|
101
|
+
# PostsTreaty.call!(version: "v2.0.0", params: {})
|
|
102
|
+
# # => VersionNotFound (should use "2")
|
|
103
|
+
# ```
|
|
104
|
+
#
|
|
105
|
+
# ## Best Practices
|
|
106
|
+
#
|
|
107
|
+
# ### For API Providers
|
|
108
|
+
#
|
|
109
|
+
# 1. **Version numbering consistency**:
|
|
110
|
+
# ```ruby
|
|
111
|
+
# # Choose one format and stick with it
|
|
112
|
+
# version 1 do ... end
|
|
113
|
+
# version 2 do ... end
|
|
114
|
+
# # OR
|
|
115
|
+
# version "1.0.0" do ... end
|
|
116
|
+
# version "2.0.0" do ... end
|
|
117
|
+
# ```
|
|
118
|
+
#
|
|
119
|
+
# 2. **Document available versions** in API documentation
|
|
120
|
+
#
|
|
121
|
+
# 3. **Provide version discovery endpoint**:
|
|
122
|
+
# ```ruby
|
|
123
|
+
# GET /api/versions
|
|
124
|
+
# # => { available_versions: ["1", "2", "3"], default: "3" }
|
|
125
|
+
# ```
|
|
126
|
+
#
|
|
127
|
+
# 4. **Use deprecation** before removal:
|
|
128
|
+
# ```ruby
|
|
129
|
+
# version 1 do
|
|
130
|
+
# deprecated true # Warn before removing
|
|
131
|
+
# end
|
|
132
|
+
# ```
|
|
133
|
+
#
|
|
134
|
+
# ### For API Clients
|
|
135
|
+
#
|
|
136
|
+
# 1. **Validate version before requests**
|
|
137
|
+
# 2. **Handle version errors gracefully**
|
|
138
|
+
# 3. **Check API documentation** for available versions
|
|
139
|
+
# 4. **Implement version fallback logic** when appropriate
|
|
140
|
+
#
|
|
141
|
+
# ## Difference from SpecifiedVersionNotFound
|
|
142
|
+
#
|
|
143
|
+
# - **SpecifiedVersionNotFound**: No version specified (nil/blank), no default configured
|
|
144
|
+
# - **VersionNotFound**: Specific version specified but doesn't exist in treaty
|
|
145
|
+
#
|
|
146
|
+
# ## Difference from Deprecated
|
|
147
|
+
#
|
|
148
|
+
# - **VersionNotFound**: Version doesn't exist at all (HTTP 404)
|
|
149
|
+
# - **Deprecated**: Version exists but is marked as deprecated (HTTP 410)
|
|
150
|
+
#
|
|
151
|
+
# ## Version Resolution Order
|
|
152
|
+
#
|
|
153
|
+
# 1. Version specified → Look for exact match
|
|
154
|
+
# 2. Exact match not found → Raise VersionNotFound
|
|
155
|
+
# 3. Match found but deprecated → Raise Deprecated
|
|
156
|
+
# 4. Match found and active → Use version
|
|
157
|
+
class VersionNotFound < Base
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/treaty/version.rb
CHANGED
|
@@ -7,15 +7,15 @@ module Treaty
|
|
|
7
7
|
new(...).resolve!
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def initialize(
|
|
11
|
-
@
|
|
10
|
+
def initialize(specified_version:, collection_of_versions:)
|
|
11
|
+
@specified_version = specified_version
|
|
12
12
|
@collection_of_versions = collection_of_versions
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def resolve!
|
|
16
16
|
determined_factory =
|
|
17
|
-
if
|
|
18
|
-
default_version_factory ||
|
|
17
|
+
if specified_version_blank?
|
|
18
|
+
default_version_factory || raise_specified_version_not_found!
|
|
19
19
|
else
|
|
20
20
|
version_factory || raise_version_not_found!
|
|
21
21
|
end
|
|
@@ -30,7 +30,7 @@ module Treaty
|
|
|
30
30
|
def version_factory
|
|
31
31
|
@version_factory ||=
|
|
32
32
|
@collection_of_versions.find do |factory|
|
|
33
|
-
factory.version.version == @
|
|
33
|
+
factory.version.version == @specified_version
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -39,22 +39,22 @@ module Treaty
|
|
|
39
39
|
@collection_of_versions.find(&:default_result)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def
|
|
43
|
-
@
|
|
42
|
+
def specified_version_blank?
|
|
43
|
+
@specified_version.to_s.strip.empty?
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
##########################################################################
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
raise Treaty::Exceptions::
|
|
50
|
-
I18n.t("treaty.versioning.resolver.
|
|
48
|
+
def raise_specified_version_not_found!
|
|
49
|
+
raise Treaty::Exceptions::SpecifiedVersionNotFound,
|
|
50
|
+
I18n.t("treaty.versioning.resolver.specified_version_required")
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def raise_version_not_found!
|
|
54
|
-
raise Treaty::Exceptions::
|
|
54
|
+
raise Treaty::Exceptions::VersionNotFound,
|
|
55
55
|
I18n.t(
|
|
56
56
|
"treaty.versioning.resolver.version_not_found",
|
|
57
|
-
version: @
|
|
57
|
+
version: @specified_version
|
|
58
58
|
)
|
|
59
59
|
end
|
|
60
60
|
|
|
@@ -62,7 +62,7 @@ module Treaty
|
|
|
62
62
|
raise Treaty::Exceptions::Deprecated,
|
|
63
63
|
I18n.t(
|
|
64
64
|
"treaty.versioning.resolver.version_deprecated",
|
|
65
|
-
version: @
|
|
65
|
+
version: @specified_version
|
|
66
66
|
)
|
|
67
67
|
end
|
|
68
68
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: treaty
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton Sokolov
|
|
@@ -159,6 +159,7 @@ files:
|
|
|
159
159
|
- lib/treaty/attribute/option/modifiers/default_modifier.rb
|
|
160
160
|
- lib/treaty/attribute/option/registry.rb
|
|
161
161
|
- lib/treaty/attribute/option/registry_initializer.rb
|
|
162
|
+
- lib/treaty/attribute/option/validators/format_validator.rb
|
|
162
163
|
- lib/treaty/attribute/option/validators/inclusion_validator.rb
|
|
163
164
|
- lib/treaty/attribute/option/validators/required_validator.rb
|
|
164
165
|
- lib/treaty/attribute/option/validators/type_validator.rb
|
|
@@ -185,9 +186,11 @@ files:
|
|
|
185
186
|
- lib/treaty/exceptions/method_name.rb
|
|
186
187
|
- lib/treaty/exceptions/nested_attributes.rb
|
|
187
188
|
- lib/treaty/exceptions/not_implemented.rb
|
|
189
|
+
- lib/treaty/exceptions/specified_version_not_found.rb
|
|
188
190
|
- lib/treaty/exceptions/strategy.rb
|
|
189
191
|
- lib/treaty/exceptions/unexpected.rb
|
|
190
192
|
- lib/treaty/exceptions/validation.rb
|
|
193
|
+
- lib/treaty/exceptions/version_not_found.rb
|
|
191
194
|
- lib/treaty/info/entity/builder.rb
|
|
192
195
|
- lib/treaty/info/entity/dsl.rb
|
|
193
196
|
- lib/treaty/info/entity/result.rb
|