openapi_blocks 0.3.1 → 0.5.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.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Openapi < OpenapiBlocks::Controller
4
+ # resource <%= class_name %>Serializer
5
+ # controller <%= class_name %>Controller
6
+
7
+ # tags "<%= class_name.pluralize %>"
8
+
9
+ # operation :index do
10
+ # summary "List all <%= class_name.pluralize.downcase %>"
11
+ # description "Returns a paginated list of <%= class_name.pluralize.downcase %>"
12
+ #
13
+ # parameter :page, in: :query, type: :integer, description: "Page number"
14
+ # parameter :per_page, in: :query, type: :integer, description: "Items per page"
15
+ #
16
+ # response 200, description: "List of <%= class_name.pluralize.downcase %>", schema: { type: :array, items: :<%= class_name %> }
17
+ # response 401, description: "Unauthorized"
18
+ # end
19
+
20
+ # operation :show do
21
+ # summary "Get a <%= class_name.downcase %>"
22
+ #
23
+ # response 200, description: "<%= class_name %> found", schema: :<%= class_name %>
24
+ # response 404, description: "<%= class_name %> not found"
25
+ # end
26
+
27
+ # operation :create do
28
+ # summary "Create a <%= class_name.downcase %>"
29
+ #
30
+ # response 201, description: "<%= class_name %> created", schema: :<%= class_name %>
31
+ # response 422, description: "Invalid data"
32
+ # end
33
+
34
+ # operation :update do
35
+ # summary "Update a <%= class_name.downcase %>"
36
+ #
37
+ # response 200, description: "<%= class_name %> updated", schema: :<%= class_name %>
38
+ # response 404, description: "<%= class_name %> not found"
39
+ # response 422, description: "Invalid data"
40
+ # end
41
+
42
+ # operation :destroy do
43
+ # summary "Delete a <%= class_name.downcase %>"
44
+ #
45
+ # response 200, description: "<%= class_name %> deleted"
46
+ # response 404, description: "<%= class_name %> not found"
47
+ # end
48
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module Generators
5
+ class SerializerGenerator < Rails::Generators::NamedBase # rubocop:disable Style/Documentation
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates an OpenapiBlocks Serializer class in app/serializers/"
9
+
10
+ def create_serializer_file
11
+ template "serializer.rb.tt", "app/serializers/#{file_name}_serializer.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Serializer < OpenapiBlocks::Serializer
4
+ # model <%= class_name %> is inferred automatically from the class name
5
+
6
+ # ignore :password_digest, :reset_password_token
7
+
8
+ # association :posts, type: :array, read_only: true
9
+ # association :company
10
+
11
+ # attribute :full_name, type: :string, read_only: true
12
+ # def full_name
13
+ # "#{object.first_name} #{object.last_name}"
14
+ # end
15
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module AutoSerialize # rubocop:disable Style/Documentation
5
+ def render(options = nil, extra = nil, &) # rubocop:disable Metrics/MethodLength
6
+ if auto_serialize_candidate?(options)
7
+ object = options[:json]
8
+ serializer = Registry.resolve(object)
9
+
10
+ if serializer
11
+ log_serializer(object, serializer)
12
+ options = options.merge(json: serializer.serialize(object))
13
+ else
14
+ warn_no_serializer(object)
15
+ end
16
+ end
17
+
18
+ super
19
+ end
20
+
21
+ private
22
+
23
+ def auto_serialize_candidate?(options)
24
+ OpenapiBlocks.configuration.auto_serialize &&
25
+ options.is_a?(Hash) &&
26
+ options.key?(:json)
27
+ end
28
+
29
+ def log_serializer(object, serializer)
30
+ model = extract_model(object)
31
+ Rails.logger.debug(
32
+ "[OpenapiBlocks] #{model.name} serialized by #{serializer.name}"
33
+ )
34
+ end
35
+
36
+ def warn_no_serializer(object)
37
+ model = extract_model(object)
38
+ return unless model
39
+
40
+ Rails.logger.warn(
41
+ "[OpenapiBlocks] No serializer found for #{model.name}. " \
42
+ "Falling back to default Rails rendering. " \
43
+ "Create #{model.name}Serializer or use `serializes #{model.name}` explicitly."
44
+ )
45
+ end
46
+
47
+ def extract_model(object)
48
+ case object
49
+ when Array then object.first&.class
50
+ else object.respond_to?(:klass) ? object.klass : object.class
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,48 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiBlocks
4
- class Base # rubocop:disable Style/Documentation
5
- include Serializer
4
+ # <b>DEPRECATED:</b> please use <tt>OpenapiBlocks::Controllers</tt> and <tt>OpenapiBlocks::Resources</tt> instead.
5
+ class Base
6
+ include Concerns::Schemable
7
+ include Concerns::Documentable
8
+ include Serialization
6
9
 
7
10
  class << self
8
- attr_reader :_model, :_ignored, :_associations, :_virtual_attributes, :_operations, :_tags
9
-
10
- def model(klass = nil)
11
- klass ? @_model = klass : @_model ||= infer_model # rubocop:disable Naming/MemoizedInstanceVariableName
12
- end
13
-
14
- def ignore(*attributes)
15
- @_ignored ||= []
16
- @_ignored.concat(attributes.map(&:to_s))
17
- end
18
-
19
- def association(name, type: nil, read_only: false)
20
- @_associations ||= []
21
- @_associations << { name: name, type: type, read_only: read_only }
22
- end
23
-
24
- def attribute(name, **)
25
- @_virtual_attributes ||= []
26
- @_virtual_attributes << ({ name: name, ** })
27
- end
28
-
29
- def operation(action, &block)
30
- @_operations ||= {}
31
- builder = OperationBuilder.new
32
- builder.instance_eval(&block) if block
33
- @_operations[action] = builder
34
- end
35
-
36
- def tags(*values)
37
- values.any? ? @_tags = values : @_tags
38
- end
39
-
40
11
  private
41
12
 
42
13
  def infer_model
43
14
  model_name = name
44
15
  .gsub(/Openapi$/, "")
45
- .gsub(/Resource$/, "")
16
+ .gsub(/Serializer$/, "")
46
17
  .split("::")
47
18
  .last
48
19
 
@@ -4,21 +4,53 @@ require_relative "spec/document"
4
4
 
5
5
  module OpenapiBlocks
6
6
  class Builder # rubocop:disable Style/Documentation
7
+ REQUIRED_CONFIG_ERROR = <<~MSG
8
+ OpenapiBlocks is not configured. Add an initializer:
9
+
10
+ # config/initializers/openapi_blocks.rb
11
+ OpenapiBlocks.configure do |config|
12
+ config.openapi_version = "3.1.0" # required: "3.0.3" or "3.1.0"
13
+
14
+ config.info do
15
+ title "My API" # required
16
+ version "1.0.0" # required
17
+ end
18
+ end
19
+ MSG
20
+
7
21
  def self.build
8
22
  new.build
9
23
  end
10
24
 
11
25
  def build
26
+ validate_configuration!
12
27
  Spec::Document.new(openapi_classes).build
13
28
  end
14
29
 
15
30
  private
16
31
 
32
+ def validate_configuration! # rubocop:disable Metrics/CyclomaticComplexity
33
+ config = OpenapiBlocks.configuration
34
+ errors = []
35
+
36
+ unless config.configured?
37
+ errors << "config.openapi_version or config.info must be defined — call OpenapiBlocks.configure"
38
+ end
39
+ errors << "config.info.title is required" if config.info&.title.blank?
40
+ errors << "config.info.version is required" if config.info&.version.blank?
41
+
42
+ return if errors.empty?
43
+
44
+ raise Error, "#{REQUIRED_CONFIG_ERROR}\nMissing:\n#{errors.map { |e| " - #{e}" }.join("\n")}"
45
+ end
46
+
17
47
  def openapi_classes
18
48
  ObjectSpace.each_object(Class).select do |klass|
19
49
  name = Module.instance_method(:name).bind_call(klass)
20
- name&.end_with?("Openapi") &&
21
- (klass < OpenapiBlocks::Base || klass < OpenapiBlocks::Controller)
50
+ next unless name&.end_with?("Openapi")
51
+
52
+ klass < OpenapiBlocks::Base ||
53
+ klass < OpenapiBlocks::Controller
22
54
  end
23
55
  end
24
56
  end
@@ -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,28 @@ 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
7
+ generators do
8
+ require "generators/openapi_blocks/install/install_generator"
9
+ require "generators/openapi_blocks/openapi/openapi_generator"
10
+ require "generators/openapi_blocks/serializer/serializer_generator"
9
11
  end
10
12
 
11
13
  initializer "openapi_blocks.autoload", before: :set_autoload_paths do |app|
12
14
  app.config.eager_load_paths << app.root.join("app/openapi")
15
+ app.config.eager_load_paths << app.root.join("app/serializers")
13
16
  end
14
17
 
15
18
  config.to_prepare do
16
19
  Dir[Rails.root.join("app/openapi/**/*.rb")].each { |f| require f }
20
+ Dir[Rails.root.join("app/serializers/**/*.rb")].each { |f| require f }
21
+
22
+ if OpenapiBlocks.configuration.auto_serialize
23
+ [ActionController::Base, ActionController::API].each do |klass|
24
+ klass.include(OpenapiBlocks::AutoSerialize) unless klass.ancestors.include?(OpenapiBlocks::AutoSerialize)
25
+ end
26
+
27
+ OpenapiBlocks::Registry.build!
28
+ end
17
29
  end
18
30
  end
19
31
  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