sober_swag 0.15.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +15 -0
  3. data/.github/workflows/lint.yml +4 -9
  4. data/.github/workflows/ruby.yml +2 -6
  5. data/.gitignore +4 -0
  6. data/.rubocop.yml +50 -5
  7. data/.yardopts +7 -0
  8. data/CHANGELOG.md +29 -1
  9. data/Gemfile +8 -0
  10. data/README.md +155 -4
  11. data/bin/rspec +29 -0
  12. data/docs/serializers.md +18 -13
  13. data/example/Gemfile +2 -2
  14. data/example/app/controllers/people_controller.rb +4 -0
  15. data/example/app/controllers/posts_controller.rb +5 -0
  16. data/example/config/environments/production.rb +1 -1
  17. data/lib/sober_swag.rb +6 -1
  18. data/lib/sober_swag/compiler.rb +29 -3
  19. data/lib/sober_swag/compiler/path.rb +49 -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/controller.rb +42 -15
  24. data/lib/sober_swag/controller/route.rb +133 -28
  25. data/lib/sober_swag/input_object.rb +117 -7
  26. data/lib/sober_swag/nodes/array.rb +19 -0
  27. data/lib/sober_swag/nodes/attribute.rb +45 -4
  28. data/lib/sober_swag/nodes/base.rb +27 -7
  29. data/lib/sober_swag/nodes/binary.rb +30 -13
  30. data/lib/sober_swag/nodes/enum.rb +16 -1
  31. data/lib/sober_swag/nodes/list.rb +20 -0
  32. data/lib/sober_swag/nodes/nullable_primitive.rb +3 -0
  33. data/lib/sober_swag/nodes/object.rb +4 -1
  34. data/lib/sober_swag/nodes/one_of.rb +11 -3
  35. data/lib/sober_swag/nodes/primitive.rb +34 -2
  36. data/lib/sober_swag/nodes/sum.rb +8 -0
  37. data/lib/sober_swag/output_object.rb +35 -4
  38. data/lib/sober_swag/output_object/definition.rb +31 -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/parser.rb +7 -1
  43. data/lib/sober_swag/serializer/array.rb +27 -3
  44. data/lib/sober_swag/serializer/base.rb +75 -25
  45. data/lib/sober_swag/serializer/conditional.rb +33 -1
  46. data/lib/sober_swag/serializer/field_list.rb +18 -2
  47. data/lib/sober_swag/serializer/mapped.rb +10 -1
  48. data/lib/sober_swag/serializer/optional.rb +18 -1
  49. data/lib/sober_swag/serializer/primitive.rb +3 -0
  50. data/lib/sober_swag/server.rb +27 -11
  51. data/lib/sober_swag/type/named.rb +14 -0
  52. data/lib/sober_swag/types/comma_array.rb +4 -0
  53. data/lib/sober_swag/version.rb +1 -1
  54. data/sober_swag.gemspec +2 -2
  55. metadata +13 -10
@@ -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) }
@@ -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
@@ -1,30 +1,54 @@
1
1
  module SoberSwag
2
2
  module Serializer
3
3
  ##
4
- # Make a serialize of arrays out of a serializer of the elements
4
+ # Transform a serializer that works on elements to one that works on Arrays
5
5
  class Array < Base
6
+ ##
7
+ # Make an array serializer out of another serializer.
8
+ # @param element_serializer [SoberSwag::Serializer::Base] the serializer to use for each element.
6
9
  def initialize(element_serializer)
7
10
  @element_serializer = element_serializer
8
11
  end
9
12
 
13
+ ##
14
+ # The serializer that will be used for each element in the array.
15
+ #
16
+ # @return [SoberSwag::Serializer::Base]
17
+ attr_reader :element_serializer
18
+
19
+ ##
20
+ # Delegates to {#element_serializer}
10
21
  def lazy_type?
11
22
  @element_serializer.lazy_type?
12
23
  end
13
24
 
25
+ ##
26
+ # Delegates to {#element_serializer}, wrapped in an array
14
27
  def lazy_type
15
28
  SoberSwag::Types::Array.of(@element_serializer.lazy_type)
16
29
  end
17
30
 
31
+ ##
32
+ # Delegates to {#element_serializer}
18
33
  def finalize_lazy_type!
19
34
  @element_serializer.finalize_lazy_type!
20
35
  end
21
36
 
22
- attr_reader :element_serializer
23
-
37
+ ##
38
+ # Serialize an array of objects that can be serialized with {#element_serializer}
39
+ # by calling `element_serializer.serialize` for each item in this array.
40
+ #
41
+ # Note: since ruby is duck-typed, anything that responds to the `#map` method from
42
+ # [Enumerable](https://ruby-doc.org/core-3.0.0/Enumerable.html) should work!
43
+ #
44
+ # @param object [Array<Object>,#map] collection of objects to serialize
45
+ # @return [Array<Object>] JSON-compatible array
24
46
  def serialize(object, options = {})
25
47
  object.map { |a| element_serializer.serialize(a, options) }
26
48
  end
27
49
 
50
+ ##
51
+ # The type of items returned from {#serialize}
28
52
  def type
29
53
  SoberSwag::Types::Array.of(element_serializer.type)
30
54
  end
@@ -1,34 +1,91 @@
1
1
  module SoberSwag
2
2
  module Serializer
3
3
  ##
4
- # Base interface class that all other serializers are subclasses of.
5
- # This also defines methods as stubs, which is sort of bad ruby style, but makes documentation
6
- # easier to generate.
4
+ # Base class for everything that provides serialization functionality in SoberSwag.
5
+ # SoberSwag serializers transform Ruby types into JSON types, with some associated *schema*.
6
+ # This schema is then used in the generated OpenAPI V3 documentation.
7
7
  class Base
8
8
  ##
9
9
  # Return a new serializer that is an *array* of elements of this serializer.
10
+ # This serializer will take in an array, and use `self` to serialize every element.
11
+ #
12
+ # @return [SoberSwag::Serializer::Array]
10
13
  def array
11
14
  SoberSwag::Serializer::Array.new(self)
12
15
  end
13
16
 
14
17
  ##
15
- # Returns a serializer that will pass `nil` values on unscathed
18
+ # Returns a serializer that will pass `nil` values on unscathed.
19
+ # That means that if you try to serialize `nil` with it, it will result in a JSON `null`.
20
+ # @return [SoberSwag::Serializer::Optional]
16
21
  def optional
17
22
  SoberSwag::Serializer::Optional.new(self)
18
23
  end
19
24
 
20
25
  alias nilable optional
21
26
 
27
+ ##
28
+ # Add metadata onto the *type* of a serializer.
29
+ # Note that this *returns a new serializer with metadata added* and does not perform mutation.
30
+ # @param hash [Hash] the metadata to set.
31
+ # @return [SoberSwag::Serializer::Meta] a serializer with metadata added
32
+ def meta(hash)
33
+ SoberSwag::Serializer::Meta.new(self, hash)
34
+ end
35
+
36
+ ##
37
+ # Get a new serializer that will first run the given block before serializing an object.
38
+ # For example, if you have a serializer for strings called `StringSerializer`,
39
+ # and you want to serialize `Date` objects via encoding them to a standardized string format,
40
+ # you can use:
41
+ #
42
+ # ```
43
+ # DateSerializer = StringSerializer.via_map do |date|
44
+ # date.strftime('%Y-%m-%d')
45
+ # end
46
+ # ```
47
+ #
48
+ # @yieldparam [Object] the object before serialization
49
+ # @yieldreturn [Object] a transformed object, that will
50
+ # be passed to {#serialize}
51
+ # @return [SoberSwag::Serializer::Mapped] the new serializer
52
+ def via_map(&block)
53
+ SoberSwag::Serializer::Mapped.new(self, block)
54
+ end
55
+
22
56
  ##
23
57
  # Is this type lazily defined?
24
58
  #
25
- # Used for mutual recursion.
59
+ # If we have two serializers that are *mutually recursive*, we need to do some "fun" magic to make that work.
60
+ # This comes up in a case like:
61
+ #
62
+ # ```ruby
63
+ # SchoolClass = SoberSwag::OutputObject.define do
64
+ # field :name, primitive(:String)
65
+ # view :detail do
66
+ # field :students, -> { Student.view(:base) }
67
+ # end
68
+ # end
69
+ #
70
+ # Student = SoberSwag::OutputObject.define do
71
+ # field :name, primitive(:String)
72
+ # view :detail do
73
+ # field :classes, -> { SchoolClass.view(:base) }
74
+ # end
75
+ # end
76
+ # ```
77
+ #
78
+ # This would result in an infinite loop if we tried to define the type struct the easy way.
79
+ # So, we instead use mutation to achieve "laziness."
26
80
  def lazy_type?
27
81
  false
28
82
  end
29
83
 
30
84
  ##
31
85
  # The lazy version of this type, for mutual recursion.
86
+ # @see #lazy_type? for why this is needed
87
+ #
88
+ # Once you call {#finalize_lazy_type!}, the type will be "fleshed out," and can be actually used.
32
89
  def lazy_type
33
90
  type
34
91
  end
@@ -43,43 +100,36 @@ module SoberSwag
43
100
 
44
101
  ##
45
102
  # Serialize an object.
103
+ # @abstract
46
104
  def serialize(_object, _options = {})
47
105
  raise ArgumentError, 'not implemented!'
48
106
  end
49
107
 
50
108
  ##
51
109
  # Get the type that we serialize to.
110
+ # @abstract
52
111
  def type
53
112
  raise ArgumentError, 'not implemented!'
54
113
  end
55
114
 
56
115
  ##
57
- # Add metadata onto the *type* of a serializer.
58
- def meta(hash)
59
- SoberSwag::Serializer::Meta.new(self, hash)
60
- end
61
-
62
- ##
63
- # If I am a serializer for type 'a', and you give me a way to turn 'a's into 'b's,
64
- # I can give you a serializer for type 'b' by running the funciton you gave.
65
- # For example, if I am a serializer for {String}, and you know how to turn
66
- # an {Int} into a {String}, I can now serialize {Int}s (by turning them into a string).
116
+ # Returns self.
67
117
  #
68
- # Note that the *declared* type of this is *not* changed: from a user's perspective,
69
- # they see a "string"
70
- def via_map(&block)
71
- SoberSwag::Serializer::Mapped.new(self, block)
72
- end
73
-
74
- ##
75
- # Serializer lets you get a serializer from things that might be classes
76
- # because of the blueprint naming hack.
118
+ # This exists due to a hack.
77
119
  def serializer
78
120
  self
79
121
  end
80
122
 
81
123
  ##
82
- # Get the type name of this to be used externally, or set it if an argument is provided
124
+ # @overload identifier()
125
+ # Returns the external identifier, used to uniquely identify this object within
126
+ # the schemas section of an OpenAPI v3 document.
127
+ # @return [String] the identifier.
128
+ # @overload identifier(arg)
129
+ # Sets the external identifier to use to uniquely identify
130
+ # this object within the schemas section of an OpenAPI v3 document.
131
+ # @param arg [String] the identifier to use
132
+ # @return [String] the identifer set
83
133
  def identifier(arg = nil)
84
134
  @identifier = arg if arg
85
135
  @identifier