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,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)