openapi_blocks 0.3.1 → 0.4.1

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.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module Concerns
5
+ module Documentable # rubocop:disable Style/Documentation
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods # rubocop:disable Style/Documentation
11
+ attr_reader :_operations, :_tags
12
+
13
+ def operation(action, &block)
14
+ @_operations ||= {}
15
+ builder = OperationBuilder.new
16
+ builder.instance_eval(&block) if block
17
+ @_operations[action] = builder
18
+ end
19
+
20
+ def tags(*values)
21
+ values.any? ? @_tags = values : @_tags
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module Concerns
5
+ module Schemable # rubocop:disable Style/Documentation
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods # rubocop:disable Style/Documentation
11
+ attr_reader :_model, :_ignored, :_associations, :_virtual_attributes, :_serializes
12
+
13
+ def model(klass = nil)
14
+ klass ? @_model = klass : @_model ||= infer_model # rubocop:disable Naming/MemoizedInstanceVariableName
15
+ end
16
+
17
+ def ignore(*attributes)
18
+ @_ignored ||= []
19
+ @_ignored.concat(attributes.map(&:to_s))
20
+ end
21
+
22
+ def association(name, type: nil, read_only: false)
23
+ @_associations ||= []
24
+ @_associations << { name: name, type: type, read_only: read_only }
25
+ end
26
+
27
+ def attribute(name, **)
28
+ @_virtual_attributes ||= []
29
+ @_virtual_attributes << { name: name, ** }
30
+ end
31
+
32
+ def serializes(*models)
33
+ @_serializes ||= []
34
+ @_serializes.concat(models)
35
+ end
36
+
37
+ private
38
+
39
+ def infer_model
40
+ raise NotImplementedError, "#{name} must implement infer_model"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -9,14 +9,20 @@ module OpenapiBlocks
9
9
  SUPPORTED_VERSIONS = %w[3.1.0 3.0.3].freeze
10
10
 
11
11
  attr_reader :openapi_version
12
- attr_accessor :watch
12
+ attr_accessor :watch, :auto_serialize
13
13
 
14
14
  def initialize
15
15
  @openapi_version = "3.1.0"
16
16
  @watch = :development
17
+ @auto_serialize = false
17
18
  @info = InfoBuilder.new
18
19
  @servers = []
19
20
  @security = nil
21
+ @configured = false
22
+ end
23
+
24
+ def configured?
25
+ @configured
20
26
  end
21
27
 
22
28
  def openapi_version=(version)
@@ -29,6 +35,7 @@ module OpenapiBlocks
29
35
  end
30
36
 
31
37
  def info(&block)
38
+ @configured = true if block
32
39
  @info.instance_eval(&block) if block
33
40
  @info
34
41
  end
@@ -2,8 +2,10 @@
2
2
 
3
3
  module OpenapiBlocks
4
4
  class Controller # rubocop:disable Style/Documentation
5
+ include Concerns::Documentable
6
+
5
7
  class << self
6
- attr_reader :_resource, :_operations, :_tags, :_controller_class
8
+ attr_reader :_resource, :_controller_class
7
9
 
8
10
  def resource(klass)
9
11
  @_resource = klass
@@ -13,32 +15,10 @@ module OpenapiBlocks
13
15
  @_controller_class = klass
14
16
  end
15
17
 
16
- def model
17
- @_resource&.model
18
- end
19
-
20
- def _associations
21
- @_resource&._associations
22
- end
23
-
24
- def _virtual_attributes
25
- @_resource&._virtual_attributes
26
- end
27
-
28
- def _ignored
29
- @_resource&._ignored
30
- end
31
-
32
- def operation(action, &block)
33
- @_operations ||= {}
34
- builder = OperationBuilder.new
35
- builder.instance_eval(&block) if block
36
- @_operations[action] = builder
37
- end
38
-
39
- def tags(*values)
40
- values.any? ? @_tags = values : @_tags
41
- end
18
+ def model = @_resource&.model
19
+ def _associations = @_resource&._associations
20
+ def _virtual_attributes = @_resource&._virtual_attributes
21
+ def _ignored = @_resource&._ignored
42
22
  end
43
23
  end
44
24
  end
@@ -4,16 +4,22 @@ require "rails"
4
4
 
5
5
  module OpenapiBlocks
6
6
  class Railtie < Rails::Railtie # rubocop:disable Style/Documentation
7
- initializer "openapi_blocks.middleware" do |app|
8
- app.middleware.use OpenapiBlocks::Middleware
9
- end
10
-
11
7
  initializer "openapi_blocks.autoload", before: :set_autoload_paths do |app|
12
8
  app.config.eager_load_paths << app.root.join("app/openapi")
9
+ app.config.eager_load_paths << app.root.join("app/serializers")
13
10
  end
14
11
 
15
12
  config.to_prepare do
16
13
  Dir[Rails.root.join("app/openapi/**/*.rb")].each { |f| require f }
14
+ Dir[Rails.root.join("app/serializers/**/*.rb")].each { |f| require f }
15
+
16
+ if OpenapiBlocks.configuration.auto_serialize
17
+ [ActionController::Base, ActionController::API].each do |klass|
18
+ klass.include(OpenapiBlocks::AutoSerialize) unless klass.ancestors.include?(OpenapiBlocks::AutoSerialize)
19
+ end
20
+
21
+ OpenapiBlocks::Registry.build!
22
+ end
17
23
  end
18
24
  end
19
25
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module Registry # rubocop:disable Style/Documentation
5
+ @map = {}
6
+ @mutex = Mutex.new
7
+
8
+ class << self
9
+ def register(model, serializer)
10
+ @mutex.synchronize { @map[model] = serializer }
11
+ end
12
+
13
+ def resolve(object)
14
+ model = extract_model(object)
15
+ @mutex.synchronize { @map[model] }
16
+ end
17
+
18
+ def build!
19
+ @mutex.synchronize { @map = {} }
20
+
21
+ serializer_classes.each do |klass|
22
+ register_by_convention(klass)
23
+ register_by_explicit(klass)
24
+ end
25
+ end
26
+
27
+ def reset!
28
+ @mutex.synchronize { @map = {} }
29
+ end
30
+
31
+ private
32
+
33
+ def serializer_classes
34
+ ObjectSpace.each_object(Class).select { |klass| safe_serializer?(klass) }
35
+ end
36
+
37
+ def register_by_convention(klass)
38
+ model_name = klass_name(klass)
39
+ .gsub(/Serializer$/, "")
40
+ &.split("::")
41
+ &.last
42
+ return if model_name.blank?
43
+
44
+ model = Object.const_get(model_name)
45
+ return unless model < ActiveRecord::Base
46
+
47
+ register(model, klass)
48
+ rescue NameError
49
+ nil
50
+ end
51
+
52
+ def register_by_explicit(klass)
53
+ return unless klass.respond_to?(:_serializes) && klass._serializes&.any?
54
+
55
+ klass._serializes.each { |model| register(model, klass) }
56
+ end
57
+
58
+ def safe_serializer?(klass)
59
+ klass_name(klass)&.end_with?("Serializer") && klass < OpenapiBlocks::Serializer
60
+ rescue StandardError
61
+ false
62
+ end
63
+
64
+ def klass_name(klass)
65
+ Module.instance_method(:name).bind_call(klass)
66
+ end
67
+
68
+ def extract_model(object)
69
+ case object
70
+ when Array then object.first&.class
71
+ else object.respond_to?(:klass) ? object.klass : object.class
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module Serialization # rubocop:disable Style/Documentation
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods # rubocop:disable Metrics/ModuleLength,Style/Documentation
10
+ def serialize(resource, instance: nil) # rubocop:disable Lint/UnusedMethodArgument
11
+ extractor = compiled_extractor
12
+ if resource.respond_to?(:each)
13
+ resource.map { |r| extractor.call(r) }
14
+ else
15
+ extractor.call(resource)
16
+ end
17
+ end
18
+
19
+ def to_json(resource)
20
+ Oj.dump(serialize(resource), mode: :compat)
21
+ end
22
+
23
+ def fields
24
+ @fields ||= resolve_fields
25
+ end
26
+
27
+ def compiled_extractor
28
+ @compiled_extractor ||= build_compiled_extractor
29
+ end
30
+
31
+ private
32
+
33
+ def build_compiled_extractor # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
34
+ classified = classify_fields
35
+
36
+ model_lines = classified[:model].map { |f| %("#{f}" => object.public_send("#{f}")) }
37
+ virtual_lines = classified[:virtual].map { |f| %("#{f}" => inst.public_send("#{f}")) }
38
+ delegated_lines = classified[:delegated].map { |f| %("#{f}" => object.public_send("#{f}")) }
39
+ assoc_lines = classified[:association].map do |f|
40
+ %("#{f}" => _serialize_assoc_#{f}(object))
41
+ end
42
+
43
+ classified[:association].each { |field| build_assoc_method(field) }
44
+
45
+ all_lines = (model_lines + delegated_lines + virtual_lines + assoc_lines).join(",\n ")
46
+
47
+ if classified[:virtual].any?
48
+ serializer_klass = self
49
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
50
+ def self._extract(object)
51
+ inst = #{serializer_klass}.new(object)
52
+ {
53
+ #{all_lines}
54
+ }
55
+ end
56
+ RUBY
57
+ else
58
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
59
+ def self._extract(object)
60
+ {
61
+ #{all_lines}
62
+ }
63
+ end
64
+ RUBY
65
+ end
66
+
67
+ method(:_extract)
68
+ end
69
+
70
+ def build_assoc_method(field) # rubocop:disable Metrics/MethodLength
71
+ assoc = assoc_metadata_by_name[field]
72
+ assoc_name = assoc[:name]
73
+ serializer = resolve_assoc_serializer(assoc_name)
74
+
75
+ if serializer.nil?
76
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
77
+ def self._serialize_assoc_#{field}(object)
78
+ object.public_send(:#{assoc_name}).as_json
79
+ end
80
+ RUBY
81
+ return
82
+ end
83
+
84
+ if assoc[:type] == :array
85
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
86
+ def self._serialize_assoc_#{field}(object)
87
+ val = object.public_send(:#{assoc_name})
88
+ return nil if val.nil?
89
+ val.map { |v| #{serializer}.serialize(v) }
90
+ end
91
+ RUBY
92
+ else
93
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
94
+ def self._serialize_assoc_#{field}(object)
95
+ val = object.public_send(:#{assoc_name})
96
+ val.nil? ? nil : #{serializer}.serialize(val)
97
+ end
98
+ RUBY
99
+ end
100
+ end
101
+
102
+ def classify_fields # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
103
+ model_set = Set.new(resolve_model_fields)
104
+ assoc_set = Set.new(assoc_metadata_by_name.keys)
105
+ virtual_set = Set.new(
106
+ Array(_virtual_attributes).map { |a| a[:name].to_s }
107
+ )
108
+
109
+ result = { model: [], virtual: [], delegated: [], association: [] }
110
+ fields.each do |field|
111
+ result[if assoc_set.include?(field)
112
+ :association
113
+ elsif model_set.include?(field)
114
+ :model
115
+ elsif virtual_set.include?(field) && method_defined?(field.to_sym)
116
+ :virtual # método definido no resource
117
+ elsif virtual_set.include?(field)
118
+ :delegated # método definido no model
119
+ else # rubocop:disable Lint/DuplicateBranch
120
+ :delegated
121
+ end] << field
122
+ end
123
+ result
124
+ end
125
+
126
+ def assoc_metadata_by_name
127
+ @assoc_metadata_by_name ||= begin
128
+ klass = self
129
+ result = {}
130
+ while klass && klass != serialization_sentinel
131
+ Array(klass._associations)
132
+ .each { |a| result[a[:name].to_s] ||= a }
133
+ klass = klass.superclass
134
+ end
135
+ result
136
+ end
137
+ end
138
+
139
+ def resolve_fields # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
140
+ klass = self
141
+ sentinel = serialization_sentinel
142
+
143
+ model_fields = resolve_model_fields
144
+ virtual_fields = []
145
+ ignored_fields = []
146
+ assoc_fields = []
147
+
148
+ while klass && klass != sentinel
149
+ virtual_fields += Array(klass._virtual_attributes).map { |a| a[:name].to_s }
150
+ ignored_fields += Array(klass._ignored)
151
+ assoc_fields += Array(klass._associations).map { |a| a[:name].to_s }
152
+ klass = klass.superclass
153
+ end
154
+
155
+ (model_fields + virtual_fields + assoc_fields - ignored_fields).uniq
156
+ end
157
+
158
+ def resolve_model_fields
159
+ klass = self
160
+ sentinel = serialization_sentinel
161
+ while klass && klass != sentinel
162
+ begin
163
+ return klass.model.column_names
164
+ rescue StandardError
165
+ klass = klass.superclass
166
+ end
167
+ end
168
+ []
169
+ end
170
+
171
+ def serialization_sentinel
172
+ raise NotImplementedError
173
+ end
174
+
175
+ def resolve_assoc_serializer(assoc_name)
176
+ classified = assoc_name.to_s.classify
177
+
178
+ ["#{classified}Serializer", "#{classified}Openapi"].each do |name|
179
+ klass = Object.const_get(name)
180
+ return klass._resource if klass.respond_to?(:_resource) && klass._resource
181
+ return klass if klass.respond_to?(:serialize)
182
+ rescue NameError
183
+ next
184
+ end
185
+
186
+ nil
187
+ end
188
+ end
189
+
190
+ attr_reader :object, :parent
191
+
192
+ def initialize(object, parent = nil)
193
+ @object = object
194
+ @parent = parent
195
+ end
196
+ end
197
+ end
@@ -1,193 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiBlocks
4
- module Serializer # rubocop:disable Style/Documentation
5
- def self.included(base)
6
- base.extend(ClassMethods)
7
- end
8
-
9
- module ClassMethods # rubocop:disable Metrics/ModuleLength,Style/Documentation
10
- def serialize(resource, instance: nil) # rubocop:disable Lint/UnusedMethodArgument
11
- extractor = compiled_extractor
12
- if resource.respond_to?(:each)
13
- resource.map { |r| extractor.call(r) }
14
- else
15
- extractor.call(resource)
16
- end
17
- end
18
-
19
- def to_json(resource)
20
- Oj.dump(serialize(resource), mode: :compat)
21
- end
22
-
23
- def fields
24
- @fields ||= resolve_fields
25
- end
26
-
27
- def compiled_extractor
28
- @compiled_extractor ||= build_compiled_extractor
29
- end
4
+ class Serializer # rubocop:disable Style/Documentation
5
+ include Concerns::Schemable
6
+ include Serialization
30
7
 
8
+ class << self
31
9
  private
32
10
 
33
- def build_compiled_extractor # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
34
- classified = classify_fields
35
-
36
- model_lines = classified[:model].map { |f| %("#{f}" => object.public_send(:#{f})) }
37
- virtual_lines = classified[:virtual].map { |f| %("#{f}" => inst.public_send(:#{f})) }
38
- delegated_lines = classified[:delegated].map { |f| %("#{f}" => object.public_send(:#{f})) }
39
- assoc_lines = classified[:association].map do |f|
40
- %("#{f}" => _serialize_assoc_#{f}(object))
41
- end
42
-
43
- classified[:association].each { |field| build_assoc_method(field) }
44
-
45
- all_lines = (model_lines + delegated_lines + virtual_lines + assoc_lines).join(",\n ")
46
-
47
- if classified[:virtual].any?
48
- serializer_klass = self
49
- class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
50
- def self._extract(object)
51
- inst = #{serializer_klass}.new(object)
52
- {
53
- #{all_lines}
54
- }
55
- end
56
- RUBY
57
- else
58
- class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
59
- def self._extract(object)
60
- {
61
- #{all_lines}
62
- }
63
- end
64
- RUBY
65
- end
66
-
67
- method(:_extract)
68
- end
69
-
70
- def build_assoc_method(field) # rubocop:disable Metrics/MethodLength
71
- assoc = assoc_metadata_by_name[field]
72
- assoc_name = assoc[:name]
73
- serializer = resolve_assoc_serializer(assoc_name)
74
-
75
- if serializer.nil?
76
- class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
77
- def self._serialize_assoc_#{field}(object)
78
- object.public_send(:#{assoc_name}).as_json
79
- end
80
- RUBY
81
- return
82
- end
83
-
84
- if assoc[:type] == :array
85
- class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
86
- def self._serialize_assoc_#{field}(object)
87
- val = object.public_send(:#{assoc_name})
88
- return nil if val.nil?
89
- val.map { |v| #{serializer}.serialize(v) }
90
- end
91
- RUBY
92
- else
93
- class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
94
- def self._serialize_assoc_#{field}(object)
95
- val = object.public_send(:#{assoc_name})
96
- val.nil? ? nil : #{serializer}.serialize(val)
97
- end
98
- RUBY
99
- end
100
- end
101
-
102
- def resolve_assoc_serializer(assoc_name)
103
- classified = assoc_name.to_s.classify
104
-
105
- ["#{classified}Resource", "#{classified}Openapi"].each do |name|
106
- klass = Object.const_get(name)
107
- return klass._resource if klass.respond_to?(:_resource) && klass._resource
108
- return klass if klass.respond_to?(:serialize)
109
- rescue NameError
110
- next
111
- end
112
-
113
- nil
114
- end
115
-
116
- def classify_fields # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
117
- model_set = Set.new(resolve_model_fields)
118
- assoc_set = Set.new(assoc_metadata_by_name.keys)
119
- virtual_set = Set.new(
120
- Array(_virtual_attributes).map { |a| a[:name].to_s }
121
- )
122
-
123
- result = { model: [], virtual: [], delegated: [], association: [] }
124
- fields.each do |field|
125
- result[if assoc_set.include?(field)
126
- :association
127
- elsif model_set.include?(field)
128
- :model
129
- elsif virtual_set.include?(field) && method_defined?(field.to_sym)
130
- :virtual # método definido no resource
131
- elsif virtual_set.include?(field)
132
- :delegated # método definido no model
133
- else # rubocop:disable Lint/DuplicateBranch
134
- :delegated
135
- end] << field
136
- end
137
- result
138
- end
139
-
140
- def assoc_metadata_by_name
141
- @assoc_metadata_by_name ||= begin
142
- klass = self
143
- result = {}
144
- while klass && klass != OpenapiBlocks::Base
145
- Array(klass._associations)
146
- .each { |a| result[a[:name].to_s] ||= a }
147
- klass = klass.superclass
148
- end
149
- result
150
- end
151
- end
152
-
153
- def resolve_fields # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
154
- klass = self
155
-
156
- model_fields = resolve_model_fields
157
- virtual_fields = []
158
- ignored_fields = []
159
- assoc_fields = []
11
+ def serialization_sentinel = OpenapiBlocks::Serializer
160
12
 
161
- while klass && klass != OpenapiBlocks::Base
162
- virtual_fields += Array(klass._virtual_attributes)
163
- .map { |a| a[:name].to_s }
164
- ignored_fields += Array(klass._ignored)
165
- assoc_fields += Array(klass._associations)
166
- .map { |a| a[:name].to_s }
167
- klass = klass.superclass
168
- end
169
-
170
- (model_fields + virtual_fields + assoc_fields - ignored_fields).uniq
13
+ def infer_model
14
+ model_name = name
15
+ .gsub(/Serializer$/, "")
16
+ .split("::").last
17
+ Object.const_get(model_name)
18
+ rescue NameError
19
+ raise Error, "Could not infer model from #{name}. Use `model ModelClass` to define it explicitly."
171
20
  end
172
-
173
- def resolve_model_fields
174
- klass = self
175
- while klass && klass != OpenapiBlocks::Base
176
- begin
177
- return klass.model.column_names
178
- rescue StandardError
179
- klass = klass.superclass
180
- end
181
- end
182
- []
183
- end
184
- end
185
-
186
- attr_reader :object, :parent
187
-
188
- def initialize(object, parent = nil)
189
- @object = object
190
- @parent = parent
191
21
  end
192
22
  end
193
23
  end
@@ -16,15 +16,17 @@ module OpenapiBlocks
16
16
  schemas = @openapi_classes.each_with_object({}) do |klass, hash|
17
17
  schema_klass = klass.respond_to?(:_resource) && klass._resource ? klass._resource : klass
18
18
 
19
- begin
20
- next unless schema_klass.model
19
+ model = begin
20
+ schema_klass.model
21
21
  rescue StandardError
22
22
  next
23
23
  end
24
24
 
25
- schema_name = schema_klass.model.name
25
+ next unless model
26
+
27
+ schema_name = model.name
26
28
  extractor = Schema::Extractor.new(schema_klass)
27
- validator = Schema::Validator.new(schema_klass.model)
29
+ validator = Schema::Validator.new(model)
28
30
 
29
31
  schema = extractor.extract
30
32
  schema[:properties] = merge_validations(schema[:properties], validator.extract)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiBlocks
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.1"
5
5
  end