easy_talk 3.1.0 → 3.2.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +75 -0
  5. data/README.md +616 -35
  6. data/Rakefile +27 -0
  7. data/docs/.gitignore +1 -0
  8. data/docs/about.markdown +28 -8
  9. data/docs/getting-started.markdown +102 -0
  10. data/docs/index.markdown +51 -4
  11. data/docs/json_schema_compliance.md +55 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/property-types.markdown +212 -0
  14. data/docs/schema-definition.markdown +180 -0
  15. data/lib/easy_talk/builders/base_builder.rb +4 -2
  16. data/lib/easy_talk/builders/composition_builder.rb +10 -12
  17. data/lib/easy_talk/builders/object_builder.rb +45 -30
  18. data/lib/easy_talk/builders/registry.rb +168 -0
  19. data/lib/easy_talk/builders/typed_array_builder.rb +15 -4
  20. data/lib/easy_talk/configuration.rb +31 -1
  21. data/lib/easy_talk/error_formatter/base.rb +100 -0
  22. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  23. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  24. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  25. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  26. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  27. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  28. data/lib/easy_talk/error_formatter.rb +143 -0
  29. data/lib/easy_talk/errors.rb +2 -0
  30. data/lib/easy_talk/errors_helper.rb +63 -34
  31. data/lib/easy_talk/model.rb +123 -90
  32. data/lib/easy_talk/model_helper.rb +13 -0
  33. data/lib/easy_talk/naming_strategies.rb +20 -0
  34. data/lib/easy_talk/property.rb +16 -94
  35. data/lib/easy_talk/ref_helper.rb +27 -0
  36. data/lib/easy_talk/schema.rb +198 -0
  37. data/lib/easy_talk/schema_definition.rb +7 -1
  38. data/lib/easy_talk/schema_methods.rb +80 -0
  39. data/lib/easy_talk/tools/function_builder.rb +1 -1
  40. data/lib/easy_talk/type_introspection.rb +178 -0
  41. data/lib/easy_talk/types/base_composer.rb +2 -1
  42. data/lib/easy_talk/types/composer.rb +4 -0
  43. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +329 -0
  44. data/lib/easy_talk/validation_adapters/base.rb +144 -0
  45. data/lib/easy_talk/validation_adapters/none_adapter.rb +36 -0
  46. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  47. data/lib/easy_talk/validation_builder.rb +28 -309
  48. data/lib/easy_talk/version.rb +1 -1
  49. data/lib/easy_talk.rb +41 -0
  50. metadata +26 -4
  51. data/docs/404.html +0 -25
  52. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  53. data/easy_talk.gemspec +0 -39
@@ -0,0 +1,212 @@
1
+ ---
2
+ layout: page
3
+ title: Property Types
4
+ permalink: /property-types/
5
+ ---
6
+
7
+ # Property Types
8
+
9
+ EasyTalk supports Ruby's built-in types plus Sorbet-style generic types for more complex schemas.
10
+
11
+ ## Basic Types
12
+
13
+ ### String
14
+
15
+ ```ruby
16
+ property :name, String
17
+ # => { "type": "string" }
18
+ ```
19
+
20
+ **Constraints:**
21
+
22
+ | Constraint | Description | Example |
23
+ |------------|-------------|---------|
24
+ | `min_length` | Minimum length | `min_length: 1` |
25
+ | `max_length` | Maximum length | `max_length: 100` |
26
+ | `pattern` | Regex pattern | `pattern: /^[a-z]+$/` |
27
+ | `format` | JSON Schema format | `format: "email"` |
28
+ | `enum` | Allowed values | `enum: %w[a b c]` |
29
+
30
+ **Common formats:** `email`, `uri`, `uuid`, `date`, `date-time`, `time`, `ipv4`, `ipv6`, `hostname`
31
+
32
+ ### Integer
33
+
34
+ ```ruby
35
+ property :age, Integer
36
+ # => { "type": "integer" }
37
+ ```
38
+
39
+ **Constraints:**
40
+
41
+ | Constraint | Description | Example |
42
+ |------------|-------------|---------|
43
+ | `minimum` | Minimum value (inclusive) | `minimum: 0` |
44
+ | `maximum` | Maximum value (inclusive) | `maximum: 100` |
45
+ | `exclusive_minimum` | Minimum value (exclusive) | `exclusive_minimum: 0` |
46
+ | `exclusive_maximum` | Maximum value (exclusive) | `exclusive_maximum: 100` |
47
+ | `multiple_of` | Must be multiple of | `multiple_of: 5` |
48
+ | `enum` | Allowed values | `enum: [1, 2, 3]` |
49
+
50
+ ### Float / Number
51
+
52
+ ```ruby
53
+ property :price, Float
54
+ # => { "type": "number" }
55
+ ```
56
+
57
+ Supports the same constraints as Integer.
58
+
59
+ ### Boolean
60
+
61
+ ```ruby
62
+ property :active, T::Boolean
63
+ # => { "type": "boolean" }
64
+ ```
65
+
66
+ Note: Use `T::Boolean` (not Ruby's `TrueClass`/`FalseClass`).
67
+
68
+ ## Date and Time Types
69
+
70
+ ### Date
71
+
72
+ ```ruby
73
+ property :birth_date, Date
74
+ # => { "type": "string", "format": "date" }
75
+ ```
76
+
77
+ ### DateTime
78
+
79
+ ```ruby
80
+ property :created_at, DateTime
81
+ # => { "type": "string", "format": "date-time" }
82
+ ```
83
+
84
+ ### Time
85
+
86
+ ```ruby
87
+ property :start_time, Time
88
+ # => { "type": "string", "format": "time" }
89
+ ```
90
+
91
+ ## Generic Types
92
+
93
+ EasyTalk uses Sorbet-style generics for complex types.
94
+
95
+ ### Arrays
96
+
97
+ ```ruby
98
+ property :tags, T::Array[String]
99
+ # => { "type": "array", "items": { "type": "string" } }
100
+ ```
101
+
102
+ **Array Constraints:**
103
+
104
+ | Constraint | Description | Example |
105
+ |------------|-------------|---------|
106
+ | `min_items` | Minimum array length | `min_items: 1` |
107
+ | `max_items` | Maximum array length | `max_items: 10` |
108
+ | `unique_items` | All items must be unique | `unique_items: true` |
109
+
110
+ ```ruby
111
+ property :scores, T::Array[Integer], min_items: 1, max_items: 5
112
+ ```
113
+
114
+ ### Nullable Types
115
+
116
+ Use `T.nilable` to allow null values:
117
+
118
+ ```ruby
119
+ property :nickname, T.nilable(String)
120
+ # => { "anyOf": [{ "type": "string" }, { "type": "null" }] }
121
+ ```
122
+
123
+ **Note:** `T.nilable` makes the property nullable but still required. To make it optional as well:
124
+
125
+ ```ruby
126
+ property :nickname, T.nilable(String), optional: true
127
+ ```
128
+
129
+ Or use the helper method:
130
+
131
+ ```ruby
132
+ nullable_optional_property :nickname, String
133
+ ```
134
+
135
+ ## Nested Models
136
+
137
+ Reference other EasyTalk models directly:
138
+
139
+ ```ruby
140
+ class Address
141
+ include EasyTalk::Model
142
+ define_schema do
143
+ property :street, String
144
+ property :city, String
145
+ end
146
+ end
147
+
148
+ class User
149
+ include EasyTalk::Model
150
+ define_schema do
151
+ property :name, String
152
+ property :address, Address # Nested model
153
+ end
154
+ end
155
+ ```
156
+
157
+ Arrays of models:
158
+
159
+ ```ruby
160
+ property :addresses, T::Array[Address]
161
+ ```
162
+
163
+ ## Composition Types
164
+
165
+ ### OneOf
166
+
167
+ Exactly one schema must match:
168
+
169
+ ```ruby
170
+ property :contact, T::OneOf[Email, Phone]
171
+ ```
172
+
173
+ ### AnyOf
174
+
175
+ At least one schema must match:
176
+
177
+ ```ruby
178
+ property :identifier, T::AnyOf[UserId, Email, Username]
179
+ ```
180
+
181
+ ### AllOf
182
+
183
+ All schemas must match (for combining schemas):
184
+
185
+ ```ruby
186
+ property :profile, T::AllOf[BasicInfo, ExtendedInfo]
187
+ ```
188
+
189
+ ## Null Type
190
+
191
+ For explicit null-only values:
192
+
193
+ ```ruby
194
+ property :deprecated_field, NilClass
195
+ # => { "type": "null" }
196
+ ```
197
+
198
+ ## Type Summary
199
+
200
+ | Ruby Type | JSON Schema Type |
201
+ |-----------|------------------|
202
+ | `String` | `"string"` |
203
+ | `Integer` | `"integer"` |
204
+ | `Float` | `"number"` |
205
+ | `T::Boolean` | `"boolean"` |
206
+ | `Date` | `"string"` + `"date"` format |
207
+ | `DateTime` | `"string"` + `"date-time"` format |
208
+ | `Time` | `"string"` + `"time"` format |
209
+ | `T::Array[T]` | `"array"` |
210
+ | `T.nilable(T)` | `anyOf` with null |
211
+ | `NilClass` | `"null"` |
212
+ | Model class | `"object"` (inline or `$ref`) |
@@ -0,0 +1,180 @@
1
+ ---
2
+ layout: page
3
+ title: Schema Definition
4
+ permalink: /schema-definition/
5
+ ---
6
+
7
+ # Schema Definition
8
+
9
+ The `define_schema` block is where you declare your model's structure. It provides a clean DSL for defining JSON Schema properties and metadata.
10
+
11
+ ## Basic Structure
12
+
13
+ ```ruby
14
+ class MyModel
15
+ include EasyTalk::Model
16
+
17
+ define_schema do
18
+ title "Model Title"
19
+ description "What this model represents"
20
+
21
+ property :field_name, Type, constraints...
22
+ end
23
+ end
24
+ ```
25
+
26
+ ## Schema Metadata
27
+
28
+ ### title
29
+
30
+ Sets the schema title (appears in JSON Schema output):
31
+
32
+ ```ruby
33
+ define_schema do
34
+ title "User Account"
35
+ end
36
+ ```
37
+
38
+ ### description
39
+
40
+ Adds a description to the schema:
41
+
42
+ ```ruby
43
+ define_schema do
44
+ description "Represents a user account in the system"
45
+ end
46
+ ```
47
+
48
+ ## Defining Properties
49
+
50
+ The `property` method defines a schema property:
51
+
52
+ ```ruby
53
+ property :name, Type, option: value, ...
54
+ ```
55
+
56
+ ### Required vs Optional
57
+
58
+ By default, all properties are **required**. Use `optional: true` to make a property optional:
59
+
60
+ ```ruby
61
+ define_schema do
62
+ property :name, String # Required
63
+ property :nickname, String, optional: true # Optional
64
+ end
65
+ ```
66
+
67
+ ### Property Titles and Descriptions
68
+
69
+ Add metadata to individual properties:
70
+
71
+ ```ruby
72
+ property :email, String,
73
+ title: "Email Address",
74
+ description: "The user's primary email"
75
+ ```
76
+
77
+ ### Property Renaming
78
+
79
+ Use `:as` to rename a property in the JSON Schema output:
80
+
81
+ ```ruby
82
+ property :created_at, String, as: :createdAt
83
+ ```
84
+
85
+ This creates a property named `createdAt` in the schema while keeping `created_at` as the Ruby attribute.
86
+
87
+ ## Type Constraints
88
+
89
+ Different types support different constraints. See [Property Types](property-types) for the full list.
90
+
91
+ ### String Constraints
92
+
93
+ ```ruby
94
+ property :username, String,
95
+ min_length: 3,
96
+ max_length: 20,
97
+ pattern: /^[a-z0-9_]+$/
98
+ ```
99
+
100
+ ### Numeric Constraints
101
+
102
+ ```ruby
103
+ property :age, Integer,
104
+ minimum: 0,
105
+ maximum: 150
106
+
107
+ property :price, Float,
108
+ exclusive_minimum: 0
109
+ ```
110
+
111
+ ### Enum Values
112
+
113
+ ```ruby
114
+ property :status, String, enum: %w[active inactive pending]
115
+ ```
116
+
117
+ ## Composition
118
+
119
+ ### compose
120
+
121
+ Use `compose` to include schemas from other models:
122
+
123
+ ```ruby
124
+ class FullProfile
125
+ include EasyTalk::Model
126
+
127
+ define_schema do
128
+ compose T::AllOf[BasicInfo, ContactInfo, Preferences]
129
+ end
130
+ end
131
+ ```
132
+
133
+ ### Composition Types
134
+
135
+ - `T::AllOf[A, B]` - Must match all schemas
136
+ - `T::AnyOf[A, B]` - Must match at least one schema
137
+ - `T::OneOf[A, B]` - Must match exactly one schema
138
+
139
+ ## Configuration Options
140
+
141
+ ### Per-Model Validation Control
142
+
143
+ Disable automatic validations for a specific model:
144
+
145
+ ```ruby
146
+ define_schema(validations: false) do
147
+ property :data, String
148
+ end
149
+ ```
150
+
151
+ ### Per-Property Validation Control
152
+
153
+ Disable validation for specific properties:
154
+
155
+ ```ruby
156
+ property :legacy_field, String, validate: false
157
+ ```
158
+
159
+ ## Example: Complete Model
160
+
161
+ ```ruby
162
+ class Product
163
+ include EasyTalk::Model
164
+
165
+ define_schema do
166
+ title "Product"
167
+ description "A product in the catalog"
168
+
169
+ property :id, String, format: "uuid"
170
+ property :name, String, min_length: 1, max_length: 100
171
+ property :description, String, optional: true
172
+ property :price, Float, minimum: 0
173
+ property :currency, String, enum: %w[USD EUR GBP], default: "USD"
174
+ property :category, String
175
+ property :tags, T::Array[String], optional: true
176
+ property :active, T::Boolean, default: true
177
+ property :created_at, DateTime, as: :createdAt
178
+ end
179
+ end
180
+ ```
@@ -12,7 +12,9 @@ module EasyTalk
12
12
  COMMON_OPTIONS = {
13
13
  title: { type: T.nilable(String), key: :title },
14
14
  description: { type: T.nilable(String), key: :description },
15
- optional: { type: T.nilable(T::Boolean), key: :optional }
15
+ optional: { type: T.nilable(T::Boolean), key: :optional },
16
+ as: { type: T.nilable(T.any(String, Symbol)), key: :as },
17
+ validate: { type: T.nilable(T::Boolean), key: :validate }
16
18
  }.freeze
17
19
 
18
20
  attr_reader :property_name, :schema, :options
@@ -42,7 +44,7 @@ module EasyTalk
42
44
  # Builds the schema object based on the provided options.
43
45
  sig { returns(T::Hash[Symbol, T.untyped]) }
44
46
  def build
45
- @valid_options.each_with_object(schema) do |(constraint_name, value), obj|
47
+ @valid_options.except(:ref).each_with_object(schema) do |(constraint_name, value), obj|
46
48
  next if @options[constraint_name].nil?
47
49
 
48
50
  # Use our centralized validation
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'collection_helpers'
4
+ require_relative '../ref_helper'
4
5
 
5
6
  module EasyTalk
6
7
  module Builders
@@ -15,17 +16,18 @@ module EasyTalk
15
16
  'OneOfBuilder' => 'oneOf'
16
17
  }.freeze
17
18
 
18
- sig { params(name: Symbol, type: T.untyped, _constraints: Hash).void }
19
+ sig { params(name: Symbol, type: T.untyped, constraints: Hash).void }
19
20
  # Initializes a new instance of the CompositionBuilder class.
20
21
  #
21
22
  # @param name [Symbol] The name of the composition.
22
23
  # @param type [Class] The type of the composition.
23
- # @param _constraints [Hash] The constraints for the composition (not used in this method).
24
- def initialize(name, type, _constraints)
24
+ # @param constraints [Hash] The constraints for the composition.
25
+ def initialize(name, type, constraints)
25
26
  @composer_type = self.class.name.split('::').last
26
27
  @name = name
27
28
  @type = type
28
29
  @context = {}
30
+ @constraints = constraints
29
31
  end
30
32
 
31
33
  # Builds the composed JSON schema.
@@ -50,17 +52,13 @@ module EasyTalk
50
52
  # @return [Array<Hash>] The array of schemas.
51
53
  def schemas
52
54
  items.map do |type|
53
- if type.respond_to?(:schema)
55
+ if EasyTalk::RefHelper.should_use_ref?(type, @constraints)
56
+ EasyTalk::RefHelper.build_ref_schema(type, @constraints)
57
+ elsif type.respond_to?(:schema)
54
58
  type.schema
55
59
  else
56
- # Map Float type to 'number' in JSON Schema
57
- json_type = case type.to_s
58
- when 'Float', 'BigDecimal'
59
- 'number'
60
- else
61
- type.to_s.downcase
62
- end
63
- { type: json_type }
60
+ # Map Ruby type to JSON Schema type
61
+ { type: TypeIntrospection.json_schema_type(type) }
64
62
  end
65
63
  end
66
64
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'base_builder'
4
+ require_relative '../model_helper'
4
5
 
5
6
  module EasyTalk
6
7
  module Builders
@@ -33,8 +34,8 @@ module EasyTalk
33
34
  def initialize(schema_definition)
34
35
  # Keep a reference to the original schema definition
35
36
  @schema_definition = schema_definition
36
- # Duplicate the raw schema hash so we can mutate it safely
37
- @original_schema = schema_definition.schema.dup
37
+ # Deep duplicate the raw schema hash so we can mutate it safely
38
+ @original_schema = deep_dup(schema_definition.schema)
38
39
 
39
40
  # We'll collect required property names in this Set
40
41
  @required_properties = Set.new
@@ -56,6 +57,24 @@ module EasyTalk
56
57
 
57
58
  private
58
59
 
60
+ ##
61
+ # Deep duplicates a hash, including nested hashes.
62
+ # This prevents mutations from leaking back to the original schema.
63
+ #
64
+ def deep_dup(obj)
65
+ case obj
66
+ when Hash
67
+ obj.transform_values { |v| deep_dup(v) }
68
+ when Array
69
+ obj.map { |v| deep_dup(v) }
70
+ when Class, Module
71
+ # Don't duplicate Class or Module objects - they represent types
72
+ obj
73
+ else
74
+ obj.duplicable? ? obj.dup : obj
75
+ end
76
+ end
77
+
59
78
  ##
60
79
  # Main aggregator: merges the top-level schema keys (like :properties, :subschemas)
61
80
  # into a single hash that we'll feed to BaseBuilder.
@@ -99,16 +118,18 @@ module EasyTalk
99
118
  # Cache with a key based on property name and its full configuration
100
119
  @properties_cache ||= {}
101
120
 
102
- properties_hash.each_with_object({}) do |(prop_name, prop_options), result|
103
- cache_key = [prop_name, prop_options].hash
121
+ properties_hash.each_with_object({}) do |(original_name, prop_options), result|
122
+ # Use :as constraint for property name without mutating original constraints
123
+ property_name = (prop_options[:constraints][:as] || original_name).to_sym
124
+ cache_key = [property_name, prop_options].hash
104
125
 
105
126
  # Use cache if the exact property and configuration have been processed before
106
127
  @properties_cache[cache_key] ||= begin
107
- mark_required_unless_optional(prop_name, prop_options)
108
- build_property(prop_name, prop_options)
128
+ mark_required_unless_optional(property_name, prop_options)
129
+ build_property(property_name, prop_options)
109
130
  end
110
131
 
111
- result[prop_name] = @properties_cache[cache_key]
132
+ result[property_name] = @properties_cache[cache_key]
112
133
  end
113
134
  end
114
135
 
@@ -145,8 +166,8 @@ module EasyTalk
145
166
 
146
167
  # Memoize so we only build each property once
147
168
  @property_cache[prop_name] ||= begin
148
- # Remove optional constraints from the property
149
- constraints = prop_options[:constraints].except(:optional)
169
+ # Remove internal constraints that shouldn't be passed to Property
170
+ constraints = prop_options[:constraints].except(:optional, :as)
150
171
  prop_type = prop_options[:type]
151
172
 
152
173
  # Track models that will use $ref for later $defs generation
@@ -165,10 +186,11 @@ module EasyTalk
165
186
  # Check if this type should use $ref
166
187
  if should_collect_ref?(prop_type, constraints)
167
188
  @ref_models.add(prop_type)
189
+ elsif prop_type.is_a?(EasyTalk::Types::Composer)
190
+ collect_ref_models(prop_type.items, constraints)
168
191
  # Handle typed arrays with EasyTalk model items
169
- elsif typed_array_with_model?(prop_type)
170
- inner_type = prop_type.type.raw_type
171
- @ref_models.add(inner_type) if should_collect_ref?(inner_type, constraints)
192
+ elsif typed_array?(prop_type)
193
+ extract_inner_types(prop_type).each { |inner_type| collect_ref_models(inner_type, constraints) }
172
194
  # Handle nilable types
173
195
  elsif nilable_with_model?(prop_type)
174
196
  actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
@@ -180,7 +202,7 @@ module EasyTalk
180
202
  # Determines if a type should be collected for $ref based on config and constraints.
181
203
  #
182
204
  def should_collect_ref?(check_type, constraints)
183
- return false unless easytalk_model?(check_type)
205
+ return false unless ModelHelper.easytalk_model?(check_type)
184
206
 
185
207
  # Per-property constraint takes precedence
186
208
  return constraints[:ref] if constraints.key?(:ref)
@@ -189,25 +211,18 @@ module EasyTalk
189
211
  EasyTalk.configuration.use_refs
190
212
  end
191
213
 
192
- ##
193
- # Checks if a type is an EasyTalk model.
194
- #
195
- def easytalk_model?(check_type)
196
- check_type.is_a?(Class) &&
197
- check_type.respond_to?(:schema) &&
198
- check_type.respond_to?(:ref_template) &&
199
- defined?(EasyTalk::Model) &&
200
- check_type.include?(EasyTalk::Model)
214
+ def typed_array?(prop_type)
215
+ prop_type.is_a?(T::Types::TypedArray)
201
216
  end
202
217
 
203
- ##
204
- # Checks if type is a typed array containing an EasyTalk model.
205
- #
206
- def typed_array_with_model?(prop_type)
207
- return false unless prop_type.is_a?(T::Types::TypedArray)
218
+ def extract_inner_types(prop_type)
219
+ return [] unless typed_array?(prop_type)
208
220
 
209
- inner_type = prop_type.type.raw_type
210
- easytalk_model?(inner_type)
221
+ if prop_type.type.is_a?(EasyTalk::Types::Composer)
222
+ prop_type.type.items
223
+ else
224
+ [prop_type.type.raw_type]
225
+ end
211
226
  end
212
227
 
213
228
  ##
@@ -219,7 +234,7 @@ module EasyTalk
219
234
  return false unless prop_type.types.any? { |t| t.raw_type == NilClass }
220
235
 
221
236
  actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
222
- easytalk_model?(actual_type)
237
+ ModelHelper.easytalk_model?(actual_type)
223
238
  end
224
239
 
225
240
  ##