diff --git a/app/services/packages/nuget/odata_package_entry_service.rb b/app/services/packages/nuget/odata_package_entry_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..0cdcc38de16965c2dc10d268b0aa6a0b11b26dce --- /dev/null +++ b/app/services/packages/nuget/odata_package_entry_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class OdataPackageEntryService + include API::Helpers::RelatedResourcesHelpers + + SEMVER_LATEST_VERSION_PLACEHOLDER = '0.0.0-latest-version' + LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT = 'latest' + + def initialize(project, params) + @project = project + @params = params + end + + def execute + ServiceResponse.success(payload: package_entry) + end + + private + + attr_reader :project, :params + + def package_entry + <<-XML.squish + + #{id_url} + + #{params[:package_name]} + + + #{package_version} + + + XML + end + + def package_version + params[:package_version] || SEMVER_LATEST_VERSION_PLACEHOLDER + end + + def id_url + expose_url "#{api_v4_projects_packages_nuget_v2_path(id: project.id)}" \ + "/Packages(Id='#{params[:package_name]}',Version='#{package_version}')" + end + + # TODO: use path helper when download endpoint is merged + def download_url + expose_url "#{api_v4_projects_packages_nuget_v2_path(id: project.id)}" \ + "/download/#{params[:package_name]}/#{download_url_package_version}" + end + + def download_url_package_version + if latest_version? + LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT + else + params[:package_version] + end + end + + def latest_version? + params[:package_version].nil? || params[:package_version] == SEMVER_LATEST_VERSION_PLACEHOLDER + end + + def xml_base + expose_url api_v4_projects_packages_nuget_v2_path(id: project.id) + end + end + end +end diff --git a/doc/api/packages/nuget.md b/doc/api/packages/nuget.md index a549d6af0869b802d7b69fe8fce8e3634aaf9a70..ee304ab28df3f394f3dde9d81e9a3aa29ed820da 100644 --- a/doc/api/packages/nuget.md +++ b/doc/api/packages/nuget.md @@ -425,10 +425,12 @@ Example response: } ``` -## V2 Feed Metadata Endpoint +## V2 Feed Metadata Endpoints > Introduced in GitLab 16.3. +### $metadata endpoint + Authentication is not required. Returns metadata for a V2 feed available endpoints: ```plaintext @@ -436,7 +438,7 @@ GET /v2/$metadata ``` ```shell - curl "https://gitlab.example.com/api/v4/projects/1/packages/nuget/v2/$metadata" +curl "https://gitlab.example.com/api/v4/projects/1/packages/nuget/v2/$metadata" ``` Example response: @@ -475,3 +477,36 @@ Example response: ``` + +### OData package entry endpoints + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127667) in GitLab 16.4. + +| Endpoint | Description | +| -------- | ----------- | +| `GET projects/:id/packages/nuget/v2/Packages()?$filter=(tolower(Id) eq '')` | Returns an OData XML document containing information about the package with the given name. | +| `GET projects/:id/packages/nuget/v2/FindPackagesById()?id=''` | Returns an OData XML document containing information about the package with the given name. | +| `GET projects/:id/packages/nuget/v2/Packages(Id='',Version='')` | Returns an OData XML document containing information about the package with the given name and version. | + +NOTE: +GitLab doesn't receive an authentication token for the `Packages()` and `FindPackagesByID()` endpoints. +To not reveal the package version to unauthenticated users, the actual latest package version is not returned. Instead, a placeholder version is returned. +The latest version is obtained in the subsequent download request where the authentication token is sent. + +```shell +curl "https://gitlab.example.com/api/v4/projects/1/packages/nuget/v2/Packages()?$filter=(tolower(Id) eq 'mynugetpkg')" +``` + +Example response: + +```xml + + https://gitlab.example.com/api/v4/projects/1/packages/nuget/v2/Packages(Id='mynugetpkg',Version='0.0.0-latest-version') + + mynugetpkg + + + 0.0.0-latest-version + + +``` diff --git a/lib/api/nuget_project_packages.rb b/lib/api/nuget_project_packages.rb index 20bc6299fe1af1295241cafb8b991301598f0129..dbc789c68b6c7b6983557875dfe1ba34cfaa6898 100644 --- a/lib/api/nuget_project_packages.rb +++ b/lib/api/nuget_project_packages.rb @@ -132,6 +132,22 @@ def format_filename(package) return "#{params[:package_filename]}.#{params[:format]}" if package.version == params[:package_version] return "#{params[:package_filename].sub(params[:package_version], package.version)}.#{params[:format]}" if package.normalized_nuget_version == params[:package_version] end + + def present_odata_entry + project = find_project(params[:project_id]) + + not_found! unless project + + env['api.format'] = :binary + content_type 'application/xml; charset=utf-8' + + odata_entry = ::Packages::Nuget::OdataPackageEntryService + .new(project, declared_params) + .execute + .payload + + present odata_entry + end end params do @@ -327,5 +343,74 @@ def format_filename(package) end end end + + params do + requires :project_id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project', + regexp: ::API::Concerns::Packages::Nuget::PrivateEndpoints::POSITIVE_INTEGER_REGEX + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + namespace ':project_id/packages/nuget/v2' do + # https://joelverhagen.github.io/NuGetUndocs/?http#endpoint-find-packages-by-id + desc 'The NuGet V2 Feed Find Packages by ID endpoint' do + detail 'This feature was introduced in GitLab 16.4' + success code: 200 + failure [ + { code: 404, message: 'Not Found' }, + { code: 400, message: 'Bad Request' } + ] + tags %w[nuget_packages] + end + + params do + requires :id, as: :package_name, type: String, allow_blank: false, coerce_with: ->(val) { val.delete("'") }, + desc: 'The NuGet package name', regexp: Gitlab::Regex.nuget_package_name_regex, + documentation: { example: 'mynugetpkg' } + end + get 'FindPackagesById\(\)', urgency: :low do + present_odata_entry + end + + # https://joelverhagen.github.io/NuGetUndocs/?http#endpoint-enumerate-packages + desc 'The NuGet V2 Feed Enumerate Packages endpoint' do + detail 'This feature was introduced in GitLab 16.4' + success code: 200 + failure [ + { code: 404, message: 'Not Found' }, + { code: 400, message: 'Bad Request' } + ] + tags %w[nuget_packages] + end + + params do + requires :$filter, as: :package_name, type: String, allow_blank: false, + coerce_with: ->(val) { val.match(/tolower\(Id\) eq '(.+?)'/)&.captures&.first }, + desc: 'The NuGet package name', regexp: Gitlab::Regex.nuget_package_name_regex, + documentation: { example: 'mynugetpkg' } + end + get 'Packages\(\)', urgency: :low do + present_odata_entry + end + + # https://joelverhagen.github.io/NuGetUndocs/?http#endpoint-get-a-single-package + desc 'The NuGet V2 Feed Single Package Metadata endpoint' do + detail 'This feature was introduced in GitLab 16.4' + success code: 200 + failure [ + { code: 404, message: 'Not Found' }, + { code: 400, message: 'Bad Request' } + ] + tags %w[nuget_packages] + end + params do + requires :package_name, type: String, allow_blank: false, desc: 'The NuGet package name', + regexp: Gitlab::Regex.nuget_package_name_regex, documentation: { example: 'mynugetpkg' } + requires :package_version, type: String, allow_blank: false, desc: 'The NuGet package version', + regexp: Gitlab::Regex.nuget_version_regex, documentation: { example: '1.3.0.17' } + end + get 'Packages\(Id=\'*package_name\',Version=\'*package_version\'\)', urgency: :low do + present_odata_entry + end + end + end end end diff --git a/spec/requests/api/nuget_project_packages_spec.rb b/spec/requests/api/nuget_project_packages_spec.rb index da74409cd77964c1e73cbcf507a83a16b5599bcc..b55d992c1e44c508f27ba61f3b43b120f53c81fe 100644 --- a/spec/requests/api/nuget_project_packages_spec.rb +++ b/spec/requests/api/nuget_project_packages_spec.rb @@ -34,6 +34,37 @@ def snowplow_context(user_role: :developer) it_behaves_like 'returning response status', :ok end + shared_examples 'nuget serialize odata package endpoint' do + subject { get api(url), params: params } + + it { is_expected.to have_request_urgency(:low) } + + it_behaves_like 'returning response status', :success + + it 'returns a valid xml response and invokes OdataPackageEntryService' do + expect(Packages::Nuget::OdataPackageEntryService).to receive(:new).with(target, service_params).and_call_original + + subject + + expect(response.media_type).to eq('application/xml') + end + + [nil, '', '%20', '..%2F..', '../..'].each do |value| + context "with invalid package name #{value}" do + let(:package_name) { value } + + it_behaves_like 'returning response status', :bad_request + end + end + + context 'with missing required params' do + let(:params) { {} } + let(:package_version) { nil } + + it_behaves_like 'returning response status', :bad_request + end + end + describe 'GET /api/v4/projects/:id/packages/nuget' do let(:url) { "/projects/#{target.id}/packages/nuget/index.json" } @@ -228,6 +259,43 @@ def snowplow_context(user_role: :developer) it_behaves_like 'rejects nuget access with invalid target id' end + describe 'GET /api/v4/projects/:id/packages/nuget/v2/FindPackagesById()' do + it_behaves_like 'nuget serialize odata package endpoint' do + let(:url) { "/projects/#{target.id}/packages/nuget/v2/FindPackagesById()" } + let(:params) { { id: "'#{package_name}'" } } + let(:service_params) { { package_name: package_name } } + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/v2/Packages()' do + it_behaves_like 'nuget serialize odata package endpoint' do + let(:url) { "/projects/#{target.id}/packages/nuget/v2/Packages()" } + let(:params) { { '$filter' => "(tolower(Id) eq '#{package_name&.downcase}')" } } + let(:service_params) { { package_name: package_name&.downcase } } + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/v2/Packages(Id=\'*\',Version=\'*\')' do + let(:package_version) { '1.0.0' } + let(:url) { "/projects/#{target.id}/packages/nuget/v2/Packages(Id='#{package_name}',Version='#{package_version}')" } + let(:params) { {} } + let(:service_params) { { package_name: package_name, package_version: package_version } } + + it_behaves_like 'nuget serialize odata package endpoint' + + context 'with invalid package version' do + subject { get api(url) } + + ['', '1', '1./2.3', '%20', '..%2F..', '../..'].each do |value| + context "with invalid package version #{value}" do + let(:package_version) { value } + + it_behaves_like 'returning response status', :bad_request + end + end + end + end + describe 'PUT /api/v4/projects/:id/packages/nuget/authorize' do it_behaves_like 'nuget authorize upload endpoint' do let(:url) { "/projects/#{target.id}/packages/nuget/authorize" } diff --git a/spec/services/packages/nuget/odata_package_entry_service_spec.rb b/spec/services/packages/nuget/odata_package_entry_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d4c47538ce24b894511064ae2f4bbec71b7f3569 --- /dev/null +++ b/spec/services/packages/nuget/odata_package_entry_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::OdataPackageEntryService, feature_category: :package_registry do + let_it_be(:project) { build_stubbed(:project) } + let_it_be(:params) { { package_name: 'dummy', package_version: '1.0.0' } } + let(:doc) { Nokogiri::XML(subject.payload) } + + subject { described_class.new(project, params).execute } + + describe '#execute' do + shared_examples 'returning a package entry with the correct attributes' do |pkg_version, content_url_pkg_version| + it 'returns a package entry with the correct attributes' do + expect(doc.root.name).to eq('entry') + expect(doc_node('id').text).to include( + id_url(project.id, params[:package_name], pkg_version) + ) + expect(doc_node('title').text).to eq(params[:package_name]) + expect(doc_node('content').attr('src')).to include( + content_url(project.id, params[:package_name], content_url_pkg_version) + ) + expect(doc_node('Version').text).to eq(pkg_version) + end + end + + context 'when package_version is present' do + it 'returns a success ServiceResponse' do + expect(subject).to be_success + end + + it_behaves_like 'returning a package entry with the correct attributes', '1.0.0', '1.0.0' + end + + context 'when package_version is nil' do + let(:params) { { package_name: 'dummy', package_version: nil } } + + it 'returns a success ServiceResponse' do + expect(subject).to be_success + end + + it_behaves_like 'returning a package entry with the correct attributes', + described_class::SEMVER_LATEST_VERSION_PLACEHOLDER, described_class::LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT + end + + context 'when package_version is 0.0.0-latest-version' do + let(:params) { { package_name: 'dummy', package_version: described_class::SEMVER_LATEST_VERSION_PLACEHOLDER } } + + it 'returns a success ServiceResponse' do + expect(subject).to be_success + end + + it_behaves_like 'returning a package entry with the correct attributes', + described_class::SEMVER_LATEST_VERSION_PLACEHOLDER, described_class::LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT + end + end + + def doc_node(name) + doc.css('*').detect { |el| el.name == name } + end + + def id_url(id, package_name, package_version) + "api/v4/projects/#{id}/packages/nuget/v2/Packages(Id='#{package_name}',Version='#{package_version}')" + end + + def content_url(id, package_name, package_version) + "api/v4/projects/#{id}/packages/nuget/v2/download/#{package_name}/#{package_version}" + end +end