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,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
@@ -10,19 +10,49 @@ module SoberSwag
10
10
  # This is a very weird, not-very-Ruby-like abstraction, *upon which* we can build abstractions that are actually use for users.
11
11
  # It lets you build abstractions like "Use this serializer if a type has this class, otherwise use this other one."
12
12
  # When composed together, you can make arbitrary decision trees.
13
+ #
14
+ # This class is heavily inspired by
15
+ # the [Decideable](https://hackage.haskell.org/package/contravariant-1.5.3/docs/Data-Functor-Contravariant-Divisible.html#t:Decidable)
16
+ # typeclass from Haskell.
13
17
  class Conditional < Base
14
18
  ##
15
19
  # Error thrown when a chooser proc returns a non left-or-right value.
16
20
  class BadChoiceError < Error; end
17
21
 
22
+ ##
23
+ # Create a new conditional serializer, from a "chooser" proc, a "left" serializer, and a "right" serializer.
24
+ #
25
+ # @param chooser [Proc,Lambda] the proc that chooses which "side" to use
26
+ # @param left [SoberSwag::Serializer::Base] a serializer for the "left" side
27
+ # @param right [SoberSwag::Serializer::Base] a serializer for the "right" side
18
28
  def initialize(chooser, left, right)
19
29
  @chooser = chooser
20
30
  @left = left
21
31
  @right = right
22
32
  end
23
33
 
24
- attr_reader :chooser, :left, :right
34
+ ##
35
+ # @return [Proc,Lambda] the "chooser" proc.
36
+ attr_reader :chooser
37
+
38
+ ##
39
+ # @return [SoberSwag::Serializer::Base] the serializer to use if the "chooser" proc chooses `:right`.
40
+ # Also called the "left-side serializer."
41
+ attr_reader :left
25
42
 
43
+ ##
44
+ # @return [SoberSwag::Serializer::Base] the serializer to use if the "chooser" proc chooses `:right`.
45
+ # Also called the "right-side serializer."
46
+ attr_reader :right
47
+
48
+ ##
49
+ # First, call {#chooser} with `object` and `options` to see what serializer to use, and *what* to serialize.
50
+ # Then, if it returns `[:left, val]`, use {#left} to serialize `val`.
51
+ # Otherwise, if it returns `[:right, val]`, use {#right} to serialize `val`.
52
+ # If it returns neither, throw {BadChoiceError}.
53
+ #
54
+ # @raise [BadChoiceError] if {#chooser} did not choose what side to use
55
+ # @return [Hash] a JSON-compatible object
26
56
  def serialize(object, options = {})
27
57
  tag, val = chooser.call(object, options)
28
58
  case tag
@@ -58,6 +88,8 @@ module SoberSwag
58
88
  left.lazy_type? || right.lazy_type?
59
89
  end
60
90
 
91
+ ##
92
+ # Finalize both {#left} and {#right}
61
93
  def finalize_lazy_type!
62
94
  [left, right].each(&:finalize_lazy_type!)
63
95
  end
@@ -1,13 +1,18 @@
1
1
  module SoberSwag
2
2
  module Serializer
3
3
  ##
4
- # Extract out a hash from a list of
5
- # name/serializer pairs.
4
+ # Extracts a JSON hash from a list of {SoberSwag::OutputObject::Field} structs.
6
5
  class FieldList < Base
6
+ ##
7
+ # Create a new field-list serializer.
8
+ #
9
+ # @param field_list [Array<SoberSwag::OutputObject::Field>] descriptions of each field
7
10
  def initialize(field_list)
8
11
  @field_list = field_list
9
12
  end
10
13
 
14
+ ##
15
+ # @return [Array<SoberSwag::OutputObject::Field>] the list of fields to use.
11
16
  attr_reader :field_list
12
17
 
13
18
  ##
@@ -16,16 +21,27 @@ module SoberSwag
16
21
  SoberSwag::Serializer.Primitive(SoberSwag::Types.const_get(symbol))
17
22
  end
18
23
 
24
+ ##
25
+ # Serialize an object to a JSON hash by using each field in the list.
26
+ # @param object [Object] object to serialize
27
+ # @param options [Hash] arbitrary options
28
+ # @return [Hash] serialized object.
19
29
  def serialize(object, options = {})
20
30
  field_list.map { |field|
21
31
  [field.name, field.serializer.serialize(object, options)]
22
32
  }.to_h
23
33
  end
24
34
 
35
+ ##
36
+ # Construct a Dry::Struct from the fields given.
37
+ # This Struct will be swagger-able.
38
+ # @return [Dry::Struct]
25
39
  def type
26
40
  @type ||= make_struct_type!
27
41
  end
28
42
 
43
+ ##
44
+ # These types are always constructed lazily.
29
45
  def lazy_type?
30
46
  true
31
47
  end
@@ -3,12 +3,21 @@ module SoberSwag
3
3
  ##
4
4
  # A new serializer by mapping over the serialization function
5
5
  class Mapped < Base
6
+ ##
7
+ # Create a new mapped serializer.
8
+ # @param base [SoberSwag::Serializer::Base] a serializer to use after mapping
9
+ # @param map_f [Proc,Lambda] a mapping function to use before serialization
6
10
  def initialize(base, map_f)
7
11
  @base = base
8
12
  @map_f = map_f
9
13
  end
10
14
 
11
- attr_reader :base, :map_f
15
+ ##
16
+ # @return [SoberSwag::Serializer::Base] serializer to use after mapping
17
+ attr_reader :base
18
+ ##
19
+ # @return [Proc, Lambda, #call] function to use before serialization
20
+ attr_reader :map_f
12
21
 
13
22
  def serialize(object, options = {})
14
23
  @base.serialize(@map_f.call(object), options)
@@ -5,11 +5,20 @@ module SoberSwag
5
5
  # this can be used to make a serializer of type 'A | nil'.
6
6
  #
7
7
  # Or, put another way, makes serializers not crash on nil values.
8
+ # If {#serialize} is passed nil, it will return `nil` immediately, and not
9
+ # try to call the serializer of {#inner}.
8
10
  class Optional < Base
11
+ ##
12
+ # An error thrown when trying to nest optional serializers.
13
+ class NestedOptionalError < Error; end
14
+ ##
15
+ # @param inner [SoberSwag::Serializer::Base] the serializer to use for non-nil values
9
16
  def initialize(inner)
10
17
  @inner = inner
11
18
  end
12
19
 
20
+ ##
21
+ # @return [SoberSwag::Serializer::Base] the serializer to use for non-nil values.
13
22
  attr_reader :inner
14
23
 
15
24
  def lazy_type?
@@ -24,6 +33,9 @@ module SoberSwag
24
33
  @inner.finalize_lazy_type!
25
34
  end
26
35
 
36
+ ##
37
+ # If `object` is nil, return `nil`.
38
+ # Otherwise, call `inner.serialize(object, options)`.
27
39
  def serialize(object, options = {})
28
40
  if object.nil?
29
41
  object
@@ -36,8 +48,13 @@ module SoberSwag
36
48
  inner.type.optional
37
49
  end
38
50
 
51
+ ##
52
+ # Since nesting optional types is bad, this will always raise an ArgumentError
53
+ #
54
+ # @raise [NestedOptionalError] always
55
+ # @return [void] nothing, always raises.
39
56
  def optional(*)
40
- raise ArgumentError, 'no nesting optionals please'
57
+ raise NestedOptionalError, 'no nesting optionals please'
41
58
  end
42
59
  end
43
60
  end
@@ -4,6 +4,9 @@ module SoberSwag
4
4
  # A class that does *no* serialization: you give it a type,
5
5
  # and it will pass any serialized input on verbatim.
6
6
  class Primitive < Base
7
+ ##
8
+ # Construct a primitive serializer with a description of the type it serializes to.
9
+ # @param type [Class] a swagger-able type
7
10
  def initialize(type)
8
11
  @type = type
9
12
  end
@@ -21,7 +21,7 @@ module SoberSwag
21
21
  # Start up.
22
22
  #
23
23
  # @param controller_proc [Proc] a proc that, when called, gives a list of {SoberSwag::Controller}s to document
24
- # @param cache [Bool | Proc] if we should cache our defintions (default false)
24
+ # @param cache [Bool | Proc] if we should cache our definitions (default false)
25
25
  # @param redoc_version [String] what version of the redoc library to use to display UI (default 'next', the latest version).
26
26
  def initialize(
27
27
  controller_proc: RAILS_CONTROLLER_PROC,
@@ -60,6 +60,8 @@ module SoberSwag
60
60
  </html>
61
61
  HTML
62
62
 
63
+ ##
64
+ # Standard Rack call method.
63
65
  def call(env)
64
66
  req = Rack::Request.new(env)
65
67
  if req.path_info&.match?(/json/si) || req.get_header('Accept')&.match?(/json/si)
@@ -69,6 +71,8 @@ module SoberSwag
69
71
  end
70
72
  end
71
73
 
74
+ private
75
+
72
76
  def generate_json_string
73
77
  if cache?
74
78
  @json_string ||= JSON.dump(generate_swagger)