dry_validation_openapi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +245 -0
- data/lib/dry_validation_openapi/contract_parser.rb +200 -0
- data/lib/dry_validation_openapi/convertable.rb +41 -0
- data/lib/dry_validation_openapi/schema_builder.rb +125 -0
- data/lib/dry_validation_openapi/version.rb +3 -0
- data/lib/dry_validation_openapi.rb +8 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 29d8e1b449631809fa220bb41125221b44443097ae83533a7b71c97cc88d6e7e
|
|
4
|
+
data.tar.gz: d070b609ff5ae658efe41c6ede707c4520473195819dd52b918b6e39779a5297
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a58f1f2e0ad933889dbd367d23a148ba9dc1ed537e9d999ba6cfeccefc01b9e59e28b66d6c7f85b7392bd4357f8d07382785e712afda8267bfc1c7b7a539f776
|
|
7
|
+
data.tar.gz: 2fcb73cc379258c609bec33e59133cf398161930677ec1b46da2626188288acf4d9b748c5d295cf221c464b7d0ef59d89d2e2d46ee06431c0c96f35cc320e71d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2025-12-11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial release
|
|
12
|
+
- Support for required and optional fields
|
|
13
|
+
- Support for basic types: string, integer, float, decimal, boolean
|
|
14
|
+
- Type format specifications (int32, double, float, date, date-time)
|
|
15
|
+
- Support for array types with item schemas
|
|
16
|
+
- Support for nested objects/hashes with properties
|
|
17
|
+
- Support for arrays of objects with nested schemas
|
|
18
|
+
- Support for multiple nested objects at the same level
|
|
19
|
+
- Support for both `.value()` and `.filled()` predicates
|
|
20
|
+
- `Convertable` module for easy integration with dry-validation contracts
|
|
21
|
+
- Static parsing using Ruby's built-in Ripper parser
|
|
22
|
+
- Zero runtime dependencies (besides Ruby standard library)
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
- `extend DryValidationOpenapi::Convertable` to add `.open_api_schema` method to contracts
|
|
26
|
+
- Automatic parent tracking for nested schemas
|
|
27
|
+
- OpenAPI 3.0 compatible schema generation
|
|
28
|
+
- Works with rswag and other OpenAPI tools
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 [Your Name]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# DryValidationOpenapi
|
|
2
|
+
|
|
3
|
+
Automatically generate OpenAPI 3.0 schemas from [dry-validation](https://dry-rb.org/gems/dry-validation/) contracts for use with [rswag](https://github.com/rswag/rswag) and other OpenAPI tools.
|
|
4
|
+
|
|
5
|
+
Instead of manually writing OpenAPI schemas for your API documentation, this gem extracts type information directly from your dry-validation contract definitions using static parsing.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 🎯 **Zero runtime overhead** - Uses static parsing, no contract instantiation needed
|
|
10
|
+
- 🔄 **Automatic conversion** - Converts dry-validation types to OpenAPI types with formats
|
|
11
|
+
- 🪆 **Nested schemas** - Handles nested objects and arrays of objects
|
|
12
|
+
- 📝 **Simple API** - Just `extend` your contract and call `.open_api_schema`
|
|
13
|
+
- ✅ **Type formats** - Includes OpenAPI format specifications (int32, double, date-time, etc.)
|
|
14
|
+
|
|
15
|
+
## Supported Features
|
|
16
|
+
|
|
17
|
+
### ✅ Currently Supported
|
|
18
|
+
|
|
19
|
+
- [x] Required and optional fields
|
|
20
|
+
- [x] Basic types: string, integer, float, decimal, boolean
|
|
21
|
+
- [x] Type formats: int32, double, float, date, date-time
|
|
22
|
+
- [x] Array types with item schemas
|
|
23
|
+
- [x] Nested objects/hashes with properties
|
|
24
|
+
- [x] Arrays of objects with nested schemas
|
|
25
|
+
- [x] Multiple nested objects at the same level
|
|
26
|
+
- [x] `.value()` and `.filled()` predicates
|
|
27
|
+
|
|
28
|
+
### 📋 Not Yet Implemented
|
|
29
|
+
|
|
30
|
+
- [ ] Custom type formats (email, uuid, etc.)
|
|
31
|
+
- [ ] Descriptions and examples
|
|
32
|
+
- [ ] Min/max constraints
|
|
33
|
+
- [ ] Enums
|
|
34
|
+
- [ ] Custom rules/validations
|
|
35
|
+
- [ ] Pattern validations
|
|
36
|
+
- [ ] Maybe/nil handling
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Add this line to your application's Gemfile:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
gem 'dry_validation_openapi'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
And then execute:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or install it yourself as:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
gem install dry_validation_openapi
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
### Basic Usage
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
require 'dry_validation_openapi'
|
|
64
|
+
|
|
65
|
+
class CreateUserContract < Dry::Validation::Contract
|
|
66
|
+
extend DryValidationOpenapi::Convertable
|
|
67
|
+
|
|
68
|
+
params do
|
|
69
|
+
required(:email).value(:string)
|
|
70
|
+
required(:age).value(:integer)
|
|
71
|
+
optional(:name).value(:string)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Generate OpenAPI schema
|
|
76
|
+
schema = CreateUserContract.open_api_schema
|
|
77
|
+
# => {
|
|
78
|
+
# type: :object,
|
|
79
|
+
# properties: {
|
|
80
|
+
# email: { type: :string },
|
|
81
|
+
# age: { type: :integer, format: 'int32' },
|
|
82
|
+
# name: { type: :string }
|
|
83
|
+
# },
|
|
84
|
+
# required: ['email', 'age']
|
|
85
|
+
# }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With rswag
|
|
89
|
+
|
|
90
|
+
Use in your rswag request specs:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# spec/requests/users_spec.rb
|
|
94
|
+
require 'swagger_helper'
|
|
95
|
+
|
|
96
|
+
RSpec.describe 'Users API' do
|
|
97
|
+
path '/users' do
|
|
98
|
+
post 'Creates a user' do
|
|
99
|
+
tags 'Users'
|
|
100
|
+
consumes 'application/json'
|
|
101
|
+
|
|
102
|
+
parameter name: :body,
|
|
103
|
+
in: :body,
|
|
104
|
+
required: true,
|
|
105
|
+
schema: CreateUserContract.open_api_schema
|
|
106
|
+
|
|
107
|
+
response '201', 'user created' do
|
|
108
|
+
let(:body) { { email: 'user@example.com', age: 25 } }
|
|
109
|
+
run_test!
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Nested Objects
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class CreateOrderContract < Dry::Validation::Contract
|
|
120
|
+
extend DryValidationOpenapi::Convertable
|
|
121
|
+
|
|
122
|
+
params do
|
|
123
|
+
required(:order_id).value(:string)
|
|
124
|
+
required(:customer).hash do
|
|
125
|
+
required(:name).value(:string)
|
|
126
|
+
required(:email).value(:string)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
CreateOrderContract.open_api_schema
|
|
132
|
+
# => {
|
|
133
|
+
# type: :object,
|
|
134
|
+
# properties: {
|
|
135
|
+
# order_id: { type: :string },
|
|
136
|
+
# customer: {
|
|
137
|
+
# type: :object,
|
|
138
|
+
# properties: {
|
|
139
|
+
# name: { type: :string },
|
|
140
|
+
# email: { type: :string }
|
|
141
|
+
# },
|
|
142
|
+
# required: ['name', 'email']
|
|
143
|
+
# }
|
|
144
|
+
# },
|
|
145
|
+
# required: ['order_id', 'customer']
|
|
146
|
+
# }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Arrays of Objects
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class CreateInvoiceContract < Dry::Validation::Contract
|
|
153
|
+
extend DryValidationOpenapi::Convertable
|
|
154
|
+
|
|
155
|
+
params do
|
|
156
|
+
required(:issue_date).value(:time)
|
|
157
|
+
required(:line_items).array(:hash) do
|
|
158
|
+
required(:description).filled(:string)
|
|
159
|
+
required(:amount).filled(:decimal)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
CreateInvoiceContract.open_api_schema
|
|
165
|
+
# => {
|
|
166
|
+
# type: :object,
|
|
167
|
+
# properties: {
|
|
168
|
+
# issue_date: { type: :string, format: 'date-time' },
|
|
169
|
+
# line_items: {
|
|
170
|
+
# type: :array,
|
|
171
|
+
# items: {
|
|
172
|
+
# type: :object,
|
|
173
|
+
# properties: {
|
|
174
|
+
# description: { type: :string },
|
|
175
|
+
# amount: { type: :number, format: 'double' }
|
|
176
|
+
# },
|
|
177
|
+
# required: ['description', 'amount']
|
|
178
|
+
# }
|
|
179
|
+
# }
|
|
180
|
+
# },
|
|
181
|
+
# required: ['issue_date', 'line_items']
|
|
182
|
+
# }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Type Mappings
|
|
186
|
+
|
|
187
|
+
| dry-validation type | OpenAPI type | OpenAPI format |
|
|
188
|
+
|---------------------|--------------|----------------|
|
|
189
|
+
| `:string` | `string` | - |
|
|
190
|
+
| `:integer`, `:int` | `integer` | `int32` |
|
|
191
|
+
| `:float` | `number` | `float` |
|
|
192
|
+
| `:decimal`, `:number` | `number` | `double` |
|
|
193
|
+
| `:bool`, `:boolean` | `boolean` | - |
|
|
194
|
+
| `:date` | `string` | `date` |
|
|
195
|
+
| `:time`, `:date_time` | `string` | `date-time` |
|
|
196
|
+
| `:hash` | `object` | - |
|
|
197
|
+
| `:array` | `array` | - |
|
|
198
|
+
|
|
199
|
+
## How It Works
|
|
200
|
+
|
|
201
|
+
This gem uses **static parsing** rather than runtime introspection:
|
|
202
|
+
|
|
203
|
+
1. **Reads the contract file** as plain text using the contract's source location
|
|
204
|
+
2. **Parses to AST** using Ruby's built-in Ripper parser
|
|
205
|
+
3. **Walks the AST** to find the `params do...end` block
|
|
206
|
+
4. **Extracts field definitions** (required/optional, names, types, nesting)
|
|
207
|
+
5. **Converts to OpenAPI schema** format
|
|
208
|
+
|
|
209
|
+
This approach is:
|
|
210
|
+
- ✅ Fast and lightweight (no contract instantiation)
|
|
211
|
+
- ✅ Simple to understand (just parsing Ruby code)
|
|
212
|
+
- ✅ Works without dry-validation loaded
|
|
213
|
+
- ⚠️ Cannot handle dynamically-generated contracts (rare in practice)
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
After checking out the repo, run:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
bundle install
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Run the tests:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
bundle exec rspec
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Build the gem:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
gem build dry_validation_openapi.gemspec
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Contributing
|
|
236
|
+
|
|
237
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/troptropcontent/dry_validation_openapi.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
242
|
+
|
|
243
|
+
## Credits
|
|
244
|
+
|
|
245
|
+
Created to solve the problem of maintaining duplicate schema definitions in dry-validation contracts and OpenAPI documentation.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
require 'ripper'
|
|
2
|
+
|
|
3
|
+
module DryValidationOpenapi
|
|
4
|
+
class ContractParser
|
|
5
|
+
def self.parse_file(file_path)
|
|
6
|
+
source = File.read(file_path)
|
|
7
|
+
new(source).parse
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(source)
|
|
11
|
+
@source = source
|
|
12
|
+
@schema_definitions = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse
|
|
16
|
+
sexp = Ripper.sexp(@source)
|
|
17
|
+
find_params_block(sexp)
|
|
18
|
+
@schema_definitions
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_params_block(node)
|
|
24
|
+
return unless node.is_a?(Array)
|
|
25
|
+
|
|
26
|
+
# Look for method_add_block with params
|
|
27
|
+
if node[0] == :method_add_block
|
|
28
|
+
method_call = node[1]
|
|
29
|
+
if method_call.is_a?(Array) && is_params_call?(method_call)
|
|
30
|
+
# Found the params block
|
|
31
|
+
do_block = node[2]
|
|
32
|
+
if do_block && do_block[0] == :do_block
|
|
33
|
+
extract_params_body(do_block)
|
|
34
|
+
end
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Recursively search
|
|
40
|
+
node.each { |child| find_params_block(child) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def is_params_call?(node)
|
|
44
|
+
return false unless node.is_a?(Array)
|
|
45
|
+
|
|
46
|
+
case node[0]
|
|
47
|
+
when :method_add_arg
|
|
48
|
+
fcall = node[1]
|
|
49
|
+
return fcall.is_a?(Array) && fcall[0] == :fcall && fcall[1].is_a?(Array) && fcall[1][1] == 'params'
|
|
50
|
+
when :fcall
|
|
51
|
+
return node[1].is_a?(Array) && node[1][1] == 'params'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_params_body(do_block)
|
|
58
|
+
# do_block structure: [:do_block, params, [:bodystmt, statements, ...]]
|
|
59
|
+
body = do_block[2]
|
|
60
|
+
return unless body && body[0] == :bodystmt
|
|
61
|
+
|
|
62
|
+
statements = body[1]
|
|
63
|
+
extract_statements(statements, depth: 0, parent: nil)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_statements(statements, depth:, parent:)
|
|
67
|
+
return unless statements.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
statements.each do |stmt|
|
|
70
|
+
case stmt[0]
|
|
71
|
+
when :method_add_arg, :call
|
|
72
|
+
# Simple field definition like required(:email).value(:string)
|
|
73
|
+
# or optional(:metadata).hash
|
|
74
|
+
field_info = parse_method_chain(stmt, depth: depth, parent: parent)
|
|
75
|
+
@schema_definitions << field_info if field_info
|
|
76
|
+
when :method_add_block
|
|
77
|
+
# Field with nested block like required(:settings).hash do...end
|
|
78
|
+
field_info = parse_method_chain(stmt[1], depth: depth, parent: parent, has_block: true)
|
|
79
|
+
if field_info
|
|
80
|
+
@schema_definitions << field_info
|
|
81
|
+
|
|
82
|
+
# Extract nested fields with this field as their parent
|
|
83
|
+
nested_block = stmt[2]
|
|
84
|
+
if nested_block && nested_block[0] == :do_block
|
|
85
|
+
nested_body = nested_block[2]
|
|
86
|
+
if nested_body && nested_body[0] == :bodystmt
|
|
87
|
+
extract_statements(nested_body[1], depth: depth + 1, parent: field_info[:name])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_method_chain(node, depth:, parent:, has_block: false)
|
|
96
|
+
# Extract the base method (required/optional)
|
|
97
|
+
base_method = find_base_method(node)
|
|
98
|
+
return nil unless base_method && ['required', 'optional'].include?(base_method)
|
|
99
|
+
|
|
100
|
+
# Extract field name (symbol argument to required/optional)
|
|
101
|
+
field_name = find_field_name(node)
|
|
102
|
+
return nil unless field_name
|
|
103
|
+
|
|
104
|
+
# Extract type information (.value, .array, .hash, etc.)
|
|
105
|
+
type_info = find_type_info(node, has_block: has_block)
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
required: base_method == 'required',
|
|
109
|
+
name: field_name,
|
|
110
|
+
type: type_info[:type],
|
|
111
|
+
sub_type: type_info[:sub_type],
|
|
112
|
+
has_nested: type_info[:has_nested],
|
|
113
|
+
depth: depth,
|
|
114
|
+
parent: parent
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find_base_method(node)
|
|
119
|
+
return nil unless node.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
case node[0]
|
|
122
|
+
when :method_add_arg, :call
|
|
123
|
+
return find_base_method(node[1])
|
|
124
|
+
when :fcall
|
|
125
|
+
ident = node[1]
|
|
126
|
+
return ident[1] if ident && ident[0] == :@ident
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def find_field_name(node)
|
|
133
|
+
symbols = find_all_symbols(node)
|
|
134
|
+
symbols.first # The first symbol is the field name
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def find_all_symbols(node, symbols = [])
|
|
138
|
+
return symbols unless node.is_a?(Array)
|
|
139
|
+
|
|
140
|
+
if node[0] == :symbol_literal
|
|
141
|
+
symbol_node = node[1]
|
|
142
|
+
if symbol_node && symbol_node[0] == :symbol
|
|
143
|
+
ident = symbol_node[1]
|
|
144
|
+
if ident && ident[0] == :@ident
|
|
145
|
+
symbols << ident[1]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
node.each { |child| find_all_symbols(child, symbols) }
|
|
151
|
+
symbols
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def find_type_info(node, has_block: false)
|
|
155
|
+
# Look for method calls like .value(:string), .filled(:string), .array(:string), .hash
|
|
156
|
+
type_method = find_type_method_name(node)
|
|
157
|
+
|
|
158
|
+
case type_method
|
|
159
|
+
when 'value', 'filled'
|
|
160
|
+
# Both .value() and .filled() take the same type argument
|
|
161
|
+
# .filled() additionally ensures the value is not nil/empty
|
|
162
|
+
symbols = find_all_symbols(node)
|
|
163
|
+
type_value = symbols[1] # First symbol is field name, second is type
|
|
164
|
+
{ type: type_value, sub_type: nil, has_nested: false }
|
|
165
|
+
when 'array'
|
|
166
|
+
symbols = find_all_symbols(node)
|
|
167
|
+
sub_type = symbols[1] # Type of array items
|
|
168
|
+
{ type: 'array', sub_type: sub_type, has_nested: has_block }
|
|
169
|
+
when 'hash'
|
|
170
|
+
{ type: 'hash', sub_type: nil, has_nested: has_block }
|
|
171
|
+
else
|
|
172
|
+
# No type specified, default to string
|
|
173
|
+
{ type: nil, sub_type: nil, has_nested: false }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def find_type_method_name(node)
|
|
178
|
+
return nil unless node.is_a?(Array)
|
|
179
|
+
|
|
180
|
+
case node[0]
|
|
181
|
+
when :call
|
|
182
|
+
# [:call, receiver, period, method_name]
|
|
183
|
+
method_name = node[3]
|
|
184
|
+
if method_name && method_name[0] == :@ident
|
|
185
|
+
return method_name[1]
|
|
186
|
+
end
|
|
187
|
+
# Continue searching in receiver
|
|
188
|
+
find_type_method_name(node[1])
|
|
189
|
+
when :method_add_arg
|
|
190
|
+
find_type_method_name(node[1])
|
|
191
|
+
else
|
|
192
|
+
node.each do |child|
|
|
193
|
+
result = find_type_method_name(child)
|
|
194
|
+
return result if result
|
|
195
|
+
end
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module DryValidationOpenapi
|
|
2
|
+
# Module to extend dry-validation contracts with OpenAPI schema generation
|
|
3
|
+
#
|
|
4
|
+
# @example
|
|
5
|
+
# class CreateUserContract < Dry::Validation::Contract
|
|
6
|
+
# extend DryValidationOpenapi::Convertable
|
|
7
|
+
#
|
|
8
|
+
# params do
|
|
9
|
+
# required(:email).value(:string)
|
|
10
|
+
# required(:age).value(:integer)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# # In your rswag spec
|
|
15
|
+
# parameter name: :body, in: :body, schema: CreateUserContract.open_api_schema
|
|
16
|
+
#
|
|
17
|
+
module Convertable
|
|
18
|
+
# Returns the file path where this contract class is defined
|
|
19
|
+
#
|
|
20
|
+
# @return [String] the absolute path to the contract file
|
|
21
|
+
def location
|
|
22
|
+
@location ||= Object.const_source_location(name)[0]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Generates an OpenAPI schema from this contract's params block
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash] OpenAPI schema hash with type, properties, and required fields
|
|
28
|
+
def open_api_schema
|
|
29
|
+
@open_api_schema ||= SchemaBuilder.build(location)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Clears the cached schema, forcing regeneration on next access
|
|
33
|
+
# Useful during development or when contract definition changes
|
|
34
|
+
#
|
|
35
|
+
# @return [nil]
|
|
36
|
+
def clear_schema_cache!
|
|
37
|
+
@open_api_schema = nil
|
|
38
|
+
@location = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require_relative 'contract_parser'
|
|
2
|
+
|
|
3
|
+
module DryValidationOpenapi
|
|
4
|
+
class SchemaBuilder
|
|
5
|
+
# Maps dry-validation types to OpenAPI type (and optionally format)
|
|
6
|
+
# See: https://swagger.io/specification/#data-types
|
|
7
|
+
TYPE_MAPPING = {
|
|
8
|
+
'string' => :string,
|
|
9
|
+
'integer' => { type: :integer, format: 'int32' },
|
|
10
|
+
'int' => { type: :integer, format: 'int32' },
|
|
11
|
+
'float' => { type: :number, format: 'float' },
|
|
12
|
+
'decimal' => { type: :number, format: 'double' },
|
|
13
|
+
'number' => { type: :number, format: 'double' },
|
|
14
|
+
'bool' => :boolean,
|
|
15
|
+
'boolean' => :boolean,
|
|
16
|
+
'date' => { type: :string, format: 'date' },
|
|
17
|
+
'time' => { type: :string, format: 'date-time' },
|
|
18
|
+
'date_time' => { type: :string, format: 'date-time' },
|
|
19
|
+
'array' => :array,
|
|
20
|
+
'hash' => :object
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.build(contract_file_path)
|
|
24
|
+
new(contract_file_path).build
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(contract_file_path)
|
|
28
|
+
@contract_file_path = contract_file_path
|
|
29
|
+
@definitions = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build
|
|
33
|
+
@definitions = ContractParser.parse_file(@contract_file_path)
|
|
34
|
+
build_schema(@definitions)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_schema(definitions, depth = 0, parent = nil)
|
|
40
|
+
schema = {
|
|
41
|
+
type: :object,
|
|
42
|
+
properties: {},
|
|
43
|
+
required: []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Group definitions by depth and parent to handle nesting
|
|
47
|
+
current_level = definitions.select { |d| d[:depth] == depth && d[:parent] == parent }
|
|
48
|
+
|
|
49
|
+
current_level.each do |field|
|
|
50
|
+
property_schema = build_property_schema(field, definitions, depth)
|
|
51
|
+
schema[:properties][field[:name].to_sym] = property_schema
|
|
52
|
+
schema[:required] << field[:name] if field[:required]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clean up empty required array
|
|
56
|
+
schema.delete(:required) if schema[:required].empty?
|
|
57
|
+
|
|
58
|
+
schema
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_property_schema(field, all_definitions, current_depth)
|
|
62
|
+
case field[:type]
|
|
63
|
+
when 'array'
|
|
64
|
+
build_array_schema(field, all_definitions, current_depth)
|
|
65
|
+
when 'hash'
|
|
66
|
+
build_hash_schema(field, all_definitions, current_depth)
|
|
67
|
+
else
|
|
68
|
+
build_simple_schema(field)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_simple_schema(field)
|
|
73
|
+
type_info = TYPE_MAPPING[field[:type]] || :string
|
|
74
|
+
|
|
75
|
+
# type_info can be either a symbol (:string) or a hash ({type: :integer, format: 'int32'})
|
|
76
|
+
if type_info.is_a?(Hash)
|
|
77
|
+
type_info.dup # Return a copy to avoid modifying the frozen hash
|
|
78
|
+
else
|
|
79
|
+
{ type: type_info }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_array_schema(field, all_definitions, current_depth)
|
|
84
|
+
schema = { type: :array }
|
|
85
|
+
|
|
86
|
+
if field[:has_nested]
|
|
87
|
+
# Array items have nested schema (e.g., array of objects with defined properties)
|
|
88
|
+
# Filter by both depth and parent to get only this array's nested fields
|
|
89
|
+
nested_fields = all_definitions.select { |d| d[:depth] == current_depth + 1 && d[:parent] == field[:name] }
|
|
90
|
+
|
|
91
|
+
if nested_fields.any?
|
|
92
|
+
# Build nested schema for array items
|
|
93
|
+
nested_schema = build_schema(all_definitions, current_depth + 1, field[:name])
|
|
94
|
+
schema[:items] = nested_schema
|
|
95
|
+
else
|
|
96
|
+
# Has nested block but no fields found - default to object
|
|
97
|
+
type_info = TYPE_MAPPING[field[:sub_type]] || :object
|
|
98
|
+
schema[:items] = type_info.is_a?(Hash) ? type_info.dup : { type: type_info }
|
|
99
|
+
end
|
|
100
|
+
elsif field[:sub_type]
|
|
101
|
+
# Simple array with specified item type
|
|
102
|
+
type_info = TYPE_MAPPING[field[:sub_type]] || :string
|
|
103
|
+
schema[:items] = type_info.is_a?(Hash) ? type_info.dup : { type: type_info }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
schema
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_hash_schema(field, all_definitions, current_depth)
|
|
110
|
+
if field[:has_nested]
|
|
111
|
+
# Find nested fields for this specific hash (filter by parent)
|
|
112
|
+
nested_fields = all_definitions.select { |d| d[:depth] == current_depth + 1 && d[:parent] == field[:name] }
|
|
113
|
+
|
|
114
|
+
if nested_fields.any?
|
|
115
|
+
# Build nested schema
|
|
116
|
+
nested_schema = build_schema(all_definitions, current_depth + 1, field[:name])
|
|
117
|
+
return nested_schema
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Empty hash or hash without nested definition
|
|
122
|
+
{ type: :object }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
require_relative 'dry_validation_openapi/version'
|
|
2
|
+
require_relative 'dry_validation_openapi/contract_parser'
|
|
3
|
+
require_relative 'dry_validation_openapi/schema_builder'
|
|
4
|
+
require_relative 'dry_validation_openapi/convertable'
|
|
5
|
+
|
|
6
|
+
module DryValidationOpenapi
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dry_validation_openapi
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- troptropcontent
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rspec
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.12'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.12'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: dry-validation
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.10'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.10'
|
|
54
|
+
description: Automatically convert dry-validation contract definitions to OpenAPI
|
|
55
|
+
3.0 schemas for use with rswag and other OpenAPI tools. Uses static parsing to extract
|
|
56
|
+
type information from contract params blocks.
|
|
57
|
+
email:
|
|
58
|
+
- troptropcontent@gmail.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- lib/dry_validation_openapi.rb
|
|
67
|
+
- lib/dry_validation_openapi/contract_parser.rb
|
|
68
|
+
- lib/dry_validation_openapi/convertable.rb
|
|
69
|
+
- lib/dry_validation_openapi/schema_builder.rb
|
|
70
|
+
- lib/dry_validation_openapi/version.rb
|
|
71
|
+
homepage: https://github.com/troptropcontent/dry_validation_openapi
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
homepage_uri: https://github.com/troptropcontent/dry_validation_openapi
|
|
76
|
+
source_code_uri: https://github.com/troptropcontent/dry_validation_openapi
|
|
77
|
+
changelog_uri: https://github.com/troptropcontent/dry_validation_openapi/blob/main/CHANGELOG.md
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: 3.1.0
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.6.9
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: Generate OpenAPI schemas from dry-validation contracts
|
|
95
|
+
test_files: []
|