sober_swag 0.19.0 → 0.20.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/lint.yml +1 -1
  3. data/.github/workflows/ruby.yml +1 -1
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +5 -1
  6. data/.yardopts +7 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +8 -0
  9. data/README.md +1 -1
  10. data/docs/serializers.md +3 -0
  11. data/example/Gemfile +1 -1
  12. data/example/config/environments/production.rb +1 -1
  13. data/lib/sober_swag.rb +6 -1
  14. data/lib/sober_swag/compiler.rb +29 -3
  15. data/lib/sober_swag/compiler/path.rb +42 -3
  16. data/lib/sober_swag/compiler/paths.rb +20 -0
  17. data/lib/sober_swag/compiler/primitive.rb +17 -0
  18. data/lib/sober_swag/compiler/type.rb +105 -22
  19. data/lib/sober_swag/controller.rb +39 -12
  20. data/lib/sober_swag/controller/route.rb +103 -20
  21. data/lib/sober_swag/input_object.rb +90 -7
  22. data/lib/sober_swag/nodes/array.rb +19 -0
  23. data/lib/sober_swag/nodes/attribute.rb +45 -4
  24. data/lib/sober_swag/nodes/base.rb +27 -7
  25. data/lib/sober_swag/nodes/binary.rb +30 -13
  26. data/lib/sober_swag/nodes/enum.rb +16 -1
  27. data/lib/sober_swag/nodes/list.rb +20 -0
  28. data/lib/sober_swag/nodes/nullable_primitive.rb +3 -0
  29. data/lib/sober_swag/nodes/object.rb +4 -1
  30. data/lib/sober_swag/nodes/one_of.rb +11 -3
  31. data/lib/sober_swag/nodes/primitive.rb +34 -2
  32. data/lib/sober_swag/nodes/sum.rb +8 -0
  33. data/lib/sober_swag/output_object.rb +35 -4
  34. data/lib/sober_swag/output_object/definition.rb +31 -1
  35. data/lib/sober_swag/output_object/field.rb +31 -11
  36. data/lib/sober_swag/output_object/field_syntax.rb +19 -3
  37. data/lib/sober_swag/output_object/view.rb +46 -1
  38. data/lib/sober_swag/parser.rb +7 -1
  39. data/lib/sober_swag/serializer/array.rb +27 -3
  40. data/lib/sober_swag/serializer/base.rb +75 -25
  41. data/lib/sober_swag/serializer/conditional.rb +33 -1
  42. data/lib/sober_swag/serializer/field_list.rb +18 -2
  43. data/lib/sober_swag/serializer/mapped.rb +10 -1
  44. data/lib/sober_swag/serializer/optional.rb +18 -1
  45. data/lib/sober_swag/serializer/primitive.rb +3 -0
  46. data/lib/sober_swag/server.rb +5 -1
  47. data/lib/sober_swag/type/named.rb +14 -0
  48. data/lib/sober_swag/types/comma_array.rb +4 -0
  49. data/lib/sober_swag/version.rb +1 -1
  50. metadata +3 -2
@@ -1,5 +1,8 @@
1
1
  module SoberSwag
2
2
  module Nodes
3
+ ##
4
+ # Exactly like a {SoberSwag::Nodes::Primitive} node, except it can be null.
5
+ # @todo: make this a boolean parameter of {SoberSwag::Nodes::Primitive}
3
6
  class NullablePrimitive < Primitive
4
7
  end
5
8
  end
@@ -2,8 +2,11 @@ module SoberSwag
2
2
  module Nodes
3
3
  ##
4
4
  # Objects might have attribute keys, so they're
5
- # basically a list of attributes
5
+ # basically a list of attributes.
6
6
  class Object < SoberSwag::Nodes::Array
7
+ ##
8
+ # @return [Hash{Symbol => Array<SoberSwag::Nodes::Attribute>}]
9
+ # the attributes, wrapped in an `attributes:` key.
7
10
  def deconstruct_keys(_)
8
11
  { attributes: @elements }
9
12
  end
@@ -1,11 +1,19 @@
1
1
  module SoberSwag
2
2
  module Nodes
3
3
  ##
4
- # Swagges uses an array of OneOf types, so we
5
- # transform our sum nodes into this
4
+ # OpenAPI v3 represents types that are a "choice" between multiple alternatives as an array.
5
+ # However, it is easier to model these as a sum type initially: if a type can be either an `A`, a `B`, or a `C`, we can model this as:
6
+ #
7
+ # `Sum.new(A, Sum.new(B, C))`.
8
+ #
9
+ # This means we only ever need to deal with two types at once.
10
+ # So, we initially serialize to a sum type, then later transform to this array type for further serialization.
6
11
  class OneOf < ::SoberSwag::Nodes::Array
12
+ ##
13
+ # @return [Hash{Symbol => SoberSwag::Nodes::Base}]
14
+ # the alternatives, wrapped in an `alternatives:` key.
7
15
  def deconstruct_keys(_)
8
- { alternatives: @elemenets }
16
+ { alternatives: @elements }
9
17
  end
10
18
  end
11
19
  end
@@ -1,27 +1,59 @@
1
1
  module SoberSwag
2
2
  module Nodes
3
3
  ##
4
- # Root node of the tree
4
+ # Root node of the tree.
5
+ # This contains "primitive values."
6
+ # Initially, this is probably the types of attributes or array elements or whatever.
7
+ # As we use {#cata} to transform this, it will start containing swagger-compatible type objects.
8
+ #
9
+ # This node can contain metadata as well.
5
10
  class Primitive < Base
11
+ ##
12
+ # @param value [Object] the primitive value to store
13
+ # @param metadata [Hash] the metadata
6
14
  def initialize(value, metadata = {})
7
15
  @value = value
8
16
  @metadata = metadata
9
17
  end
10
18
 
11
- attr_reader :value, :metadata
19
+ ##
20
+ # @return [Object] the contained value
21
+ attr_reader :value
12
22
 
23
+ ##
24
+ # @return [Hash] metadata associated with the contained value.
25
+ attr_reader :metadata
26
+
27
+ ##
28
+ # @see SoberSwag::Nodes::Base#map
29
+ #
30
+ # This will actually map over {#value}.
13
31
  def map(&block)
14
32
  self.class.new(block.call(value), metadata.dup)
15
33
  end
16
34
 
35
+ ##
36
+ # Deconstructs to the value and the metadata.
37
+ #
38
+ # @return [Array(Object, Hash)] containing value and metadata.
17
39
  def deconstruct
18
40
  [value, metadata]
19
41
  end
20
42
 
43
+ ##
44
+ # Wraps the attributes in a hash.
45
+ #
46
+ # @return [Hash{Symbol => Object, Hash}]
47
+ # {#value} in `value:`, {#metadata} in `metadata:`
21
48
  def deconstruct_keys(_)
22
49
  { value: value, metadata: metadata }
23
50
  end
24
51
 
52
+ ##
53
+ # @see SoberSwag::Nodes::Base#cata
54
+ # As this is a root node, we actually call the block directly.
55
+ # @yieldparam node [SoberSwag::Nodes::Primitive] this node.
56
+ # @return whatever the block returns.
25
57
  def cata(&block)
26
58
  block.call(self.class.new(value, metadata))
27
59
  end
@@ -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
@@ -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,11 @@ 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.
38
40
  def self.define(&block)
39
41
  d = Definition.new.tap do |o|
40
42
  o.instance_eval(&block)
@@ -42,28 +44,51 @@ module SoberSwag
42
44
  new(d.fields, d.views, d.identifier)
43
45
  end
44
46
 
47
+ ##
48
+ # @param fields [Array<SoberSwag::OutputObject::Field>] the fields for this OutputObject
49
+ # @param views [Array<SoberSwag::OutputObject::View>] the views for this OutputObject
50
+ # @param identifier [String] the external identifier for this OutputObject
45
51
  def initialize(fields, views, identifier)
46
52
  @fields = fields
47
53
  @views = views
48
54
  @identifier = identifier
49
55
  end
50
56
 
51
- attr_reader :fields, :views, :identifier
57
+ ##
58
+ # @return [Array<SoberSwag::OutputObject::Field>]
59
+ attr_reader :fields
60
+ ##
61
+ # @return [Array<SoberSwag::OutputObject::View>]
62
+ attr_reader :views
63
+ ##
64
+ # @return [String] the external ID to use for this object
65
+ attr_reader :identifier
52
66
 
67
+ ##
68
+ # Perform serialization.
53
69
  def serialize(obj, opts = {})
54
70
  serializer.serialize(obj, opts)
55
71
  end
56
72
 
73
+ ##
74
+ # Get a Dry::Struct of the type this OutputObject will serialize to.
57
75
  def type
58
76
  serializer.type
59
77
  end
60
78
 
79
+ ##
80
+ # Get a serializer for a single view contained in this output object.
81
+ # Note: given `:base`, it will return a serializer for the base OutputObject
82
+ # @param name [Symbol] the name of the view
83
+ # @return [SoberSwag::Serializer::Base] the serializer
61
84
  def view(name)
62
85
  return base_serializer if name == :base
63
86
 
64
87
  @views.find { |v| v.name == name }
65
88
  end
66
89
 
90
+ ##
91
+ # A serializer for the "base type" of this OutputObject, with no views.
67
92
  def base
68
93
  base_serializer
69
94
  end
@@ -72,6 +97,8 @@ module SoberSwag
72
97
  # Compile down this to an appropriate serializer.
73
98
  # It uses {SoberSwag::Serializer::Conditional} to do view-parsing,
74
99
  # and {SoberSwag::Serializer::FieldList} to do the actual serialization.
100
+ #
101
+ # @todo: optimize view selection to use binary instead of linear search
75
102
  def serializer # rubocop:disable Metrics/MethodLength
76
103
  @serializer ||=
77
104
  begin
@@ -92,10 +119,14 @@ module SoberSwag
92
119
  end
93
120
  end
94
121
 
122
+ ##
123
+ # @return [String]
95
124
  def to_s
96
125
  "<SoberSwag::OutputObject(#{identifier})>"
97
126
  end
98
127
 
128
+ ##
129
+ # @return [SoberSwag::Serializer::FieldList] serializer for this output object.
99
130
  def base_serializer
100
131
  @base_serializer ||= SoberSwag::Serializer::FieldList.new(fields).tap do |s|
101
132
  s.identifier(identifier)
@@ -9,14 +9,30 @@ 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 new field to the fields array
24
+ # @param field [SoberSwag::OutputObject::Field]
16
25
  def add_field!(field)
17
26
  @fields << field
18
27
  end
19
28
 
29
+ ##
30
+ # Define a new view, with the view DSL
31
+ # @param name [Symbol] the name of the view
32
+ # @param inherits [Symbol] the name of another view this
33
+ # view will "inherit" from
34
+ # @yieldself [SoberSwag::OutputObject::View]
35
+ # @return [nil] nothing interesting.
20
36
  def view(name, inherits: nil, &block)
21
37
  initial_fields =
22
38
  if inherits.nil? || inherits == :base
@@ -31,6 +47,14 @@ module SoberSwag
31
47
  @views << view
32
48
  end
33
49
 
50
+ ##
51
+ # @overload identifier()
52
+ # Get the identifier of this output object.
53
+ # @return [String] the identifier
54
+ # @overload identifier(arg)
55
+ # Set the identifier of this output object.
56
+ # @param arg [String] the external identifier to use for this OutputObject
57
+ # @return [String] `arg`
34
58
  def identifier(arg = nil)
35
59
  @identifier = arg if arg
36
60
  @identifier
@@ -38,6 +62,12 @@ module SoberSwag
38
62
 
39
63
  private
40
64
 
65
+ ##
66
+ # Get the already-defined view with a specific name.
67
+ #
68
+ # @param name [Symbol] name of view to look up
69
+ # @return [SoberSwag::OutputObject::View] the view found
70
+ # @raise [ArgumentError] if no view with that name found
41
71
  def find_view(name)
42
72
  @views.find { |view| view.name == name } || (raise ArgumentError, "no view #{name.inspect} defined!")
43
73
  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) }