easy_talk 3.0.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/.yardopts +13 -0
- data/CHANGELOG.md +105 -0
- data/README.md +1268 -40
- data/Rakefile +27 -0
- data/docs/.gitignore +1 -0
- data/docs/about.markdown +28 -8
- data/docs/getting-started.markdown +102 -0
- data/docs/index.markdown +51 -4
- data/docs/json_schema_compliance.md +55 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +4 -2
- data/lib/easy_talk/builders/composition_builder.rb +10 -12
- data/lib/easy_talk/builders/object_builder.rb +119 -10
- data/lib/easy_talk/builders/registry.rb +168 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +20 -6
- data/lib/easy_talk/configuration.rb +51 -1
- data/lib/easy_talk/error_formatter/base.rb +100 -0
- data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
- data/lib/easy_talk/error_formatter/flat.rb +38 -0
- data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
- data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
- data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
- data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
- data/lib/easy_talk/error_formatter.rb +143 -0
- data/lib/easy_talk/errors.rb +2 -0
- data/lib/easy_talk/errors_helper.rb +63 -34
- data/lib/easy_talk/keywords.rb +2 -0
- data/lib/easy_talk/model.rb +125 -41
- data/lib/easy_talk/model_helper.rb +13 -0
- data/lib/easy_talk/naming_strategies.rb +20 -0
- data/lib/easy_talk/property.rb +32 -44
- data/lib/easy_talk/ref_helper.rb +27 -0
- data/lib/easy_talk/schema.rb +198 -0
- data/lib/easy_talk/schema_definition.rb +7 -1
- data/lib/easy_talk/schema_methods.rb +80 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +178 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +329 -0
- data/lib/easy_talk/validation_adapters/base.rb +144 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +36 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +28 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +41 -0
- metadata +28 -6
- data/docs/404.html +0 -25
- data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
- 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,
|
|
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
|
|
24
|
-
def initialize(name, type,
|
|
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
|
|
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
|
|
57
|
-
|
|
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,12 +34,15 @@ 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
|
-
#
|
|
37
|
-
@original_schema = schema_definition.schema
|
|
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
|
|
41
42
|
|
|
43
|
+
# Collect models that are referenced via $ref for $defs generation
|
|
44
|
+
@ref_models = Set.new
|
|
45
|
+
|
|
42
46
|
# Usually the name is a string (class name). Fallback to :klass if nil.
|
|
43
47
|
name_for_builder = schema_definition.name ? schema_definition.name.to_sym : :klass
|
|
44
48
|
|
|
@@ -53,6 +57,24 @@ module EasyTalk
|
|
|
53
57
|
|
|
54
58
|
private
|
|
55
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
|
+
|
|
56
78
|
##
|
|
57
79
|
# Main aggregator: merges the top-level schema keys (like :properties, :subschemas)
|
|
58
80
|
# into a single hash that we'll feed to BaseBuilder.
|
|
@@ -60,12 +82,20 @@ module EasyTalk
|
|
|
60
82
|
# Start with a copy of the raw schema
|
|
61
83
|
merged = @original_schema.dup
|
|
62
84
|
|
|
85
|
+
# Remove schema_version and schema_id as they're handled separately in json_schema output
|
|
86
|
+
merged.delete(:schema_version)
|
|
87
|
+
merged.delete(:schema_id)
|
|
88
|
+
|
|
63
89
|
# Extract and build sub-schemas first (handles allOf/anyOf/oneOf references, etc.)
|
|
64
90
|
process_subschemas(merged)
|
|
65
91
|
|
|
66
92
|
# Build :properties into a final form (and find "required" props)
|
|
93
|
+
# This also collects models that use $ref into @ref_models
|
|
67
94
|
merged[:properties] = build_properties(merged.delete(:properties))
|
|
68
95
|
|
|
96
|
+
# Add $defs for any models that are referenced via $ref
|
|
97
|
+
add_ref_model_defs(merged) if @ref_models.any?
|
|
98
|
+
|
|
69
99
|
# Populate the final "required" array from @required_properties
|
|
70
100
|
merged[:required] = @required_properties.to_a if @required_properties.any?
|
|
71
101
|
|
|
@@ -88,16 +118,18 @@ module EasyTalk
|
|
|
88
118
|
# Cache with a key based on property name and its full configuration
|
|
89
119
|
@properties_cache ||= {}
|
|
90
120
|
|
|
91
|
-
properties_hash.each_with_object({}) do |(
|
|
92
|
-
|
|
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
|
|
93
125
|
|
|
94
126
|
# Use cache if the exact property and configuration have been processed before
|
|
95
127
|
@properties_cache[cache_key] ||= begin
|
|
96
|
-
mark_required_unless_optional(
|
|
97
|
-
build_property(
|
|
128
|
+
mark_required_unless_optional(property_name, prop_options)
|
|
129
|
+
build_property(property_name, prop_options)
|
|
98
130
|
end
|
|
99
131
|
|
|
100
|
-
result[
|
|
132
|
+
result[property_name] = @properties_cache[cache_key]
|
|
101
133
|
end
|
|
102
134
|
end
|
|
103
135
|
|
|
@@ -127,17 +159,94 @@ module EasyTalk
|
|
|
127
159
|
##
|
|
128
160
|
# Builds a single property. Could be a nested schema if it has sub-properties,
|
|
129
161
|
# or a standard scalar property (String, Integer, etc.).
|
|
162
|
+
# Also tracks EasyTalk models that should be added to $defs when using $ref.
|
|
130
163
|
#
|
|
131
164
|
def build_property(prop_name, prop_options)
|
|
132
165
|
@property_cache ||= {}
|
|
133
166
|
|
|
134
167
|
# Memoize so we only build each property once
|
|
135
168
|
@property_cache[prop_name] ||= begin
|
|
136
|
-
# Remove
|
|
137
|
-
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)
|
|
171
|
+
prop_type = prop_options[:type]
|
|
172
|
+
|
|
173
|
+
# Track models that will use $ref for later $defs generation
|
|
174
|
+
collect_ref_models(prop_type, constraints)
|
|
175
|
+
|
|
138
176
|
# Normal property: e.g. { type: String, constraints: {...} }
|
|
139
|
-
Property.new(prop_name,
|
|
177
|
+
Property.new(prop_name, prop_type, constraints)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
##
|
|
182
|
+
# Collects EasyTalk models that will be referenced via $ref.
|
|
183
|
+
# These models need to be added to $defs in the final schema.
|
|
184
|
+
#
|
|
185
|
+
def collect_ref_models(prop_type, constraints)
|
|
186
|
+
# Check if this type should use $ref
|
|
187
|
+
if should_collect_ref?(prop_type, constraints)
|
|
188
|
+
@ref_models.add(prop_type)
|
|
189
|
+
elsif prop_type.is_a?(EasyTalk::Types::Composer)
|
|
190
|
+
collect_ref_models(prop_type.items, constraints)
|
|
191
|
+
# Handle typed arrays with EasyTalk model items
|
|
192
|
+
elsif typed_array?(prop_type)
|
|
193
|
+
extract_inner_types(prop_type).each { |inner_type| collect_ref_models(inner_type, constraints) }
|
|
194
|
+
# Handle nilable types
|
|
195
|
+
elsif nilable_with_model?(prop_type)
|
|
196
|
+
actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
|
|
197
|
+
@ref_models.add(actual_type) if should_collect_ref?(actual_type, constraints)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
##
|
|
202
|
+
# Determines if a type should be collected for $ref based on config and constraints.
|
|
203
|
+
#
|
|
204
|
+
def should_collect_ref?(check_type, constraints)
|
|
205
|
+
return false unless ModelHelper.easytalk_model?(check_type)
|
|
206
|
+
|
|
207
|
+
# Per-property constraint takes precedence
|
|
208
|
+
return constraints[:ref] if constraints.key?(:ref)
|
|
209
|
+
|
|
210
|
+
# Fall back to global configuration
|
|
211
|
+
EasyTalk.configuration.use_refs
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def typed_array?(prop_type)
|
|
215
|
+
prop_type.is_a?(T::Types::TypedArray)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def extract_inner_types(prop_type)
|
|
219
|
+
return [] unless typed_array?(prop_type)
|
|
220
|
+
|
|
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
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
##
|
|
229
|
+
# Checks if type is nilable and contains an EasyTalk model.
|
|
230
|
+
#
|
|
231
|
+
def nilable_with_model?(prop_type)
|
|
232
|
+
return false unless prop_type.respond_to?(:types)
|
|
233
|
+
return false unless prop_type.types.all? { |t| t.respond_to?(:raw_type) }
|
|
234
|
+
return false unless prop_type.types.any? { |t| t.raw_type == NilClass }
|
|
235
|
+
|
|
236
|
+
actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
|
|
237
|
+
ModelHelper.easytalk_model?(actual_type)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
##
|
|
241
|
+
# Adds $defs entries for all collected ref models.
|
|
242
|
+
#
|
|
243
|
+
def add_ref_model_defs(schema_hash)
|
|
244
|
+
definitions = @ref_models.each_with_object({}) do |model, acc|
|
|
245
|
+
acc[model.name] = model.schema
|
|
140
246
|
end
|
|
247
|
+
|
|
248
|
+
existing_defs = schema_hash[:defs] || {}
|
|
249
|
+
schema_hash[:defs] = existing_defs.merge(definitions)
|
|
141
250
|
end
|
|
142
251
|
|
|
143
252
|
##
|