openapi_blocks 0.2.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +86 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +304 -0
  6. data/README.pt-BR.md +495 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/openapi_blocks/spec_controller.rb +110 -0
  9. data/config/routes.rb +7 -0
  10. data/lib/openapi_blocks/base.rb +52 -0
  11. data/lib/openapi_blocks/builder.rb +23 -0
  12. data/lib/openapi_blocks/cache.rb +28 -0
  13. data/lib/openapi_blocks/configuration/contact_builder.rb +23 -0
  14. data/lib/openapi_blocks/configuration/info_builder.rb +42 -0
  15. data/lib/openapi_blocks/configuration/license_builder.rb +19 -0
  16. data/lib/openapi_blocks/configuration/security_builder.rb +33 -0
  17. data/lib/openapi_blocks/configuration/server_builder.rb +19 -0
  18. data/lib/openapi_blocks/configuration/servers_builder.rb +21 -0
  19. data/lib/openapi_blocks/configuration.rb +55 -0
  20. data/lib/openapi_blocks/engine.rb +13 -0
  21. data/lib/openapi_blocks/file_watcher.rb +42 -0
  22. data/lib/openapi_blocks/middleware.rb +54 -0
  23. data/lib/openapi_blocks/operation_builder.rb +57 -0
  24. data/lib/openapi_blocks/railtie.rb +19 -0
  25. data/lib/openapi_blocks/routing/extractor.rb +187 -0
  26. data/lib/openapi_blocks/routing/operation.rb +45 -0
  27. data/lib/openapi_blocks/schema/extractor.rb +103 -0
  28. data/lib/openapi_blocks/schema/types.rb +52 -0
  29. data/lib/openapi_blocks/schema/validator.rb +86 -0
  30. data/lib/openapi_blocks/spec/components.rb +67 -0
  31. data/lib/openapi_blocks/spec/document.rb +47 -0
  32. data/lib/openapi_blocks/spec/paths.rb +17 -0
  33. data/lib/openapi_blocks/version.rb +5 -0
  34. data/lib/openapi_blocks.rb +35 -0
  35. data/sig/openapi_blocks.rbs +4 -0
  36. metadata +177 -0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Cache # rubocop:disable Style/Documentation
5
+ def initialize
6
+ @store = {}
7
+ @mutex = Mutex.new
8
+ end
9
+
10
+ def get(key)
11
+ @store[key]
12
+ end
13
+
14
+ def set(key, value)
15
+ @mutex.synchronize { @store[key] = value }
16
+ end
17
+
18
+ def invalidate!(key = nil)
19
+ @mutex.synchronize do
20
+ key ? @store.delete(key) : @store.clear
21
+ end
22
+ end
23
+
24
+ def cached?(key)
25
+ @store.key?(key)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Configuration
5
+ class ContactBuilder # rubocop:disable Style/Documentation
6
+ def name(value = nil)
7
+ value ? @name = value : @name
8
+ end
9
+
10
+ def email(value = nil)
11
+ value ? @email = value : @email
12
+ end
13
+
14
+ def url(value = nil)
15
+ value ? @url = value : @url
16
+ end
17
+
18
+ def to_h
19
+ { name: @name, email: @email, url: @url }.compact
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "contact_builder"
4
+ require_relative "license_builder"
5
+
6
+ module OpenapiBlocks
7
+ class Configuration
8
+ class InfoBuilder # rubocop:disable Style/Documentation
9
+ def title(value = nil)
10
+ value ? @title = value : @title
11
+ end
12
+
13
+ def version(value = nil)
14
+ value ? @version = value : @version
15
+ end
16
+
17
+ def description(value = nil)
18
+ value ? @description = value : @description
19
+ end
20
+
21
+ def contact(&block)
22
+ @contact = ContactBuilder.new
23
+ @contact.instance_eval(&block) if block
24
+ end
25
+
26
+ def license(&block)
27
+ @license = LicenseBuilder.new
28
+ @license.instance_eval(&block) if block
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ title: @title,
34
+ version: @version,
35
+ description: @description,
36
+ contact: @contact&.to_h,
37
+ license: @license&.to_h
38
+ }.compact
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Configuration
5
+ class LicenseBuilder # rubocop:disable Style/Documentation
6
+ def name(value = nil)
7
+ value ? @name = value : @name
8
+ end
9
+
10
+ def url(value = nil)
11
+ value ? @url = value : @url
12
+ end
13
+
14
+ def to_h
15
+ { name: @name, url: @url }.compact
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Configuration
5
+ class SecurityBuilder # rubocop:disable Style/Documentation
6
+ attr_reader :schemes
7
+
8
+ def initialize
9
+ @schemes = {}
10
+ end
11
+
12
+ def bearer_token(format: "JWT")
13
+ @schemes[:bearerAuth] = {
14
+ type: "http",
15
+ scheme: "bearer",
16
+ bearerFormat: format
17
+ }
18
+ end
19
+
20
+ def api_key(name: "X-API-Key", in: :header)
21
+ @schemes[:apiKey] = {
22
+ type: "apiKey",
23
+ name: name,
24
+ in: binding.local_variable_get(:in).to_s
25
+ }
26
+ end
27
+
28
+ def to_h
29
+ @schemes
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class Configuration
5
+ class ServerBuilder # rubocop:disable Style/Documentation
6
+ def url(value = nil)
7
+ value ? @url = value : @url
8
+ end
9
+
10
+ def description(value = nil)
11
+ value ? @description = value : @description
12
+ end
13
+
14
+ def to_h
15
+ { url: @url, description: @description }.compact
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server_builder"
4
+
5
+ module OpenapiBlocks
6
+ class Configuration
7
+ class ServersBuilder # rubocop:disable Style/Documentation
8
+ attr_reader :servers
9
+
10
+ def initialize
11
+ @servers = []
12
+ end
13
+
14
+ def server(&block)
15
+ s = ServerBuilder.new
16
+ s.instance_eval(&block) if block
17
+ @servers << s
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration/info_builder"
4
+ require_relative "configuration/servers_builder"
5
+ require_relative "configuration/security_builder"
6
+
7
+ module OpenapiBlocks
8
+ class Configuration # rubocop:disable Style/Documentation
9
+ SUPPORTED_VERSIONS = %w[3.1.0 3.0.3].freeze
10
+
11
+ attr_reader :openapi_version
12
+ attr_accessor :watch
13
+
14
+ def initialize
15
+ @openapi_version = "3.1.0"
16
+ @watch = :development
17
+ @info = InfoBuilder.new
18
+ @servers = []
19
+ @security = nil
20
+ end
21
+
22
+ def openapi_version=(version)
23
+ unless SUPPORTED_VERSIONS.include?(version.to_s)
24
+ raise ArgumentError,
25
+ "Unsupported OpenAPI version: #{version.inspect}. Supported versions: #{SUPPORTED_VERSIONS.join(', ')}"
26
+ end
27
+
28
+ @openapi_version = version.to_s
29
+ end
30
+
31
+ def info(&block)
32
+ @info.instance_eval(&block) if block
33
+ @info
34
+ end
35
+
36
+ def servers(&block)
37
+ builder = ServersBuilder.new
38
+ builder.instance_eval(&block) if block
39
+ @servers = builder.servers
40
+ end
41
+
42
+ def security(&block)
43
+ @security ||= SecurityBuilder.new
44
+ @security.instance_eval(&block) if block
45
+ @security
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ info: @info.to_h,
51
+ servers: @servers.map(&:to_h)
52
+ }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module OpenapiBlocks
6
+ class Engine < Rails::Engine # rubocop:disable Style/Documentation
7
+ isolate_namespace OpenapiBlocks
8
+
9
+ engine_name "openapi_blocks"
10
+
11
+ # config.autoload_paths << File.expand_path("app/controllers", __dir__)
12
+ end
13
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class FileWatcher # rubocop:disable Style/Documentation
5
+ WATCH_PATTERNS = [
6
+ "app/openapi/**/*.rb",
7
+ "app/models/**/*.rb",
8
+ "config/routes.rb",
9
+ "db/schema.rb"
10
+ ].freeze
11
+
12
+ def initialize(root_path)
13
+ @root_path = root_path
14
+ @mtimes = {}
15
+ snapshot!
16
+ end
17
+
18
+ def stale?
19
+ watched_files.any? do |file|
20
+ mtime(file) != @mtimes[file]
21
+ end
22
+ end
23
+
24
+ def snapshot!
25
+ watched_files.each do |file|
26
+ @mtimes[file] = mtime(file)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def watched_files
33
+ WATCH_PATTERNS.flat_map do |pattern|
34
+ Dir.glob(File.join(@root_path, pattern))
35
+ end
36
+ end
37
+
38
+ def mtime(file)
39
+ File.exist?(file) ? File.mtime(file) : nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_watcher"
4
+ require_relative "cache"
5
+
6
+ module OpenapiBlocks
7
+ class Middleware # rubocop:disable Style/Documentation
8
+ CACHE_KEY = :openapi_spec
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ @cache = Cache.new
13
+ @file_watcher = FileWatcher.new(root_path)
14
+ end
15
+
16
+ def call(env)
17
+ @app.call(env)
18
+ ensure
19
+ invalidate_if_stale!
20
+ end
21
+
22
+ private
23
+
24
+ def invalidate_if_stale!
25
+ return unless watch_enabled?
26
+ return unless @file_watcher.stale?
27
+
28
+ @cache.invalidate!(CACHE_KEY)
29
+ @file_watcher.snapshot!
30
+ end
31
+
32
+ def spec
33
+ @cache.set(CACHE_KEY, Builder.build) unless @cache.cached?(CACHE_KEY)
34
+
35
+ @cache.get(CACHE_KEY)
36
+ end
37
+
38
+ def watch_enabled?
39
+ watched_envs.include?(current_env)
40
+ end
41
+
42
+ def watched_envs
43
+ Array(OpenapiBlocks.configuration.watch)
44
+ end
45
+
46
+ def current_env
47
+ Rails.env.to_sym
48
+ end
49
+
50
+ def root_path
51
+ Rails.root.to_s
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ class OperationBuilder # rubocop:disable Style/Documentation
5
+ attr_reader :_summary, :_description, :_parameters, :_responses, :_security, :_tags
6
+
7
+ def summary(value = nil)
8
+ value ? @_summary = value : @_summary
9
+ end
10
+
11
+ def description(value = nil)
12
+ value ? @_description = value : @_description
13
+ end
14
+
15
+ def tags(*values)
16
+ values.any? ? @_tags = values : @_tags
17
+ end
18
+
19
+ def parameter(name, in:, type:, description: nil, required: false)
20
+ @_parameters ||= []
21
+ @_parameters << {
22
+ name: name,
23
+ in: binding.local_variable_get(:in),
24
+ type: type,
25
+ description: description,
26
+ required: required
27
+ }.compact
28
+ end
29
+
30
+ def response(status, description:, schema: nil)
31
+ @_responses ||= {}
32
+ @_responses[status.to_s] = {
33
+ description: description,
34
+ schema: schema
35
+ }.compact
36
+ end
37
+
38
+ def security(*schemes)
39
+ @_security = schemes.map { |s| { s => [] } }
40
+ end
41
+
42
+ def no_security!
43
+ @_security = []
44
+ end
45
+
46
+ def to_h
47
+ {
48
+ summary: @_summary,
49
+ description: @_description,
50
+ parameters: @_parameters,
51
+ responses: @_responses,
52
+ security: @_security,
53
+ tags: @_tags
54
+ }.compact
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module OpenapiBlocks
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
+ initializer "openapi_blocks.autoload", before: :set_autoload_paths do |app|
12
+ app.config.eager_load_paths << app.root.join("app/openapi")
13
+ end
14
+
15
+ config.to_prepare do
16
+ Dir[Rails.root.join("app/openapi/**/*.rb")].each { |f| require f }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "operation"
4
+
5
+ module OpenapiBlocks
6
+ module Routing
7
+ class Extractor # rubocop:disable Style/Documentation,Metrics/ClassLength
8
+ IGNORED_CONTROLLERS = %w[
9
+ rails/
10
+ action_mailbox/
11
+ active_storage/
12
+ openapi_blocks/
13
+ ].freeze
14
+
15
+ def initialize(app = Rails.application)
16
+ @app = app
17
+ end
18
+
19
+ def extract
20
+ routes.each_with_object({}) do |operation, hash|
21
+ next unless operation.valid?
22
+
23
+ hash[operation.path] ||= {}
24
+
25
+ operation.verbs.each do |verb|
26
+ hash[operation.path][verb] = build_operation(operation)
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def routes
34
+ @app.routes.routes.filter_map do |route|
35
+ defaults = route.defaults
36
+ next unless defaults[:controller] && defaults[:action]
37
+ next if IGNORED_CONTROLLERS.any? { |c| defaults[:controller].start_with?(c) }
38
+
39
+ Operation.new(
40
+ controller: defaults[:controller],
41
+ action: defaults[:action],
42
+ path: route.path.spec.to_s
43
+ )
44
+ end
45
+ end
46
+
47
+ def build_operation(operation) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/CyclomaticComplexity
48
+ meta = operation_meta(operation)
49
+ parameters = build_parameters(operation, meta)
50
+ tags = build_tags(operation, meta)
51
+
52
+ op = {
53
+ tags: tags,
54
+ summary: meta&._summary || build_summary(operation),
55
+ operationId: build_operation_id(operation),
56
+ responses: meta&._responses ? build_custom_responses(meta) : build_default_responses(operation)
57
+ }
58
+
59
+ op[:description] = meta._description if meta&._description
60
+ op[:parameters] = parameters if parameters.any?
61
+ op[:requestBody] = build_request_body(operation) if operation.has_body
62
+ op[:security] = meta._security if meta&._security
63
+
64
+ op
65
+ end
66
+
67
+ def build_tags(operation, meta)
68
+ return meta._tags if meta&._tags&.any?
69
+
70
+ openapi_class = find_openapi_class(operation)
71
+ return openapi_class._tags if openapi_class&._tags&.any?
72
+
73
+ [operation.schema_name]
74
+ end
75
+
76
+ def build_summary(operation)
77
+ "#{operation.action.humanize} #{operation.schema_name}"
78
+ end
79
+
80
+ def build_operation_id(operation)
81
+ "#{operation.action}#{operation.schema_name}"
82
+ end
83
+
84
+ def build_parameters(operation, meta)
85
+ params = build_path_parameters(operation)
86
+ params += build_query_parameters(meta) if meta&._parameters
87
+ params
88
+ end
89
+
90
+ def build_path_parameters(operation)
91
+ operation.path_parameters.map do |param|
92
+ {
93
+ name: param,
94
+ in: "path",
95
+ required: true,
96
+ schema: { type: "string" }
97
+ }
98
+ end
99
+ end
100
+
101
+ def build_query_parameters(meta)
102
+ meta._parameters.map do |param|
103
+ {
104
+ name: param[:name],
105
+ in: param[:in].to_s,
106
+ required: param[:required] || false,
107
+ description: param[:description],
108
+ schema: { type: param[:type].to_s }
109
+ }.compact
110
+ end
111
+ end
112
+
113
+ def build_default_responses(operation) # rubocop:disable Metrics/MethodLength
114
+ status = operation.action == "create" ? "201" : "200"
115
+ ref = { "$ref" => "#/components/schemas/#{operation.schema_name}" }
116
+
117
+ responses = {
118
+ status => {
119
+ description: "#{operation.action.humanize} #{operation.schema_name}",
120
+ content: {
121
+ "application/json" => {
122
+ schema: operation.action == "index" ? { type: "array", items: ref } : ref
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ responses["422"] = { description: "Unprocessable entity" } if operation.has_body
129
+ responses["404"] = { description: "Not found" } if %w[show update destroy].include?(operation.action)
130
+
131
+ responses
132
+ end
133
+
134
+ def build_custom_responses(meta)
135
+ meta._responses.each_with_object({}) do |(status, response), hash|
136
+ hash[status.to_s] = build_response_object(response)
137
+ end
138
+ end
139
+
140
+ def build_response_object(response)
141
+ obj = { description: response[:description] }
142
+ return obj unless response[:schema]
143
+
144
+ obj[:content] = {
145
+ "application/json" => {
146
+ schema: resolve_schema(response[:schema])
147
+ }
148
+ }
149
+ obj
150
+ end
151
+
152
+ def resolve_schema(schema)
153
+ case schema
154
+ in { type: :array, items: Symbol => ref }
155
+ { type: "array", items: { "$ref" => "#/components/schemas/#{ref.to_s.classify}" } }
156
+ in Symbol => ref
157
+ { "$ref" => "#/components/schemas/#{ref.to_s.classify}" }
158
+ else
159
+ schema
160
+ end
161
+ end
162
+
163
+ def build_request_body(operation)
164
+ ref = { "$ref" => "#/components/schemas/#{operation.schema_name}Input" }
165
+
166
+ {
167
+ required: true,
168
+ content: {
169
+ "application/json" => { schema: ref }
170
+ }
171
+ }
172
+ end
173
+
174
+ def operation_meta(operation)
175
+ openapi_class = find_openapi_class(operation)
176
+ openapi_class&._operations&.dig(operation.action.to_sym)
177
+ end
178
+
179
+ def find_openapi_class(operation)
180
+ openapi_name = "#{operation.schema_name}Openapi"
181
+ Object.const_get(openapi_name)
182
+ rescue NameError
183
+ nil
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiBlocks
4
+ module Routing
5
+ class Operation # rubocop:disable Style/Documentation
6
+ ACTIONS_MAP = {
7
+ "index" => { verbs: ["get"], has_body: false },
8
+ "show" => { verbs: ["get"], has_body: false },
9
+ "create" => { verbs: ["post"], has_body: true },
10
+ "update" => { verbs: %w[put patch], has_body: true },
11
+ "destroy" => { verbs: ["delete"], has_body: false }
12
+ }.freeze
13
+
14
+ attr_reader :verbs, :path, :action, :controller, :has_body
15
+
16
+ def initialize(route)
17
+ @controller = route[:controller]
18
+ @action = route[:action]
19
+ @path = normalize_path(route[:path])
20
+ @verbs = ACTIONS_MAP.dig(@action, :verbs)
21
+ @has_body = ACTIONS_MAP.dig(@action, :has_body)
22
+ end
23
+
24
+ def valid?
25
+ ACTIONS_MAP.key?(@action) && @verbs.present?
26
+ end
27
+
28
+ def path_parameters
29
+ @path.scan(/\{(\w+)\}/).flatten
30
+ end
31
+
32
+ def schema_name
33
+ @controller.split("/").last.classify
34
+ end
35
+
36
+ private
37
+
38
+ def normalize_path(path)
39
+ path
40
+ .gsub("(.:format)", "")
41
+ .gsub(/:(\w+)/, '{\1}')
42
+ end
43
+ end
44
+ end
45
+ end