sober_swag 0.19.0 → 0.20.0

Sign up to get free protection for your applications and to get access to all the features.
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) }