openapi_blocks 0.2.0 → 0.3.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.
@@ -4,14 +4,19 @@ require "action_controller/api"
4
4
 
5
5
  module OpenapiBlocks
6
6
  class SpecController < ActionController::API # rubocop:disable Style/Documentation
7
- SWAGGER_UI_CSS = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css"
8
- SWAGGER_UI_STANDALONE_JS = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"
9
- SWAGGER_UI_JS = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"
7
+ SWAGGER_UI_CSS = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css"
8
+ SWAGGER_UI_STANDALONE_JS = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"
9
+ SWAGGER_UI_JS = "https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"
10
+ SCALAR_JS = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
10
11
 
11
12
  def ui
12
13
  render html: swagger_ui_html.html_safe
13
14
  end
14
15
 
16
+ def scalar
17
+ render html: scalar_html.html_safe
18
+ end
19
+
15
20
  def show
16
21
  spec = OpenapiBlocks::Builder.build.deep_stringify_keys
17
22
  spec["servers"] = swagger_ui_servers(spec)
@@ -25,6 +30,42 @@ module OpenapiBlocks
25
30
 
26
31
  private
27
32
 
33
+ def scalar_html
34
+ spec_url = "#{swagger_spec_base_url}.json"
35
+ title = "#{OpenapiBlocks.configuration.info.title} - Scalar"
36
+
37
+ <<~HTML
38
+ <!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
43
+ <title>#{title}</title>
44
+ </head>
45
+ <body>
46
+ <script
47
+ id="api-reference"
48
+ data-url="#{spec_url}"
49
+ data-configuration='#{scalar_configuration.to_json}'
50
+ ></script>
51
+ <script src="#{SCALAR_JS}"></script>
52
+ </body>
53
+ </html>
54
+ HTML
55
+ end
56
+
57
+ def scalar_configuration
58
+ {
59
+ theme: "default",
60
+ layout: "modern",
61
+ displayRequestDuration: true,
62
+ defaultHttpClient: {
63
+ targetKey: "ruby",
64
+ clientKey: "net_http"
65
+ }
66
+ }
67
+ end
68
+
28
69
  def swagger_ui_html # rubocop:disable Metrics/MethodLength
29
70
  urls = swagger_ui_urls
30
71
 
@@ -69,22 +110,16 @@ module OpenapiBlocks
69
110
  "#{OpenapiBlocks.configuration.info.title} - SwaggerUI"
70
111
  end
71
112
 
72
- def swagger_ui_urls # rubocop:disable Metrics/MethodLength
113
+ def swagger_ui_urls
73
114
  spec_base = swagger_spec_base_url
74
- servers = OpenapiBlocks.configuration.to_h[:servers]
115
+ servers = OpenapiBlocks.configuration.to_h[:servers]
75
116
 
76
117
  return default_swagger_ui_urls if servers.blank?
77
118
 
78
119
  servers.flat_map do |server|
79
120
  [
80
- {
81
- url: "#{spec_base}.json",
82
- name: "#{server[:url]} JSON"
83
- },
84
- {
85
- url: "#{spec_base}.yaml",
86
- name: "#{server[:url]} YAML"
87
- }
121
+ { url: "#{spec_base}.json", name: "#{server[:url]} JSON" },
122
+ { url: "#{spec_base}.yaml", name: "#{server[:url]} YAML" }
88
123
  ]
89
124
  end
90
125
  end
data/config/routes.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  OpenapiBlocks::Engine.routes.draw do
4
- root to: "spec#ui"
4
+ root to: "spec#scalar"
5
+ get "swagger", to: "spec#ui"
5
6
  get "openapi.json", to: "spec#show", defaults: { format: "json" }
6
7
  get "openapi.yaml", to: "spec#show", defaults: { format: "yaml" }
7
8
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module OpenapiBlocks
4
4
  class Base # rubocop:disable Style/Documentation
5
+ include Serializer
6
+
5
7
  class << self
6
8
  attr_reader :_model, :_ignored, :_associations, :_virtual_attributes, :_operations, :_tags
7
9
 
@@ -14,9 +16,9 @@ module OpenapiBlocks
14
16
  @_ignored.concat(attributes.map(&:to_s))
15
17
  end
16
18
 
17
- def association(name, type: nil, input: true)
19
+ def association(name, type: nil, read_only: false)
18
20
  @_associations ||= []
19
- @_associations << { name: name, type: type, input: input }
21
+ @_associations << { name: name, type: type, read_only: read_only }
20
22
  end
21
23
 
22
24
  def attribute(name, **)
@@ -40,6 +42,7 @@ module OpenapiBlocks
40
42
  def infer_model
41
43
  model_name = name
42
44
  .gsub(/Openapi$/, "")
45
+ .gsub(/Resource$/, "")
43
46
  .split("::")
44
47
  .last
45
48
 
@@ -16,7 +16,9 @@ module OpenapiBlocks
16
16
 
17
17
  def openapi_classes
18
18
  ObjectSpace.each_object(Class).select do |klass|
19
- klass < OpenapiBlocks::Base
19
+ name = Module.instance_method(:name).bind_call(klass)
20
+ name&.end_with?("Openapi") &&
21
+ (klass < OpenapiBlocks::Base || klass < OpenapiBlocks::Controller)
20
22
  end
21
23
  end
22
24
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Controller # rubocop:disable Style/Documentation
5
+ class << self
6
+ attr_reader :_resource, :_operations, :_tags
7
+
8
+ def resource(klass)
9
+ @_resource = klass
10
+ end
11
+
12
+ def model
13
+ @_resource&.model
14
+ end
15
+
16
+ def _associations
17
+ @_resource&._associations
18
+ end
19
+
20
+ def _virtual_attributes
21
+ @_resource&._virtual_attributes
22
+ end
23
+
24
+ def _ignored
25
+ @_resource&._ignored
26
+ end
27
+
28
+ def operation(action, &block)
29
+ @_operations ||= {}
30
+ builder = OperationBuilder.new
31
+ builder.instance_eval(&block) if block
32
+ @_operations[action] = builder
33
+ end
34
+
35
+ def tags(*values)
36
+ values.any? ? @_tags = values : @_tags
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Resource < Base # rubocop:disable Style/Documentation
5
+ class << self
6
+ private
7
+
8
+ def infer_model
9
+ model_name = name
10
+ .gsub(/Resource$/, "")
11
+ .split("::")
12
+ .last
13
+
14
+ Object.const_get(model_name)
15
+ rescue NameError
16
+ raise Error, "Could not infer model from #{name}. Use `model ModelClass` to define it explicitly."
17
+ end
18
+ end
19
+ end
20
+ end
@@ -63,11 +63,11 @@ module OpenapiBlocks
63
63
  Array(@openapi_class._associations).each_with_object({}) do |assoc, hash|
64
64
  name = assoc[:name]
65
65
  type = assoc[:type]
66
- input = assoc.fetch(:input, true)
67
- ref = { "$ref" => "#/components/schemas/#{name.to_s.classify}" }
66
+ read_only = assoc.fetch(:read_only, false)
67
+ ref = { "$ref" => "#/components/schemas/#{name.to_s.classify}" }
68
68
 
69
69
  schema = type == :array ? { type: "array", items: ref } : ref
70
- schema[:readOnly] = true unless input
70
+ schema[:readOnly] = true if read_only
71
71
 
72
72
  hash[name.to_s] = schema
73
73
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
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
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 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 = []
160
+
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
171
+ 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
+ end
192
+ end
193
+ end
@@ -12,17 +12,25 @@ module OpenapiBlocks
12
12
  @openapi_classes = openapi_classes
13
13
  end
14
14
 
15
- def build # rubocop:disable Metrics/AbcSize
15
+ def build # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
16
16
  schemas = @openapi_classes.each_with_object({}) do |klass, hash|
17
- schema_name = klass.model.name
18
- extractor = Schema::Extractor.new(klass)
19
- validator = Schema::Validator.new(klass.model)
17
+ schema_klass = klass.respond_to?(:_resource) && klass._resource ? klass._resource : klass
18
+
19
+ begin
20
+ next unless schema_klass.model
21
+ rescue StandardError
22
+ next
23
+ end
24
+
25
+ schema_name = schema_klass.model.name
26
+ extractor = Schema::Extractor.new(schema_klass)
27
+ validator = Schema::Validator.new(schema_klass.model)
20
28
 
21
29
  schema = extractor.extract
22
30
  schema[:properties] = merge_validations(schema[:properties], validator.extract)
23
31
 
24
- hash[schema_name] = schema
25
- hash["#{schema_name}Input"] = build_input(schema, klass)
32
+ hash[schema_name] = schema
33
+ hash["#{schema_name}Input"] = build_input(schema, schema_klass)
26
34
  end
27
35
 
28
36
  { schemas: schemas }
@@ -30,14 +38,19 @@ module OpenapiBlocks
30
38
 
31
39
  private
32
40
 
33
- def build_input(schema, openapi_class) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
41
+ def build_input(schema, openapi_class) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
34
42
  read_only_virtuals = Array(openapi_class._virtual_attributes)
35
43
  .select { |attr| attr[:read_only] == true }
36
44
  .map { |attr| attr[:name].to_s }
37
45
 
46
+ read_only_associations = Array(openapi_class._associations)
47
+ .select { |assoc| assoc[:read_only] == true }
48
+ .map { |assoc| assoc[:name].to_s }
49
+
38
50
  input_properties = schema[:properties].reject do |name, property|
39
51
  INPUT_IGNORED_PROPERTIES.include?(name.to_s) ||
40
- read_only_virtuals.include?(name.to_s) ||
52
+ read_only_virtuals.include?(name.to_s) ||
53
+ read_only_associations.include?(name.to_s) ||
41
54
  property[:readOnly] == true
42
55
  end
43
56
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiBlocks
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "active_support/all"
4
4
  require "active_record"
5
+ require "oj"
5
6
 
6
7
  require_relative "openapi_blocks/version"
7
8
  require_relative "openapi_blocks/configuration"
@@ -17,10 +18,13 @@ require_relative "openapi_blocks/spec/components"
17
18
  require_relative "openapi_blocks/spec/paths"
18
19
  require_relative "openapi_blocks/spec/document"
19
20
  require_relative "openapi_blocks/builder"
21
+ require_relative "openapi_blocks/serializer"
20
22
  require_relative "openapi_blocks/base"
21
23
  require_relative "openapi_blocks/engine"
22
24
  require_relative "openapi_blocks/railtie"
23
25
  require_relative "openapi_blocks/operation_builder"
26
+ require_relative "openapi_blocks/resource"
27
+ require_relative "openapi_blocks/controller"
24
28
 
25
29
  module OpenapiBlocks # rubocop:disable Style/Documentation
26
30
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_blocks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caio Santos
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: oj
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: rails
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -134,16 +148,19 @@ files:
134
148
  - lib/openapi_blocks/configuration/security_builder.rb
135
149
  - lib/openapi_blocks/configuration/server_builder.rb
136
150
  - lib/openapi_blocks/configuration/servers_builder.rb
151
+ - lib/openapi_blocks/controller.rb
137
152
  - lib/openapi_blocks/engine.rb
138
153
  - lib/openapi_blocks/file_watcher.rb
139
154
  - lib/openapi_blocks/middleware.rb
140
155
  - lib/openapi_blocks/operation_builder.rb
141
156
  - lib/openapi_blocks/railtie.rb
157
+ - lib/openapi_blocks/resource.rb
142
158
  - lib/openapi_blocks/routing/extractor.rb
143
159
  - lib/openapi_blocks/routing/operation.rb
144
160
  - lib/openapi_blocks/schema/extractor.rb
145
161
  - lib/openapi_blocks/schema/types.rb
146
162
  - lib/openapi_blocks/schema/validator.rb
163
+ - lib/openapi_blocks/serializer.rb
147
164
  - lib/openapi_blocks/spec/components.rb
148
165
  - lib/openapi_blocks/spec/document.rb
149
166
  - lib/openapi_blocks/spec/paths.rb