gitlab-grape-openapi 0.0.0 → 0.1.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +222 -4
  4. data/lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb +23 -0
  5. data/lib/gitlab/grape_openapi/concerns/limit_resolver.rb +31 -0
  6. data/lib/gitlab/grape_openapi/concerns/regex_converter.rb +58 -0
  7. data/lib/gitlab/grape_openapi/concerns/serializable.rb +19 -0
  8. data/lib/gitlab/grape_openapi/configuration.rb +24 -0
  9. data/lib/gitlab/grape_openapi/converters/coercer_resolver.rb +74 -0
  10. data/lib/gitlab/grape_openapi/converters/entity_converter.rb +267 -0
  11. data/lib/gitlab/grape_openapi/converters/operation_converter.rb +250 -0
  12. data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +252 -0
  13. data/lib/gitlab/grape_openapi/converters/path_converter.rb +152 -0
  14. data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +97 -0
  15. data/lib/gitlab/grape_openapi/converters/response_converter.rb +185 -0
  16. data/lib/gitlab/grape_openapi/converters/tag_converter.rb +36 -0
  17. data/lib/gitlab/grape_openapi/converters/type_resolver.rb +75 -0
  18. data/lib/gitlab/grape_openapi/generator.rb +60 -0
  19. data/lib/gitlab/grape_openapi/models/info.rb +29 -0
  20. data/lib/gitlab/grape_openapi/models/operation.rb +47 -0
  21. data/lib/gitlab/grape_openapi/models/parameter.rb +43 -0
  22. data/lib/gitlab/grape_openapi/models/path_item.rb +26 -0
  23. data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +250 -0
  24. data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +87 -0
  25. data/lib/gitlab/grape_openapi/models/response.rb +48 -0
  26. data/lib/gitlab/grape_openapi/models/schema.rb +61 -0
  27. data/lib/gitlab/grape_openapi/models/security_scheme.rb +130 -0
  28. data/lib/gitlab/grape_openapi/models/server.rb +31 -0
  29. data/lib/gitlab/grape_openapi/models/server_variable.rb +25 -0
  30. data/lib/gitlab/grape_openapi/models/tag.rb +44 -0
  31. data/lib/gitlab/grape_openapi/request_body_registry.rb +57 -0
  32. data/lib/gitlab/grape_openapi/schema_registry.rb +26 -0
  33. data/lib/gitlab/grape_openapi/serializers/time.rb +19 -0
  34. data/lib/gitlab/grape_openapi/tag_registry.rb +29 -0
  35. data/lib/gitlab/grape_openapi/version.rb +8 -0
  36. data/lib/gitlab-grape-openapi.rb +64 -0
  37. metadata +162 -12
  38. data/lib/gitlab/grape/openapi/version.rb +0 -9
  39. data/lib/gitlab/grape/openapi.rb +0 -21
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#response-object
7
+ class Response
8
+ attr_reader :status_code, :description, :entity_class, :headers, :content_type
9
+
10
+ def initialize(
11
+ status_code:, description:, entity_class:, headers: {}, content_type: 'application/json',
12
+ example: nil, examples: nil)
13
+ @status_code = status_code.to_s
14
+ @description = description
15
+ @entity_class = entity_class
16
+ @headers = headers
17
+ @content_type = content_type
18
+ @example = example
19
+ @examples = examples
20
+ end
21
+
22
+ def to_h(schema_registry)
23
+ response = {
24
+ description: description,
25
+ content: {
26
+ content_type => {
27
+ schema: { '$ref' => schema_ref(schema_registry) },
28
+ example: @example,
29
+ examples: @examples
30
+ }.compact
31
+ }
32
+ }
33
+
34
+ response[:headers] = headers if headers.any?
35
+
36
+ response
37
+ end
38
+
39
+ private
40
+
41
+ def schema_ref(schema_registry)
42
+ normalized_name = schema_registry.register(entity_class, nil)
43
+ "#/components/schemas/#{normalized_name}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#schema-object
7
+ class Schema
8
+ attr_accessor :properties, :type
9
+
10
+ def initialize
11
+ @properties = {}
12
+ end
13
+
14
+ def method_missing(method_name, *_args)
15
+ raise NoMethodError unless respond_to_missing?(method_name)
16
+
17
+ properties[method_name]
18
+ end
19
+
20
+ def respond_to_missing?(method_name, _include_private = false)
21
+ properties.key?(method_name)
22
+ end
23
+
24
+ def to_h
25
+ build_schema_hash
26
+ end
27
+
28
+ private
29
+
30
+ def build_schema_hash
31
+ {}.tap do |hash|
32
+ add_type(hash)
33
+ add_properties(hash)
34
+ end
35
+ end
36
+
37
+ def add_type(hash)
38
+ hash[:type] = type if type
39
+ end
40
+
41
+ def add_properties(hash)
42
+ return if properties.empty?
43
+
44
+ hash[:properties] = properties.transform_values(&:to_h)
45
+ end
46
+
47
+ def description
48
+ @properties[:description]
49
+ end
50
+
51
+ def default
52
+ @properties[:default]
53
+ end
54
+
55
+ def example
56
+ @properties[:example]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#security-scheme-object
7
+ class SecurityScheme
8
+ VALID_TYPES = %w[apiKey http oauth2 openIdConnect].freeze
9
+ VALID_IN_VALUES = %w[query header cookie].freeze
10
+ VALID_HTTP_SCHEMES = %w[basic bearer oauth].freeze
11
+
12
+ attr_accessor :type, :description, :name, :in, :scheme, :bearer_format,
13
+ :flows, :open_id_connect_url
14
+
15
+ def initialize(type:, **options)
16
+ @type = type
17
+ validate_type!
18
+
19
+ @description = options[:description]
20
+
21
+ case @type
22
+ when 'apiKey'
23
+ @name = options[:name] || raise(ArgumentError, "name is required for apiKey type")
24
+ @in = options[:in] || raise(ArgumentError, "in is required for apiKey type")
25
+ validate_in!
26
+ when 'http'
27
+ @scheme = options[:scheme] || raise(ArgumentError, "scheme is required for http type")
28
+ validate_http_scheme!
29
+ @bearer_format = options[:bearer_format] if @scheme == 'bearer'
30
+ when 'oauth2'
31
+ @flows = options[:flows] || raise(ArgumentError, "flows is required for oauth2 type")
32
+ validate_oauth2_flows!
33
+ when 'openIdConnect'
34
+ @open_id_connect_url = options[:open_id_connect_url] ||
35
+ raise(ArgumentError, "open_id_connect_url is required for openIdConnect type")
36
+ end
37
+ end
38
+
39
+ def to_h
40
+ hash = { 'type' => @type }
41
+ hash['description'] = @description if @description
42
+
43
+ case @type
44
+ when 'apiKey'
45
+ hash['name'] = @name
46
+ hash['in'] = @in
47
+ when 'http'
48
+ hash['scheme'] = @scheme
49
+ hash['bearerFormat'] = @bearer_format if @bearer_format
50
+ when 'oauth2'
51
+ hash['flows'] = flows_to_hash(@flows)
52
+ when 'openIdConnect'
53
+ hash['openIdConnectUrl'] = @open_id_connect_url
54
+ end
55
+
56
+ hash
57
+ end
58
+
59
+ private
60
+
61
+ def validate_type!
62
+ return if VALID_TYPES.include?(@type)
63
+
64
+ raise ArgumentError, "Invalid type: #{@type}. Must be one of: #{VALID_TYPES.join(', ')}"
65
+ end
66
+
67
+ def validate_in!
68
+ return if VALID_IN_VALUES.include?(@in)
69
+
70
+ raise ArgumentError, "Invalid 'in' value: #{@in}. Must be one of: #{VALID_IN_VALUES.join(', ')}"
71
+ end
72
+
73
+ def validate_http_scheme!
74
+ return if VALID_HTTP_SCHEMES.include?(@scheme.downcase)
75
+
76
+ raise ArgumentError, "Invalid HTTP scheme: #{@scheme}. Common values: #{VALID_HTTP_SCHEMES.join(', ')}"
77
+ end
78
+
79
+ def validate_oauth2_flows!
80
+ raise ArgumentError, "flows must be a Hash" unless @flows.is_a?(Hash)
81
+
82
+ valid_flow_types = %w[implicit password clientCredentials authorizationCode]
83
+ @flows.each do |flow_type, flow_config|
84
+ unless valid_flow_types.include?(flow_type.to_s)
85
+ raise ArgumentError, "Invalid flow type: #{flow_type}. Must be one of: #{valid_flow_types.join(', ')}"
86
+ end
87
+
88
+ validate_flow_config!(flow_type.to_s, flow_config)
89
+ end
90
+ end
91
+
92
+ # rubocop:disable Metrics/CyclomaticComplexity -- Method is clear and readable
93
+ def validate_flow_config!(flow_type, config)
94
+ raise ArgumentError, "Flow configuration must be a Hash" unless config.is_a?(Hash)
95
+
96
+ case flow_type
97
+ when 'implicit'
98
+ raise ArgumentError, "authorizationUrl required for implicit flow" unless config[:authorizationUrl]
99
+ raise ArgumentError, "scopes required for implicit flow" unless config[:scopes]
100
+ when 'password'
101
+ raise ArgumentError, "tokenUrl required for password flow" unless config[:tokenUrl]
102
+ raise ArgumentError, "scopes required for password flow" unless config[:scopes]
103
+ when 'clientCredentials'
104
+ raise ArgumentError, "tokenUrl required for clientCredentials flow" unless config[:tokenUrl]
105
+ raise ArgumentError, "scopes required for clientCredentials flow" unless config[:scopes]
106
+ when 'authorizationCode'
107
+ raise ArgumentError, "authorizationUrl required for authorizationCode flow" unless config[:authorizationUrl]
108
+ raise ArgumentError, "tokenUrl required for authorizationCode flow" unless config[:tokenUrl]
109
+ raise ArgumentError, "scopes required for authorizationCode flow" unless config[:scopes]
110
+ end
111
+ end
112
+ # rubocop:enable Metrics/CyclomaticComplexity
113
+
114
+ def flows_to_hash(flows)
115
+ result = {}
116
+ flows.each do |flow_type, config|
117
+ flow_hash = {}
118
+ flow_hash['authorizationUrl'] = config[:authorizationUrl] if config[:authorizationUrl]
119
+ flow_hash['tokenUrl'] = config[:tokenUrl] if config[:tokenUrl]
120
+ flow_hash['refreshUrl'] = config[:refreshUrl] if config[:refreshUrl]
121
+ scopes = config[:scopes]
122
+ flow_hash['scopes'] = scopes.respond_to?(:call) ? scopes.call : (scopes || {})
123
+ result[flow_type.to_s] = flow_hash
124
+ end
125
+ result
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#server-object
7
+ class Server
8
+ attr_reader :url, :description, :variables
9
+
10
+ def initialize(url:, description: nil, variables: nil)
11
+ @url = url
12
+ @description = description
13
+ @variables = variables
14
+ end
15
+
16
+ def to_h
17
+ hash = { url: url }
18
+ hash[:description] = description if description.present?
19
+ hash[:variables] = variables_to_h if variables.present?
20
+ hash
21
+ end
22
+
23
+ private
24
+
25
+ def variables_to_h
26
+ variables.transform_values(&:to_h)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#server-variable-object
7
+ class ServerVariable
8
+ attr_reader :default, :description, :enum
9
+
10
+ def initialize(default:, description: nil, enum: nil)
11
+ @default = default
12
+ @description = description
13
+ @enum = enum
14
+ end
15
+
16
+ def to_h
17
+ hash = { default: default }
18
+ hash[:description] = description if description.present?
19
+ hash[:enum] = enum if enum.present?
20
+ hash
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ class Tag
7
+ def self.normalize_tag_name(tag_name)
8
+ name = tag_name.split('_').join(' ').capitalize
9
+ apply_overrides(name)
10
+ end
11
+
12
+ def self.apply_overrides(text)
13
+ overrides = Gitlab::GrapeOpenapi.configuration.tag_overrides
14
+
15
+ return text if overrides.nil? || overrides.empty?
16
+
17
+ pattern = Regexp.union(overrides.keys.map { |key| /\b#{Regexp.escape(key)}\b/i })
18
+ text.gsub(pattern) do |match|
19
+ overrides[match] || overrides[match.downcase] || overrides[match.capitalize] || match
20
+ end
21
+ end
22
+
23
+ attr_reader :name
24
+
25
+ def initialize(name)
26
+ @name = Gitlab::GrapeOpenapi::Models::Tag.normalize_tag_name(name)
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ name: name,
32
+ description: description
33
+ }.compact
34
+ end
35
+
36
+ def description
37
+ desc_name = name.downcase
38
+ desc_adjusted = self.class.apply_overrides(desc_name)
39
+ "Operations related to #{desc_adjusted}."
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Gitlab
6
+ module GrapeOpenapi
7
+ class RequestBodyRegistry
8
+ SCHEMA_PATH_PREFIX = '#/components/schemas/'
9
+
10
+ attr_reader :schemas
11
+
12
+ def initialize
13
+ @schemas = {}
14
+ @schema_hashes = {}
15
+ end
16
+
17
+ def register(schema)
18
+ return nil if schema.blank?
19
+
20
+ schema_hash = compute_hash(schema)
21
+
22
+ if @schema_hashes.key?(schema_hash)
23
+ existing_name = @schema_hashes[schema_hash]
24
+ return { '$ref' => "#{SCHEMA_PATH_PREFIX}#{existing_name}" }
25
+ end
26
+
27
+ name = "RequestBody_#{short_hash(schema_hash)}"
28
+ @schemas[name] = schema
29
+ @schema_hashes[schema_hash] = name
30
+
31
+ { '$ref' => "#{SCHEMA_PATH_PREFIX}#{name}" }
32
+ end
33
+
34
+ private
35
+
36
+ def compute_hash(schema)
37
+ normalized = normalize_for_hash(schema)
38
+ OpenSSL::Digest::SHA256.hexdigest(normalized.to_s)
39
+ end
40
+
41
+ def short_hash(full_hash)
42
+ full_hash[0, 12]
43
+ end
44
+
45
+ def normalize_for_hash(obj)
46
+ case obj
47
+ when Hash
48
+ obj.sort.map { |k, v| [k.to_s, normalize_for_hash(v)] }
49
+ when Array
50
+ obj.map { |v| normalize_for_hash(v) }
51
+ else
52
+ obj
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ class SchemaRegistry
6
+ attr_reader :schemas
7
+
8
+ def initialize
9
+ @schemas = {}
10
+ end
11
+
12
+ def register(entity_class, schema)
13
+ normalized_name = normalize_entity_class(entity_class)
14
+ return normalized_name if @schemas.key?(normalized_name)
15
+ return normalized_name unless schema.is_a?(Models::Schema)
16
+
17
+ @schemas[normalized_name] = schema
18
+ normalized_name
19
+ end
20
+
21
+ def normalize_entity_class(entity_class)
22
+ entity_class.name.delete(':')
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Serializers
6
+ class Time
7
+ DEFAULT_TIME = '2025-08-01T00:00:00.000Z'
8
+
9
+ def serialize(value, example: nil)
10
+ return unless defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone)
11
+
12
+ return example if example
13
+
14
+ DEFAULT_TIME
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ class TagRegistry
6
+ attr_reader :tags
7
+
8
+ def initialize
9
+ @tags = []
10
+ end
11
+
12
+ def register(tag)
13
+ return if tag_exists?(tag)
14
+
15
+ tags << tag.to_h
16
+ end
17
+
18
+ def to_h
19
+ { tags: @tags }
20
+ end
21
+
22
+ private
23
+
24
+ def tag_exists?(tag)
25
+ tags.any? { |existing_tag| existing_tag[:name] == tag.name }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ # Version of the gem
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gitlab/grape_openapi/version"
4
+ require_relative "gitlab/grape_openapi/configuration"
5
+ require_relative "gitlab/grape_openapi/generator"
6
+ require_relative "gitlab/grape_openapi/schema_registry"
7
+ require_relative "gitlab/grape_openapi/request_body_registry"
8
+ require_relative "gitlab/grape_openapi/tag_registry"
9
+
10
+ # Concerns
11
+ require_relative "gitlab/grape_openapi/concerns/serializable"
12
+ require_relative "gitlab/grape_openapi/concerns/limit_resolver"
13
+ require_relative "gitlab/grape_openapi/concerns/fail_fast_annotatable"
14
+ require_relative "gitlab/grape_openapi/concerns/regex_converter"
15
+
16
+ # Serializers
17
+ require_relative "gitlab/grape_openapi/serializers/time"
18
+
19
+ # Converters
20
+ require_relative "gitlab/grape_openapi/converters/coercer_resolver"
21
+ require_relative "gitlab/grape_openapi/converters/entity_converter"
22
+ require_relative "gitlab/grape_openapi/converters/type_resolver"
23
+ require_relative "gitlab/grape_openapi/converters/tag_converter"
24
+ require_relative "gitlab/grape_openapi/converters/operation_converter"
25
+ require_relative "gitlab/grape_openapi/converters/path_converter"
26
+ require_relative "gitlab/grape_openapi/converters/parameter_converter"
27
+ require_relative "gitlab/grape_openapi/converters/response_converter"
28
+ require_relative "gitlab/grape_openapi/converters/request_body_converter"
29
+
30
+ # Models
31
+ require_relative "gitlab/grape_openapi/models/request_body/parameter_schema"
32
+ require_relative "gitlab/grape_openapi/models/request_body/parameters"
33
+ require_relative "gitlab/grape_openapi/models/schema"
34
+ require_relative "gitlab/grape_openapi/models/tag"
35
+ require_relative "gitlab/grape_openapi/models/server_variable"
36
+ require_relative "gitlab/grape_openapi/models/server"
37
+ require_relative "gitlab/grape_openapi/models/operation"
38
+ require_relative "gitlab/grape_openapi/models/path_item"
39
+ require_relative "gitlab/grape_openapi/models/response"
40
+ require_relative "gitlab/grape_openapi/models/security_scheme"
41
+ require_relative "gitlab/grape_openapi/models/info"
42
+ require_relative "gitlab/grape_openapi/models/parameter"
43
+
44
+ module Gitlab
45
+ module GrapeOpenapi
46
+ GenerationError = Class.new(StandardError)
47
+
48
+ class << self
49
+ attr_writer :configuration
50
+
51
+ def configuration
52
+ @configuration ||= Configuration.new
53
+ end
54
+
55
+ def configure
56
+ yield(configuration)
57
+ end
58
+
59
+ def generate(options = {})
60
+ Generator.new(options).generate
61
+ end
62
+ end
63
+ end
64
+ end