From ece3d3d2af7871c1115cceaccdeddd41f1815f6e Mon Sep 17 00:00:00 2001 From: moaz-khalifa Date: Tue, 8 Aug 2023 18:31:03 +0200 Subject: [PATCH] NuGet v2 $metadata endpoint Introduce NuGet v2 feed $metadata endpoint which provides information about the available endpoints. Changelog: added --- .../nuget/v2/metadata_index_presenter.rb | 48 +++++++++++++++++ doc/api/packages/nuget.md | 51 +++++++++++++++++++ .../packages/nuget/public_endpoints.rb | 16 ++++++ .../nuget/v2/metadata_index_presenter_spec.rb | 35 +++++++++++++ .../api/nuget_project_packages_spec.rb | 38 ++++++++++++++ .../api/nuget_packages_shared_examples.rb | 28 ++++++++++ 6 files changed, 216 insertions(+) create mode 100644 app/presenters/packages/nuget/v2/metadata_index_presenter.rb create mode 100644 spec/presenters/packages/nuget/v2/metadata_index_presenter_spec.rb diff --git a/app/presenters/packages/nuget/v2/metadata_index_presenter.rb b/app/presenters/packages/nuget/v2/metadata_index_presenter.rb new file mode 100644 index 00000000000000..0ce7c8956b38e8 --- /dev/null +++ b/app/presenters/packages/nuget/v2/metadata_index_presenter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module V2 + class MetadataIndexPresenter + def xml + Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml['edmx'].Edmx('xmlns:edmx' => 'http://schemas.microsoft.com/ado/2007/06/edmx', Version: '1.0') do + xml['edmx'].DataServices('xmlns:m' => 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata', + 'm:DataServiceVersion' => '2.0', 'm:MaxDataServiceVersion' => '2.0') do + xml.Schema(xmlns: 'http://schemas.microsoft.com/ado/2006/04/edm', Namespace: 'NuGetGallery.OData') do + xml.EntityType(Name: 'V2FeedPackage', 'm:HasStream' => true) do + xml.Key do + xml.PropertyRef(Name: 'Id') + xml.PropertyRef(Name: 'Version') + end + xml.Property(Name: 'Id', Type: 'Edm.String', Nullable: false) + xml.Property(Name: 'Version', Type: 'Edm.String', Nullable: false) + xml.Property(Name: 'Authors', Type: 'Edm.String') + xml.Property(Name: 'Dependencies', Type: 'Edm.String') + xml.Property(Name: 'Description', Type: 'Edm.String') + xml.Property(Name: 'DownloadCount', Type: 'Edm.Int64', Nullable: false) + xml.Property(Name: 'IconUrl', Type: 'Edm.String') + xml.Property(Name: 'Published', Type: 'Edm.DateTime', Nullable: false) + xml.Property(Name: 'ProjectUrl', Type: 'Edm.String') + xml.Property(Name: 'Tags', Type: 'Edm.String') + xml.Property(Name: 'Title', Type: 'Edm.String') + xml.Property(Name: 'LicenseUrl', Type: 'Edm.String') + end + end + xml.Schema(xmlns: 'http://schemas.microsoft.com/ado/2006/04/edm', Namespace: 'NuGetGallery') do + xml.EntityContainer(Name: 'V2FeedContext', 'm:IsDefaultEntityContainer' => true) do + xml.EntitySet(Name: 'Packages', EntityType: 'NuGetGallery.OData.V2FeedPackage') + xml.FunctionImport(Name: 'FindPackagesById', + ReturnType: 'Collection(NuGetGallery.OData.V2FeedPackage)', EntitySet: 'Packages') do + xml.Parameter(Name: 'id', Type: 'Edm.String', FixedLength: 'false', Unicode: 'false') + end + end + end + end + end + end + end + end + end + end +end diff --git a/doc/api/packages/nuget.md b/doc/api/packages/nuget.md index 075201d14cbc4f..5697639c7498b6 100644 --- a/doc/api/packages/nuget.md +++ b/doc/api/packages/nuget.md @@ -424,3 +424,54 @@ Example response: ] } ``` + +## V2 Feed Metadata Endpoint + +> Introduced in GitLab 16.3. + +Authentication is not required. Returns metadata for a V2 feed available endpoints: + +```plaintext +GET /v2/$metadata +``` + +```shell + curl "https://gitlab.example.com/api/v4/projects/1/packages/nuget/v2/$metadata" +``` + +Example response: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` diff --git a/lib/api/concerns/packages/nuget/public_endpoints.rb b/lib/api/concerns/packages/nuget/public_endpoints.rb index d5be136c7a28d8..b0c9177f4526e7 100644 --- a/lib/api/concerns/packages/nuget/public_endpoints.rb +++ b/lib/api/concerns/packages/nuget/public_endpoints.rb @@ -60,6 +60,22 @@ module PublicEndpoints .new(project_or_group_without_auth) .xml end + + # https://www.nuget.org/api/v2/$metadata + desc 'The NuGet V2 Feed Package $metadata endpoint' do + detail 'This feature was introduced in GitLab 16.3' + success code: 200 + tags %w[nuget_packages] + end + + get '$metadata', format: :xml, urgency: :low do + env['api.format'] = :xml + content_type 'application/xml; charset=utf-8' + # needed to allow browser default inline styles in xml response + header 'Content-Security-Policy', "nonce-#{SecureRandom.base64(16)}" + + present ::Packages::Nuget::V2::MetadataIndexPresenter.new.xml + end end end end diff --git a/spec/presenters/packages/nuget/v2/metadata_index_presenter_spec.rb b/spec/presenters/packages/nuget/v2/metadata_index_presenter_spec.rb new file mode 100644 index 00000000000000..598db641b75e1d --- /dev/null +++ b/spec/presenters/packages/nuget/v2/metadata_index_presenter_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::V2::MetadataIndexPresenter, feature_category: :package_registry do + describe '#xml' do + let(:presenter) { described_class.new } + + subject(:xml) { Nokogiri::XML(presenter.xml.to_xml) } + + specify { expect(xml.root.name).to eq('Edmx') } + + specify { expect(xml.at_xpath('//edmx:Edmx')).to be_present } + + specify { expect(xml.at_xpath('//edmx:Edmx/edmx:DataServices')).to be_present } + + specify do + expect(xml.css('*').map(&:name)).to include( + 'Schema', 'EntityType', 'Key', 'PropertyRef', 'EntityContainer', 'EntitySet', 'FunctionImport', 'Parameter' + ) + end + + specify do + expect(xml.css('*').select { |el| el.name == 'Property' }.map { |el| el.attribute_nodes.first.value }) + .to match_array( + %w[Id Version Authors Dependencies Description DownloadCount IconUrl Published ProjectUrl Tags Title + LicenseUrl] + ) + end + + specify { expect(xml.css('*').detect { |el| el.name == 'EntityContainer' }.attr('Name')).to eq('V2FeedContext') } + + specify { expect(xml.css('*').detect { |el| el.name == 'FunctionImport' }.attr('Name')).to eq('FindPackagesById') } + end +end diff --git a/spec/requests/api/nuget_project_packages_spec.rb b/spec/requests/api/nuget_project_packages_spec.rb index 2d3781da42bb5a..da74409cd77964 100644 --- a/spec/requests/api/nuget_project_packages_spec.rb +++ b/spec/requests/api/nuget_project_packages_spec.rb @@ -50,6 +50,44 @@ def snowplow_context(user_role: :developer) it_behaves_like 'accept get request on private project with access to package registry for everyone' end + describe 'GET /api/v4/projects/:id/packages/nuget/v2/$metadata' do + let(:url) { "/projects/#{target.id}/packages/nuget/v2/$metadata" } + + subject(:api_request) { get api(url) } + + it { is_expected.to have_request_urgency(:low) } + + context 'with valid target' do + using RSpec::Parameterized::TableSyntax + + where(:visibility_level, :user_role, :member, :expected_status) do + 'PUBLIC' | :developer | true | :success + 'PUBLIC' | :guest | true | :success + 'PUBLIC' | :developer | false | :success + 'PUBLIC' | :guest | false | :success + 'PUBLIC' | :anonymous | false | :success + 'PRIVATE' | :developer | true | :success + 'PRIVATE' | :guest | true | :success + 'PRIVATE' | :developer | false | :success + 'PRIVATE' | :guest | false | :success + 'PRIVATE' | :anonymous | false | :success + 'INTERNAL' | :developer | true | :success + 'INTERNAL' | :guest | true | :success + 'INTERNAL' | :developer | false | :success + 'INTERNAL' | :guest | false | :success + 'INTERNAL' | :anonymous | false | :success + end + + with_them do + before do + update_visibility_to(Gitlab::VisibilityLevel.const_get(visibility_level, false)) + end + + it_behaves_like 'process nuget v2 $metadata service request', params[:user_role], params[:expected_status], params[:member] + end + end + end + describe 'GET /api/v4/projects/:id/packages/nuget/metadata/*package_name/index' do let(:url) { "/projects/#{target.id}/packages/nuget/metadata/#{package_name}/index.json" } diff --git a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb index 5854958a06e17c..2e66bae26badec 100644 --- a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb @@ -51,6 +51,34 @@ end end +RSpec.shared_examples 'process nuget v2 $metadata service request' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + target.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it 'returns a valid xml response' do + api_request + + doc = Nokogiri::XML(body) + + expect(response.media_type).to eq('application/xml') + expect(doc.at_xpath('//edmx:Edmx')).to be_present + expect(doc.at_xpath('//edmx:Edmx/edmx:DataServices')).to be_present + expect(doc.css('*').map(&:name)).to include( + 'Schema', 'EntityType', 'Key', 'PropertyRef', 'EntityContainer', 'EntitySet', 'FunctionImport', 'Parameter' + ) + expect(doc.css('*').select { |el| el.name == 'Property' }.map { |el| el.attribute_nodes.first.value }) + .to match_array(%w[Id Version Authors Dependencies Description DownloadCount IconUrl Published ProjectUrl + Tags Title LicenseUrl] + ) + expect(doc.css('*').detect { |el| el.name == 'FunctionImport' }.attr('Name')).to eq('FindPackagesById') + end + end +end + RSpec.shared_examples 'returning nuget metadata json response with json schema' do |json_schema| it 'returns a valid json response' do subject -- GitLab