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
@@ -2,15 +2,28 @@ module SoberSwag
2
2
  class Compiler
3
3
  ##
4
4
  # Compile multiple routes into a paths set.
5
+ # This basically just aggregates {SoberSwag::Controller::Route} objects.
5
6
  class Paths
7
+ ##
8
+ # Set up a new paths compiler with no routes in it.
6
9
  def initialize
7
10
  @routes = []
8
11
  end
9
12
 
13
+ ##
14
+ # Add on a new {SoberSwag::Controller::Route}
15
+ #
16
+ # @param route [SoberSwag::Controller::Route] the route description to add to compilation
17
+ # @return [SoberSwag::Compiler::Paths] self
10
18
  def add_route(route)
11
19
  @routes << route
20
+
21
+ self
12
22
  end
13
23
 
24
+ ##
25
+ # In the OpenAPI V3 spec, we group action definitions by their path.
26
+ # This helps us do that.
14
27
  def grouped_paths
15
28
  routes.group_by(&:path)
16
29
  end
@@ -20,6 +33,9 @@ module SoberSwag
20
33
  # paths list. Since this is only a compiler for paths,
21
34
  # it has *no idea* how to handle types. So, it takes a compiler
22
35
  # which it will use to do that for it.
36
+ #
37
+ # @param compiler [SoberSwag::Compiler::Type] the type compiler to use
38
+ # @return [Hash] a schema for all contained routes.
23
39
  def paths_list(compiler)
24
40
  grouped_paths.transform_values do |values|
25
41
  values.map { |route|
@@ -31,6 +47,8 @@ module SoberSwag
31
47
  ##
32
48
  # Get a list of all types we discovered when compiling
33
49
  # the paths.
50
+ #
51
+ # @yield [Class] all the types found in all the routes described in here.
34
52
  def found_types
35
53
  return enum_for(:found_types) unless block_given?
36
54
 
@@ -41,6 +59,8 @@ module SoberSwag
41
59
  end
42
60
  end
43
61
 
62
+ ##
63
+ # @return [Array<SoberSwag::Controller::Route>] the routes to document
44
64
  attr_reader :routes
45
65
 
46
66
  private
@@ -4,7 +4,11 @@ module SoberSwag
4
4
  # Compiles a primitive type.
5
5
  # Almost always constructed with the values from
6
6
  # {SoberSwag::Nodes::Primitive}.
7
+ #
8
+ # This works by either generating a swagger primitive definition, *or* a `$ref` to one with a given identifier.
7
9
  class Primitive
10
+ ##
11
+ # @param type [Class] the swagger-able class to document.
8
12
  def initialize(type)
9
13
  @type = type
10
14
 
@@ -13,6 +17,8 @@ module SoberSwag
13
17
 
14
18
  attr_reader :type
15
19
 
20
+ ##
21
+ # Is this documenting one of the build-in swagger types?
16
22
  def swagger_primitive?
17
23
  SWAGGER_PRIMITIVE_DEFS.include?(type)
18
24
  end
@@ -25,6 +31,9 @@ module SoberSwag
25
31
 
26
32
  ##
27
33
  # Turn this type into a swagger hash with a proper type key.
34
+ # This is suitable for use as the value of a `schema` key in a definition.
35
+ #
36
+ # @return [Hash] the schema.
28
37
  def type_hash
29
38
  if swagger_primitive?
30
39
  SWAGGER_PRIMITIVE_DEFS.fetch(type)
@@ -37,9 +46,16 @@ module SoberSwag
37
46
  end
38
47
  end
39
48
 
49
+ ##
50
+ # Primitive schema used for ruby `Date` values.
40
51
  DATE_PRIMITIVE = { type: :string, format: :date }.freeze
52
+ ##
53
+ # Primitive schema used for ruby `DateTime` values.
41
54
  DATE_TIME_PRIMITIVE = { type: :string, format: :'date-time' }.freeze
55
+ HASH_PRIMITIVE = { type: :object, additionalProperties: true }.freeze
42
56
 
57
+ ##
58
+ # Map of types that are considered "primitive types" in the OpenAPI V3 spec.
43
59
  SWAGGER_PRIMITIVE_DEFS =
44
60
  {
45
61
  NilClass => :null,
@@ -52,9 +68,12 @@ module SoberSwag
52
68
  .to_h.merge(
53
69
  Date => DATE_PRIMITIVE,
54
70
  DateTime => DATE_TIME_PRIMITIVE,
55
- Time => DATE_TIME_PRIMITIVE
71
+ Time => DATE_TIME_PRIMITIVE,
72
+ Hash => HASH_PRIMITIVE
56
73
  ).transform_values(&:freeze).freeze
57
74
 
75
+ ##
76
+ # @return [String] the schema reference
58
77
  def ref_name
59
78
  raise Error, 'is not a type that is named!' if swagger_primitive?
60
79
 
@@ -1,45 +1,95 @@
1
1
  module SoberSwag
2
2
  class Compiler
3
3
  ##
4
- # A compiler for DRY-Struct data types, essentially.
5
- # It only consumes one type at a time.
4
+ # A compiler for swagger-able types.
5
+ #
6
+ # This class turns Swagger-able types into a *schema*.
7
+ # This Schema may be:
8
+ # - a [schema object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#schemaObject) with {#object_schema}
9
+ # - a [path schema](https://swagger.io/docs/specification/describing-parameters/#path-parameters) with {#path_schema}
10
+ # - a [query schema](https://swagger.io/docs/specification/describing-parameters/#query-parameters) with {#query_schema}
11
+ #
12
+ # As such, it compiles all types to all applicable schemas.
13
+ #
14
+ # While this class compiles *one* type at a time, it *keeps track* of the other types needed to describe this schema.
15
+ # It stores these types in a set, available at {#found_types}.
16
+ #
17
+ # For example, with a schema like:
18
+ #
19
+ # ```ruby
20
+ # class Bar < SoberSwag::InputObject
21
+ # attribute :baz, primitive(:String)
22
+ # end
23
+ #
24
+ # class Foo < SoberSwag::InputObject
25
+ # attribute :bar, Bar
26
+ # end
27
+ # ```
28
+ #
29
+ # If you compile `Foo` with this class, {#found_types} will include `Bar`.
30
+ #
6
31
  class Type # rubocop:disable Metrics/ClassLength
32
+ ##
33
+ # An error raised when a type is too complicated for a given schema.
34
+ # This may be due to containing too many layers of nesting.
7
35
  class TooComplicatedError < ::SoberSwag::Compiler::Error; end
36
+ ##
37
+ # An error raised when a type is too complicated to transform into a *path* schema.
8
38
  class TooComplicatedForPathError < TooComplicatedError; end
39
+ ##
40
+ # An error raised when a type is too complicated to transform into a *query* schema.
9
41
  class TooComplicatedForQueryError < TooComplicatedError; end
10
42
 
43
+ ##
44
+ # A list of acceptable keys to use as metadata for an object schema.
45
+ # All other metadata keys defined on a type with {SoberSwag::InputObject.meta} will be ignored.
46
+ #
47
+ # @return [Array<Symbol>] valid keys.
11
48
  METADATA_KEYS = %i[description deprecated].freeze
12
49
 
50
+ ##
51
+ # Create a new compiler for a swagger-able type.
52
+ # @param type [Class] the type to compile
13
53
  def initialize(type)
14
54
  @type = type
15
55
  end
16
56
 
57
+ ##
58
+ # @return [Class] the type we are compiling.
17
59
  attr_reader :type
18
60
 
19
61
  ##
20
62
  # Is this type standalone, IE, worth serializing on its own
21
63
  # in the schemas section of our schema?
64
+ # @return [true,false]
22
65
  def standalone?
23
66
  type.is_a?(Class)
24
67
  end
25
68
 
69
+ ##
70
+ # Get back the [schema object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#schemaObject)
71
+ # for the type described.
72
+ #
73
+ # @return [Hash]
26
74
  def object_schema
27
75
  @object_schema ||=
28
76
  make_object_schema
29
77
  end
30
78
 
31
- def object_schema_meta
32
- return {} unless standalone? && type <= SoberSwag::Type::Named
33
-
34
- {
35
- description: type.description
36
- }.reject { |_, v| v.nil? }
37
- end
38
-
79
+ ##
80
+ # Give a "stub type" for this schema.
81
+ # This is suitable to use as the schema for attributes of other schemas.
82
+ # Almost always generates a ref object.
83
+ # @return [Hash] the OpenAPI V3 schema stub
39
84
  def schema_stub
40
85
  @schema_stub ||= generate_schema_stub
41
86
  end
42
87
 
88
+ ##
89
+ # The schema for this type when it is path of the path.
90
+ #
91
+ # @raise [TooComplicatedForPathError] when the compiled type is too complicated to use in a path
92
+ # @return [Hash] a [path parameters hash](https://swagger.io/docs/specification/describing-parameters/#path-parameters) for this type.
43
93
  def path_schema
44
94
  path_schema_stub.map do |e|
45
95
  ensure_uncomplicated(e[:name], e[:schema])
@@ -51,16 +101,30 @@ module SoberSwag
51
101
 
52
102
  DEFAULT_QUERY_SCHEMA_ATTRS = { in: :query, style: :deepObject, explode: true }.freeze
53
103
 
104
+ ##
105
+ # The schema for this type when it is part of the query.
106
+ # @raise [TooComplicatedForQueryError] when this type is too complicated to use in a query schema
107
+ # @return [Hash] a [query parameters hash](https://swagger.io/docs/specification/describing-parameters/#query-parameters) for this type.
54
108
  def query_schema
55
109
  path_schema_stub.map { |e| DEFAULT_QUERY_SCHEMA_ATTRS.merge(e) }
56
110
  rescue TooComplicatedError => e
57
111
  raise TooComplicatedForQueryError, e.message
58
112
  end
59
113
 
114
+ ##
115
+ # Get the name of this type if it is to be used in a `$ref` key.
116
+ # This is useful if we are going to use this type compiler to compile an *attribute* of another object.
117
+ #
118
+ # @return [String] a reference specifier for this type
60
119
  def ref_name
61
120
  SoberSwag::Compiler::Primitive.new(type).ref_name
62
121
  end
63
122
 
123
+ ##
124
+ # Get a set of all other types needed to compile this type.
125
+ # This set will *not* include the type being compiled.
126
+ #
127
+ # @return [Set<Class>]
64
128
  def found_types
65
129
  @found_types ||=
66
130
  begin
@@ -69,32 +133,51 @@ module SoberSwag
69
133
  end
70
134
  end
71
135
 
72
- def mapped_type
73
- @mapped_type ||= parsed_type.map { |v| SoberSwag::Compiler::Primitive.new(v).type_hash }
74
- end
75
-
76
- def parsed_type
77
- @parsed_type ||=
78
- begin
79
- (parsed,) = parsed_result
80
- parsed
81
- end
82
- end
83
-
136
+ ##
137
+ # This type, parsed into an AST.
84
138
  def parsed_result
85
139
  @parsed_result ||= Parser.new(type_for_parser).run_parser
86
140
  end
87
141
 
142
+ ##
143
+ # Standard ruby equality.
88
144
  def eql?(other)
89
145
  other.class == self.class && other.type == type
90
146
  end
91
147
 
148
+ ##
149
+ # Standard ruby hashing method.
150
+ # Compilers hash to the same value if they are compiling the same type.
92
151
  def hash
93
152
  [self.class, type].hash
94
153
  end
95
154
 
96
155
  private
97
156
 
157
+ ##
158
+ # Get metadata attributes to be used if compiling an object schema.
159
+ #
160
+ # @return [Hash]
161
+ def object_schema_meta
162
+ return {} unless standalone? && type <= SoberSwag::Type::Named
163
+
164
+ {
165
+ description: type.description
166
+ }.reject { |_, v| v.nil? }
167
+ end
168
+
169
+ def parsed_type
170
+ @parsed_type ||=
171
+ begin
172
+ (parsed,) = parsed_result
173
+ parsed
174
+ end
175
+ end
176
+
177
+ def mapped_type
178
+ @mapped_type ||= parsed_type.map { |v| SoberSwag::Compiler::Primitive.new(v).type_hash }
179
+ end
180
+
98
181
  def generate_schema_stub
99
182
  if type.is_a?(Class)
100
183
  SoberSwag::Compiler::Primitive.new(type).type_hash
@@ -2,7 +2,8 @@ require 'active_support/concern'
2
2
 
3
3
  module SoberSwag
4
4
  ##
5
- # Controller concern
5
+ # This module can be included in any subclass of `ActionController` or `ActionController::API` to make it `SoberSwag`-able.
6
+ # This means that you can use the mechanics of SoberSwag to define a type-safe API, with generated Swagger documentation!
6
7
  module Controller
7
8
  extend ActiveSupport::Concern
8
9
 
@@ -17,14 +18,18 @@ module SoberSwag
17
18
  include ::Dry::Types()
18
19
  end
19
20
 
20
- class_methods do
21
+ ##
22
+ # Module containing class methods.
23
+ # Any class that `include`s {SoberSwag::Controller} will also `extend` {SoberSwag::Controller::ClassMethods}.
24
+ module ClassMethods
21
25
  ##
22
26
  # Define a new action with the given HTTP method, action name, and path.
23
- # This will eventaully delegate to making an actual method on your controller,
27
+ # This will eventually delegate to making an actual method on your controller,
24
28
  # so you can use controllers as you wish with no harm.
25
29
  #
26
30
  # This method takes a block, evaluated in the context of a {SoberSwag::Controller::Route}.
27
31
  # Used like:
32
+ #
28
33
  # define(:get, :show, '/posts/{id}') do
29
34
  # path_params do
30
35
  # attribute :id, Types::Integer
@@ -36,32 +41,46 @@ module SoberSwag
36
41
  # end
37
42
  #
38
43
  # This will define an "action module" on this class to contain the generated types.
39
- # In the above example, the following constants will be deifned on the controller:
40
- # PostsController::Show # the container module for everything in this action
41
- # PostsController::Show::PathParams # the dry-struct type for the path attribute.
44
+ # In the above example, the following constants will be defined on the controller:
45
+ #
46
+ # - `PostsController::Show` - the container module for everything in this action
47
+ # - `PostsController::Show::PathParams` - the dry-struct type for the path attribute.
48
+ #
42
49
  # So, in the same controller, you can refer to Show::PathParams to get the type created by the 'path_params' block above.
50
+ #
51
+ # The block given evaluates in the context of `SoberSwag::Controller::Route`.
52
+ #
53
+ # @todo Explore parsing the `path` parameter from rails routes so we can avoid forcing the duplicate boilerplate.
54
+ #
55
+ # @param method [Symbol] the HTTP method of this route
56
+ # @param action [Symbol] the name of the controller method this maps onto
57
+ # @param path [String] an OpenAPI v3 Path Specifier
43
58
  def define(method, action, path, &block)
44
59
  r = Route.new(method, action, path)
45
60
  r.instance_eval(&block)
46
61
  const_set(r.action_module_name, r.action_module)
47
62
  defined_routes << r
48
- define_method(action, r.action) if r.action
49
63
  end
50
64
 
51
65
  ##
52
66
  # All the routes that this controller knows about.
67
+ # @return [Array<SoberSwag::Controller::Route>
53
68
  def defined_routes
54
69
  @defined_routes ||= []
55
70
  end
56
71
 
57
72
  ##
58
73
  # Find a route with the given name.
74
+ # @param name [Symbol] the name
75
+ # @return [SoberSwag::Controller::Route]
59
76
  def find_route(name)
60
77
  defined_routes.find { |r| r.action_name.to_s == name.to_s }
61
78
  end
62
79
 
63
80
  ##
64
- # A swagger definition for *this controller only*.
81
+ # Get the OpenAPI v3 definition for this controller.
82
+ #
83
+ # @return [Hash]
65
84
  def swagger_info
66
85
  @swagger_info ||=
67
86
  begin
@@ -74,14 +93,19 @@ module SoberSwag
74
93
  end
75
94
  end
76
95
 
96
+ included do |base|
97
+ base.extend ClassMethods
98
+ end
99
+
77
100
  ##
78
- # Action to get the singular swagger for this entire API.
101
+ # ActiveController action to get the swagger definition for this API.
102
+ # It renders a JSON of the OpenAPI v3 schema for this API.
79
103
  def swagger
80
104
  render json: self.class.swagger_info
81
105
  end
82
106
 
83
107
  ##
84
- # Get the path parameters, parsed into the type you defined with {SoberSwag::Controller.define}
108
+ # Get the path parameters, parsed into the type you defined with {SoberSwag::Controller::ClassMethods#define}
85
109
  # @raise [UndefinedPathError] if there's no path params defined for this route
86
110
  # @raise [Dry::Struct::Error] if we cannot convert the path params to the defined type.
87
111
  def parsed_path
@@ -90,12 +114,12 @@ module SoberSwag
90
114
  r = current_action_def
91
115
  raise UndefinedPathError unless r&.path_params_class
92
116
 
93
- r.path_params_class.new(request.path_parameters)
117
+ r.path_params_class.call(request.path_parameters)
94
118
  end
95
119
  end
96
120
 
97
121
  ##
98
- # Get the request body, parsed into the type you defined with {SoberSwag::Controller.define}.
122
+ # Get the request body, parsed into the type you defined with {SoberSwag::Controller::ClassMethods#define}.
99
123
  # @raise [UndefinedBodyError] if there's no request body defined for this route
100
124
  # @raise [Dry::Struct::Error] if we cannot convert the path params to the defined type.
101
125
  def parsed_body
@@ -104,12 +128,12 @@ module SoberSwag
104
128
  r = current_action_def
105
129
  raise UndefinedBodyError unless r&.request_body_class
106
130
 
107
- r.request_body_class.new(body_params)
131
+ r.request_body_class.call(body_params)
108
132
  end
109
133
  end
110
134
 
111
135
  ##
112
- # Get the query params, parsed into the type you defined with {SoberSwag::Controller.define}
136
+ # Get the query params, parsed into the type you defined with {SoberSwag::Controller::ClassMethods#define}
113
137
  # @raise [UndefinedQueryError] if there's no query params defined for this route
114
138
  # @raise [Dry::Struct::Error] if we cannot convert the path params to the defined type.
115
139
  def parsed_query
@@ -118,7 +142,7 @@ module SoberSwag
118
142
  r = current_action_def
119
143
  raise UndefinedQueryError unless r&.query_params_class
120
144
 
121
- r.query_params_class.new(request.query_parameters)
145
+ r.query_params_class.call(request.query_parameters)
122
146
  end
123
147
  end
124
148
 
@@ -140,6 +164,8 @@ module SoberSwag
140
164
  # This kinda violates the "be liberal in what you accept" principle,
141
165
  # but it keeps the docs honest: parameters sent in the body *must* be
142
166
  # in the body.
167
+ #
168
+ # @return [Hash]
143
169
  def body_params
144
170
  request.request_parameters
145
171
  end
@@ -147,6 +173,7 @@ module SoberSwag
147
173
  ##
148
174
  # Get the action-definition for the current action.
149
175
  # Under the hood, delegates to the `:action` key of rails params.
176
+ # @return [SoberSwag::Controller::Route]
150
177
  def current_action_def
151
178
  self.class.find_route(params[:action])
152
179
  end