From ecd69ba6d4bdfe85484198da9aa32f0e18d6656e Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:00:57 -0700 Subject: [PATCH 01/15] Add operation model --- .../lib/gitlab-grape-openapi.rb | 1 + .../gitlab/grape_openapi/models/operation.rb | 25 ++++ .../grape_openapi/models/operation_spec.rb | 109 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb create mode 100644 gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/operation_spec.rb diff --git a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb index 4a190df3e7cfe3..c2b755d4f05f29 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb @@ -15,6 +15,7 @@ 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/security_scheme" require_relative "gitlab/grape_openapi/models/info" 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 00000000000000..e82404d59dd3f5 --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb @@ -0,0 +1,25 @@ +module Gitlab + module GrapeOpenapi + module Models + class Operation + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operation-object + attr_accessor :operation_id, :description, :tags, :responses + + def initialize + @tags = [] + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/572545 + @responses = { '200' => { description: 'Mock response' } } + 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/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 00000000000000..dd79b32ef431dc --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/operation_spec.rb @@ -0,0 +1,109 @@ +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 with default responses' do + expect(operation.responses).to eq({ '200' => { description: 'Mock response' } }) + 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 'includes required responses field' do + result = operation.to_h + + expect(result[:responses]).to eq({ '200' => { description: 'Mock response' } }) + 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 = ['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(['users', 'admin']) + expect(result[:responses]).to eq({ '200' => { description: 'Mock response' } }) + 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 -- GitLab From 6cd52c10b0e8ebadb2836eb39349c02ea3b821c3 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:04:26 -0700 Subject: [PATCH 02/15] Add path item model --- .../lib/gitlab-grape-openapi.rb | 1 + .../gitlab/grape_openapi/models/path_item.rb | 22 ++++ .../grape_openapi/models/path_item_spec.rb | 115 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/path_item.rb create mode 100644 gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/path_item_spec.rb diff --git a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb index c2b755d4f05f29..0c2473f03de90c 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb @@ -16,6 +16,7 @@ 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/models/path_item.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/path_item.rb new file mode 100644 index 00000000000000..be690afdf36fb2 --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/path_item.rb @@ -0,0 +1,22 @@ +module Gitlab + module GrapeOpenapi + module Models + class PathItem + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.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/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 00000000000000..63c8f690c2b617 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/models/path_item_spec.rb @@ -0,0 +1,115 @@ +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'], + responses: { '200' => { description: 'Mock response' } } + }) + 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 -- GitLab From 17d5868f63dde40b41915c6e03b60af0c041e1b2 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:08:59 -0700 Subject: [PATCH 03/15] Add operation converter --- .../lib/gitlab-grape-openapi.rb | 1 + .../converters/operation_converter.rb | 87 ++++++++++ .../converters/operation_convertor_spec.rb | 152 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/operation_converter.rb create mode 100644 gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb diff --git a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb index 0c2473f03de90c..c5b54a3ccb2dd8 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb @@ -10,6 +10,7 @@ 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" # Models require_relative "gitlab/grape_openapi/models/schema" 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 00000000000000..09059038d59780 --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/operation_converter.rb @@ -0,0 +1,87 @@ +module Gitlab + module GrapeOpenapi + module Converters + class OperationConverter + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operation-object + def self.convert(route) + new(route).convert + end + + def initialize(route) + @route = route + end + + def convert + Models::Operation.new.tap do |operation| + operation.operation_id = generate_operation_id + operation.description = extract_description + operation.tags = extract_tags + end + end + + private + + attr_reader :route + + def generate_operation_id + method = http_method.downcase + resource = extract_resource_name + "#{method}#{resource}" + end + + def extract_description + 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 extract_resource_name + segments = path_segments + return '' if segments.empty? + + resource = segments.first + camelize(resource) + end + + def path_segments + normalized_path.split('/').reject do |segment| + segment.empty? || segment.start_with?(':') || segment.start_with?('{') + end + end + + def normalized_path + path = pattern.instance_variable_get(:@origin) + path.gsub(%r{^/api/:[^/]+/}, '') + end + + def camelize(string) + string.split('_').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/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb new file mode 100644 index 00000000000000..a094d3875a372a --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb @@ -0,0 +1,152 @@ +require 'spec_helper' + +RSpec.describe Gitlab::GrapeOpenapi::Converters::OperationConverter do + describe '.convert' do + subject(:operation) { described_class.convert(route) } + + let(:route) { double('Grape::Router::Route') } + let(:pattern) { double('Grape::Router::Pattern') } + let(:endpoint) { double('Endpoint') } + let(:inheritable_setting) { double('InheritableSetting') } + + before do + allow(route).to receive(:instance_variable_get).with(:@pattern).and_return(pattern) + allow(route).to receive(:instance_variable_get).with(:@options).and_return(options) + allow(route).to receive(:instance_variable_get).with(:@app).and_return(endpoint) + allow(pattern).to receive(:instance_variable_get).with(:@origin).and_return(path) + allow(endpoint).to receive(:instance_variable_get).with(:@inheritable_setting).and_return(inheritable_setting) + allow(inheritable_setting).to receive(:namespace).and_return(namespace_values) + end + + context 'with GET request to /api/:version/issues' do + let(:path) { '/api/:version/issues' } + let(:options) { { method: 'GET' } } + let(:namespace_values) { { description: 'Get all issues' } } + + it 'returns operation with correct operation_id' do + expect(operation.operation_id).to eq('getIssues') + end + + it 'returns operation with correct description' do + expect(operation.description).to eq('Get all issues') + end + + it 'returns operation with correct tags' do + expect(operation.tags).to eq(['issues']) + end + end + + context 'with POST request to /api/:version/projects' do + let(:path) { '/api/:version/projects' } + let(:options) { { method: 'POST' } } + let(:namespace_values) { { description: 'Create a new project' } } + + it 'returns operation with correct operation_id' do + expect(operation.operation_id).to eq('postProjects') + end + + it 'returns operation with correct description' do + expect(operation.description).to eq('Create a new project') + end + + it 'returns operation with correct tags' do + expect(operation.tags).to eq(['projects']) + end + end + + context 'with DELETE request to /api/:version/users/:id' do + let(:path) { '/api/:version/users/:id' } + let(:options) { { method: 'DELETE' } } + let(:namespace_values) { { description: 'Delete a user' } } + + it 'returns operation with correct operation_id' do + expect(operation.operation_id).to eq('deleteUsers') + end + + it 'returns operation with correct tags' do + expect(operation.tags).to eq(['users']) + end + end + + context 'with nested resource path' do + let(:path) { '/api/:version/projects/:project_id/merge_requests' } + let(:options) { { method: 'GET' } } + let(:namespace_values) { { description: 'Get merge requests' } } + + it 'uses first resource segment for operation_id' do + expect(operation.operation_id).to eq('getProjects') + end + + it 'uses first resource segment for tags' do + expect(operation.tags).to eq(['projects']) + end + end + + context 'with underscored resource name' do + let(:path) { '/api/:version/merge_requests' } + let(:options) { { method: 'GET' } } + let(:namespace_values) { { description: 'Get merge requests' } } + + it 'camelizes the resource name' do + expect(operation.operation_id).to eq('getMergeRequests') + end + + it 'downcases tag' do + expect(operation.tags).to eq(['merge_requests']) + end + end + + context 'without description' do + let(:path) { '/api/:version/issues' } + let(:options) { { method: 'GET' } } + let(:namespace_values) { {} } + + it 'returns operation with nil description' do + expect(operation.description).to be_nil + end + + it 'still generates operation_id' do + expect(operation.operation_id).to eq('getIssues') + end + + it 'still generates tags' do + expect(operation.tags).to eq(['issues']) + end + end + + context 'with namespace_values not a hash' do + let(:path) { '/api/:version/issues' } + let(:options) { { method: 'GET' } } + let(:namespace_values) { 'not a hash' } + + it 'returns operation with nil description' do + expect(operation.description).to be_nil + end + end + + context 'with nil endpoint' do + let(:path) { '/api/:version/issues' } + let(:options) { { method: 'GET' } } + let(:endpoint) { nil } + let(:namespace_values) { nil } + + it 'returns operation with nil description' do + expect(operation.description).to be_nil + end + + it 'still generates operation_id' do + expect(operation.operation_id).to eq('getIssues') + end + end + + context 'with PATCH method' do + let(:path) { '/api/:version/issues/:id' } + let(:options) { { method: 'PATCH' } } + let(:namespace_values) { { description: 'Update an issue' } } + + it 'returns operation with correct operation_id' do + expect(operation.operation_id).to eq('patchIssues') + end + end + end +end -- GitLab From 2f4de5ae8ceff833333166c99a89ca9d7f1eb0ad Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:11:25 -0700 Subject: [PATCH 04/15] Add path converter --- .../lib/gitlab-grape-openapi.rb | 1 + .../converters/path_converter.rb | 56 ++++++ .../converters/path_converter_spec.rb | 166 ++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/path_converter.rb create mode 100644 gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/path_converter_spec.rb diff --git a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb index c5b54a3ccb2dd8..0f2fad56b8d49b 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab-grape-openapi.rb @@ -11,6 +11,7 @@ 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" 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 00000000000000..e77d877cef438e --- /dev/null +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/path_converter.rb @@ -0,0 +1,56 @@ +module Gitlab + module GrapeOpenapi + module Converters + class PathConverter + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#paths-object + def self.convert(routes) + new(routes).convert + end + + def initialize(routes) + @routes = routes + end + + def convert + grouped_routes.transform_values do |routes_for_path| + build_path_item(routes_for_path) + end + end + + private + + attr_reader :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..]}}" } + 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/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 00000000000000..8a64355e9b1a33 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/path_converter_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' + +RSpec.describe Gitlab::GrapeOpenapi::Converters::PathConverter do + describe '.convert' do + subject(:paths) { described_class.convert(routes) } + + def build_route(path, method, description = nil) + route = double('Grape::Router::Route') + pattern = double('Grape::Router::Pattern') + endpoint = double('Endpoint') + inheritable_setting = double('InheritableSetting') + + namespace_values = description ? { description: description } : {} + + allow(route).to receive(:instance_variable_get).with(:@pattern).and_return(pattern) + allow(route).to receive(:instance_variable_get).with(:@options).and_return({ method: method }) + allow(route).to receive(:instance_variable_get).with(:@app).and_return(endpoint) + allow(pattern).to receive(:instance_variable_get).with(:@origin).and_return(path) + allow(endpoint).to receive(:instance_variable_get).with(:@inheritable_setting).and_return(inheritable_setting) + allow(inheritable_setting).to receive(:namespace).and_return(namespace_values) + + route + end + + context 'with empty routes' do + let(:routes) { [] } + + it 'returns empty hash' do + expect(paths).to eq({}) + end + end + + context 'with single route' do + let(:routes) do + [build_route('/api/:version/issues', 'GET', 'Get all issues')] + end + + it 'returns hash with normalized path' do + expect(paths.keys).to eq(['/api/{version}/issues']) + end + + it 'includes operation for the method' do + expect(paths['/api/{version}/issues'].keys).to eq(['get']) + end + + it 'includes operation details' do + operation = paths['/api/{version}/issues']['get'] + + expect(operation[:operationId]).to eq('getIssues') + expect(operation[:description]).to eq('Get all issues') + expect(operation[:tags]).to eq(['issues']) + end + end + + context 'with multiple methods on same path' do + let(:routes) do + [ + build_route('/api/:version/issues/:id', 'GET', 'Get an issue'), + build_route('/api/:version/issues/:id', 'PATCH', 'Update an issue'), + build_route('/api/:version/issues/:id', 'DELETE', 'Delete an issue') + ] + end + + it 'groups under single path' do + expect(paths.keys).to eq(['/api/{version}/issues/{id}']) + end + + it 'includes all operations' do + path_item = paths['/api/{version}/issues/{id}'] + + expect(path_item.keys).to contain_exactly('get', 'patch', 'delete') + end + + it 'has correct operation details for each method' do + path_item = paths['/api/{version}/issues/{id}'] + + expect(path_item['get'][:operationId]).to eq('getIssues') + expect(path_item['get'][:description]).to eq('Get an issue') + + expect(path_item['patch'][:operationId]).to eq('patchIssues') + expect(path_item['patch'][:description]).to eq('Update an issue') + + expect(path_item['delete'][:operationId]).to eq('deleteIssues') + expect(path_item['delete'][:description]).to eq('Delete an issue') + end + end + + context 'with multiple different paths' do + let(:routes) do + [ + build_route('/api/:version/issues', 'GET', 'Get all issues'), + build_route('/api/:version/projects', 'GET', 'Get all projects'), + build_route('/api/:version/users', 'POST', 'Create a user') + ] + end + + it 'creates separate path items' do + expect(paths.keys).to contain_exactly( + '/api/{version}/issues', + '/api/{version}/projects', + '/api/{version}/users' + ) + end + + it 'has correct operations for each path' do + expect(paths['/api/{version}/issues']['get'][:operationId]).to eq('getIssues') + expect(paths['/api/{version}/projects']['get'][:operationId]).to eq('getProjects') + expect(paths['/api/{version}/users']['post'][:operationId]).to eq('postUsers') + end + end + + context 'with path parameters' do + let(:routes) do + [build_route('/api/:version/projects/:project_id/issues/:id', 'GET', 'Get project issue')] + end + + it 'normalizes all path parameters' do + expect(paths.keys).to eq(['/api/{version}/projects/{project_id}/issues/{id}']) + end + end + + context 'with optional format parameter' do + let(:routes) do + [build_route('/api/:version/issues(.:format)', 'GET', 'Get all issues')] + end + + it 'removes format parameter' do + expect(paths.keys).to eq(['/api/{version}/issues']) + end + end + + context 'with nested resources' do + let(:routes) do + [ + build_route('/api/:version/projects/:id', 'GET', 'Get a project'), + build_route('/api/:version/projects/:project_id/merge_requests', 'GET', 'Get merge requests') + ] + end + + it 'creates separate paths' do + expect(paths.keys).to contain_exactly( + '/api/{version}/projects/{id}', + '/api/{version}/projects/{project_id}/merge_requests' + ) + end + + it 'tags operations correctly' do + expect(paths['/api/{version}/projects/{id}']['get'][:tags]).to eq(['projects']) + expect(paths['/api/{version}/projects/{project_id}/merge_requests']['get'][:tags]).to eq(['projects']) + end + end + + context 'with routes without descriptions' do + let(:routes) do + [build_route('/api/:version/issues', 'GET')] + end + + it 'still generates operation' do + operation = paths['/api/{version}/issues']['get'] + + expect(operation[:operationId]).to eq('getIssues') + expect(operation[:description]).to be_nil + end + end + end +end -- GitLab From dad1e56156906a3c80dbc764ba20249248504ebf Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:29:15 -0700 Subject: [PATCH 05/15] Update generator with paths --- .../converters/operation_converter.rb | 4 + .../lib/gitlab/grape_openapi/generator.rb | 8 +- .../spec/fixtures/apis/users_api.rb | 11 ++ .../{ => entities}/user/person_entity.rb | 0 .../fixtures/{ => entities}/user_entity.rb | 0 .../converters/operation_convertor_spec.rb | 148 +++------------- .../converters/path_converter_spec.rb | 167 +++--------------- .../gitlab/grape_openapi/generator_spec.rb | 26 ++- gems/gitlab-grape-openapi/spec/spec_helper.rb | 5 +- 9 files changed, 86 insertions(+), 283 deletions(-) create mode 100644 gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb rename gems/gitlab-grape-openapi/spec/fixtures/{ => entities}/user/person_entity.rb (100%) rename gems/gitlab-grape-openapi/spec/fixtures/{ => entities}/user_entity.rb (100%) 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 index 09059038d59780..fb7a0ba168df80 100644 --- 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 @@ -30,6 +30,10 @@ def generate_operation_id 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 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 9041bd09baf890..91819c18163c2a 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/spec/fixtures/apis/users_api.rb b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb new file mode 100644 index 00000000000000..45012044ad0e01 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb @@ -0,0 +1,11 @@ +module TestApis + class UsersApi < Grape::API + desc 'Get all users' + get '/api/:version/users' do + end + + desc 'Create a user' + post '/api/:version/users' do + end + end +end 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/converters/operation_convertor_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb index a094d3875a372a..a533f9b70fd46d 100644 --- a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb @@ -2,150 +2,44 @@ RSpec.describe Gitlab::GrapeOpenapi::Converters::OperationConverter do describe '.convert' do - subject(:operation) { described_class.convert(route) } + let(:users_api) { TestApis::UsersApi } + let(:routes) { users_api.routes } + let(:get_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'GET' } } + let(:post_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'POST' } } - let(:route) { double('Grape::Router::Route') } - let(:pattern) { double('Grape::Router::Pattern') } - let(:endpoint) { double('Endpoint') } - let(:inheritable_setting) { double('InheritableSetting') } + context 'with GET route' do + subject(:operation) { described_class.convert(get_route) } - before do - allow(route).to receive(:instance_variable_get).with(:@pattern).and_return(pattern) - allow(route).to receive(:instance_variable_get).with(:@options).and_return(options) - allow(route).to receive(:instance_variable_get).with(:@app).and_return(endpoint) - allow(pattern).to receive(:instance_variable_get).with(:@origin).and_return(path) - allow(endpoint).to receive(:instance_variable_get).with(:@inheritable_setting).and_return(inheritable_setting) - allow(inheritable_setting).to receive(:namespace).and_return(namespace_values) - end - - context 'with GET request to /api/:version/issues' do - let(:path) { '/api/:version/issues' } - let(:options) { { method: 'GET' } } - let(:namespace_values) { { description: 'Get all issues' } } - - it 'returns operation with correct operation_id' do - expect(operation.operation_id).to eq('getIssues') - end - - it 'returns operation with correct description' do - expect(operation.description).to eq('Get all issues') - end - - it 'returns operation with correct tags' do - expect(operation.tags).to eq(['issues']) - end - end - - context 'with POST request to /api/:version/projects' do - let(:path) { '/api/:version/projects' } - let(:options) { { method: 'POST' } } - let(:namespace_values) { { description: 'Create a new project' } } - - it 'returns operation with correct operation_id' do - expect(operation.operation_id).to eq('postProjects') + it 'generates correct operation_id' do + expect(operation.operation_id).to eq('getUsers') end - it 'returns operation with correct description' do - expect(operation.description).to eq('Create a new project') + it 'extracts description' do + expect(operation.description).to eq('Get all users') end - it 'returns operation with correct tags' do - expect(operation.tags).to eq(['projects']) - end - end - - context 'with DELETE request to /api/:version/users/:id' do - let(:path) { '/api/:version/users/:id' } - let(:options) { { method: 'DELETE' } } - let(:namespace_values) { { description: 'Delete a user' } } - - it 'returns operation with correct operation_id' do - expect(operation.operation_id).to eq('deleteUsers') - end - - it 'returns operation with correct tags' do + it 'extracts tags' do expect(operation.tags).to eq(['users']) end - end - - context 'with nested resource path' do - let(:path) { '/api/:version/projects/:project_id/merge_requests' } - let(:options) { { method: 'GET' } } - let(:namespace_values) { { description: 'Get merge requests' } } - - it 'uses first resource segment for operation_id' do - expect(operation.operation_id).to eq('getProjects') - end - it 'uses first resource segment for tags' do - expect(operation.tags).to eq(['projects']) + it 'includes default responses' do + expect(operation.responses).to eq({ '200' => { description: 'Mock response' } }) end end - context 'with underscored resource name' do - let(:path) { '/api/:version/merge_requests' } - let(:options) { { method: 'GET' } } - let(:namespace_values) { { description: 'Get merge requests' } } + context 'with POST route' do + subject(:operation) { described_class.convert(post_route) } - it 'camelizes the resource name' do - expect(operation.operation_id).to eq('getMergeRequests') + it 'generates correct operation_id' do + expect(operation.operation_id).to eq('postUsers') end - it 'downcases tag' do - expect(operation.tags).to eq(['merge_requests']) - end - end - - context 'without description' do - let(:path) { '/api/:version/issues' } - let(:options) { { method: 'GET' } } - let(:namespace_values) { {} } - - it 'returns operation with nil description' do - expect(operation.description).to be_nil - end - - it 'still generates operation_id' do - expect(operation.operation_id).to eq('getIssues') + it 'extracts description' do + expect(operation.description).to eq('Create a user') end - it 'still generates tags' do - expect(operation.tags).to eq(['issues']) - end - end - - context 'with namespace_values not a hash' do - let(:path) { '/api/:version/issues' } - let(:options) { { method: 'GET' } } - let(:namespace_values) { 'not a hash' } - - it 'returns operation with nil description' do - expect(operation.description).to be_nil - end - end - - context 'with nil endpoint' do - let(:path) { '/api/:version/issues' } - let(:options) { { method: 'GET' } } - let(:endpoint) { nil } - let(:namespace_values) { nil } - - it 'returns operation with nil description' do - expect(operation.description).to be_nil - end - - it 'still generates operation_id' do - expect(operation.operation_id).to eq('getIssues') - end - end - - context 'with PATCH method' do - let(:path) { '/api/:version/issues/:id' } - let(:options) { { method: 'PATCH' } } - let(:namespace_values) { { description: 'Update an issue' } } - - it 'returns operation with correct operation_id' do - expect(operation.operation_id).to eq('patchIssues') + it 'extracts tags' do + expect(operation.tags).to eq(['users']) 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 index 8a64355e9b1a33..21cfacfccc5342 100644 --- 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 @@ -2,164 +2,41 @@ RSpec.describe Gitlab::GrapeOpenapi::Converters::PathConverter do describe '.convert' do - subject(:paths) { described_class.convert(routes) } - - def build_route(path, method, description = nil) - route = double('Grape::Router::Route') - pattern = double('Grape::Router::Pattern') - endpoint = double('Endpoint') - inheritable_setting = double('InheritableSetting') - - namespace_values = description ? { description: description } : {} - - allow(route).to receive(:instance_variable_get).with(:@pattern).and_return(pattern) - allow(route).to receive(:instance_variable_get).with(:@options).and_return({ method: method }) - allow(route).to receive(:instance_variable_get).with(:@app).and_return(endpoint) - allow(pattern).to receive(:instance_variable_get).with(:@origin).and_return(path) - allow(endpoint).to receive(:instance_variable_get).with(:@inheritable_setting).and_return(inheritable_setting) - allow(inheritable_setting).to receive(:namespace).and_return(namespace_values) - - route - end - - context 'with empty routes' do - let(:routes) { [] } - - it 'returns empty hash' do - expect(paths).to eq({}) - end - end - - context 'with single route' do - let(:routes) do - [build_route('/api/:version/issues', 'GET', 'Get all issues')] - end - - it 'returns hash with normalized path' do - expect(paths.keys).to eq(['/api/{version}/issues']) - end - - it 'includes operation for the method' do - expect(paths['/api/{version}/issues'].keys).to eq(['get']) - end - - it 'includes operation details' do - operation = paths['/api/{version}/issues']['get'] - - expect(operation[:operationId]).to eq('getIssues') - expect(operation[:description]).to eq('Get all issues') - expect(operation[:tags]).to eq(['issues']) - end - end - - context 'with multiple methods on same path' do - let(:routes) do - [ - build_route('/api/:version/issues/:id', 'GET', 'Get an issue'), - build_route('/api/:version/issues/:id', 'PATCH', 'Update an issue'), - build_route('/api/:version/issues/:id', 'DELETE', 'Delete an issue') - ] - end - - it 'groups under single path' do - expect(paths.keys).to eq(['/api/{version}/issues/{id}']) - end + let(:users_api) { TestApis::UsersApi } + let(:routes) { users_api.routes } - it 'includes all operations' do - path_item = paths['/api/{version}/issues/{id}'] - - expect(path_item.keys).to contain_exactly('get', 'patch', 'delete') - end + subject(:paths) { described_class.convert(routes) } - it 'has correct operation details for each method' do - path_item = paths['/api/{version}/issues/{id}'] - - expect(path_item['get'][:operationId]).to eq('getIssues') - expect(path_item['get'][:description]).to eq('Get an issue') - - expect(path_item['patch'][:operationId]).to eq('patchIssues') - expect(path_item['patch'][:description]).to eq('Update an issue') - - expect(path_item['delete'][:operationId]).to eq('deleteIssues') - expect(path_item['delete'][:description]).to eq('Delete an issue') - end + it 'groups routes by normalized path' do + expect(paths.keys).to eq(['/api/{version}/users']) end - context 'with multiple different paths' do - let(:routes) do - [ - build_route('/api/:version/issues', 'GET', 'Get all issues'), - build_route('/api/:version/projects', 'GET', 'Get all projects'), - build_route('/api/:version/users', 'POST', 'Create a user') - ] - end - - it 'creates separate path items' do - expect(paths.keys).to contain_exactly( - '/api/{version}/issues', - '/api/{version}/projects', - '/api/{version}/users' - ) - end - - it 'has correct operations for each path' do - expect(paths['/api/{version}/issues']['get'][:operationId]).to eq('getIssues') - expect(paths['/api/{version}/projects']['get'][:operationId]).to eq('getProjects') - expect(paths['/api/{version}/users']['post'][:operationId]).to eq('postUsers') - end + it 'includes both operations' do + expect(paths['/api/{version}/users'].keys).to contain_exactly('get', 'post') end - context 'with path parameters' do - let(:routes) do - [build_route('/api/:version/projects/:project_id/issues/:id', 'GET', 'Get project issue')] - end + it 'has correct GET operation details' do + get_operation = paths['/api/{version}/users']['get'] - it 'normalizes all path parameters' do - expect(paths.keys).to eq(['/api/{version}/projects/{project_id}/issues/{id}']) - end + expect(get_operation[:operationId]).to eq('getUsers') + expect(get_operation[:description]).to eq('Get all users') + expect(get_operation[:tags]).to eq(['users']) + expect(get_operation[:responses]).to eq({ '200' => { description: 'Mock response' } }) end - context 'with optional format parameter' do - let(:routes) do - [build_route('/api/:version/issues(.:format)', 'GET', 'Get all issues')] - end + it 'has correct POST operation details' do + post_operation = paths['/api/{version}/users']['post'] - it 'removes format parameter' do - expect(paths.keys).to eq(['/api/{version}/issues']) - end + expect(post_operation[:operationId]).to eq('postUsers') + expect(post_operation[:description]).to eq('Create a user') + expect(post_operation[:tags]).to eq(['users']) end - context 'with nested resources' do - let(:routes) do - [ - build_route('/api/:version/projects/:id', 'GET', 'Get a project'), - build_route('/api/:version/projects/:project_id/merge_requests', 'GET', 'Get merge requests') - ] - end - - it 'creates separate paths' do - expect(paths.keys).to contain_exactly( - '/api/{version}/projects/{id}', - '/api/{version}/projects/{project_id}/merge_requests' - ) - end - - it 'tags operations correctly' do - expect(paths['/api/{version}/projects/{id}']['get'][:tags]).to eq(['projects']) - expect(paths['/api/{version}/projects/{project_id}/merge_requests']['get'][:tags]).to eq(['projects']) - end - end - - context 'with routes without descriptions' do - let(:routes) do - [build_route('/api/:version/issues', 'GET')] - end + context 'with empty routes' do + let(:routes) { [] } - it 'still generates operation' do - operation = paths['/api/{version}/issues']['get'] - - expect(operation[:operationId]).to eq('getIssues') - expect(operation[:description]).to be_nil + it 'returns empty hash' do + expect(paths).to eq({}) 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 1b0f8fd78e85cc..6aba225a1f2dde 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,8 @@ -# frozen_string_literal: true +require 'spec_helper' RSpec.describe Gitlab::GrapeOpenapi::Generator do - subject(:generator) { described_class.new(api_classes, options) } - - let(:api_classes) { [API::TestAuditEvents] } - let(:options) { {} } + let(:api_classes) { [TestApis::UsersApi] } + let(:generator) { described_class.new(api_classes) } before do Gitlab::GrapeOpenapi.configure do |config| @@ -26,6 +24,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 +38,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('/api/{version}/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['/api/{version}/users'].keys).to contain_exactly('get', 'post') end end end diff --git a/gems/gitlab-grape-openapi/spec/spec_helper.rb b/gems/gitlab-grape-openapi/spec/spec_helper.rb index 882c676bcb76a6..67b9271b3f397b 100644 --- a/gems/gitlab-grape-openapi/spec/spec_helper.rb +++ b/gems/gitlab-grape-openapi/spec/spec_helper.rb @@ -3,9 +3,10 @@ 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" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure -- GitLab From 6004545e0f2fbb283ceb1f65717d2c7a7517192e Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:39:31 -0700 Subject: [PATCH 06/15] Fix rubocop offenses --- .../grape_openapi/converters/operation_converter.rb | 2 ++ .../lib/gitlab/grape_openapi/converters/path_converter.rb | 2 ++ .../lib/gitlab/grape_openapi/models/operation.rb | 2 ++ .../lib/gitlab/grape_openapi/models/path_item.rb | 2 ++ gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb | 8 +++++++- ...tion_convertor_spec.rb => operation_converter_spec.rb} | 2 ++ .../grape_openapi/converters/path_converter_spec.rb | 2 ++ .../spec/gitlab/grape_openapi/generator_spec.rb | 2 ++ .../spec/gitlab/grape_openapi/models/operation_spec.rb | 6 ++++-- .../spec/gitlab/grape_openapi/models/path_item_spec.rb | 2 ++ 10 files changed, 27 insertions(+), 3 deletions(-) rename gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/{operation_convertor_spec.rb => operation_converter_spec.rb} (97%) 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 index fb7a0ba168df80..29bbeb51dd888a 100644 --- 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Gitlab module GrapeOpenapi module Converters 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 index e77d877cef438e..65929f4f52ccb2 100644 --- 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Gitlab module GrapeOpenapi module Converters 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 index e82404d59dd3f5..1d368f730031a7 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Gitlab module GrapeOpenapi module Models 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 index be690afdf36fb2..6e2b69c66fd9f0 100644 --- 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Gitlab module GrapeOpenapi module Models diff --git a/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb index 45012044ad0e01..9531b3cdb555bf 100644 --- a/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb @@ -1,11 +1,17 @@ +# 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 + nil end - + desc 'Create a user' post '/api/:version/users' do + nil end end end +# rubocop:enable API/Base diff --git a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_converter_spec.rb similarity index 97% rename from gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb rename to gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_converter_spec.rb index a533f9b70fd46d..68499ed576bfee 100644 --- a/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_convertor_spec.rb +++ b/gems/gitlab-grape-openapi/spec/gitlab/grape_openapi/converters/operation_converter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Gitlab::GrapeOpenapi::Converters::OperationConverter do 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 index 21cfacfccc5342..e251b956713343 100644 --- 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Gitlab::GrapeOpenapi::Converters::PathConverter do 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 6aba225a1f2dde..ac493f09f04c3c 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,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Gitlab::GrapeOpenapi::Generator do 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 index dd79b32ef431dc..a0a5aeb7eac98e 100644 --- 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Gitlab::GrapeOpenapi::Models::Operation do @@ -49,7 +51,7 @@ before do operation.operation_id = 'createUser' operation.description = 'Creates a new user in the system' - operation.tags = ['users', 'admin'] + operation.tags = %w[users admin] end it 'includes all fields' do @@ -57,7 +59,7 @@ expect(result[:operationId]).to eq('createUser') expect(result[:description]).to eq('Creates a new user in the system') - expect(result[:tags]).to eq(['users', 'admin']) + expect(result[:tags]).to eq(%w[users admin]) expect(result[:responses]).to eq({ '200' => { description: 'Mock response' } }) 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 index 63c8f690c2b617..31ca06eb07c5e0 100644 --- 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 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Gitlab::GrapeOpenapi::Models::PathItem do -- GitLab From a7172d2372d6a4c194c6df8d8f26d547bad54e80 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 19:46:07 -0700 Subject: [PATCH 07/15] Handle nested APIs --- .../converters/operation_converter.rb | 6 +- .../spec/fixtures/apis/nested_api.rb | 27 ++++ .../converters/operation_converter_spec.rb | 139 +++++++++++++++--- gems/gitlab-grape-openapi/spec/spec_helper.rb | 1 + 4 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb 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 index 29bbeb51dd888a..47cdebcc59f545 100644 --- 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 @@ -27,8 +27,10 @@ def convert def generate_operation_id method = http_method.downcase - resource = extract_resource_name - "#{method}#{resource}" + segments = path_segments + resource_parts = segments.map { |s| camelize(s) }.join + + "#{method}#{resource_parts}" end def extract_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 00000000000000..4cac5e0341d4d5 --- /dev/null +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# rubocop:disable API/Base -- Test fixture +module TestApis + class NestedApi < Grape::API + desc 'Get all users' + get '/api/:version/users' do + nil + end + + desc 'Get admin users' + get '/api/:version/admin/users' do + nil + end + + desc 'Get project users' + get '/api/:version/projects/:project_id/users' do + nil + end + + desc 'Get project merge requests' + get '/api/:version/projects/:project_id/merge_requests' do + nil + end + end +end +# rubocop:enable API/Base 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 index 68499ed576bfee..ffb2f29fdd1f56 100644 --- 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 @@ -4,44 +4,137 @@ RSpec.describe Gitlab::GrapeOpenapi::Converters::OperationConverter do describe '.convert' do - let(:users_api) { TestApis::UsersApi } - let(:routes) { users_api.routes } - let(:get_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'GET' } } - let(:post_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'POST' } } + context 'with simple routes' do + let(:users_api) { TestApis::UsersApi } + let(:routes) { users_api.routes } + let(:get_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'GET' } } + let(:post_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'POST' } } - context 'with GET route' do - subject(:operation) { described_class.convert(get_route) } + context 'with GET route' do + subject(:operation) { described_class.convert(get_route) } - it 'generates correct operation_id' do - expect(operation.operation_id).to eq('getUsers') + it 'generates correct operation_id' do + expect(operation.operation_id).to eq('getUsers') + 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 + + it 'includes default responses' do + expect(operation.responses).to eq({ '200' => { description: 'Mock response' } }) + end end - it 'extracts description' do - expect(operation.description).to eq('Get all users') + context 'with POST route' do + subject(:operation) { described_class.convert(post_route) } + + it 'generates correct operation_id' do + expect(operation.operation_id).to eq('postUsers') + 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(:nested_api) { TestApis::NestedApi } + let(:routes) { nested_api.routes } - it 'extracts tags' do - expect(operation.tags).to eq(['users']) + 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( + 'getUsers', + 'getAdminUsers', + 'getProjectsUsers', + 'getProjectsMergeRequests' + ) end - it 'includes default responses' do - expect(operation.responses).to eq({ '200' => { description: 'Mock response' } }) + 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 - end - context 'with POST route' do - subject(:operation) { described_class.convert(post_route) } + context 'with /api/:version/users route' do + let(:route) do + routes.find do |r| + r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == '/api/:version/users' + end + end - it 'generates correct operation_id' do - expect(operation.operation_id).to eq('postUsers') + it 'generates simple operation_id' do + operation = described_class.convert(route) + expect(operation.operation_id).to eq('getUsers') + end end - it 'extracts description' do - expect(operation.description).to eq('Create a user') + context 'with /api/:version/admin/users route' do + let(:route) do + routes.find do |r| + r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == '/api/:version/admin/users' + end + end + + it 'generates operation_id with admin prefix' do + operation = described_class.convert(route) + expect(operation.operation_id).to eq('getAdminUsers') + end + + it 'extracts correct tags' do + operation = described_class.convert(route) + expect(operation.tags).to eq(['admin']) + end end - it 'extracts tags' do - expect(operation.tags).to eq(['users']) + context 'with /api/:version/projects/:project_id/users route' do + let(:route) do + routes.find do |r| + r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == + '/api/:version/projects/:project_id/users' + end + end + + it 'generates operation_id with all segments' do + operation = described_class.convert(route) + expect(operation.operation_id).to eq('getProjectsUsers') + end + + it 'extracts correct tags from first segment' do + operation = described_class.convert(route) + expect(operation.tags).to eq(['projects']) + end + end + + context 'with /api/:version/projects/:project_id/merge_requests route' do + let(:route) do + routes.find do |r| + r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == + '/api/:version/projects/:project_id/merge_requests' + end + end + + it 'generates operation_id with camelized segments' do + operation = described_class.convert(route) + expect(operation.operation_id).to eq('getProjectsMergeRequests') + end + + it 'preserves underscores in tags' do + operation = described_class.convert(route) + expect(operation.tags).to eq(['projects']) + 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 67b9271b3f397b..03c7b5d42c6e84 100644 --- a/gems/gitlab-grape-openapi/spec/spec_helper.rb +++ b/gems/gitlab-grape-openapi/spec/spec_helper.rb @@ -7,6 +7,7 @@ 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 -- GitLab From 17c8470b96a640133883c57987f1d4396324fc19 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Wed, 15 Oct 2025 20:36:05 -0700 Subject: [PATCH 08/15] Handle dupe op IDs --- .../converters/operation_converter.rb | 37 ++++++++++++------- .../converters/operation_converter_spec.rb | 8 ++-- 2 files changed, 27 insertions(+), 18 deletions(-) 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 index 47cdebcc59f545..36cd5b09a51b6b 100644 --- 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 @@ -27,10 +27,25 @@ def convert def generate_operation_id method = http_method.downcase - segments = path_segments - resource_parts = segments.map { |s| camelize(s) }.join + 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..] + "By#{camelize(param_name)}" + elsif seg =~ /^:(\w+)\.(\w+)$/ + param = ::Regexp.last_match(1) + ext = ::Regexp.last_match(2) + "By#{camelize(param)}#{camelize(ext)}" + else + camelize(seg) + end + end - "#{method}#{resource_parts}" + "#{method}#{parts.join}" end def extract_description @@ -51,27 +66,21 @@ def extract_tags [segments.first] end - def extract_resource_name - segments = path_segments - return '' if segments.empty? - - resource = segments.first - camelize(resource) - end - def path_segments - normalized_path.split('/').reject do |segment| + segments = normalized_path.split('/').reject do |segment| segment.empty? || segment.start_with?(':') || segment.start_with?('{') end + + segments.reject { |seg| seg == '-' } end def normalized_path path = pattern.instance_variable_get(:@origin) - path.gsub(%r{^/api/:[^/]+/}, '') + path.gsub(%r{^/api/:[^/]+/}, '').gsub(/\(\.:format\)$/, '') end def camelize(string) - string.split('_').map(&:capitalize).join + string.gsub(/[@.-]/, '_').split('_').reject(&:empty?).map(&:capitalize).join end def http_method 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 index ffb2f29fdd1f56..fea03d702c64a0 100644 --- 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 @@ -57,8 +57,8 @@ expect(operation_ids).to contain_exactly( 'getUsers', 'getAdminUsers', - 'getProjectsUsers', - 'getProjectsMergeRequests' + 'getProjectsByProjectIdUsers', + 'getProjectsByProjectIdMergeRequests' ) end @@ -109,7 +109,7 @@ it 'generates operation_id with all segments' do operation = described_class.convert(route) - expect(operation.operation_id).to eq('getProjectsUsers') + expect(operation.operation_id).to eq('getProjectsByProjectIdUsers') end it 'extracts correct tags from first segment' do @@ -128,7 +128,7 @@ it 'generates operation_id with camelized segments' do operation = described_class.convert(route) - expect(operation.operation_id).to eq('getProjectsMergeRequests') + expect(operation.operation_id).to eq('getProjectsByProjectIdMergeRequests') end it 'preserves underscores in tags' do -- GitLab From 772ed68d8f941c38f4b6c3f4970b43e4041bad00 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Fri, 17 Oct 2025 13:35:08 -0700 Subject: [PATCH 09/15] Remove unused edge case --- .../gitlab/grape_openapi/converters/operation_converter.rb | 4 ---- 1 file changed, 4 deletions(-) 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 index 36cd5b09a51b6b..6fcf0468239847 100644 --- 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 @@ -36,10 +36,6 @@ def generate_operation_id if seg.start_with?(':', '*') param_name = seg[1..] "By#{camelize(param_name)}" - elsif seg =~ /^:(\w+)\.(\w+)$/ - param = ::Regexp.last_match(1) - ext = ::Regexp.last_match(2) - "By#{camelize(param)}#{camelize(ext)}" else camelize(seg) end -- GitLab From ee6ea808d49a35fefc972549f07c3457823201c4 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Fri, 17 Oct 2025 15:13:42 -0700 Subject: [PATCH 10/15] Refactor to generalize Grape API prefix and version --- config/initializers/gitlab_grape_openapi.rb | 4 + .../lib/gitlab/grape_openapi/configuration.rb | 6 +- .../converters/operation_converter.rb | 18 ++-- .../converters/path_converter.rb | 4 +- .../grape_openapi/configuration_spec.rb | 20 ++++- .../converters/operation_converter_spec.rb | 82 +++++++++---------- .../converters/path_converter_spec.rb | 16 ++-- .../gitlab/grape_openapi/generator_spec.rb | 7 +- 8 files changed, 92 insertions(+), 65 deletions(-) diff --git a/config/initializers/gitlab_grape_openapi.rb b/config/initializers/gitlab_grape_openapi.rb index be24b8aa85cb4b..1d14c4be5479c5 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/lib/gitlab/grape_openapi/configuration.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/configuration.rb index 3a84611cc86400..d3d9cf8bcb5c85 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 index 6fcf0468239847..ae23731c7ed737 100644 --- 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 @@ -11,6 +11,7 @@ def self.convert(route) def initialize(route) @route = route + @config = Gitlab::GrapeOpenapi.configuration end def convert @@ -23,7 +24,7 @@ def convert private - attr_reader :route + attr_reader :config, :route def generate_operation_id method = http_method.downcase @@ -33,9 +34,9 @@ def generate_operation_id parts = segments.filter_map do |seg| next 'Dash' if seg == '-' - if seg.start_with?(':', '*') - param_name = seg[1..] - "By#{camelize(param_name)}" + if seg.start_with?('{') + param_name = seg[1..-2] + camelize(param_name) else camelize(seg) end @@ -64,15 +65,18 @@ def extract_tags def path_segments segments = normalized_path.split('/').reject do |segment| - segment.empty? || segment.start_with?(':') || segment.start_with?('{') + segment.empty? || segment.start_with?('{') end - segments.reject { |seg| seg == '-' } + segments.reject { |seg| seg == config.api_prefix || seg == config.api_version || seg == '-' } end def normalized_path path = pattern.instance_variable_get(:@origin) - path.gsub(%r{^/api/:[^/]+/}, '').gsub(/\(\.:format\)$/, '') + path + .gsub(/\(\.:format\)$/, '') + .gsub(/:\w+/) { |match| "{#{match[1..]}}" } + .gsub('{version}', config.api_version) end def camelize(string) 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 index 65929f4f52ccb2..50bed1619a0ad2 100644 --- 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 @@ -11,6 +11,7 @@ def self.convert(routes) def initialize(routes) @routes = routes + @config = Gitlab::GrapeOpenapi.configuration end def convert @@ -21,7 +22,7 @@ def convert private - attr_reader :routes + attr_reader :config, :routes def grouped_routes routes.group_by { |route| normalize_path(route) } @@ -34,6 +35,7 @@ def normalize_path(route) path .gsub(/\(\.:format\)$/, '') .gsub(/:\w+/) { |match| "{#{match[1..]}}" } + .gsub('{version}', config.api_version) end def build_path_item(routes_for_path) 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 9682a3bfc61e48..27865a5c4735b5 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 index fea03d702c64a0..db8515f3014ce5 100644 --- 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 @@ -3,18 +3,32 @@ require 'spec_helper' 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(:users_api) { TestApis::UsersApi } - let(:routes) { users_api.routes } - let(:get_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'GET' } } - let(:post_route) { routes.find { |r| r.instance_variable_get(:@options)[:method] == 'POST' } } + let(:routes) { TestApis::UsersApi.routes } context 'with GET route' do - subject(:operation) { described_class.convert(get_route) } + subject(:operation) { described_class.convert(find_route_by_method(routes, 'GET')) } it 'generates correct operation_id' do - expect(operation.operation_id).to eq('getUsers') + expect(operation.operation_id).to eq("get#{api_prefix_camelized}Users") end it 'extracts description' do @@ -31,10 +45,10 @@ end context 'with POST route' do - subject(:operation) { described_class.convert(post_route) } + subject(:operation) { described_class.convert(find_route_by_method(routes, 'POST')) } it 'generates correct operation_id' do - expect(operation.operation_id).to eq('postUsers') + expect(operation.operation_id).to eq("post#{api_prefix_camelized}Users") end it 'extracts description' do @@ -48,17 +62,16 @@ end context 'with nested routes to ensure uniqueness' do - let(:nested_api) { TestApis::NestedApi } - let(:routes) { nested_api.routes } + 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( - 'getUsers', - 'getAdminUsers', - 'getProjectsByProjectIdUsers', - 'getProjectsByProjectIdMergeRequests' + "get#{api_prefix_camelized}Users", + "get#{api_prefix_camelized}AdminUsers", + "get#{api_prefix_camelized}ProjectsProjectIdUsers", + "get#{api_prefix_camelized}ProjectsProjectIdMergeRequests" ) end @@ -69,69 +82,50 @@ end context 'with /api/:version/users route' do - let(:route) do - routes.find do |r| - r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == '/api/:version/users' - end - end - 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('getUsers') + expect(operation.operation_id).to eq("get#{api_prefix_camelized}Users") end end context 'with /api/:version/admin/users route' do - let(:route) do - routes.find do |r| - r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == '/api/:version/admin/users' - end - end - 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('getAdminUsers') + 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 - let(:route) do - routes.find do |r| - r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == - '/api/:version/projects/:project_id/users' - end - end - 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('getProjectsByProjectIdUsers') + 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 - let(:route) do - routes.find do |r| - r.instance_variable_get(:@pattern).instance_variable_get(:@origin) == - '/api/:version/projects/:project_id/merge_requests' - end - end - 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('getProjectsByProjectIdMergeRequests') + 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 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 index e251b956713343..75eda30882e145 100644 --- 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 @@ -3,6 +3,10 @@ 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 } @@ -10,26 +14,26 @@ subject(:paths) { described_class.convert(routes) } it 'groups routes by normalized path' do - expect(paths.keys).to eq(['/api/{version}/users']) + expect(paths.keys).to eq(["#{base_path}/users"]) end it 'includes both operations' do - expect(paths['/api/{version}/users'].keys).to contain_exactly('get', 'post') + expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'post') end it 'has correct GET operation details' do - get_operation = paths['/api/{version}/users']['get'] + get_operation = paths["#{base_path}/users"]['get'] - expect(get_operation[:operationId]).to eq('getUsers') + expect(get_operation[:operationId]).to eq('getApiV1Users') expect(get_operation[:description]).to eq('Get all users') expect(get_operation[:tags]).to eq(['users']) expect(get_operation[:responses]).to eq({ '200' => { description: 'Mock response' } }) end it 'has correct POST operation details' do - post_operation = paths['/api/{version}/users']['post'] + post_operation = paths["#{base_path}/users"]['post'] - expect(post_operation[:operationId]).to eq('postUsers') + expect(post_operation[:operationId]).to eq('postApiV1Users') expect(post_operation[:description]).to eq('Create a user') expect(post_operation[:tags]).to eq(['users']) 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 ac493f09f04c3c..854a93dbb2f015 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 @@ -3,6 +3,9 @@ require 'spec_helper' 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) } @@ -41,7 +44,7 @@ end it 'includes paths from API classes' do - expect(spec[:paths]).to have_key('/api/{version}/users') + expect(spec[:paths]).to have_key("#{base_path}/users") end end @@ -53,7 +56,7 @@ end it 'includes operations from API classes' do - expect(paths['/api/{version}/users'].keys).to contain_exactly('get', 'post') + expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'post') end end end -- GitLab From 69ab3cdd3f7168d9bb78b67761a4da18a4409927 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Fri, 17 Oct 2025 15:18:29 -0700 Subject: [PATCH 11/15] Remove mock response --- .../lib/gitlab/grape_openapi/models/operation.rb | 2 -- .../converters/operation_converter_spec.rb | 4 ---- .../grape_openapi/converters/path_converter_spec.rb | 1 - .../gitlab/grape_openapi/models/operation_spec.rb | 11 ----------- .../gitlab/grape_openapi/models/path_item_spec.rb | 3 +-- 5 files changed, 1 insertion(+), 20 deletions(-) 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 index 1d368f730031a7..22462044e0a0ef 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb @@ -9,8 +9,6 @@ class Operation def initialize @tags = [] - # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/572545 - @responses = { '200' => { description: 'Mock response' } } end def to_h 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 index db8515f3014ce5..fceb28e8e46b0f 100644 --- 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 @@ -38,10 +38,6 @@ def find_route_by_pattern(routes, pattern) it 'extracts tags' do expect(operation.tags).to eq(['users']) end - - it 'includes default responses' do - expect(operation.responses).to eq({ '200' => { description: 'Mock response' } }) - end end context 'with POST route' do 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 index 75eda30882e145..ae7dbfacec2078 100644 --- 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 @@ -27,7 +27,6 @@ expect(get_operation[:operationId]).to eq('getApiV1Users') expect(get_operation[:description]).to eq('Get all users') expect(get_operation[:tags]).to eq(['users']) - expect(get_operation[:responses]).to eq({ '200' => { description: 'Mock response' } }) end it 'has correct POST operation details' do 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 index a0a5aeb7eac98e..dbb57e42e63251 100644 --- 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 @@ -10,10 +10,6 @@ expect(operation.tags).to eq([]) end - it 'initializes with default responses' do - expect(operation.responses).to eq({ '200' => { description: 'Mock response' } }) - end - it 'initializes other fields as nil' do expect(operation.operation_id).to be_nil expect(operation.description).to be_nil @@ -34,12 +30,6 @@ expect(result[:description]).to eq('Get all users') end - it 'includes required responses field' do - result = operation.to_h - - expect(result[:responses]).to eq({ '200' => { description: 'Mock response' } }) - end - it 'omits empty tags array' do result = operation.to_h @@ -60,7 +50,6 @@ 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]) - expect(result[:responses]).to eq({ '200' => { description: 'Mock response' } }) 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 index 31ca06eb07c5e0..0eb6ea065a1174 100644 --- 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 @@ -76,8 +76,7 @@ expect(result['get']).to eq({ operationId: 'getIssues', description: 'Get all issues', - tags: ['issues'], - responses: { '200' => { description: 'Mock response' } } + tags: ['issues'] }) end end -- GitLab From 1f5392f54f812e763d4b9cad0e206395cce80c4a Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Mon, 20 Oct 2025 15:38:29 -0700 Subject: [PATCH 12/15] Rename operation ID method --- .../gitlab/grape_openapi/converters/operation_converter.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ae23731c7ed737..c68767ba72cd62 100644 --- 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 @@ -16,7 +16,7 @@ def initialize(route) def convert Models::Operation.new.tap do |operation| - operation.operation_id = generate_operation_id + operation.operation_id = operation_id operation.description = extract_description operation.tags = extract_tags end @@ -26,7 +26,7 @@ def convert attr_reader :config, :route - def generate_operation_id + def operation_id method = http_method.downcase normalized = normalized_path segments = normalized.split('/').reject(&:empty?) -- GitLab From 368208db07e8b7c73115073cc02f86ca959ea45b Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Mon, 20 Oct 2025 15:39:11 -0700 Subject: [PATCH 13/15] Remove unused spec helper loading --- .../gitlab/grape_openapi/converters/operation_converter_spec.rb | 2 -- 1 file changed, 2 deletions(-) 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 index fceb28e8e46b0f..0e6eaffaa3d401 100644 --- 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 @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - RSpec.describe Gitlab::GrapeOpenapi::Converters::OperationConverter do let(:api_prefix) { '/api' } let(:api_version) { 'v1' } -- GitLab From c025925adbf15e51c111565ca1fe266829d26558 Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Mon, 20 Oct 2025 15:41:34 -0700 Subject: [PATCH 14/15] Pin OAS spec version to 3.0.0 --- gems/gitlab-grape-openapi/README.md | 2 +- gems/gitlab-grape-openapi/gitlab-grape-openapi.gemspec | 4 ++-- .../gitlab/grape_openapi/converters/operation_converter.rb | 2 +- .../lib/gitlab/grape_openapi/converters/path_converter.rb | 2 +- .../lib/gitlab/grape_openapi/models/operation.rb | 2 +- .../lib/gitlab/grape_openapi/models/path_item.rb | 2 +- .../lib/gitlab/grape_openapi/models/schema.rb | 2 +- .../lib/gitlab/grape_openapi/models/security_scheme.rb | 2 +- .../lib/gitlab/grape_openapi/models/server.rb | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gems/gitlab-grape-openapi/README.md b/gems/gitlab-grape-openapi/README.md index 489e628ab56ee5..b87abf6184be64 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 41c5db8b7e3030..e360fa2b0c8e10 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/converters/operation_converter.rb b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/converters/operation_converter.rb index c68767ba72cd62..7f38eceff5cefd 100644 --- 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 @@ -4,7 +4,7 @@ module Gitlab module GrapeOpenapi module Converters class OperationConverter - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operation-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#operation-object def self.convert(route) new(route).convert 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 index 50bed1619a0ad2..073c463bb24818 100644 --- 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 @@ -4,7 +4,7 @@ module Gitlab module GrapeOpenapi module Converters class PathConverter - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#paths-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#paths-object def self.convert(routes) new(routes).convert 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 index 22462044e0a0ef..52dbd06b7afeea 100644 --- a/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb +++ b/gems/gitlab-grape-openapi/lib/gitlab/grape_openapi/models/operation.rb @@ -4,7 +4,7 @@ module Gitlab module GrapeOpenapi module Models class Operation - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operation-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#operation-object attr_accessor :operation_id, :description, :tags, :responses def initialize 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 index 6e2b69c66fd9f0..b882da37c2a6dc 100644 --- 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 @@ -4,7 +4,7 @@ module Gitlab module GrapeOpenapi module Models class PathItem - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#path-item-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#path-item-object attr_reader :operations def initialize 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 3945e005c94ca9..e5f80682608e2a 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 b67440313e7566..43fab90a08491c 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 88b5d57448b7b1..4632a4b2e1b93f 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 -- GitLab From 4fbf623a9beea0ac2a2d6c9edd18c7c77fc346be Mon Sep 17 00:00:00 2001 From: Chance Feick Date: Mon, 20 Oct 2025 16:01:05 -0700 Subject: [PATCH 15/15] Add HTTP verbs to test fixtures --- .../spec/fixtures/apis/nested_api.rb | 49 ++++++++++++++++--- .../spec/fixtures/apis/users_api.rb | 43 +++++++++++++++- .../converters/operation_converter_spec.rb | 5 +- .../converters/path_converter_spec.rb | 4 +- .../gitlab/grape_openapi/generator_spec.rb | 2 +- 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb b/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb index 4cac5e0341d4d5..db762d7eba0904 100644 --- a/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/nested_api.rb @@ -3,24 +3,57 @@ # rubocop:disable API/Base -- Test fixture module TestApis class NestedApi < Grape::API - desc 'Get all users' + desc 'No nesting' get '/api/:version/users' do - nil + status 200 + [ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' } + ] end - desc 'Get admin users' + desc '1 level of nesting' get '/api/:version/admin/users' do - nil + status 200 + [ + { id: 1, name: 'Admin User', email: 'admin@example.com', role: 'admin' } + ] end - desc 'Get project users' + desc '2 levels of nesting (GET)' get '/api/:version/projects/:project_id/users' do - nil + status 200 + [ + { id: 1, name: 'Project Member', project_id: params[:project_id].to_i, role: 'developer' } + ] end - desc 'Get project merge requests' + 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 - nil + 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 diff --git a/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb index 9531b3cdb555bf..ebe485ef7ad72c 100644 --- a/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb +++ b/gems/gitlab-grape-openapi/spec/fixtures/apis/users_api.rb @@ -5,11 +5,52 @@ module TestApis class UsersApi < Grape::API desc 'Get all users' get '/api/:version/users' do - nil + 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 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 index 0e6eaffaa3d401..843d0d52d08488 100644 --- 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 @@ -65,7 +65,10 @@ def find_route_by_pattern(routes, pattern) "get#{api_prefix_camelized}Users", "get#{api_prefix_camelized}AdminUsers", "get#{api_prefix_camelized}ProjectsProjectIdUsers", - "get#{api_prefix_camelized}ProjectsProjectIdMergeRequests" + "get#{api_prefix_camelized}ProjectsProjectIdMergeRequests", + "get#{api_prefix_camelized}ProjectsProjectIdMergeRequestsMergeRequestIdComments", + "post#{api_prefix_camelized}ProjectsProjectIdMergeRequestsMergeRequestIdComments", + "post#{api_prefix_camelized}ProjectsProjectIdUsers" ) 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 index ae7dbfacec2078..2778513c6eba03 100644 --- 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 @@ -14,11 +14,11 @@ subject(:paths) { described_class.convert(routes) } it 'groups routes by normalized path' do - expect(paths.keys).to eq(["#{base_path}/users"]) + 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', 'post') + expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'options', 'post') end it 'has correct GET operation details' do 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 854a93dbb2f015..5eff91eb7a9343 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 @@ -56,7 +56,7 @@ end it 'includes operations from API classes' do - expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'post') + expect(paths["#{base_path}/users"].keys).to contain_exactly('get', 'options', 'post') end end end -- GitLab