sober_swag 0.1.0 → 0.6.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -0
  3. data/.github/workflows/lint.yml +15 -0
  4. data/.github/workflows/ruby.yml +33 -2
  5. data/.gitignore +4 -0
  6. data/.rubocop.yml +75 -1
  7. data/.ruby-version +1 -1
  8. data/README.md +154 -1
  9. data/bin/console +16 -15
  10. data/docs/serializers.md +203 -0
  11. data/example/.rspec +1 -0
  12. data/example/.ruby-version +1 -1
  13. data/example/Gemfile +9 -7
  14. data/example/Gemfile.lock +96 -79
  15. data/example/app/controllers/people_controller.rb +41 -23
  16. data/example/app/controllers/posts_controller.rb +110 -0
  17. data/example/app/models/application_record.rb +3 -0
  18. data/example/app/models/person.rb +6 -0
  19. data/example/app/models/post.rb +9 -0
  20. data/example/app/output_objects/person_errors_output_object.rb +5 -0
  21. data/example/app/output_objects/person_output_object.rb +15 -0
  22. data/example/app/output_objects/post_output_object.rb +10 -0
  23. data/example/bin/bundle +24 -20
  24. data/example/bin/rails +1 -1
  25. data/example/bin/rake +1 -1
  26. data/example/config/application.rb +11 -7
  27. data/example/config/environments/development.rb +0 -1
  28. data/example/config/environments/production.rb +3 -3
  29. data/example/config/puma.rb +5 -5
  30. data/example/config/routes.rb +3 -0
  31. data/example/config/spring.rb +4 -4
  32. data/example/db/migrate/20200311152021_create_people.rb +0 -1
  33. data/example/db/migrate/20200603172347_create_posts.rb +11 -0
  34. data/example/db/schema.rb +16 -7
  35. data/example/spec/rails_helper.rb +64 -0
  36. data/example/spec/requests/people/create_spec.rb +52 -0
  37. data/example/spec/requests/people/get_spec.rb +35 -0
  38. data/example/spec/requests/people/index_spec.rb +69 -0
  39. data/example/spec/spec_helper.rb +94 -0
  40. data/lib/sober_swag.rb +6 -3
  41. data/lib/sober_swag/compiler/error.rb +2 -0
  42. data/lib/sober_swag/compiler/path.rb +2 -5
  43. data/lib/sober_swag/compiler/paths.rb +0 -1
  44. data/lib/sober_swag/compiler/type.rb +86 -56
  45. data/lib/sober_swag/controller.rb +16 -11
  46. data/lib/sober_swag/controller/route.rb +18 -21
  47. data/lib/sober_swag/controller/undefined_body_error.rb +3 -0
  48. data/lib/sober_swag/controller/undefined_path_error.rb +3 -0
  49. data/lib/sober_swag/controller/undefined_query_error.rb +3 -0
  50. data/lib/sober_swag/input_object.rb +28 -0
  51. data/lib/sober_swag/nodes/array.rb +1 -1
  52. data/lib/sober_swag/nodes/base.rb +5 -3
  53. data/lib/sober_swag/nodes/binary.rb +2 -1
  54. data/lib/sober_swag/nodes/enum.rb +4 -2
  55. data/lib/sober_swag/nodes/list.rb +0 -1
  56. data/lib/sober_swag/nodes/primitive.rb +6 -5
  57. data/lib/sober_swag/output_object.rb +102 -0
  58. data/lib/sober_swag/output_object/definition.rb +30 -0
  59. data/lib/sober_swag/{blueprint → output_object}/field.rb +14 -4
  60. data/lib/sober_swag/{blueprint → output_object}/field_syntax.rb +2 -2
  61. data/lib/sober_swag/{blueprint → output_object}/view.rb +15 -6
  62. data/lib/sober_swag/parser.rb +9 -4
  63. data/lib/sober_swag/serializer.rb +5 -2
  64. data/lib/sober_swag/serializer/array.rb +12 -0
  65. data/lib/sober_swag/serializer/base.rb +50 -1
  66. data/lib/sober_swag/serializer/conditional.rb +19 -2
  67. data/lib/sober_swag/serializer/field_list.rb +29 -6
  68. data/lib/sober_swag/serializer/mapped.rb +15 -3
  69. data/lib/sober_swag/serializer/meta.rb +35 -0
  70. data/lib/sober_swag/serializer/optional.rb +17 -2
  71. data/lib/sober_swag/serializer/primitive.rb +4 -1
  72. data/lib/sober_swag/server.rb +83 -0
  73. data/lib/sober_swag/types.rb +3 -0
  74. data/lib/sober_swag/version.rb +1 -1
  75. data/sober_swag.gemspec +8 -4
  76. metadata +79 -47
  77. data/Gemfile.lock +0 -92
  78. data/example/person.json +0 -4
  79. data/example/test/controllers/.keep +0 -0
  80. data/example/test/fixtures/.keep +0 -0
  81. data/example/test/fixtures/files/.keep +0 -0
  82. data/example/test/fixtures/people.yml +0 -11
  83. data/example/test/integration/.keep +0 -0
  84. data/example/test/models/.keep +0 -0
  85. data/example/test/models/person_test.rb +0 -7
  86. data/example/test/test_helper.rb +0 -13
  87. data/lib/sober_swag/blueprint.rb +0 -113
  88. data/lib/sober_swag/path.rb +0 -8
  89. data/lib/sober_swag/path/integer.rb +0 -21
  90. data/lib/sober_swag/path/lit.rb +0 -41
  91. data/lib/sober_swag/path/literal.rb +0 -29
  92. data/lib/sober_swag/path/param.rb +0 -33
@@ -10,10 +10,19 @@ module SoberSwag
10
10
  autoload :UndefinedPathError, 'sober_swag/controller/undefined_path_error'
11
11
  autoload :UndefinedQueryError, 'sober_swag/controller/undefined_query_error'
12
12
 
13
+ ##
14
+ # Types module, so you can more easily access Types::Whatever
15
+ # without having to type SoberSwag::Types::Whatever.
13
16
  module Types
14
17
  include ::Dry::Types()
15
18
  end
16
19
 
20
+ included do
21
+ rescue_from Dry::Struct::Error do
22
+ head :bad_request
23
+ end
24
+ end
25
+
17
26
  class_methods do
18
27
  ##
19
28
  # Define a new action with the given HTTP method, action name, and path.
@@ -65,10 +74,7 @@ module SoberSwag
65
74
  res = defined_routes.reduce(SoberSwag::Compiler.new) { |c, r| c.add_route(r) }
66
75
  {
67
76
  openapi: '3.0.0',
68
- info: {
69
- version: '1',
70
- title: self.name
71
- }
77
+ info: { version: '1', title: name }
72
78
  }.merge(res.to_swagger)
73
79
  end
74
80
  end
@@ -89,6 +95,7 @@ module SoberSwag
89
95
  begin
90
96
  r = current_action_def
91
97
  raise UndefinedPathError unless r&.path_params_class
98
+
92
99
  r.path_params_class.new(request.path_parameters)
93
100
  end
94
101
  end
@@ -102,6 +109,7 @@ module SoberSwag
102
109
  begin
103
110
  r = current_action_def
104
111
  raise UndefinedBodyError unless r&.request_body_class
112
+
105
113
  r.request_body_class.new(body_params)
106
114
  end
107
115
  end
@@ -115,6 +123,7 @@ module SoberSwag
115
123
  begin
116
124
  r = current_action_def
117
125
  raise UndefinedQueryError unless r&.query_params_class
126
+
118
127
  r.query_params_class.new(request.query_parameters)
119
128
  end
120
129
  end
@@ -124,11 +133,11 @@ module SoberSwag
124
133
  # @todo figure out how to specify views and other options for the serializer here
125
134
  # @param status [Symbol] the HTTP status symbol to use for the status code
126
135
  # @param entity the thing to serialize
127
- def respond!(status, entity)
136
+ def respond!(status, entity, serializer_opts: {}, rails_opts: {})
128
137
  r = current_action_def
129
138
  serializer = r.response_serializers[Rack::Utils.status_code(status)]
130
139
  serializer ||= serializer.new if serializer.respond_to?(:new)
131
- render json: serializer.serialize(entity)
140
+ render json: serializer.serialize(entity, serializer_opts), status: status, **rails_opts
132
141
  end
133
142
 
134
143
  ##
@@ -138,10 +147,7 @@ module SoberSwag
138
147
  # but it keeps the docs honest: parameters sent in the body *must* be
139
148
  # in the body.
140
149
  def body_params
141
- bparams = params.reject do |k, _|
142
- request.query_parameters.key?(k) || request.path_parameters.key?(k)
143
- end
144
- bparams.permit(bparams.keys)
150
+ request.request_parameters
145
151
  end
146
152
 
147
153
  ##
@@ -150,7 +156,6 @@ module SoberSwag
150
156
  def current_action_def
151
157
  self.class.find_route(params[:action])
152
158
  end
153
-
154
159
  end
155
160
  end
156
161
 
@@ -1,9 +1,8 @@
1
- require 'sober_swag/blueprint'
2
-
3
1
  module SoberSwag
4
2
  module Controller
3
+ ##
4
+ # Describe a single controller endpoint.
5
5
  class Route
6
-
7
6
  def initialize(method, action_name, path)
8
7
  @method = method
9
8
  @path = path
@@ -12,12 +11,8 @@ module SoberSwag
12
11
  @response_descriptions = {}
13
12
  end
14
13
 
15
- attr_reader :response_serializers
16
- attr_reader :response_descriptions
17
- attr_reader :controller
18
- attr_reader :method
19
- attr_reader :path
20
- attr_reader :action_name
14
+ attr_reader :response_serializers, :response_descriptions, :controller, :method, :path, :action_name
15
+
21
16
  ##
22
17
  # What to parse the request body in to.
23
18
  attr_reader :request_body_class
@@ -31,10 +26,10 @@ module SoberSwag
31
26
 
32
27
  ##
33
28
  # Define the request body, using SoberSwag's type-definition scheme.
34
- # The block passed will be used to define the body of a new sublcass of `base` (defaulted to {Dry::Struct}.)
29
+ # The block passed will be used to define the body of a new sublcass of `base` (defaulted to {SoberSwag::InputObject}.)
35
30
  # If you want, you can also define utility methods in here
36
- def request_body(base = Dry::Struct, &block)
37
- @request_body_class = make_struct!(base, &block)
31
+ def request_body(base = SoberSwag::InputObject, &block)
32
+ @request_body_class = make_input_object!(base, &block)
38
33
  action_module.const_set('ResponseBody', @request_body_class)
39
34
  end
40
35
 
@@ -48,8 +43,8 @@ module SoberSwag
48
43
  # Define the shape of the query_params parameters, using SoberSwag's type-definition scheme.
49
44
  # The block passed is the body of the newly-defined type.
50
45
  # You can also include a base type.
51
- def query_params(base = Dry::Struct, &block)
52
- @query_params_class = make_struct!(base, &block)
46
+ def query_params(base = SoberSwag::InputObject, &block)
47
+ @query_params_class = make_input_object!(base, &block)
53
48
  action_module.const_set('QueryParams', @query_params_class)
54
49
  end
55
50
 
@@ -61,10 +56,10 @@ module SoberSwag
61
56
 
62
57
  ##
63
58
  # Define the shape of the *path* parameters, using SoberSwag's type-definition scheme.
64
- # The block passed will be the body of a new subclass of `base` (defaulted to {Dry::Struct}).
59
+ # The block passed will be the body of a new subclass of `base` (defaulted to {SoberSwag::InputObject}).
65
60
  # Names of this should match the names in the path template originally passed to {SoberSwag::Controller::Route.new}
66
- def path_params(base = Dry::Struct, &block)
67
- @path_params_class = make_struct!(base, &block)
61
+ def path_params(base = SoberSwag::InputObject, &block)
62
+ @path_params_class = make_input_object!(base, &block)
68
63
  action_module.const_set('PathParams', @path_params_class)
69
64
  end
70
65
 
@@ -104,13 +99,13 @@ module SoberSwag
104
99
  ##
105
100
  # Define a serializer for a response with the given status code.
106
101
  # You may either give a serializer you defined elsewhere, or define one inline as if passed to
107
- # {SoberSwag::Blueprint.define}
102
+ # {SoberSwag::OutputObject.define}
108
103
  def response(status_code, description, serializer = nil, &block)
109
104
  status_key = Rack::Utils.status_code(status_code)
110
105
 
111
106
  raise ArgumentError, 'Response defiend!' if @response_serializers.key?(status_key)
112
107
 
113
- serializer ||= SoberSwag::Blueprint.define(&block)
108
+ serializer ||= SoberSwag::OutputObject.define(&block)
114
109
  response_module.const_set(status_code.to_s.classify, serializer)
115
110
  @response_serializers[status_key] = serializer
116
111
  @response_descriptions[status_key] = description
@@ -128,8 +123,10 @@ module SoberSwag
128
123
  @response_module ||= Module.new.tap { |m| action_module.const_set(:Response, m) }
129
124
  end
130
125
 
131
- def make_struct!(base, &block)
132
- Class.new(base, &block).tap { |e| e.transform_keys(&:to_sym) if base == Dry::Struct }
126
+ 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)
129
+ end
133
130
  end
134
131
  end
135
132
  end
@@ -1,5 +1,8 @@
1
1
  module SoberSwag
2
2
  module Controller
3
+ ##
4
+ # Error class thrown if you have no body defined,
5
+ # but try to call `parsed_body`.
3
6
  class UndefinedBodyError < Error
4
7
  end
5
8
  end
@@ -1,5 +1,8 @@
1
1
  module SoberSwag
2
2
  module Controller
3
+ ##
4
+ # Error class thrown if you have no path defined,
5
+ # but try to call `parse_path`.
3
6
  class UndefinedPathError < Error
4
7
  end
5
8
  end
@@ -1,5 +1,8 @@
1
1
  module SoberSwag
2
2
  module Controller
3
+ ##
4
+ # Error class thrown if you have no query defined,
5
+ # but try to call `parsed_query`.
3
6
  class UndefinedQueryError < Error
4
7
  end
5
8
  end
@@ -0,0 +1,28 @@
1
+ module SoberSwag
2
+ ##
3
+ # A variant of Dry::Struct that allows you to set a "model name" that is publically visible.
4
+ # If you do not set one, it will be the Ruby class name, with any '::' replaced with a '.'.
5
+ #
6
+ # This otherwise behaves exactly like a Dry::Struct.
7
+ # Please see the documentation for that class to see how it works.
8
+ class InputObject < Dry::Struct
9
+ transform_keys(&:to_sym)
10
+
11
+ class << self
12
+ ##
13
+ # The name to use for this type in external documentation.
14
+ def identifier(arg = nil)
15
+ @identifier = arg if arg
16
+ @identifier || name.to_s.gsub('::', '.')
17
+ end
18
+
19
+ def primitive(sym)
20
+ SoberSwag::Types.const_get(sym)
21
+ end
22
+
23
+ def param(sym)
24
+ SoberSwag::Types::Params.const_get(sym)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -22,7 +22,7 @@ module SoberSwag
22
22
  @elements
23
23
  end
24
24
 
25
- def deconstruct_keys(keys)
25
+ def deconstruct_keys(_keys)
26
26
  { elements: @elements }
27
27
  end
28
28
  end
@@ -9,7 +9,6 @@ module SoberSwag
9
9
  # - #deconstruct_keys, which returns a hash of *everything needed to identify the node*.
10
10
  # We use this later.
11
11
  class Base
12
-
13
12
  include Comparable
14
13
 
15
14
  ##
@@ -38,14 +37,17 @@ module SoberSwag
38
37
  #
39
38
  # When working with these definition nodes, we very often want to transform something recursively.
40
39
  # This method allows us to do so by focusing on a single level at a time, keeping the actual recursion *abstract*.
41
- def cata(&block)
40
+ def cata
42
41
  raise ArgumentError, 'Base is abstract'
43
42
  end
44
43
 
45
- def map(&block)
44
+ def map
46
45
  raise ArgumentError, 'Base is abstract'
47
46
  end
48
47
 
48
+ def flatten_one_ofs
49
+ raise ArgumentError, 'Base is abstract'
50
+ end
49
51
  end
50
52
  end
51
53
  end
@@ -11,6 +11,7 @@ module SoberSwag
11
11
  end
12
12
 
13
13
  attr_reader :lhs, :rhs
14
+
14
15
  ##
15
16
  # Map the root values of the node.
16
17
  # This just calls map on the lhs and the rhs
@@ -25,7 +26,7 @@ module SoberSwag
25
26
  [lhs, rhs]
26
27
  end
27
28
 
28
- def deconstruct_keys(keys)
29
+ def deconstruct_keys(_keys)
29
30
  { lhs: lhs, rhs: rhs }
30
31
  end
31
32
 
@@ -1,7 +1,9 @@
1
1
  module SoberSwag
2
2
  module Nodes
3
+ ##
4
+ # Compiler node to represent an enum value.
5
+ # Enums are special enough to have their own node.
3
6
  class Enum < Base
4
-
5
7
  def initialize(values)
6
8
  @values = values
7
9
  end
@@ -16,7 +18,7 @@ module SoberSwag
16
18
  [values]
17
19
  end
18
20
 
19
- def deconstruct_keys(keys)
21
+ def deconstruct_keys(_keys)
20
22
  { values: values }
21
23
  end
22
24
 
@@ -34,7 +34,6 @@ module SoberSwag
34
34
  element.map(&block)
35
35
  )
36
36
  end
37
-
38
37
  end
39
38
  end
40
39
  end
@@ -3,26 +3,27 @@ module SoberSwag
3
3
  ##
4
4
  # Root node of the tree
5
5
  class Primitive < Base
6
- def initialize(value)
6
+ def initialize(value, metadata = {})
7
7
  @value = value
8
+ @metadata = metadata
8
9
  end
9
10
 
10
- attr_reader :value
11
+ attr_reader :value, :metadata
11
12
 
12
13
  def map(&block)
13
14
  self.class.new(block.call(value))
14
15
  end
15
16
 
16
17
  def deconstruct
17
- [value]
18
+ [value, metadata]
18
19
  end
19
20
 
20
21
  def deconstruct_keys(_)
21
- { value: value }
22
+ { value: value, metadata: metadata }
22
23
  end
23
24
 
24
25
  def cata(&block)
25
- block.call(self.class.new(value))
26
+ block.call(self.class.new(value, metadata))
26
27
  end
27
28
  end
28
29
  end
@@ -0,0 +1,102 @@
1
+ require 'sober_swag/serializer'
2
+
3
+ module SoberSwag
4
+ ##
5
+ # Create a serializer that is heavily inspired by the "Blueprinter" library.
6
+ # This allows you to make "views" and such inside.
7
+ #
8
+ # Under the hood, this is actually all based on {SoberSwag::Serialzier::Base}.
9
+ class OutputObject < SoberSwag::Serializer::Base
10
+ autoload(:Field, 'sober_swag/output_object/field')
11
+ autoload(:Definition, 'sober_swag/output_object/definition')
12
+ autoload(:FieldSyntax, 'sober_swag/output_object/field_syntax')
13
+ autoload(:View, 'sober_swag/output_object/view')
14
+
15
+ ##
16
+ # Use a OutputObject to define a new serializer.
17
+ # It will be based on {SoberSwag::Serializer::Base}.
18
+ #
19
+ # An example is illustrative:
20
+ #
21
+ # PersonSerializer = SoberSwag::OutputObject.define do
22
+ # field :id, primitive(:Integer)
23
+ # field :name, primtive(:String).optional
24
+ #
25
+ # view :complex do
26
+ # field :age, primitive(:Integer)
27
+ # field :title, primitive(:String)
28
+ # end
29
+ # end
30
+ #
31
+ # Note: This currently will generate a new *class* that does serialization.
32
+ # However, this is only a hack to get rid of the weird naming issue when
33
+ # generating swagger from dry structs: their section of the schema area
34
+ # is defined by their *Ruby Class Name*. In the future, if we get rid of this,
35
+ # we might be able to keep this on the value-level, in which case {#define}
36
+ # can simply return an *instance* of SoberSwag::Serializer that does
37
+ # the correct thing, with the name you give it. This works for now, though.
38
+ def self.define(&block)
39
+ d = Definition.new.tap do |o|
40
+ o.instance_eval(&block)
41
+ end
42
+ new(d.fields, d.views, d.identifier)
43
+ end
44
+
45
+ def initialize(fields, views, identifier)
46
+ @fields = fields
47
+ @views = views
48
+ @identifier = identifier
49
+ end
50
+
51
+ attr_reader :fields, :views, :identifier
52
+
53
+ def serialize(obj, opts = {})
54
+ serializer.serialize(obj, opts)
55
+ end
56
+
57
+ def type
58
+ serializer.type
59
+ end
60
+
61
+ def view(name)
62
+ return base_serializer if name == :base
63
+
64
+ @views.find { |v| v.name == name }
65
+ end
66
+
67
+ def base
68
+ base_serializer
69
+ end
70
+
71
+ ##
72
+ # Compile down this to an appropriate serializer.
73
+ # It uses {SoberSwag::Serializer::Conditional} to do view-parsing,
74
+ # and {SoberSwag::Serializer::FieldList} to do the actual serialization.
75
+ def serializer # rubocop:disable Metrics/MethodLength
76
+ @serializer ||=
77
+ begin
78
+ views.reduce(base_serializer) do |base, view|
79
+ view_serializer = view.serializer
80
+ view_serializer.identifier("#{identifier}.#{view.name.to_s.classify}") if identifier
81
+ SoberSwag::Serializer::Conditional.new(
82
+ proc do |object, options|
83
+ if options[:view].to_s == view.name.to_s
84
+ [:left, object]
85
+ else
86
+ [:right, object]
87
+ end
88
+ end,
89
+ view_serializer,
90
+ base
91
+ )
92
+ end
93
+ end
94
+ end
95
+
96
+ def base_serializer
97
+ @base_serializer ||= SoberSwag::Serializer::FieldList.new(fields).tap do |s|
98
+ s.identifier(identifier)
99
+ end
100
+ end
101
+ end
102
+ end