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