sober_swag 0.14.0 → 0.19.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +15 -0
  3. data/.github/workflows/lint.yml +41 -9
  4. data/.github/workflows/ruby.yml +1 -5
  5. data/.gitignore +2 -0
  6. data/.rubocop.yml +50 -5
  7. data/CHANGELOG.md +34 -0
  8. data/README.md +155 -4
  9. data/bin/console +36 -0
  10. data/bin/rspec +29 -0
  11. data/docs/serializers.md +74 -9
  12. data/example/Gemfile +2 -2
  13. data/example/app/controllers/application_controller.rb +5 -0
  14. data/example/app/controllers/people_controller.rb +4 -0
  15. data/example/app/controllers/posts_controller.rb +5 -0
  16. data/lib/sober_swag.rb +1 -0
  17. data/lib/sober_swag/compiler.rb +1 -0
  18. data/lib/sober_swag/compiler/path.rb +7 -0
  19. data/lib/sober_swag/compiler/primitive.rb +77 -0
  20. data/lib/sober_swag/compiler/type.rb +57 -96
  21. data/lib/sober_swag/controller.rb +3 -9
  22. data/lib/sober_swag/controller/route.rb +30 -8
  23. data/lib/sober_swag/input_object.rb +36 -3
  24. data/lib/sober_swag/nodes/attribute.rb +8 -7
  25. data/lib/sober_swag/nodes/enum.rb +2 -2
  26. data/lib/sober_swag/nodes/primitive.rb +1 -1
  27. data/lib/sober_swag/output_object/definition.rb +14 -2
  28. data/lib/sober_swag/output_object/field_syntax.rb +16 -0
  29. data/lib/sober_swag/parser.rb +10 -5
  30. data/lib/sober_swag/serializer/base.rb +2 -0
  31. data/lib/sober_swag/serializer/meta.rb +3 -1
  32. data/lib/sober_swag/server.rb +22 -10
  33. data/lib/sober_swag/type.rb +7 -0
  34. data/lib/sober_swag/type/named.rb +35 -0
  35. data/lib/sober_swag/types.rb +2 -0
  36. data/lib/sober_swag/types/comma_array.rb +17 -0
  37. data/lib/sober_swag/version.rb +1 -1
  38. data/sober_swag.gemspec +2 -2
  39. metadata +18 -10
@@ -17,12 +17,6 @@ module SoberSwag
17
17
  include ::Dry::Types()
18
18
  end
19
19
 
20
- included do
21
- rescue_from Dry::Struct::Error do
22
- head :bad_request
23
- end
24
- end
25
-
26
20
  class_methods do
27
21
  ##
28
22
  # Define a new action with the given HTTP method, action name, and path.
@@ -96,7 +90,7 @@ module SoberSwag
96
90
  r = current_action_def
97
91
  raise UndefinedPathError unless r&.path_params_class
98
92
 
99
- r.path_params_class.new(request.path_parameters)
93
+ r.path_params_class.call(request.path_parameters)
100
94
  end
101
95
  end
102
96
 
@@ -110,7 +104,7 @@ module SoberSwag
110
104
  r = current_action_def
111
105
  raise UndefinedBodyError unless r&.request_body_class
112
106
 
113
- r.request_body_class.new(body_params)
107
+ r.request_body_class.call(body_params)
114
108
  end
115
109
  end
116
110
 
@@ -124,7 +118,7 @@ module SoberSwag
124
118
  r = current_action_def
125
119
  raise UndefinedQueryError unless r&.query_params_class
126
120
 
127
- r.query_params_class.new(request.query_parameters)
121
+ r.query_params_class.call(request.query_parameters)
128
122
  end
129
123
  end
130
124
 
@@ -9,28 +9,36 @@ module SoberSwag
9
9
  @action_name = action_name
10
10
  @response_serializers = {}
11
11
  @response_descriptions = {}
12
+ @tags = []
12
13
  end
13
14
 
14
15
  attr_reader :response_serializers, :response_descriptions, :controller, :method, :path, :action_name
15
16
 
16
17
  ##
17
- # What to parse the request body in to.
18
+ # What to parse the request body into.
18
19
  attr_reader :request_body_class
19
20
  ##
20
- # What to parse the request query_params in to
21
+ # What to parse the request query_params into.
21
22
  attr_reader :query_params_class
22
-
23
23
  ##
24
- # What to parse the path params into
24
+ # What to parse the path params into.
25
25
  attr_reader :path_params_class
26
26
 
27
+ ##
28
+ # Standard swagger tags.
29
+ def tags(*args)
30
+ return @tags if args.empty?
31
+
32
+ @tags = args.flatten
33
+ end
34
+
27
35
  ##
28
36
  # Define the request body, using SoberSwag's type-definition scheme.
29
37
  # The block passed will be used to define the body of a new sublcass of `base` (defaulted to {SoberSwag::InputObject}.)
30
38
  # If you want, you can also define utility methods in here
31
39
  def request_body(base = SoberSwag::InputObject, &block)
32
40
  @request_body_class = make_input_object!(base, &block)
33
- action_module.const_set('ResponseBody', @request_body_class)
41
+ action_module.const_set('RequestBody', @request_body_class)
34
42
  end
35
43
 
36
44
  ##
@@ -103,7 +111,7 @@ module SoberSwag
103
111
  def response(status_code, description, serializer = nil, &block)
104
112
  status_key = Rack::Utils.status_code(status_code)
105
113
 
106
- raise ArgumentError, 'Response defiend!' if @response_serializers.key?(status_key)
114
+ raise ArgumentError, 'Response defined!' if @response_serializers.key?(status_key)
107
115
 
108
116
  serializer ||= SoberSwag::OutputObject.define(&block)
109
117
  response_module.const_set(status_code.to_s.classify, serializer)
@@ -124,8 +132,22 @@ module SoberSwag
124
132
  end
125
133
 
126
134
  def make_input_object!(base, &block)
127
- Class.new(base, &block).tap do |e|
128
- e.transform_keys(&:to_sym) if [SoberSwag::InputObject, Dry::Struct].include?(base)
135
+ if base.is_a?(Class)
136
+ make_input_class(base, block)
137
+ elsif block
138
+ raise ArgumentError, 'passed a non-class base and a block to an input'
139
+ else
140
+ base
141
+ end
142
+ end
143
+
144
+ def make_input_class(base, block)
145
+ if block
146
+ Class.new(base, &block).tap do |e|
147
+ e.transform_keys(&:to_sym) if [SoberSwag::InputObject, Dry::Struct].include?(base)
148
+ end
149
+ else
150
+ base
129
151
  end
130
152
  end
131
153
  end
@@ -7,6 +7,7 @@ module SoberSwag
7
7
  # Please see the documentation for that class to see how it works.
8
8
  class InputObject < Dry::Struct
9
9
  transform_keys(&:to_sym)
10
+ include SoberSwag::Type::Named
10
11
 
11
12
  class << self
12
13
  ##
@@ -17,19 +18,51 @@ module SoberSwag
17
18
  @identifier || name.to_s.gsub('::', '.')
18
19
  end
19
20
 
21
+ def attribute(key, parent = SoberSwag::InputObject, &block)
22
+ raise ArgumentError, "parent class #{parent} is not an input object type!" unless valid_field_def?(parent, block)
23
+
24
+ super(key, parent, &block)
25
+ end
26
+
27
+ def attribute?(key, parent = SoberSwag::InputObject, &block)
28
+ raise ArgumentError, "parent class #{parent} is not an input object type!" unless valid_field_def?(parent, block)
29
+
30
+ super(key, parent, &block)
31
+ end
32
+
20
33
  def meta(*args)
34
+ original = self
35
+
21
36
  super(*args).tap do |result|
22
- result.identifier(identifier) if result.is_a?(Class) # pass on identifier
37
+ return result unless result.is_a?(Class)
38
+
39
+ result.define_singleton_method(:alias?) { true }
40
+ result.define_singleton_method(:alias_of) { original }
23
41
  end
24
42
  end
25
43
 
26
- def primitive(sym)
27
- SoberSwag::Types.const_get(sym)
44
+ ##
45
+ # .primitive is already defined on Dry::Struct, so forward to the superclass if
46
+ # not called as a way to get a primitive type
47
+ def primitive(*args)
48
+ if args.length == 1
49
+ SoberSwag::Types.const_get(args.first)
50
+ else
51
+ super
52
+ end
28
53
  end
29
54
 
30
55
  def param(sym)
31
56
  SoberSwag::Types::Params.const_get(sym)
32
57
  end
58
+
59
+ private
60
+
61
+ def valid_field_def?(parent, block)
62
+ return true if block.nil?
63
+
64
+ parent.is_a?(Class) && parent <= SoberSwag::InputObject
65
+ end
33
66
  end
34
67
  end
35
68
  end
@@ -2,29 +2,30 @@ module SoberSwag
2
2
  module Nodes
3
3
  ##
4
4
  # One attribute of an object.
5
- class Attribute
6
- def initialize(key, required, value)
5
+ class Attribute < Base
6
+ def initialize(key, required, value, meta = {})
7
7
  @key = key
8
8
  @required = required
9
9
  @value = value
10
+ @meta = meta
10
11
  end
11
12
 
12
13
  def deconstruct
13
- [key, required, value]
14
+ [key, required, value, meta]
14
15
  end
15
16
 
16
17
  def deconstruct_keys
17
- { key: key, required: required, value: value }
18
+ { key: key, required: required, value: value, meta: meta }
18
19
  end
19
20
 
20
- attr_reader :key, :required, :value
21
+ attr_reader :key, :required, :value, :meta
21
22
 
22
23
  def map(&block)
23
- self.class.new(key, required, value.map(&block))
24
+ self.class.new(key, required, value.map(&block), meta)
24
25
  end
25
26
 
26
27
  def cata(&block)
27
- block.call(self.class.new(key, required, value.cata(&block)))
28
+ block.call(self.class.new(key, required, value.cata(&block), meta))
28
29
  end
29
30
  end
30
31
  end
@@ -10,8 +10,8 @@ module SoberSwag
10
10
 
11
11
  attr_reader :values
12
12
 
13
- def map(&block)
14
- self.class.new(@values.map(&block))
13
+ def map
14
+ dup
15
15
  end
16
16
 
17
17
  def deconstruct
@@ -11,7 +11,7 @@ module SoberSwag
11
11
  attr_reader :value, :metadata
12
12
 
13
13
  def map(&block)
14
- self.class.new(block.call(value))
14
+ self.class.new(block.call(value), metadata.dup)
15
15
  end
16
16
 
17
17
  def deconstruct
@@ -17,8 +17,14 @@ module SoberSwag
17
17
  @fields << field
18
18
  end
19
19
 
20
- def view(name, &block)
21
- view = View.define(name, fields, &block)
20
+ def view(name, inherits: nil, &block)
21
+ initial_fields =
22
+ if inherits.nil? || inherits == :base
23
+ fields
24
+ else
25
+ find_view(inherits).fields
26
+ end
27
+ view = View.define(name, initial_fields, &block)
22
28
 
23
29
  view.identifier("#{@identifier}.#{name.to_s.classify}") if identifier
24
30
 
@@ -29,6 +35,12 @@ module SoberSwag
29
35
  @identifier = arg if arg
30
36
  @identifier
31
37
  end
38
+
39
+ private
40
+
41
+ def find_view(name)
42
+ @views.find { |view| view.name == name } || (raise ArgumentError, "no view #{name.inspect} defined!")
43
+ end
32
44
  end
33
45
  end
34
46
  end
@@ -7,11 +7,27 @@ module SoberSwag
7
7
  add_field!(Field.new(name, serializer, from: from, &block))
8
8
  end
9
9
 
10
+ ##
11
+ # Similar to #field, but adds multiple at once.
12
+ # Named #multi because #fields was already taken.
13
+ def multi(names, serializer)
14
+ names.each { |name| field(name, serializer) }
15
+ end
16
+
10
17
  ##
11
18
  # Given a symbol to this, we will use a primitive name
12
19
  def primitive(name)
13
20
  SoberSwag::Serializer.primitive(SoberSwag::Types.const_get(name))
14
21
  end
22
+
23
+ ##
24
+ # 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)
27
+ other.fields.each do |field|
28
+ add_field!(field)
29
+ end
30
+ end
15
31
  end
16
32
  end
17
33
  end
@@ -25,7 +25,8 @@ module SoberSwag
25
25
  Nodes::Attribute.new(
26
26
  @node.name,
27
27
  @node.required? && !@node.type.default?,
28
- bind(Parser.new(@node.type))
28
+ bind(Parser.new(@node.type)),
29
+ @node.meta
29
30
  )
30
31
  when Dry::Types::Sum
31
32
  left = bind(Parser.new(@node.left))
@@ -46,14 +47,18 @@ module SoberSwag
46
47
  when Dry::Types::Constrained
47
48
  bind(Parser.new(@node.type))
48
49
  when Dry::Types::Nominal
49
- old_meta = @node.primitive.respond_to?(:meta) ? @node.primitive.meta : {}
50
- # start off with the moral equivalent of NodeTree[String]
51
- Nodes::Primitive.new(@node.primitive, old_meta.merge(@node.meta))
50
+ if @node.respond_to?(:type) && @node.type.is_a?(Dry::Types::Constrained)
51
+ bind(Parser.new(@node.type))
52
+ else
53
+ old_meta = @node.primitive.respond_to?(:meta) ? @node.primitive.meta : {}
54
+ # start off with the moral equivalent of NodeTree[String]
55
+ Nodes::Primitive.new(@node.primitive, old_meta.merge(@node.meta))
56
+ end
52
57
  else
53
58
  # Inside of this case we have a class that is some user-defined type
54
59
  # We put it in our array of found types, and consider it a primitive
55
60
  @found.add(@node)
56
- Nodes::Primitive.new(@node)
61
+ Nodes::Primitive.new(@node, @node.respond_to?(:meta) ? @node.meta : {})
57
62
  end
58
63
  end
59
64
 
@@ -17,6 +17,8 @@ module SoberSwag
17
17
  SoberSwag::Serializer::Optional.new(self)
18
18
  end
19
19
 
20
+ alias nilable optional
21
+
20
22
  ##
21
23
  # Is this type lazily defined?
22
24
  #
@@ -34,7 +34,9 @@ module SoberSwag
34
34
  # As such, we need to be a bit clever about when we tack on the identifier
35
35
  # for this type.
36
36
  %i[lazy_type type].each do |sym|
37
- public_send(sym).identifier(@base.public_send(sym).identifier) if @base.public_send(sym).respond_to?(:identifier)
37
+ if @base.public_send(sym).respond_to?(:identifier) && public_send(sym).respond_to?(:identifier)
38
+ public_send(sym).identifier(@base.public_send(sym).identifier)
39
+ end
38
40
  end
39
41
  end
40
42
 
@@ -22,28 +22,40 @@ module SoberSwag
22
22
  #
23
23
  # @param controller_proc [Proc] a proc that, when called, gives a list of {SoberSwag::Controller}s to document
24
24
  # @param cache [Bool | Proc] if we should cache our defintions (default false)
25
+ # @param redoc_version [String] what version of the redoc library to use to display UI (default 'next', the latest version).
25
26
  def initialize(
26
27
  controller_proc: RAILS_CONTROLLER_PROC,
27
- cache: false
28
+ cache: false,
29
+ redoc_version: 'next'
28
30
  )
29
31
  @controller_proc = controller_proc
30
32
  @cache = cache
33
+ @html = EFFECT_HTML.gsub(/REDOC_VERSION/, redoc_version)
31
34
  end
32
35
 
33
36
  EFFECT_HTML = <<~HTML.freeze
34
37
  <!DOCTYPE html>
35
38
  <html>
36
39
  <head>
37
- <title>Swagger-UI</title>
38
- <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
39
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@3.23.4/swagger-ui.css"></link>
40
+ <title>ReDoc</title>
41
+ <!-- needed for adaptive design -->
42
+ <meta charset="utf-8"/>
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
45
+
46
+ <!--
47
+ ReDoc doesn't change outer page styles
48
+ -->
49
+ <style>
50
+ body {
51
+ margin: 0;
52
+ padding: 0;
53
+ }
54
+ </style>
40
55
  </head>
41
56
  <body>
42
- <div id="swagger">
43
- </div>
44
- <script>
45
- SwaggerUIBundle({url: 'SCRIPT_NAME', dom_id: '#swagger'})
46
- </script>
57
+ <redoc spec-url='SCRIPT_NAME'></redoc>
58
+ <script src="https://cdn.jsdelivr.net/npm/redoc@REDOC_VERSION/bundles/redoc.standalone.js"> </script>
47
59
  </body>
48
60
  </html>
49
61
  HTML
@@ -53,7 +65,7 @@ module SoberSwag
53
65
  if req.path_info&.match?(/json/si) || req.get_header('Accept')&.match?(/json/si)
54
66
  [200, { 'Content-Type' => 'application/json' }, [generate_json_string]]
55
67
  else
56
- [200, { 'Content-Type' => 'text/html' }, [EFFECT_HTML.gsub(/SCRIPT_NAME/, env['SCRIPT_NAME'] + '.json')]]
68
+ [200, { 'Content-Type' => 'text/html' }, [@html.gsub(/SCRIPT_NAME/, "#{env['SCRIPT_NAME']}.json")]]
57
69
  end
58
70
  end
59
71
 
@@ -0,0 +1,7 @@
1
+ module SoberSwag
2
+ ##
3
+ # Namespace for type-definition-related utilities
4
+ module Type
5
+ autoload(:Named, 'sober_swag/type/named')
6
+ end
7
+ end
@@ -0,0 +1,35 @@
1
+ module SoberSwag
2
+ module Type
3
+ ##
4
+ # Mixin module used to identify types that should be considered
5
+ # standalone, named types from SoberSwag's perspective.
6
+ module Named
7
+ ##
8
+ # Class Methods Module.
9
+ # Modules that include {SoberSwag::Type::Named}
10
+ # will automatically extend this module.
11
+ module ClassMethods
12
+ def alias?
13
+ false
14
+ end
15
+
16
+ def alias_of
17
+ nil
18
+ end
19
+
20
+ def root_alias
21
+ alias_of || self
22
+ end
23
+
24
+ def description(arg = nil)
25
+ @description = arg if arg
26
+ @description
27
+ end
28
+ end
29
+
30
+ def self.included(mod)
31
+ mod.extend(ClassMethods)
32
+ end
33
+ end
34
+ end
35
+ end