sober_swag 0.1.0 → 0.2.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 +5 -0
  3. data/.github/workflows/lint.yml +15 -0
  4. data/.github/workflows/ruby.yml +23 -1
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +73 -1
  7. data/.ruby-version +1 -1
  8. data/Gemfile.lock +29 -5
  9. data/README.md +109 -0
  10. data/bin/console +15 -14
  11. data/docs/serializers.md +203 -0
  12. data/example/.rspec +1 -0
  13. data/example/.ruby-version +1 -1
  14. data/example/Gemfile +10 -6
  15. data/example/Gemfile.lock +96 -76
  16. data/example/app/controllers/people_controller.rb +37 -21
  17. data/example/app/controllers/posts_controller.rb +102 -0
  18. data/example/app/models/application_record.rb +3 -0
  19. data/example/app/models/person.rb +6 -0
  20. data/example/app/models/post.rb +9 -0
  21. data/example/app/output_objects/person_errors_output_object.rb +5 -0
  22. data/example/app/output_objects/person_output_object.rb +15 -0
  23. data/example/app/output_objects/post_output_object.rb +10 -0
  24. data/example/bin/bundle +24 -20
  25. data/example/bin/rails +1 -1
  26. data/example/bin/rake +1 -1
  27. data/example/config/application.rb +11 -7
  28. data/example/config/environments/development.rb +0 -1
  29. data/example/config/environments/production.rb +3 -3
  30. data/example/config/puma.rb +5 -5
  31. data/example/config/routes.rb +3 -0
  32. data/example/config/spring.rb +4 -4
  33. data/example/db/migrate/20200311152021_create_people.rb +0 -1
  34. data/example/db/migrate/20200603172347_create_posts.rb +11 -0
  35. data/example/db/schema.rb +16 -7
  36. data/example/spec/rails_helper.rb +64 -0
  37. data/example/spec/requests/people/create_spec.rb +52 -0
  38. data/example/spec/requests/people/get_spec.rb +35 -0
  39. data/example/spec/requests/people/index_spec.rb +69 -0
  40. data/example/spec/spec_helper.rb +94 -0
  41. data/lib/sober_swag.rb +6 -3
  42. data/lib/sober_swag/compiler/error.rb +2 -0
  43. data/lib/sober_swag/compiler/path.rb +2 -5
  44. data/lib/sober_swag/compiler/paths.rb +0 -1
  45. data/lib/sober_swag/compiler/type.rb +28 -15
  46. data/lib/sober_swag/controller.rb +16 -11
  47. data/lib/sober_swag/controller/route.rb +18 -21
  48. data/lib/sober_swag/controller/undefined_body_error.rb +3 -0
  49. data/lib/sober_swag/controller/undefined_path_error.rb +3 -0
  50. data/lib/sober_swag/controller/undefined_query_error.rb +3 -0
  51. data/lib/sober_swag/input_object.rb +28 -0
  52. data/lib/sober_swag/nodes/array.rb +1 -1
  53. data/lib/sober_swag/nodes/base.rb +2 -4
  54. data/lib/sober_swag/nodes/binary.rb +2 -1
  55. data/lib/sober_swag/nodes/enum.rb +4 -2
  56. data/lib/sober_swag/nodes/list.rb +0 -1
  57. data/lib/sober_swag/nodes/primitive.rb +6 -5
  58. data/lib/sober_swag/output_object.rb +102 -0
  59. data/lib/sober_swag/output_object/definition.rb +30 -0
  60. data/lib/sober_swag/{blueprint → output_object}/field.rb +14 -4
  61. data/lib/sober_swag/{blueprint → output_object}/field_syntax.rb +1 -1
  62. data/lib/sober_swag/{blueprint → output_object}/view.rb +15 -6
  63. data/lib/sober_swag/parser.rb +5 -3
  64. data/lib/sober_swag/serializer.rb +5 -2
  65. data/lib/sober_swag/serializer/array.rb +12 -0
  66. data/lib/sober_swag/serializer/base.rb +50 -1
  67. data/lib/sober_swag/serializer/conditional.rb +15 -2
  68. data/lib/sober_swag/serializer/field_list.rb +29 -6
  69. data/lib/sober_swag/serializer/mapped.rb +12 -2
  70. data/lib/sober_swag/serializer/meta.rb +35 -0
  71. data/lib/sober_swag/serializer/optional.rb +17 -2
  72. data/lib/sober_swag/serializer/primitive.rb +4 -1
  73. data/lib/sober_swag/server.rb +83 -0
  74. data/lib/sober_swag/types.rb +3 -0
  75. data/lib/sober_swag/version.rb +1 -1
  76. data/sober_swag.gemspec +6 -4
  77. metadata +77 -44
  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
@@ -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,13 @@ 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
-
49
47
  end
50
48
  end
51
49
  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
@@ -0,0 +1,30 @@
1
+ module SoberSwag
2
+ class OutputObject
3
+ ##
4
+ # Container to define a single output object.
5
+ # This is the DSL used in the base of {SoberSwag::OutputObject.define}.
6
+ class Definition
7
+ def initialize
8
+ @fields = []
9
+ @views = []
10
+ end
11
+
12
+ attr_reader :fields, :views
13
+
14
+ include FieldSyntax
15
+
16
+ def add_field!(field)
17
+ @fields << field
18
+ end
19
+
20
+ def view(name, &block)
21
+ @views << View.define(name, fields, &block)
22
+ end
23
+
24
+ def identifier(arg = nil)
25
+ @identifier = arg if arg
26
+ @identifier
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,8 @@
1
1
  module SoberSwag
2
- class Blueprint
2
+ class OutputObject
3
+ ##
4
+ # A single field in an output object.
5
+ # Later used to make an actual serializer from this.
3
6
  class Field
4
7
  def initialize(name, serializer, from: nil, &block)
5
8
  @name = name
@@ -11,12 +14,20 @@ module SoberSwag
11
14
  attr_reader :name
12
15
 
13
16
  def serializer
14
- @serializer ||= @root_serializer.serializer.via_map(&transform_proc)
17
+ @serializer ||= resolved_serializer.serializer.via_map(&transform_proc)
18
+ end
19
+
20
+ def resolved_serializer
21
+ if @root_serializer.is_a?(Proc)
22
+ @root_serializer.call
23
+ else
24
+ @root_serializer
25
+ end
15
26
  end
16
27
 
17
28
  private
18
29
 
19
- def transform_proc
30
+ def transform_proc # rubocop:disable Metrics/MethodLength
20
31
  if @block
21
32
  @block
22
33
  else
@@ -30,7 +41,6 @@ module SoberSwag
30
41
  end
31
42
  end
32
43
  end
33
-
34
44
  end
35
45
  end
36
46
  end
@@ -1,5 +1,5 @@
1
1
  module SoberSwag
2
- class Blueprint
2
+ class OutputObject
3
3
  ##
4
4
  # Syntax for definitions that can add fields.
5
5
  module FieldSyntax