sober_swag 0.15.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 (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