sober_swag 0.17.0 → 0.21.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +15 -0
  3. data/.github/workflows/benchmark.yml +39 -0
  4. data/.github/workflows/lint.yml +2 -4
  5. data/.github/workflows/ruby.yml +1 -1
  6. data/.gitignore +3 -0
  7. data/.rubocop.yml +5 -1
  8. data/.yardopts +7 -0
  9. data/CHANGELOG.md +21 -0
  10. data/Gemfile +12 -0
  11. data/README.md +1 -1
  12. data/bench/benchmark.rb +34 -0
  13. data/bench/benchmarks/basic_field_serializer.rb +21 -0
  14. data/bench/benchmarks/view_selection.rb +47 -0
  15. data/docs/serializers.md +4 -1
  16. data/example/Gemfile +2 -2
  17. data/example/Gemfile.lock +46 -44
  18. data/example/config/environments/production.rb +1 -1
  19. data/lib/sober_swag/compiler/path.rb +42 -3
  20. data/lib/sober_swag/compiler/paths.rb +20 -0
  21. data/lib/sober_swag/compiler/primitive.rb +20 -1
  22. data/lib/sober_swag/compiler/type.rb +105 -22
  23. data/lib/sober_swag/compiler.rb +29 -3
  24. data/lib/sober_swag/controller/route.rb +103 -20
  25. data/lib/sober_swag/controller.rb +39 -12
  26. data/lib/sober_swag/input_object.rb +124 -7
  27. data/lib/sober_swag/nodes/array.rb +19 -0
  28. data/lib/sober_swag/nodes/attribute.rb +45 -4
  29. data/lib/sober_swag/nodes/base.rb +27 -7
  30. data/lib/sober_swag/nodes/binary.rb +30 -13
  31. data/lib/sober_swag/nodes/enum.rb +16 -1
  32. data/lib/sober_swag/nodes/list.rb +20 -0
  33. data/lib/sober_swag/nodes/nullable_primitive.rb +3 -0
  34. data/lib/sober_swag/nodes/object.rb +4 -1
  35. data/lib/sober_swag/nodes/one_of.rb +11 -3
  36. data/lib/sober_swag/nodes/primitive.rb +34 -2
  37. data/lib/sober_swag/nodes/sum.rb +8 -0
  38. data/lib/sober_swag/output_object/definition.rb +57 -1
  39. data/lib/sober_swag/output_object/field.rb +31 -11
  40. data/lib/sober_swag/output_object/field_syntax.rb +19 -3
  41. data/lib/sober_swag/output_object/view.rb +46 -1
  42. data/lib/sober_swag/output_object.rb +40 -19
  43. data/lib/sober_swag/parser.rb +7 -1
  44. data/lib/sober_swag/serializer/array.rb +27 -3
  45. data/lib/sober_swag/serializer/base.rb +75 -25
  46. data/lib/sober_swag/serializer/conditional.rb +33 -1
  47. data/lib/sober_swag/serializer/field_list.rb +23 -5
  48. data/lib/sober_swag/serializer/hash.rb +53 -0
  49. data/lib/sober_swag/serializer/mapped.rb +10 -1
  50. data/lib/sober_swag/serializer/optional.rb +18 -1
  51. data/lib/sober_swag/serializer/primitive.rb +3 -0
  52. data/lib/sober_swag/serializer.rb +1 -0
  53. data/lib/sober_swag/server.rb +27 -11
  54. data/lib/sober_swag/type/named.rb +14 -0
  55. data/lib/sober_swag/types/comma_array.rb +4 -0
  56. data/lib/sober_swag/version.rb +1 -1
  57. data/lib/sober_swag.rb +6 -1
  58. metadata +9 -2
@@ -1,5 +1,13 @@
1
1
  module SoberSwag
2
2
  module Nodes
3
+ ##
4
+ # A "Sum" type represents either one type or the other.
5
+ #
6
+ # It is called "Sum" because, if a type can be either type `A` or type `B`,
7
+ # the number of possible values for the type of `number_of_values(A) + number_of_values(B)`.
8
+ #
9
+ # Internally, this is primarily used when an object can be either one type or another.
10
+ # It will latter be flattened into {SoberSwag::Nodes::OneOf}
3
11
  class Sum < Binary
4
12
  end
5
13
  end
@@ -9,14 +9,56 @@ module SoberSwag
9
9
  @views = []
10
10
  end
11
11
 
12
- attr_reader :fields, :views
12
+ ##
13
+ # @return [Array<SoberSwag::OutputObject::Field>]
14
+ attr_reader :fields
15
+
16
+ ##
17
+ # @return [Array<SoberSwag::OutputObject::View>]
18
+ attr_reader :views
13
19
 
14
20
  include FieldSyntax
15
21
 
22
+ ##
23
+ # Adds a "type key", which is basically a field that will be
24
+ # serialized out to a constant value.
25
+ #
26
+ # This is useful if you have multiple types you may want to serialize out, and want consumers of your API to distinguish between them.
27
+ #
28
+ # By default this will have the key "type" but you can set it with the keyword arg.
29
+ # ```ruby
30
+ # type_key('MyObject')
31
+ # # is equivalent to
32
+ # field :type, SoberSwag::Serializer::Primitive.new(SoberSwag::Types::String.enum('MyObject')) do |_, _|
33
+ # 'MyObject'
34
+ # end
35
+ # ```
36
+ # @param str [String, Symbol] the value to serialize (will be converted to a string)
37
+ # @param
38
+ def type_key(str, field_name: :type)
39
+ str = str.to_s
40
+ field(
41
+ field_name,
42
+ SoberSwag::Serializer::Primitive.new(
43
+ SoberSwag::Types::String.enum(str)
44
+ )
45
+ ) { |_, _| str }
46
+ end
47
+
48
+ ##
49
+ # Adds a new field to the fields array
50
+ # @param field [SoberSwag::OutputObject::Field]
16
51
  def add_field!(field)
17
52
  @fields << field
18
53
  end
19
54
 
55
+ ##
56
+ # Define a new view, with the view DSL
57
+ # @param name [Symbol] the name of the view
58
+ # @param inherits [Symbol] the name of another view this
59
+ # view will "inherit" from
60
+ # @yieldself [SoberSwag::OutputObject::View]
61
+ # @return [nil] nothing interesting.
20
62
  def view(name, inherits: nil, &block)
21
63
  initial_fields =
22
64
  if inherits.nil? || inherits == :base
@@ -31,6 +73,14 @@ module SoberSwag
31
73
  @views << view
32
74
  end
33
75
 
76
+ ##
77
+ # @overload identifier()
78
+ # Get the identifier of this output object.
79
+ # @return [String] the identifier
80
+ # @overload identifier(arg)
81
+ # Set the identifier of this output object.
82
+ # @param arg [String] the external identifier to use for this OutputObject
83
+ # @return [String] `arg`
34
84
  def identifier(arg = nil)
35
85
  @identifier = arg if arg
36
86
  @identifier
@@ -38,6 +88,12 @@ module SoberSwag
38
88
 
39
89
  private
40
90
 
91
+ ##
92
+ # Get the already-defined view with a specific name.
93
+ #
94
+ # @param name [Symbol] name of view to look up
95
+ # @return [SoberSwag::OutputObject::View] the view found
96
+ # @raise [ArgumentError] if no view with that name found
41
97
  def find_view(name)
42
98
  @views.find { |view| view.name == name } || (raise ArgumentError, "no view #{name.inspect} defined!")
43
99
  end
@@ -4,6 +4,18 @@ module SoberSwag
4
4
  # A single field in an output object.
5
5
  # Later used to make an actual serializer from this.
6
6
  class Field
7
+ ##
8
+ # @param name [Symbol] the name of this field
9
+ # @param serializer [SoberSwag::Serializer::Base, Proc, Lambda] how to serialize
10
+ # the value in this field.
11
+ # If given a `Proc` or `Lambda`, the `Proc` or `Lambda` should return
12
+ # an instance of SoberSwag::Serializer::Base when called.
13
+ # @param from [Symbol] an optional parameter specifying
14
+ # that this field should be plucked "from" another
15
+ # attribute of a ruby object
16
+ # @param block [Proc] a proc to get this field from a serialized
17
+ # object. If not given, will try to grab an attribute
18
+ # with the same name, *or* with the name of `from:` if that was sent.
7
19
  def initialize(name, serializer, from: nil, &block)
8
20
  @name = name
9
21
  @root_serializer = serializer
@@ -11,12 +23,18 @@ module SoberSwag
11
23
  @block = block
12
24
  end
13
25
 
26
+ ##
27
+ # @return [Symbol] name of this field.
14
28
  attr_reader :name
15
29
 
30
+ ##
31
+ # @return [SoberSwag::Serializer::Base]
16
32
  def serializer
17
33
  @serializer ||= resolved_serializer.serializer.via_map(&transform_proc)
18
34
  end
19
35
 
36
+ ##
37
+ # @return [SoberSwag::Serializer::Base]
20
38
  def resolved_serializer
21
39
  if @root_serializer.is_a?(Proc)
22
40
  @root_serializer.call
@@ -27,17 +45,19 @@ module SoberSwag
27
45
 
28
46
  private
29
47
 
30
- def transform_proc # rubocop:disable Metrics/MethodLength
31
- if @block
32
- @block
33
- else
34
- key = @from || @name
35
- proc do |object, _|
36
- if object.respond_to?(key)
37
- object.public_send(key)
38
- else
39
- object[key]
40
- end
48
+ ##
49
+ # @return [Proc]
50
+ def transform_proc
51
+ return @transform_proc if defined?(@transform_proc)
52
+
53
+ return @transform_proc = @block if @block
54
+
55
+ key = @from || @name
56
+ @transform_proc = proc do |object, _|
57
+ if object.respond_to?(key)
58
+ object.public_send(key)
59
+ else
60
+ object[key]
41
61
  end
42
62
  end
43
63
  end
@@ -3,6 +3,13 @@ module SoberSwag
3
3
  ##
4
4
  # Syntax for definitions that can add fields.
5
5
  module FieldSyntax
6
+ ##
7
+ # Defines a new field.
8
+ # @see SoberSwag::OutputObject::Field#initialize
9
+ # @param name [Symbol] name of this field
10
+ # @param serializer [SoberSwag::Serializer::Base] serializer to use for this field.
11
+ # @param from [Symbol] method name to extract this field from, for convenience.
12
+ # @param block [Proc] optional way to extract this field.
6
13
  def field(name, serializer, from: nil, &block)
7
14
  add_field!(Field.new(name, serializer, from: from, &block))
8
15
  end
@@ -10,22 +17,31 @@ module SoberSwag
10
17
  ##
11
18
  # Similar to #field, but adds multiple at once.
12
19
  # Named #multi because #fields was already taken.
20
+ #
21
+ # @param names [Array<Symbol>] the field names to add.
22
+ # @param serializer [SoberSwag::Serializer::Base] the serializer to use for all fields.
13
23
  def multi(names, serializer)
14
24
  names.each { |name| field(name, serializer) }
15
25
  end
16
26
 
17
27
  ##
18
28
  # Given a symbol to this, we will use a primitive name
29
+ # @param name [Symbol] symbol to look up.
30
+ # @return [SoberSwag::Serializer::Base] serializer to use.
19
31
  def primitive(name)
20
32
  SoberSwag::Serializer.primitive(SoberSwag::Types.const_get(name))
21
33
  end
22
34
 
23
35
  ##
24
36
  # Merge in anything that has a list of fields, and use it.
25
- # Note that merging in a full blueprint *will not* also merge in views, just fields defined on the base.
26
- def merge(other)
37
+ # Note that merging in a full output object *will not* also merge in views, just fields defined on the base.
38
+ #
39
+ # @param other [#fields] a field container, like a {SoberSwag::OutputObject} or something
40
+ # @param except [Array<Symbol>] optionally exclude a field from the output object being merged
41
+ # @return [void]
42
+ def merge(other, except: [])
27
43
  other.fields.each do |field|
28
- add_field!(field)
44
+ add_field!(field) unless except.include?(field.name)
29
45
  end
30
46
  end
31
47
  end
@@ -3,44 +3,87 @@ module SoberSwag
3
3
  ##
4
4
  # DSL for defining a view.
5
5
  # Used in `view` blocks within {SoberSwag::OutputObject.define}.
6
+ #
7
+ # Views are "variants" of {SoberSwag::OutputObject}s that contain
8
+ # different fields.
6
9
  class View < SoberSwag::Serializer::Base
10
+ ##
11
+ # Define a new view with the given base fields.
12
+ # @param name [Symbol] name for this view
13
+ # @param base_fields [Array<SoberSwag::OutputObject::Field>] fields already defined
14
+ # @yieldself [SoberSwag::OutputObject::View]
15
+ #
16
+ # @return [SoberSwag::OutputObject::View]
7
17
  def self.define(name, base_fields, &block)
8
18
  new(name, base_fields).tap do |view|
9
19
  view.instance_eval(&block)
10
20
  end
11
21
  end
12
22
 
23
+ ##
24
+ # An error thrown when you try to nest views inside views.
13
25
  class NestingError < Error; end
14
26
 
15
27
  include FieldSyntax
16
28
 
29
+ ##
30
+ # @param name [Sybmol] name for this view.
31
+ # @param base_fields [Array<SoberSwag::OutputObject::Field>] already-defined fields.
17
32
  def initialize(name, base_fields = [])
18
33
  @name = name
19
34
  @fields = base_fields.dup
20
35
  end
21
36
 
22
- attr_reader :name, :fields
37
+ ##
38
+ # @return [Symbol] the name of this view
39
+ attr_reader :name
40
+
41
+ ##
42
+ # @return [Array<SoberSwag::OutputObject::Fields>] the fields defined in this view.
43
+ attr_reader :fields
23
44
 
45
+ ##
46
+ # Serialize an object according to this view.
47
+ # @param object what to serialize
48
+ # @param opts [Hash] arbitrary options
49
+ # @return [Hash] the serialized result
24
50
  def serialize(obj, opts = {})
25
51
  serializer.serialize(obj, opts)
26
52
  end
27
53
 
54
+ ##
55
+ # Get the type of this view.
56
+ # @return [Class] the type, a subclass of {Dry::Struct}
28
57
  def type
29
58
  serializer.type
30
59
  end
31
60
 
61
+ ##
62
+ # Excludes a field with the given name from this view.
63
+ # @param name [Symbol] field to exclude.
64
+ # @return [nil] nothing interesting
32
65
  def except!(name)
33
66
  @fields.reject! { |f| f.name == name }
34
67
  end
35
68
 
69
+ ##
70
+ # Always raises {NestingError}
71
+ # @raise {NestingError} always
36
72
  def view(*)
37
73
  raise NestingError, 'no views in views'
38
74
  end
39
75
 
76
+ ##
77
+ # Adds a field do this view.
78
+ # @param field [SoberSwag::OutputObject::Field]
79
+ # @return [nil] nothing interesting
40
80
  def add_field!(field)
41
81
  @fields << field
42
82
  end
43
83
 
84
+ ##
85
+ # Pretty show for humans
86
+ # @return [String]
44
87
  def to_s
45
88
  "<SoberSwag::OutputObject::View(#{identifier})>"
46
89
  end
@@ -48,6 +91,8 @@ module SoberSwag
48
91
  ##
49
92
  # Get the serializer defined by this view.
50
93
  # WARNING: Don't add more fields after you call this.
94
+ #
95
+ # @return [SoberSwag::Serializer::FieldList]
51
96
  def serializer
52
97
  @serializer ||=
53
98
  SoberSwag::Serializer::FieldList.new(fields).tap { |s| s.identifier(identifier) }
@@ -5,7 +5,7 @@ module SoberSwag
5
5
  # Create a serializer that is heavily inspired by the "Blueprinter" library.
6
6
  # This allows you to make "views" and such inside.
7
7
  #
8
- # Under the hood, this is actually all based on {SoberSwag::Serialzier::Base}.
8
+ # Under the hood, this is actually all based on {SoberSwag::Serializer::Base}.
9
9
  class OutputObject < SoberSwag::Serializer::Base
10
10
  autoload(:Field, 'sober_swag/output_object/field')
11
11
  autoload(:Definition, 'sober_swag/output_object/definition')
@@ -20,7 +20,7 @@ module SoberSwag
20
20
  #
21
21
  # PersonSerializer = SoberSwag::OutputObject.define do
22
22
  # field :id, primitive(:Integer)
23
- # field :name, primtive(:String).optional
23
+ # field :name, primitive(:String).optional
24
24
  #
25
25
  # view :complex do
26
26
  # field :age, primitive(:Integer)
@@ -32,9 +32,12 @@ module SoberSwag
32
32
  # However, this is only a hack to get rid of the weird naming issue when
33
33
  # generating swagger from dry structs: their section of the schema area
34
34
  # is defined by their *Ruby Class Name*. In the future, if we get rid of this,
35
- # we might be able to keep this on the value-level, in which case {#define}
35
+ # we might be able to keep this on the value-level, in which case {.define}
36
36
  # can simply return an *instance* of SoberSwag::Serializer that does
37
37
  # the correct thing, with the name you give it. This works for now, though.
38
+ #
39
+ # @return [Class] the serializer generated.
40
+ # @yieldself [SoberSwag::OutputObject::Definition]
38
41
  def self.define(&block)
39
42
  d = Definition.new.tap do |o|
40
43
  o.instance_eval(&block)
@@ -42,28 +45,51 @@ module SoberSwag
42
45
  new(d.fields, d.views, d.identifier)
43
46
  end
44
47
 
48
+ ##
49
+ # @param fields [Array<SoberSwag::OutputObject::Field>] the fields for this OutputObject
50
+ # @param views [Array<SoberSwag::OutputObject::View>] the views for this OutputObject
51
+ # @param identifier [String] the external identifier for this OutputObject
45
52
  def initialize(fields, views, identifier)
46
53
  @fields = fields
47
54
  @views = views
48
55
  @identifier = identifier
49
56
  end
50
57
 
51
- attr_reader :fields, :views, :identifier
58
+ ##
59
+ # @return [Array<SoberSwag::OutputObject::Field>]
60
+ attr_reader :fields
61
+ ##
62
+ # @return [Array<SoberSwag::OutputObject::View>]
63
+ attr_reader :views
64
+ ##
65
+ # @return [String] the external ID to use for this object
66
+ attr_reader :identifier
52
67
 
68
+ ##
69
+ # Perform serialization.
53
70
  def serialize(obj, opts = {})
54
71
  serializer.serialize(obj, opts)
55
72
  end
56
73
 
74
+ ##
75
+ # Get a Dry::Struct of the type this OutputObject will serialize to.
57
76
  def type
58
77
  serializer.type
59
78
  end
60
79
 
80
+ ##
81
+ # Get a serializer for a single view contained in this output object.
82
+ # Note: given `:base`, it will return a serializer for the base OutputObject
83
+ # @param name [Symbol] the name of the view
84
+ # @return [SoberSwag::Serializer::Base] the serializer
61
85
  def view(name)
62
86
  return base_serializer if name == :base
63
87
 
64
88
  @views.find { |v| v.name == name }
65
89
  end
66
90
 
91
+ ##
92
+ # A serializer for the "base type" of this OutputObject, with no views.
67
93
  def base
68
94
  base_serializer
69
95
  end
@@ -72,30 +98,25 @@ module SoberSwag
72
98
  # Compile down this to an appropriate serializer.
73
99
  # It uses {SoberSwag::Serializer::Conditional} to do view-parsing,
74
100
  # and {SoberSwag::Serializer::FieldList} to do the actual serialization.
75
- def serializer # rubocop:disable Metrics/MethodLength
101
+ #
102
+ # @todo: optimize view selection to use binary instead of linear search
103
+ def serializer
76
104
  @serializer ||=
77
105
  begin
78
- views.reduce(base_serializer) do |base, view|
79
- view_serializer = view.serializer
80
- SoberSwag::Serializer::Conditional.new(
81
- proc do |object, options|
82
- if options[:view].to_s == view.name.to_s
83
- [:left, object]
84
- else
85
- [:right, object]
86
- end
87
- end,
88
- view_serializer,
89
- base
90
- )
91
- end
106
+ view_choices = views.map { |view| [view.name.to_s, view.serializer] }.to_h
107
+ view_choices['base'] = base_serializer
108
+ SoberSwag::Serializer::Hash.new(view_choices, base, proc { |_, options| options[:view]&.to_s })
92
109
  end
93
110
  end
94
111
 
112
+ ##
113
+ # @return [String]
95
114
  def to_s
96
115
  "<SoberSwag::OutputObject(#{identifier})>"
97
116
  end
98
117
 
118
+ ##
119
+ # @return [SoberSwag::Serializer::FieldList] serializer for this output object.
99
120
  def base_serializer
100
121
  @base_serializer ||= SoberSwag::Serializer::FieldList.new(fields).tap do |s|
101
122
  s.identifier(identifier)
@@ -1,13 +1,19 @@
1
1
  module SoberSwag
2
2
  ##
3
3
  # Parses a *Dry-Types Schema* into a set of nodes we can use to compile.
4
- # This is mostly because the vistior pattern sucks and catamorphisms are nice.
4
+ # This is mostly because the visitor pattern sucks and catamorphisms are nice.
5
+ #
6
+ # Do not use this class directly, as it is not part of the public api.
7
+ # Instead, use classes from the {SoberSwag::Compiler} namespace.
5
8
  class Parser
6
9
  def initialize(node)
7
10
  @node = node
8
11
  @found = Set.new
9
12
  end
10
13
 
14
+ ##
15
+ # Compile to one of our internal nodes.
16
+ # @return [SoberSwag::Nodes::Base] the node that describes this type.
11
17
  def to_syntax # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
12
18
  case @node
13
19
  when Dry::Types::Default