sober_swag 0.15.0 → 0.20.0

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