sober_swag 0.1.0 → 0.6.0

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