diff --git a/config/initializers/gitlab_grape_openapi.rb b/config/initializers/gitlab_grape_openapi.rb index be24b8aa85cb4bfbf4cec53059f412c099f36c9b..1d14c4be5479c523e161f8a30ef3a0011e23355c 100644 --- a/config/initializers/gitlab_grape_openapi.rb +++ b/config/initializers/gitlab_grape_openapi.rb @@ -11,6 +11,10 @@ terms_of_service: 'https://about.gitlab.com/terms/' ) + config.api_prefix = "api" + + config.api_version = "v4" + config.servers = [ Gitlab::GrapeOpenapi::Models::Server.new( url: Gitlab::Utils.append_path(Gitlab.config.gitlab.url, "api/v4"), diff --git a/gems/gitlab-grape-openapi/README.md b/gems/gitlab-grape-openapi/README.md index 489e628ab56ee5d70e49e4ddc43959d5aea345d4..b87abf6184be64e977b4eb6baf1fe1596caadf2b 100644 --- a/gems/gitlab-grape-openapi/README.md +++ b/gems/gitlab-grape-openapi/README.md @@ -3,7 +3,7 @@ > [!warning] > This gem is currently designed entirely for internal use at GitLab. This gem is not functional and not used in production. -Internal gem for generating OpenAPI 3.1 specifications from Grape API definitions. +Internal gem for generating OpenAPI 3.0 specifications from Grape API definitions. ## Usage diff --git a/gems/gitlab-grape-openapi/gitlab-grape-openapi.gemspec b/gems/gitlab-grape-openapi/gitlab-grape-openapi.gemspec index 41c5db8b7e3030da25e60624f4c5f7e993fdfe8a..e360fa2b0c8e1071a644d7d5d6ae421ce0268330 100644 --- a/gems/gitlab-grape-openapi/gitlab-grape-openapi.gemspec +++ b/gems/gitlab-grape-openapi/gitlab-grape-openapi.gemspec @@ -8,8 +8,8 @@ Gem::Specification.new do |spec| spec.authors = ["group::api"] spec.email = ["engineering@gitlab.com"] - spec.summary = "Generate OpenAPI 3.1 specifications from Grape APIs" - spec.description = "A Ruby gem that introspects Grape API definitions and generates OpenAPI 3.1 specification files" + spec.summary = "Generate OpenAPI 3.0 specifications from Grape APIs" + spec.description = "A Ruby gem that introspects Grape API definitions and generates OpenAPI 3.0 specification files" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" diff --git a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb index 4a190df3e7cfe387551b86487da68a1b63511549..0f2fad56b8d49b7f0748e00bfffd310c76447f2b 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb @@ -10,11 +10,15 @@ require_relative "gitlab/grape_openapi/converters/entity_converter" require_relative "gitlab/grape_openapi/converters/type_resolver" require_relative "gitlab/grape_openapi/converters/tag_converter" +require_relative "gitlab/grape_openapi/converters/operation_converter" +require_relative "gitlab/grape_openapi/converters/path_converter" # Models require_relative "gitlab/grape_openapi/models/schema" require_relative "gitlab/grape_openapi/models/tag" require_relative "gitlab/grape_openapi/models/server" +require_relative "gitlab/grape_openapi/models/operation" +require_relative "gitlab/grape_openapi/models/path_item" require_relative "gitlab/grape_openapi/models/security_scheme" require_relative "gitlab/grape_openapi/models/info" diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/configuration.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/configuration.rb index 3a84611cc8640067f20d5bb4ee49dbbb7b90667b..d3d9cf8bcb5c853ed7376669fc333d26d2e98dd1 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/configuration.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/configuration.rb @@ -3,11 +3,13 @@ module Gitlab module GrapeOpenapi class Configuration - attr_accessor :api_version, :servers, :security_schemes, :info + attr_accessor :api_version, :api_prefix, :servers, :security_schemes, :info def initialize - @api_version = "v4" + @api_prefix = "api" + @api_version = "v1" @info = nil + @servers = [] @security_schemes = [] end diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/operation_converter.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/operation_converter.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f38eceff5cefd4e8bf39eeaf5df23ba01581e31 --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/operation_converter.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Gitlab + module GrapeOpenapi + module Converters + class OperationConverter + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#operation-object + def self.convert(route) + new(route).convert + end + + def initialize(route) + @route = route + @config = Gitlab::GrapeOpenapi.configuration + end + + def convert + Models::Operation.new.tap do |operation| + operation.operation_id = operation_id + operation.description = extract_description + operation.tags = extract_tags + end + end + + private + + attr_reader :config, :route + + def operation_id + method = http_method.downcase + normalized = normalized_path + segments = normalized.split('/').reject(&:empty?) + + parts = segments.filter_map do |seg| + next 'Dash' if seg == '-' + + if seg.start_with?('{') + param_name = seg[1..-2] + camelize(param_name) + else + camelize(seg) + end + end + + "#{method}#{parts.join}" + end + + def extract_description + description_from_options = options[:description] + return description_from_options[:description] if description_from_options.is_a?(Hash) + return description_from_options if description_from_options.is_a?(String) + + return unless endpoint + + description_hash = endpoint.instance_variable_get(:@inheritable_setting)&.namespace + description_hash[:description] if description_hash.is_a?(Hash) + end + + def extract_tags + segments = path_segments + return [] if segments.empty? + + [segments.first] + end + + def path_segments + segments = normalized_path.split('/').reject do |segment| + segment.empty? || segment.start_with?('{') + end + + segments.reject { |seg| seg == config.api_prefix || seg == config.api_version || seg == '-' } + end + + def normalized_path + path = pattern.instance_variable_get(:@origin) + path + .gsub(/\(\.:format\)$/, '') + .gsub(/:\w+/) { |match| "{#{match[1..]}}" } + .gsub('{version}', config.api_version) + end + + def camelize(string) + string.gsub(/[@.-]/, '_').split('_').reject(&:empty?).map(&:capitalize).join + end + + def http_method + options[:method] + end + + def pattern + route.instance_variable_get(:@pattern) + end + + def options + route.instance_variable_get(:@options) + end + + def endpoint + route.instance_variable_get(:@app) + end + end + end + end +end diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/path_converter.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/path_converter.rb new file mode 100644 index 0000000000000000000000000000000000000000..073c463bb2481800b353b6c31277498dc1f55fce --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/path_converter.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Gitlab + module GrapeOpenapi + module Converters + class PathConverter + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#paths-object + def self.convert(routes) + new(routes).convert + end + + def initialize(routes) + @routes = routes + @config = Gitlab::GrapeOpenapi.configuration + end + + def convert + grouped_routes.transform_values do |routes_for_path| + build_path_item(routes_for_path) + end + end + + private + + attr_reader :config, :routes + + def grouped_routes + routes.group_by { |route| normalize_path(route) } + end + + def normalize_path(route) + pattern = route.instance_variable_get(:@pattern) + path = pattern.instance_variable_get(:@origin) + + path + .gsub(/\(\.:format\)$/, '') + .gsub(/:\w+/) { |match| "{#{match[1..]}}" } + .gsub('{version}', config.api_version) + end + + def build_path_item(routes_for_path) + path_item = Models::PathItem.new + + routes_for_path.each do |route| + operation = OperationConverter.convert(route) + method = extract_method(route) + path_item.add_operation(method, operation) + end + + path_item.to_h + end + + def extract_method(route) + options = route.instance_variable_get(:@options) + options[:method] + end + end + end + end +end diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/generator.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/generator.rb index 9041bd09baf890222a8bdf7e8709338d19fc4bd9..91819c18163c2a62ea36769b3e52feee8b6af0d0 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/generator.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/generator.rb @@ -21,7 +21,8 @@ def generate info: Gitlab::GrapeOpenapi.configuration.info.to_h, tags: tag_registry.tags, servers: Gitlab::GrapeOpenapi.configuration.servers.map(&:to_h), - paths: {}, # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/572540 + + paths: paths, components: { securitySchemes: security_schemes }, @@ -40,6 +41,11 @@ def initialize_tags Converters::TagConverter.new(api_class, tag_registry).convert end end + + def paths + all_routes = @api_classes.flat_map(&:routes) + Converters::PathConverter.convert(all_routes) + end end end end diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb new file mode 100644 index 0000000000000000000000000000000000000000..52dbd06b7afeeaf64afe002393469f452b9f7ad7 --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GrapeOpenapi + module Models + class Operation + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#operation-object + attr_accessor :operation_id, :description, :tags, :responses + + def initialize + @tags = [] + end + + def to_h + { + operationId: operation_id, + description: description, + tags: tags.empty? ? nil : tags, + responses: responses + }.compact + end + end + end + end +end diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/path_item.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/path_item.rb new file mode 100644 index 0000000000000000000000000000000000000000..b882da37c2a6dc6ae01f82111cbec337fbafe850 --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/path_item.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Gitlab + module GrapeOpenapi + module Models + class PathItem + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#path-item-object + attr_reader :operations + + def initialize + @operations = {} + end + + def add_operation(method, operation) + @operations[method.to_s.downcase] = operation + end + + def to_h + operations.transform_values(&:to_h) + end + end + end + end +end diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/schema.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/schema.rb index 3945e005c94ca9c89d4c72ed25f87be06dd42b68..e5f80682608e2adbbdf7a692bccf2f944ccaced9 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/schema.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/schema.rb @@ -3,7 +3,7 @@ module Gitlab module GrapeOpenapi module Models - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#schema-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#schema-object class Schema attr_accessor :properties, :type diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/security_scheme.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/security_scheme.rb index b67440313e756649667c6209d27002d531c80b22..43fab90a08491c37d14ac73f93068d1ab325cf3a 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/security_scheme.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/security_scheme.rb @@ -3,7 +3,7 @@ module Gitlab module GrapeOpenapi module Models - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#security-scheme-object class SecurityScheme VALID_TYPES = %w[apiKey http oauth2 openIdConnect].freeze VALID_IN_VALUES = %w[query header cookie].freeze diff --git a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/server.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/server.rb index 88b5d57448b7b1a624e1b40a3a890cb315535e04..4632a4b2e1b93f44a29b18aea50137ab7fcbc0a2 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/server.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/server.rb @@ -3,7 +3,7 @@ module Gitlab module GrapeOpenapi module Models - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#server-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#server-object class Server attr_reader :url, :description diff --git a/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb b/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb new file mode 100644 index 0000000000000000000000000000000000000000..db762d7eba09042a5326d3f53053ab95f41619ac --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# rubocop:disable API/Base -- Test fixture +module TestApis + class NestedApi < Grape::API + desc 'No nesting' + get '/api/:version/users' do + status 200 + [ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' } + ] + end + + desc '1 level of nesting' + get '/api/:version/admin/users' do + status 200 + [ + { id: 1, name: 'Admin User', email: 'admin@example.com', role: 'admin' } + ] + end + + desc '2 levels of nesting (GET)' + get '/api/:version/projects/:project_id/users' do + status 200 + [ + { id: 1, name: 'Project Member', project_id: params[:project_id].to_i, role: 'developer' } + ] + end + + desc '2 levels of nesting (POST)' + post '/api/:version/projects/:project_id/users' do + status 201 + { id: params[:user_id].to_i, project_id: params[:project_id].to_i, role: params[:role] } + end + + desc '2 levels of nesting with different resource' + get '/api/:version/projects/:project_id/merge_requests' do + status 200 + [ + { id: 1, title: 'Feature update', project_id: params[:project_id].to_i, state: 'open' } + ] + end + + desc '3 levels of nesting (GET)' + get '/api/:version/projects/:project_id/merge_requests/:merge_request_id/comments' do + status 200 + [ + { id: 1, body: 'Looks good!', merge_request_id: params[:merge_request_id].to_i } + ] + end + + desc '3 levels of nesting (POST)' + post '/api/:version/projects/:project_id/merge_requests/:merge_request_id/comments' do + status 201 + { id: 4, body: params[:body], merge_request_id: params[:merge_request_id].to_i } + end + end +end +# rubocop:enable API/Base diff --git a/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebe485ef7ad72cb9f8af5b65fc1fe7f96110c974 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# rubocop:disable API/Base -- Test fixture +module TestApis + class UsersApi < Grape::API + desc 'Get all users' + get '/api/:version/users' do + status 200 + [ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' } + ] + end + + desc 'Create a user' + post '/api/:version/users' do + status 201 + { id: 3, name: params[:name], email: params[:email], created_at: '2025-10-20 15:55:15.465357 -0700' } + end + + desc 'Update a user (full replacement)' + put '/api/:version/users/:id' do + status 200 + { + id: params[:id].to_i, + name: params[:name], + email: params[:email], + updated_at: '2025-10-20 15:55:15.465357 -0700' + } + end + + desc 'Update a user (partial)' + patch '/api/:version/users/:id' do + status 200 + { id: params[:id].to_i, name: params[:name], updated_at: '2025-10-20 15:55:15.465357 -0700' } + end + + desc 'Delete a user' + delete '/api/:version/users/:id' do + status 204 + nil + end + + desc 'Get user headers' + head '/api/:version/users/:id' do + status 200 + nil + end + + desc 'Get available options' + options '/api/:version/users' do + status 200 + header 'Allow', 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS' + nil + end + end +end +# rubocop:enable API/Base diff --git a/gems/gitlab-grape-openapi/spec/fixtures/user/person_entity.rb b/gems/gitlab-grape-openapi/spec/fixtures/entities/user/person_entity.rb similarity index 100% rename from gems/gitlab-grape-openapi/spec/fixtures/user/person_entity.rb rename to gems/gitlab-grape-openapi/spec/fixtures/entities/user/person_entity.rb diff --git a/gems/gitlab-grape-openapi/spec/fixtures/user_entity.rb b/gems/gitlab-grape-openapi/spec/fixtures/entities/user_entity.rb similarity index 100% rename from gems/gitlab-grape-openapi/spec/fixtures/user_entity.rb rename to gems/gitlab-grape-openapi/spec/fixtures/entities/user_entity.rb diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/configuration_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/configuration_spec.rb index 9682a3bfc61e4815bc43f378be5b482b1d24fb07..27865a5c4735b5c72eb22d3480016189cef8e406 100644 --- a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/configuration_spec.rb +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/configuration_spec.rb @@ -3,17 +3,31 @@ RSpec.describe Gitlab::GrapeOpenapi::Configuration do subject(:configuration) { described_class.new } + describe '#api_prefix' do + it 'has default value' do + expect(configuration.api_prefix).to eq('api') + end + end + + describe '#api_prefix=' do + it 'sets api_prefix' do + configuration.api_prefix = 'internal' + + expect(configuration.api_prefix).to eq('internal') + end + end + describe '#api_version' do it 'has default value' do - expect(configuration.api_version).to eq('v4') + expect(configuration.api_version).to eq('v1') end end describe '#api_version=' do it 'sets api_version' do - configuration.api_version = 'v5' + configuration.api_version = 'v4' - expect(configuration.api_version).to eq('v5') + expect(configuration.api_version).to eq('v4') end end diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_converter_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_converter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..843d0d52d08488eaa891d2e7f0bf024b55ab056a --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_converter_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.describe Gitlab::GrapeOpenapi::Converters::OperationConverter do + let(:api_prefix) { '/api' } + let(:api_version) { 'v1' } + + def api_prefix_camelized + "Api#{api_version.capitalize}" + end + + def find_route_by_method(routes, method) + routes.find { |r| r.instance_variable_get(:@options)[:method] == method } + end + + def find_route_by_pattern(routes, pattern) + routes.find do |r| + r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == pattern + end + end + + describe '.convert' do + context 'with simple routes' do + let(:routes) { TestApis::UsersApi.routes } + + context 'with GET route' do + subject(:operation) { described_class.convert(find_route_by_method(routes, 'GET')) } + + it 'generates correct operation_id' do + expect(operation.operation_id).to eq("get#{api_prefix_camelized}Users") + end + + it 'extracts description' do + expect(operation.description).to eq('Get all users') + end + + it 'extracts tags' do + expect(operation.tags).to eq(['users']) + end + end + + context 'with POST route' do + subject(:operation) { described_class.convert(find_route_by_method(routes, 'POST')) } + + it 'generates correct operation_id' do + expect(operation.operation_id).to eq("post#{api_prefix_camelized}Users") + end + + it 'extracts description' do + expect(operation.description).to eq('Create a user') + end + + it 'extracts tags' do + expect(operation.tags).to eq(['users']) + end + end + end + + context 'with nested routes to ensure uniqueness' do + let(:routes) { TestApis::NestedApi.routes } + + it 'generates unique operation IDs for all routes' do + operation_ids = routes.map { |route| described_class.convert(route).operation_id } + + expect(operation_ids).to contain_exactly( + "get#{api_prefix_camelized}Users", + "get#{api_prefix_camelized}AdminUsers", + "get#{api_prefix_camelized}ProjectsProjectIdUsers", + "get#{api_prefix_camelized}ProjectsProjectIdMergeRequests", + "get#{api_prefix_camelized}ProjectsProjectIdMergeRequestsMergeRequestIdComments", + "post#{api_prefix_camelized}ProjectsProjectIdMergeRequestsMergeRequestIdComments", + "post#{api_prefix_camelized}ProjectsProjectIdUsers" + ) + end + + it 'has no duplicate operation IDs' do + operation_ids = routes.map { |route| described_class.convert(route).operation_id } + + expect(operation_ids.uniq.size).to eq(operation_ids.size) + end + + context 'with /api/:version/users route' do + it 'generates simple operation_id' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/users") + operation = described_class.convert(route) + expect(operation.operation_id).to eq("get#{api_prefix_camelized}Users") + end + end + + context 'with /api/:version/admin/users route' do + it 'generates operation_id with admin prefix' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/admin/users") + operation = described_class.convert(route) + expect(operation.operation_id).to eq("get#{api_prefix_camelized}AdminUsers") + end + + it 'extracts correct tags' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/admin/users") + operation = described_class.convert(route) + expect(operation.tags).to eq(['admin']) + end + end + + context 'with /api/:version/projects/:project_id/users route' do + it 'generates operation_id with all segments' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/projects/:project_id/users") + operation = described_class.convert(route) + expect(operation.operation_id).to eq("get#{api_prefix_camelized}ProjectsProjectIdUsers") + end + + it 'extracts correct tags from first segment' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/projects/:project_id/users") + operation = described_class.convert(route) + expect(operation.tags).to eq(['projects']) + end + end + + context 'with /api/:version/projects/:project_id/merge_requests route' do + it 'generates operation_id with camelized segments' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/projects/:project_id/merge_requests") + operation = described_class.convert(route) + expect(operation.operation_id).to eq("get#{api_prefix_camelized}ProjectsProjectIdMergeRequests") + end + + it 'preserves underscores in tags' do + route = find_route_by_pattern(routes, "#{api_prefix}/:version/projects/:project_id/merge_requests") + operation = described_class.convert(route) + expect(operation.tags).to eq(['projects']) + end + end + end + end +end diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/path_converter_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/path_converter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2778513c6eba03be1f317dfd2a1ed1d17e80b77d --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/path_converter_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GrapeOpenapi::Converters::PathConverter do + let(:api_prefix) { '/api' } + let(:api_version) { 'v1' } + let(:base_path) { "#{api_prefix}/#{api_version}" } + + describe '.convert' do + let(:users_api) { TestApis::UsersApi } + let(:routes) { users_api.routes } + + subject(:paths) { described_class.convert(routes) } + + it 'groups routes by normalized path' do + expect(paths.keys).to eq(["#{base_path}/users", '/api/v1/users/{id}']) + end + + it 'includes both operations' do + expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'options', 'post') + end + + it 'has correct GET operation details' do + get_operation = paths["#{base_path}/users"]['get'] + + expect(get_operation[:operationId]).to eq('getApiV1Users') + expect(get_operation[:description]).to eq('Get all users') + expect(get_operation[:tags]).to eq(['users']) + end + + it 'has correct POST operation details' do + post_operation = paths["#{base_path}/users"]['post'] + + expect(post_operation[:operationId]).to eq('postApiV1Users') + expect(post_operation[:description]).to eq('Create a user') + expect(post_operation[:tags]).to eq(['users']) + end + + context 'with empty routes' do + let(:routes) { [] } + + it 'returns empty hash' do + expect(paths).to eq({}) + end + end + end +end diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/generator_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/generator_spec.rb index 1b0f8fd78e85cc71d670acd60cec72f762665f00..5eff91eb7a9343d313c2df33becbd3114eb91cf9 100644 --- a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/generator_spec.rb +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/generator_spec.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true -RSpec.describe Gitlab::GrapeOpenapi::Generator do - subject(:generator) { described_class.new(api_classes, options) } +require 'spec_helper' - let(:api_classes) { [API::TestAuditEvents] } - let(:options) { {} } +RSpec.describe Gitlab::GrapeOpenapi::Generator do + let(:api_prefix) { '/api' } + let(:api_version) { 'v1' } + let(:base_path) { "#{api_prefix}/#{api_version}" } + let(:api_classes) { [TestApis::UsersApi] } + let(:generator) { described_class.new(api_classes) } before do Gitlab::GrapeOpenapi.configure do |config| @@ -26,6 +29,8 @@ end describe '#generate' do + subject(:spec) { generator.generate } + it 'returns the correct keys' do expect(generator.generate).to include(:openapi, :info, :servers, :components, :security, :paths) end @@ -38,10 +43,20 @@ expect(generator.generate[:security]).to eq([{ 'http' => [] }]) end - it 'executes generate_tags' do - generator.generate + it 'includes paths from API classes' do + expect(spec[:paths]).to have_key("#{base_path}/users") + end + end + + describe '#paths' do + subject(:paths) { generator.paths } + + it 'returns paths hash' do + expect(paths).to be_a(Hash) + end - expect(generator.tag_registry.tags.size).to eq(5) + it 'includes operations from API classes' do + expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'options', 'post') end end end diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/operation_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/operation_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..dbb57e42e6325105a93936d3a70d5348195974d7 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/operation_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GrapeOpenapi::Models::Operation do + subject(:operation) { described_class.new } + + describe '#initialize' do + it 'initializes with empty tags array' do + expect(operation.tags).to eq([]) + end + + it 'initializes other fields as nil' do + expect(operation.operation_id).to be_nil + expect(operation.description).to be_nil + end + end + + describe '#to_h' do + context 'with minimal fields' do + before do + operation.operation_id = 'getUsers' + operation.description = 'Get all users' + end + + it 'includes set fields' do + result = operation.to_h + + expect(result[:operationId]).to eq('getUsers') + expect(result[:description]).to eq('Get all users') + end + + it 'omits empty tags array' do + result = operation.to_h + + expect(result).not_to have_key(:tags) + end + end + + context 'with all fields populated' do + before do + operation.operation_id = 'createUser' + operation.description = 'Creates a new user in the system' + operation.tags = %w[users admin] + end + + it 'includes all fields' do + result = operation.to_h + + expect(result[:operationId]).to eq('createUser') + expect(result[:description]).to eq('Creates a new user in the system') + expect(result[:tags]).to eq(%w[users admin]) + end + end + + context 'with tags' do + before do + operation.operation_id = 'getIssues' + operation.tags = ['issues'] + end + + it 'includes tags when present' do + result = operation.to_h + + expect(result[:tags]).to eq(['issues']) + end + end + + context 'with custom responses' do + before do + operation.operation_id = 'deleteUser' + operation.responses = { + '204' => { description: 'User deleted' }, + '404' => { description: 'User not found' } + } + end + + it 'uses custom responses' do + result = operation.to_h + + expect(result[:responses]).to eq({ + '204' => { description: 'User deleted' }, + '404' => { description: 'User not found' } + }) + end + end + + context 'without description' do + before do + operation.operation_id = 'getProjects' + end + + it 'omits nil description' do + result = operation.to_h + + expect(result).not_to have_key(:description) + end + end + end +end diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/path_item_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/path_item_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0eb6ea065a11746448059ce9ae2abf287dc75826 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/path_item_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GrapeOpenapi::Models::PathItem do + subject(:path_item) { described_class.new } + + describe '#initialize' do + it 'initializes with empty operations hash' do + expect(path_item.operations).to eq({}) + end + end + + describe '#add_operation' do + let(:operation) { Gitlab::GrapeOpenapi::Models::Operation.new } + + before do + operation.operation_id = 'getUsers' + operation.description = 'Get all users' + end + + it 'adds operation with lowercase method key' do + path_item.add_operation('GET', operation) + + expect(path_item.operations['get']).to eq(operation) + end + + it 'handles symbol method' do + path_item.add_operation(:post, operation) + + expect(path_item.operations['post']).to eq(operation) + end + + it 'handles already lowercase method' do + path_item.add_operation('delete', operation) + + expect(path_item.operations['delete']).to eq(operation) + end + + it 'adds multiple operations' do + get_operation = Gitlab::GrapeOpenapi::Models::Operation.new + get_operation.operation_id = 'getUser' + + post_operation = Gitlab::GrapeOpenapi::Models::Operation.new + post_operation.operation_id = 'createUser' + + path_item.add_operation('GET', get_operation) + path_item.add_operation('POST', post_operation) + + expect(path_item.operations.keys).to contain_exactly('get', 'post') + expect(path_item.operations['get']).to eq(get_operation) + expect(path_item.operations['post']).to eq(post_operation) + end + end + + describe '#to_h' do + context 'with no operations' do + it 'returns empty hash' do + expect(path_item.to_h).to eq({}) + end + end + + context 'with single operation' do + before do + operation = Gitlab::GrapeOpenapi::Models::Operation.new + operation.operation_id = 'getIssues' + operation.description = 'Get all issues' + operation.tags = ['issues'] + + path_item.add_operation('GET', operation) + end + + it 'serializes operation' do + result = path_item.to_h + + expect(result['get']).to eq({ + operationId: 'getIssues', + description: 'Get all issues', + tags: ['issues'] + }) + end + end + + context 'with multiple operations' do + before do + get_operation = Gitlab::GrapeOpenapi::Models::Operation.new + get_operation.operation_id = 'getUser' + get_operation.description = 'Get a user' + get_operation.tags = ['users'] + + post_operation = Gitlab::GrapeOpenapi::Models::Operation.new + post_operation.operation_id = 'createUser' + post_operation.description = 'Create a user' + post_operation.tags = ['users'] + + delete_operation = Gitlab::GrapeOpenapi::Models::Operation.new + delete_operation.operation_id = 'deleteUser' + delete_operation.description = 'Delete a user' + delete_operation.tags = ['users'] + + path_item.add_operation('GET', get_operation) + path_item.add_operation('POST', post_operation) + path_item.add_operation('DELETE', delete_operation) + end + + it 'serializes all operations' do + result = path_item.to_h + + expect(result.keys).to contain_exactly('get', 'post', 'delete') + expect(result['get'][:operationId]).to eq('getUser') + expect(result['post'][:operationId]).to eq('createUser') + expect(result['delete'][:operationId]).to eq('deleteUser') + end + end + end +end diff --git a/gems/gitlab-grape-openapi/spec/spec_helper.rb b/gems/gitlab-grape-openapi/spec/spec_helper.rb index 882c676bcb76a68bcc47f23c952bf0f926e2ec9c..03c7b5d42c6e847ff1a38bd40931f73c19406a30 100644 --- a/gems/gitlab-grape-openapi/spec/spec_helper.rb +++ b/gems/gitlab-grape-openapi/spec/spec_helper.rb @@ -3,9 +3,11 @@ require "gitlab-grape-openapi" require "grape" require "grape-entity" -require "fixtures/user_entity" -require "fixtures/user/person_entity" require "fixtures/test_audit_events" +require "fixtures/entities/user_entity" +require "fixtures/entities/user/person_entity" +require "fixtures/apis/users_api" +require "fixtures/apis/nested_api" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure