sober_swag 0.14.0 → 0.19.0

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