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.
@@ -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')
@@ -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: :newstyle })
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
- serilaizer.new(my_record)
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, 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 a `OutputObject`.
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" sorta thing that will do type-checking and throw errors, however, the cost of doing so in production is probably not worth it.
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 string.
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 nils.
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, too.
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`.
@@ -1,2 +1,7 @@
1
+ ##
2
+ # Standard application controller.
1
3
  class ApplicationController < ActionController::API
4
+ rescue_from Dry::Struct::Error do
5
+ head :bad_request
6
+ end
2
7
  end
@@ -21,6 +21,7 @@ module SoberSwag
21
21
  autoload :Controller, 'sober_swag/controller'
22
22
  autoload :InputObject, 'sober_swag/input_object'
23
23
  autoload :Server, 'sober_swag/server'
24
+ autoload :Type, 'sober_swag/type'
24
25
 
25
26
  ##
26
27
  # Define a struct of something.
@@ -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
- normalize(parsed_type).cata(&method(:to_object_schema))
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| e.merge(in: :query, style: :deepObject, explode: true) }
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
- self.class.safe_name(type)
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 # rubocop:disable Metrics/MethodLength
125
- return self.class.primitive_def(type) if self.class.primitive?(type)
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
- raise SoberSwag::Compiler::Error, "Cannot generate a schema stub for #{type} (#{type.class})"
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
- if object.deconstruct.include?({ type: 'null' })
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 = object.value
228
- metadata = object.metadata
229
- if self.class.primitive?(value)
230
- md = self.class.primitive_def(value)
231
- METADATA_KEYS.select(&metadata.method(:key?)).reduce(md) do |definition, key|
232
- definition.merge(key => metadata[key])
233
- end
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
- { '$ref': self.class.get_ref(value) }
191
+ { oneOf: flatten_oneofs_hash(rejected), nullable: true }
236
192
  end
237
193
  else
238
- raise ArgumentError, "Got confusing node #{object} (#{object.class})"
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
- object_schema[:properties].map do |k, v|
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