sober_swag 0.11.0 → 0.16.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/lint.yml +44 -9
- data/.gitignore +2 -0
- data/.rubocop.yml +50 -5
- data/CHANGELOG.md +22 -0
- data/README.md +141 -4
- data/bin/console +18 -30
- data/bin/rspec +29 -0
- data/docs/serializers.md +74 -9
- data/example/app/controllers/application_controller.rb +5 -0
- data/lib/sober_swag.rb +1 -0
- data/lib/sober_swag/compiler.rb +1 -0
- data/lib/sober_swag/compiler/primitive.rb +75 -0
- data/lib/sober_swag/compiler/type.rb +56 -92
- data/lib/sober_swag/controller.rb +3 -9
- data/lib/sober_swag/controller/route.rb +21 -8
- data/lib/sober_swag/input_object.rb +42 -2
- data/lib/sober_swag/nodes/attribute.rb +8 -7
- data/lib/sober_swag/nodes/enum.rb +2 -2
- data/lib/sober_swag/nodes/primitive.rb +1 -1
- data/lib/sober_swag/output_object/definition.rb +14 -2
- data/lib/sober_swag/output_object/field_syntax.rb +16 -0
- data/lib/sober_swag/parser.rb +10 -4
- data/lib/sober_swag/serializer/base.rb +2 -0
- data/lib/sober_swag/serializer/meta.rb +10 -6
- data/lib/sober_swag/type.rb +7 -0
- data/lib/sober_swag/type/named.rb +35 -0
- data/lib/sober_swag/types.rb +2 -0
- data/lib/sober_swag/types/comma_array.rb +17 -0
- data/lib/sober_swag/version.rb +1 -1
- data/sober_swag.gemspec +2 -2
- metadata +16 -10
data/bin/rspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require 'pathname'
|
12
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path('bundle', __dir__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require 'rubygems'
|
27
|
+
require 'bundler/setup'
|
28
|
+
|
29
|
+
load Gem.bin_path('rspec-core', 'rspec')
|
data/docs/serializers.md
CHANGED
@@ -25,21 +25,21 @@ For example, you might have a serializer that can return a date in two formats,
|
|
25
25
|
In this case, it might be used as:
|
26
26
|
|
27
27
|
```ruby
|
28
|
-
serializer.new(my_record, { format: :
|
28
|
+
serializer.new(my_record, { format: :new_style })
|
29
29
|
```
|
30
30
|
|
31
31
|
However, since it is *always* optional, you can also do:
|
32
32
|
|
33
33
|
```ruby
|
34
|
-
|
34
|
+
serializer.new(my_record)
|
35
35
|
```
|
36
36
|
|
37
37
|
And it *should* pick some default format.
|
38
38
|
|
39
39
|
### Primitives
|
40
40
|
|
41
|
-
Primitive serializers
|
42
|
-
They are implemented as [`SoberSwag::Serializer::Primitive`](../lib/sober_swag/serializer/primitive.rb), or as the `#primitive` method on
|
41
|
+
Primitive serializers — or "identity serializers" — are serializers that do nothing.
|
42
|
+
They are implemented as [`SoberSwag::Serializer::Primitive`](../lib/sober_swag/serializer/primitive.rb), or as the `#primitive` method on an `OutputObject`.
|
43
43
|
Since they don't do anything, they can be considered the most "basic" serializer.
|
44
44
|
|
45
45
|
These serializers *do not* check types.
|
@@ -51,12 +51,12 @@ serializer.serialize(10) # => 10
|
|
51
51
|
```
|
52
52
|
|
53
53
|
Thus, care should be used when working with these serializers.
|
54
|
-
In the future, we might add some "debug mode"
|
54
|
+
In the future, we might add some "debug mode" thing that will do type-checking and throw errors, however, the cost of doing so in production is probably not worth it.
|
55
55
|
|
56
56
|
### Mapped
|
57
57
|
|
58
58
|
Sometimes, you can create a serializer via a *proc*.
|
59
|
-
For example, let's say that I want a serializer that takes a `Date` and returns a
|
59
|
+
For example, let's say that I want a serializer that takes a `Date` and returns a String.
|
60
60
|
I can do this:
|
61
61
|
|
62
62
|
```ruby
|
@@ -74,7 +74,7 @@ In the future, we might add a debug mode.
|
|
74
74
|
Oftentimes, we want to give a serializer the ability to serialize `nil` values.
|
75
75
|
This is often useful in serializing fields.
|
76
76
|
|
77
|
-
It turns out that it's pretty easy to make a serializer that can serialize `nil` values: just propogate
|
77
|
+
It turns out that it's pretty easy to make a serializer that can serialize `nil` values: just propogate `nil`s.
|
78
78
|
For example, let's say I have the following code:
|
79
79
|
|
80
80
|
```ruby
|
@@ -99,7 +99,7 @@ Continuing our example from earlier:
|
|
99
99
|
my_serializer.array.serialize([Foo.new(10, 11)]) #=> [{ bar: 10, baz: 11 }]
|
100
100
|
```
|
101
101
|
|
102
|
-
This changes the type properly
|
102
|
+
This changes the type properly too.
|
103
103
|
|
104
104
|
## OutputObjects
|
105
105
|
|
@@ -132,9 +132,25 @@ end
|
|
132
132
|
We can see a few things here:
|
133
133
|
|
134
134
|
1. You define field names with a `field` definition, which is a way to define the serializer for a single field.
|
135
|
-
2. You must provide types with field names
|
135
|
+
2. You must provide types with field names.
|
136
136
|
3. You can use blocks to do data formatting, which lets you pick different fields and such.
|
137
137
|
|
138
|
+
|
139
|
+
### Multi
|
140
|
+
|
141
|
+
If you have a few fields of the same type, you can use `#multi` to define them all at once:
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
StudentOutputObject = SoberSwag::OutputObject.define do
|
145
|
+
multi [:first_name, :last_name], primitive(:String)
|
146
|
+
field :recent_grades, primitive(:Integer).array do |student|
|
147
|
+
student.graded_assignments.limit(100).pluck(:grade)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
```
|
151
|
+
|
152
|
+
This saves a bit of typing, and can help with refactoring later.
|
153
|
+
|
138
154
|
### Views
|
139
155
|
|
140
156
|
Sometimes, you might want to add "variant" ways to look at data.
|
@@ -201,3 +217,52 @@ end
|
|
201
217
|
```
|
202
218
|
|
203
219
|
For clarity (and to prevent infinitely-looping serializers on accident, we recommend you *always* use an explicit view for dependent output objects.
|
220
|
+
|
221
|
+
### "Inheritance"
|
222
|
+
|
223
|
+
Output objects don't support inheritance.
|
224
|
+
You can't have one output object based on another.
|
225
|
+
You *can*, however, merge one into another!
|
226
|
+
Consider this case:
|
227
|
+
|
228
|
+
```ruby
|
229
|
+
GenericBioOutput = SoberSwag::OutputObject.define do
|
230
|
+
field :name, primitive(:String)
|
231
|
+
field :brief_history, primitive(:String)
|
232
|
+
end
|
233
|
+
|
234
|
+
ExecutiveBioOutput = SoberSwag::OutputObject.define do
|
235
|
+
merge GenericBioOutput
|
236
|
+
field :company, primitive(:String)
|
237
|
+
field :position, primitive(:String)
|
238
|
+
end
|
239
|
+
```
|
240
|
+
|
241
|
+
Using `#merge` lets you add in all the fields from one output object into another.
|
242
|
+
You can even use `merge` from within a view.
|
243
|
+
|
244
|
+
Note that `merge` does *not* copy anything but fields.
|
245
|
+
Identifiers and views will not be copied over.
|
246
|
+
|
247
|
+
### View Inheritance
|
248
|
+
|
249
|
+
While defining a new Output Object, you *do not* have access to the definition of that output object.
|
250
|
+
So, how do I say that one view should be an extension of another?
|
251
|
+
Simple, use the `inherits:` kwarg:
|
252
|
+
|
253
|
+
```ruby
|
254
|
+
BioOutput = SoberSwag::OutputObject.define do
|
255
|
+
field :name, primitive(:String)
|
256
|
+
|
257
|
+
view :detail do
|
258
|
+
field :bio, primitive(:String)
|
259
|
+
end
|
260
|
+
|
261
|
+
view :super_detail, inherits: :detail do
|
262
|
+
field :age, primitive(:Integer)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
```
|
266
|
+
|
267
|
+
`inherits` will automatically merge in all the fields of the referenced view.
|
268
|
+
This means that the view `super_detail` will include fields `name`, `bio`, and `age`.
|
data/lib/sober_swag.rb
CHANGED
data/lib/sober_swag/compiler.rb
CHANGED
@@ -6,6 +6,7 @@ module SoberSwag
|
|
6
6
|
class Compiler
|
7
7
|
autoload(:Type, 'sober_swag/compiler/type')
|
8
8
|
autoload(:Error, 'sober_swag/compiler/error')
|
9
|
+
autoload(:Primitive, 'sober_swag/compiler/primitive')
|
9
10
|
autoload(:Path, 'sober_swag/compiler/path')
|
10
11
|
autoload(:Paths, 'sober_swag/compiler/paths')
|
11
12
|
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
class Compiler
|
3
|
+
##
|
4
|
+
# Compiles a primitive type.
|
5
|
+
# Almost always constructed with the values from
|
6
|
+
# {SoberSwag::Nodes::Primitive}.
|
7
|
+
class Primitive
|
8
|
+
def initialize(type)
|
9
|
+
@type = type
|
10
|
+
|
11
|
+
raise Error, "#{type.inspect} is not a class!" unless @type.is_a?(Class)
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :type
|
15
|
+
|
16
|
+
def swagger_primitive?
|
17
|
+
SWAGGER_PRIMITIVE_DEFS.include?(type)
|
18
|
+
end
|
19
|
+
|
20
|
+
##
|
21
|
+
# Is the wrapped type a named type, causing us to make a ref?
|
22
|
+
def named?
|
23
|
+
type <= SoberSwag::Type::Named
|
24
|
+
end
|
25
|
+
|
26
|
+
##
|
27
|
+
# Turn this type into a swagger hash with a proper type key.
|
28
|
+
def type_hash
|
29
|
+
if swagger_primitive?
|
30
|
+
SWAGGER_PRIMITIVE_DEFS.fetch(type)
|
31
|
+
else
|
32
|
+
{
|
33
|
+
oneOf: [
|
34
|
+
{ '$ref'.to_sym => named_ref }
|
35
|
+
]
|
36
|
+
}
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
DATE_PRIMITIVE = { type: :string, format: :date }.freeze
|
41
|
+
DATE_TIME_PRIMITIVE = { type: :string, format: :'date-time' }.freeze
|
42
|
+
|
43
|
+
SWAGGER_PRIMITIVE_DEFS =
|
44
|
+
{
|
45
|
+
NilClass => :null,
|
46
|
+
TrueClass => :boolean,
|
47
|
+
FalseClass => :boolean,
|
48
|
+
Float => :number,
|
49
|
+
Integer => :integer,
|
50
|
+
String => :string
|
51
|
+
}.transform_values { |v| { type: v.freeze } }
|
52
|
+
.to_h.merge(
|
53
|
+
Date => DATE_PRIMITIVE,
|
54
|
+
DateTime => DATE_TIME_PRIMITIVE,
|
55
|
+
Time => DATE_TIME_PRIMITIVE
|
56
|
+
).transform_values(&:freeze).freeze
|
57
|
+
|
58
|
+
def ref_name
|
59
|
+
raise Error, 'is not a type that is named!' if swagger_primitive?
|
60
|
+
|
61
|
+
if type <= SoberSwag::Type::Named
|
62
|
+
type.root_alias.identifier
|
63
|
+
else
|
64
|
+
type.name.gsub('::', '.')
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def named_ref
|
71
|
+
"#/components/schemas/#{ref_name}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -4,46 +4,6 @@ module SoberSwag
|
|
4
4
|
# A compiler for DRY-Struct data types, essentially.
|
5
5
|
# It only consumes one type at a time.
|
6
6
|
class Type # rubocop:disable Metrics/ClassLength
|
7
|
-
class << self
|
8
|
-
def get_ref(klass)
|
9
|
-
"#/components/schemas/#{safe_name(klass)}"
|
10
|
-
end
|
11
|
-
|
12
|
-
def safe_name(klass)
|
13
|
-
if klass.respond_to?(:identifier)
|
14
|
-
klass.identifier
|
15
|
-
else
|
16
|
-
klass.to_s.gsub('::', '.')
|
17
|
-
end
|
18
|
-
end
|
19
|
-
|
20
|
-
def primitive?(value)
|
21
|
-
primitive_def(value) != nil
|
22
|
-
end
|
23
|
-
|
24
|
-
def primitive_def(value)
|
25
|
-
value = value.primitive if value.is_a?(Dry::Types::Nominal)
|
26
|
-
|
27
|
-
return nil unless value.is_a?(Class)
|
28
|
-
|
29
|
-
if (name = primitive_name(value))
|
30
|
-
{ type: name }
|
31
|
-
elsif value == Date
|
32
|
-
{ type: 'string', format: 'date' }
|
33
|
-
elsif [Time, DateTime].any?(&value.ancestors.method(:include?))
|
34
|
-
{ type: 'string', format: 'date-time' }
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
def primitive_name(value)
|
39
|
-
return 'null' if value == NilClass
|
40
|
-
return 'integer' if value == Integer
|
41
|
-
return 'number' if value == Float
|
42
|
-
return 'string' if value == String
|
43
|
-
return 'boolean' if [TrueClass, FalseClass].include?(value)
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
7
|
class TooComplicatedError < ::SoberSwag::Compiler::Error; end
|
48
8
|
class TooComplicatedForPathError < TooComplicatedError; end
|
49
9
|
class TooComplicatedForQueryError < TooComplicatedError; end
|
@@ -65,7 +25,15 @@ module SoberSwag
|
|
65
25
|
|
66
26
|
def object_schema
|
67
27
|
@object_schema ||=
|
68
|
-
|
28
|
+
make_object_schema
|
29
|
+
end
|
30
|
+
|
31
|
+
def object_schema_meta
|
32
|
+
return {} unless standalone? && type <= SoberSwag::Type::Named
|
33
|
+
|
34
|
+
{
|
35
|
+
description: type.description
|
36
|
+
}.reject { |_, v| v.nil? }
|
69
37
|
end
|
70
38
|
|
71
39
|
def schema_stub
|
@@ -81,14 +49,16 @@ module SoberSwag
|
|
81
49
|
raise TooComplicatedForPathError, e.message
|
82
50
|
end
|
83
51
|
|
52
|
+
DEFAULT_QUERY_SCHEMA_ATTRS = { in: :query, style: :deepObject, explode: true }.freeze
|
53
|
+
|
84
54
|
def query_schema
|
85
|
-
path_schema_stub.map { |e|
|
55
|
+
path_schema_stub.map { |e| DEFAULT_QUERY_SCHEMA_ATTRS.merge(e) }
|
86
56
|
rescue TooComplicatedError => e
|
87
57
|
raise TooComplicatedForQueryError, e.message
|
88
58
|
end
|
89
59
|
|
90
60
|
def ref_name
|
91
|
-
|
61
|
+
SoberSwag::Compiler::Primitive.new(type).ref_name
|
92
62
|
end
|
93
63
|
|
94
64
|
def found_types
|
@@ -99,6 +69,10 @@ module SoberSwag
|
|
99
69
|
end
|
100
70
|
end
|
101
71
|
|
72
|
+
def mapped_type
|
73
|
+
@mapped_type ||= parsed_type.map { |v| SoberSwag::Compiler::Primitive.new(v).type_hash }
|
74
|
+
end
|
75
|
+
|
102
76
|
def parsed_type
|
103
77
|
@parsed_type ||=
|
104
78
|
begin
|
@@ -121,20 +95,11 @@ module SoberSwag
|
|
121
95
|
|
122
96
|
private
|
123
97
|
|
124
|
-
def generate_schema_stub
|
125
|
-
|
126
|
-
|
127
|
-
case type
|
128
|
-
when Class
|
129
|
-
{ :$ref => self.class.get_ref(type) }
|
130
|
-
when Dry::Types::Constrained
|
131
|
-
self.class.new(type.type).schema_stub
|
132
|
-
when Dry::Types::Array::Member
|
133
|
-
{ type: :array, items: self.class.new(type.member).schema_stub }
|
134
|
-
when Dry::Types::Sum
|
135
|
-
{ oneOf: normalize(parsed_type).elements.map { |t| self.class.new(t.value).schema_stub } }
|
98
|
+
def generate_schema_stub
|
99
|
+
if type.is_a?(Class)
|
100
|
+
SoberSwag::Compiler::Primitive.new(type).type_hash
|
136
101
|
else
|
137
|
-
|
102
|
+
object_schema
|
138
103
|
end
|
139
104
|
end
|
140
105
|
|
@@ -147,6 +112,10 @@ module SoberSwag
|
|
147
112
|
end
|
148
113
|
end
|
149
114
|
|
115
|
+
def make_object_schema(metadata_keys: METADATA_KEYS)
|
116
|
+
normalize(mapped_type).cata { |e| to_object_schema(e, metadata_keys) }.merge(object_schema_meta)
|
117
|
+
end
|
118
|
+
|
150
119
|
def normalize(object)
|
151
120
|
object.cata { |e| rewrite_sums(e) }.cata { |e| flatten_one_ofs(e) }
|
152
121
|
end
|
@@ -178,29 +147,14 @@ module SoberSwag
|
|
178
147
|
end
|
179
148
|
end
|
180
149
|
|
181
|
-
def to_object_schema(object) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
150
|
+
def to_object_schema(object, metadata_keys = METADATA_KEYS) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
182
151
|
case object
|
183
152
|
when Nodes::List
|
184
|
-
{
|
185
|
-
type: :array,
|
186
|
-
items: object.deconstruct.first
|
187
|
-
}
|
153
|
+
{ type: :array, items: object.element }
|
188
154
|
when Nodes::Enum
|
189
|
-
{
|
190
|
-
type: :string,
|
191
|
-
enum: object.deconstruct.first
|
192
|
-
}
|
155
|
+
{ type: :string, enum: object.values }
|
193
156
|
when Nodes::OneOf
|
194
|
-
|
195
|
-
rejected = object.deconstruct.reject { |e| e[:type] == 'null' }
|
196
|
-
if rejected.length == 1
|
197
|
-
rejected.first.merge(nullable: true)
|
198
|
-
else
|
199
|
-
{ oneOf: rejected, nullable: true }
|
200
|
-
end
|
201
|
-
else
|
202
|
-
{ oneOf: object.deconstruct }
|
203
|
-
end
|
157
|
+
one_of_to_schema(object)
|
204
158
|
when Nodes::Object
|
205
159
|
# openAPI requires that you give a list of required attributes
|
206
160
|
# (which IMO is the *totally* wrong thing to do but whatever)
|
@@ -214,40 +168,50 @@ module SoberSwag
|
|
214
168
|
required: required
|
215
169
|
}
|
216
170
|
when Nodes::Attribute
|
217
|
-
name, req, value = object.deconstruct
|
171
|
+
name, req, value, meta = object.deconstruct
|
172
|
+
value = value.merge(meta&.select { |k, _| metadata_keys.include?(k) } || {})
|
218
173
|
if req
|
219
174
|
[name, value.merge(required: true)]
|
220
175
|
else
|
221
176
|
[name, value]
|
222
177
|
end
|
223
|
-
# can't match on value directly as ruby uses `===` to match,
|
224
|
-
# and classes use `===` to mean `is an instance of`, as
|
225
|
-
# opposed to direct equality lmao
|
226
178
|
when Nodes::Primitive
|
227
|
-
value
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
179
|
+
object.value.merge(object.metadata.select { |k, _| metadata_keys.include?(k) })
|
180
|
+
else
|
181
|
+
raise ArgumentError, "Got confusing node #{object} (#{object.class})"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def one_of_to_schema(object)
|
186
|
+
if object.deconstruct.include?({ type: :null })
|
187
|
+
rejected = object.deconstruct.reject { |e| e[:type] == :null }
|
188
|
+
if rejected.length == 1
|
189
|
+
rejected.first.merge(nullable: true)
|
234
190
|
else
|
235
|
-
{
|
191
|
+
{ oneOf: flatten_oneofs_hash(rejected), nullable: true }
|
236
192
|
end
|
237
193
|
else
|
238
|
-
|
194
|
+
{ oneOf: flatten_oneofs_hash(object.deconstruct) }
|
239
195
|
end
|
240
196
|
end
|
241
197
|
|
198
|
+
def flatten_oneofs_hash(object)
|
199
|
+
object.map { |h|
|
200
|
+
h[:oneOf] || h
|
201
|
+
}.flatten
|
202
|
+
end
|
203
|
+
|
242
204
|
def path_schema_stub
|
243
205
|
@path_schema_stub ||=
|
244
|
-
|
206
|
+
make_object_schema(metadata_keys: METADATA_KEYS | %i[style explode])[:properties].map do |k, v|
|
245
207
|
# ensure_uncomplicated(k, v)
|
246
208
|
{
|
247
209
|
name: k,
|
248
|
-
schema: v.reject { |key, _| %i[required nullable].include?(key) },
|
249
|
-
required: object_schema[:required].include?(k) || false
|
250
|
-
|
210
|
+
schema: v.reject { |key, _| %i[required nullable explode style].include?(key) },
|
211
|
+
required: object_schema[:required].include?(k) || false,
|
212
|
+
style: v[:style],
|
213
|
+
explode: v[:explode]
|
214
|
+
}.reject { |_, v2| v2.nil? }
|
251
215
|
end
|
252
216
|
end
|
253
217
|
|