From 0e58201d1a8247561809d832eb8f576e05e5d26d Mon Sep 17 00:00:00 2001
From: KN4CK3R <admin@oldschoolhack.me>
Date: Thu, 13 Oct 2022 12:19:39 +0200
Subject: [PATCH] Add support for Chocolatey/NuGet v2 API (#21393)

Fixes #21294

This PR adds support for NuGet v2 API.

Co-authored-by: Lauris BH <lauris@nix.lv>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 docs/content/doc/packages/nuget.en-us.md      |   2 +-
 modules/packages/nuget/metadata.go            |  26 +-
 routers/api/packages/api.go                   |  16 +-
 routers/api/packages/nuget/api_v2.go          | 393 ++++++++++++++++++
 .../api/packages/nuget/{api.go => api_v3.go}  |  45 +-
 routers/api/packages/nuget/links.go           |   5 +
 routers/api/packages/nuget/nuget.go           | 191 ++++++++-
 tests/integration/api_packages_nuget_test.go  | 307 ++++++++++----
 8 files changed, 850 insertions(+), 135 deletions(-)
 create mode 100644 routers/api/packages/nuget/api_v2.go
 rename routers/api/packages/nuget/{api.go => api_v3.go} (79%)

diff --git a/docs/content/doc/packages/nuget.en-us.md b/docs/content/doc/packages/nuget.en-us.md
index 6c8aaa70af..670abca7fd 100644
--- a/docs/content/doc/packages/nuget.en-us.md
+++ b/docs/content/doc/packages/nuget.en-us.md
@@ -14,7 +14,7 @@ menu:
 
 # NuGet Packages Repository
 
-Publish [NuGet](https://www.nuget.org/) packages for your user or organization. The package registry supports [NuGet Symbol Packages](https://docs.microsoft.com/en-us/nuget/create-packages/symbol-packages-snupkg) too.
+Publish [NuGet](https://www.nuget.org/) packages for your user or organization. The package registry supports the V2 and V3 API protocol and you can work with [NuGet Symbol Packages](https://docs.microsoft.com/en-us/nuget/create-packages/symbol-packages-snupkg) too.
 
 **Table of Contents**
 
diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go
index 797bff45ac..2b555e47e9 100644
--- a/modules/packages/nuget/metadata.go
+++ b/modules/packages/nuget/metadata.go
@@ -55,12 +55,13 @@ type Package struct {
 
 // Metadata represents the metadata of a Nuget package
 type Metadata struct {
-	Description   string                  `json:"description,omitempty"`
-	ReleaseNotes  string                  `json:"release_notes,omitempty"`
-	Authors       string                  `json:"authors,omitempty"`
-	ProjectURL    string                  `json:"project_url,omitempty"`
-	RepositoryURL string                  `json:"repository_url,omitempty"`
-	Dependencies  map[string][]Dependency `json:"dependencies,omitempty"`
+	Description              string                  `json:"description,omitempty"`
+	ReleaseNotes             string                  `json:"release_notes,omitempty"`
+	Authors                  string                  `json:"authors,omitempty"`
+	ProjectURL               string                  `json:"project_url,omitempty"`
+	RepositoryURL            string                  `json:"repository_url,omitempty"`
+	RequireLicenseAcceptance bool                    `json:"require_license_acceptance"`
+	Dependencies             map[string][]Dependency `json:"dependencies,omitempty"`
 }
 
 // Dependency represents a dependency of a Nuget package
@@ -155,12 +156,13 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) {
 	}
 
 	m := &Metadata{
-		Description:   p.Metadata.Description,
-		ReleaseNotes:  p.Metadata.ReleaseNotes,
-		Authors:       p.Metadata.Authors,
-		ProjectURL:    p.Metadata.ProjectURL,
-		RepositoryURL: p.Metadata.Repository.URL,
-		Dependencies:  make(map[string][]Dependency),
+		Description:              p.Metadata.Description,
+		ReleaseNotes:             p.Metadata.ReleaseNotes,
+		Authors:                  p.Metadata.Authors,
+		ProjectURL:               p.Metadata.ProjectURL,
+		RepositoryURL:            p.Metadata.Repository.URL,
+		RequireLicenseAcceptance: p.Metadata.RequireLicenseAcceptance,
+		Dependencies:             make(map[string][]Dependency),
 	}
 
 	for _, group := range p.Metadata.Dependencies.Group {
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index a54add0621..f6ab961f5e 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -180,15 +180,19 @@ func Routes(ctx gocontext.Context) *web.Route {
 			r.Get("/*", maven.DownloadPackageFile)
 		}, reqPackageAccess(perm.AccessModeRead))
 		r.Group("/nuget", func() {
-			r.Get("/index.json", nuget.ServiceIndex) // Needs to be unauthenticated for the NuGet client.
+			r.Group("", func() { // Needs to be unauthenticated for the NuGet client.
+				r.Get("/", nuget.ServiceIndexV2)
+				r.Get("/index.json", nuget.ServiceIndexV3)
+				r.Get("/$metadata", nuget.FeedCapabilityResource)
+			})
 			r.Group("", func() {
-				r.Get("/query", nuget.SearchService)
+				r.Get("/query", nuget.SearchServiceV3)
 				r.Group("/registration/{id}", func() {
 					r.Get("/index.json", nuget.RegistrationIndex)
-					r.Get("/{version}", nuget.RegistrationLeaf)
+					r.Get("/{version}", nuget.RegistrationLeafV3)
 				})
 				r.Group("/package/{id}", func() {
-					r.Get("/index.json", nuget.EnumeratePackageVersions)
+					r.Get("/index.json", nuget.EnumeratePackageVersionsV3)
 					r.Get("/{version}/{filename}", nuget.DownloadPackageFile)
 				})
 				r.Group("", func() {
@@ -197,6 +201,10 @@ func Routes(ctx gocontext.Context) *web.Route {
 					r.Delete("/{id}/{version}", nuget.DeletePackage)
 				}, reqPackageAccess(perm.AccessModeWrite))
 				r.Get("/symbols/{filename}/{guid:[0-9a-fA-F]{32}[fF]{8}}/{filename2}", nuget.DownloadSymbolFile)
+				r.Get("/Packages(Id='{id:[^']+}',Version='{version:[^']+}')", nuget.RegistrationLeafV2)
+				r.Get("/Packages()", nuget.SearchServiceV2)
+				r.Get("/FindPackagesById()", nuget.EnumeratePackageVersionsV2)
+				r.Get("/Search()", nuget.SearchServiceV2)
 			}, reqPackageAccess(perm.AccessModeRead))
 		})
 		r.Group("/npm", func() {
diff --git a/routers/api/packages/nuget/api_v2.go b/routers/api/packages/nuget/api_v2.go
new file mode 100644
index 0000000000..60a5d9c0e4
--- /dev/null
+++ b/routers/api/packages/nuget/api_v2.go
@@ -0,0 +1,393 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package nuget
+
+import (
+	"encoding/xml"
+	"strings"
+	"time"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	nuget_module "code.gitea.io/gitea/modules/packages/nuget"
+)
+
+type AtomTitle struct {
+	Type string `xml:"type,attr"`
+	Text string `xml:",chardata"`
+}
+
+type ServiceCollection struct {
+	Href  string    `xml:"href,attr"`
+	Title AtomTitle `xml:"atom:title"`
+}
+
+type ServiceWorkspace struct {
+	Title      AtomTitle         `xml:"atom:title"`
+	Collection ServiceCollection `xml:"collection"`
+}
+
+type ServiceIndexResponseV2 struct {
+	XMLName   xml.Name         `xml:"service"`
+	Base      string           `xml:"base,attr"`
+	Xmlns     string           `xml:"xmlns,attr"`
+	XmlnsAtom string           `xml:"xmlns:atom,attr"`
+	Workspace ServiceWorkspace `xml:"workspace"`
+}
+
+type EdmxPropertyRef struct {
+	Name string `xml:"Name,attr"`
+}
+
+type EdmxProperty struct {
+	Name     string `xml:"Name,attr"`
+	Type     string `xml:"Type,attr"`
+	Nullable bool   `xml:"Nullable,attr"`
+}
+
+type EdmxEntityType struct {
+	Name       string            `xml:"Name,attr"`
+	HasStream  bool              `xml:"m:HasStream,attr"`
+	Keys       []EdmxPropertyRef `xml:"Key>PropertyRef"`
+	Properties []EdmxProperty    `xml:"Property"`
+}
+
+type EdmxFunctionParameter struct {
+	Name string `xml:"Name,attr"`
+	Type string `xml:"Type,attr"`
+}
+
+type EdmxFunctionImport struct {
+	Name       string                  `xml:"Name,attr"`
+	ReturnType string                  `xml:"ReturnType,attr"`
+	EntitySet  string                  `xml:"EntitySet,attr"`
+	Parameter  []EdmxFunctionParameter `xml:"Parameter"`
+}
+
+type EdmxEntitySet struct {
+	Name       string `xml:"Name,attr"`
+	EntityType string `xml:"EntityType,attr"`
+}
+
+type EdmxEntityContainer struct {
+	Name                     string               `xml:"Name,attr"`
+	IsDefaultEntityContainer bool                 `xml:"m:IsDefaultEntityContainer,attr"`
+	EntitySet                EdmxEntitySet        `xml:"EntitySet"`
+	FunctionImports          []EdmxFunctionImport `xml:"FunctionImport"`
+}
+
+type EdmxSchema struct {
+	Xmlns           string               `xml:"xmlns,attr"`
+	Namespace       string               `xml:"Namespace,attr"`
+	EntityType      *EdmxEntityType      `xml:"EntityType,omitempty"`
+	EntityContainer *EdmxEntityContainer `xml:"EntityContainer,omitempty"`
+}
+
+type EdmxDataServices struct {
+	XmlnsM                string       `xml:"xmlns:m,attr"`
+	DataServiceVersion    string       `xml:"m:DataServiceVersion,attr"`
+	MaxDataServiceVersion string       `xml:"m:MaxDataServiceVersion,attr"`
+	Schema                []EdmxSchema `xml:"Schema"`
+}
+
+type EdmxMetadata struct {
+	XMLName      xml.Name         `xml:"edmx:Edmx"`
+	XmlnsEdmx    string           `xml:"xmlns:edmx,attr"`
+	Version      string           `xml:"Version,attr"`
+	DataServices EdmxDataServices `xml:"edmx:DataServices"`
+}
+
+var Metadata = &EdmxMetadata{
+	XmlnsEdmx: "http://schemas.microsoft.com/ado/2007/06/edmx",
+	Version:   "1.0",
+	DataServices: EdmxDataServices{
+		XmlnsM:                "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
+		DataServiceVersion:    "2.0",
+		MaxDataServiceVersion: "2.0",
+		Schema: []EdmxSchema{
+			{
+				Xmlns:     "http://schemas.microsoft.com/ado/2006/04/edm",
+				Namespace: "NuGetGallery.OData",
+				EntityType: &EdmxEntityType{
+					Name:      "V2FeedPackage",
+					HasStream: true,
+					Keys: []EdmxPropertyRef{
+						{Name: "Id"},
+						{Name: "Version"},
+					},
+					Properties: []EdmxProperty{
+						{
+							Name: "Id",
+							Type: "Edm.String",
+						},
+						{
+							Name: "Version",
+							Type: "Edm.String",
+						},
+						{
+							Name:     "NormalizedVersion",
+							Type:     "Edm.String",
+							Nullable: true,
+						},
+						{
+							Name:     "Authors",
+							Type:     "Edm.String",
+							Nullable: true,
+						},
+						{
+							Name: "Created",
+							Type: "Edm.DateTime",
+						},
+						{
+							Name: "Dependencies",
+							Type: "Edm.String",
+						},
+						{
+							Name: "Description",
+							Type: "Edm.String",
+						},
+						{
+							Name: "DownloadCount",
+							Type: "Edm.Int64",
+						},
+						{
+							Name: "LastUpdated",
+							Type: "Edm.DateTime",
+						},
+						{
+							Name: "Published",
+							Type: "Edm.DateTime",
+						},
+						{
+							Name: "PackageSize",
+							Type: "Edm.Int64",
+						},
+						{
+							Name:     "ProjectUrl",
+							Type:     "Edm.String",
+							Nullable: true,
+						},
+						{
+							Name:     "ReleaseNotes",
+							Type:     "Edm.String",
+							Nullable: true,
+						},
+						{
+							Name:     "RequireLicenseAcceptance",
+							Type:     "Edm.Boolean",
+							Nullable: false,
+						},
+						{
+							Name:     "Title",
+							Type:     "Edm.String",
+							Nullable: true,
+						},
+						{
+							Name:     "VersionDownloadCount",
+							Type:     "Edm.Int64",
+							Nullable: false,
+						},
+					},
+				},
+			},
+			{
+				Xmlns:     "http://schemas.microsoft.com/ado/2006/04/edm",
+				Namespace: "NuGetGallery",
+				EntityContainer: &EdmxEntityContainer{
+					Name:                     "V2FeedContext",
+					IsDefaultEntityContainer: true,
+					EntitySet: EdmxEntitySet{
+						Name:       "Packages",
+						EntityType: "NuGetGallery.OData.V2FeedPackage",
+					},
+					FunctionImports: []EdmxFunctionImport{
+						{
+							Name:       "Search",
+							ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)",
+							EntitySet:  "Packages",
+							Parameter: []EdmxFunctionParameter{
+								{
+									Name: "searchTerm",
+									Type: "Edm.String",
+								},
+							},
+						},
+						{
+							Name:       "FindPackagesById",
+							ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)",
+							EntitySet:  "Packages",
+							Parameter: []EdmxFunctionParameter{
+								{
+									Name: "id",
+									Type: "Edm.String",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	},
+}
+
+type FeedEntryCategory struct {
+	Term   string `xml:"term,attr"`
+	Scheme string `xml:"scheme,attr"`
+}
+
+type FeedEntryLink struct {
+	Rel  string `xml:"rel,attr"`
+	Href string `xml:"href,attr"`
+}
+
+type TypedValue[T any] struct {
+	Type  string `xml:"type,attr,omitempty"`
+	Value T      `xml:",chardata"`
+}
+
+type FeedEntryProperties struct {
+	Version                  string                `xml:"d:Version"`
+	NormalizedVersion        string                `xml:"d:NormalizedVersion"`
+	Authors                  string                `xml:"d:Authors"`
+	Dependencies             string                `xml:"d:Dependencies"`
+	Description              string                `xml:"d:Description"`
+	VersionDownloadCount     TypedValue[int64]     `xml:"d:VersionDownloadCount"`
+	DownloadCount            TypedValue[int64]     `xml:"d:DownloadCount"`
+	PackageSize              TypedValue[int64]     `xml:"d:PackageSize"`
+	Created                  TypedValue[time.Time] `xml:"d:Created"`
+	LastUpdated              TypedValue[time.Time] `xml:"d:LastUpdated"`
+	Published                TypedValue[time.Time] `xml:"d:Published"`
+	ProjectURL               string                `xml:"d:ProjectUrl,omitempty"`
+	ReleaseNotes             string                `xml:"d:ReleaseNotes,omitempty"`
+	RequireLicenseAcceptance TypedValue[bool]      `xml:"d:RequireLicenseAcceptance"`
+	Title                    string                `xml:"d:Title"`
+}
+
+type FeedEntry struct {
+	XMLName    xml.Name             `xml:"entry"`
+	Xmlns      string               `xml:"xmlns,attr,omitempty"`
+	XmlnsD     string               `xml:"xmlns:d,attr,omitempty"`
+	XmlnsM     string               `xml:"xmlns:m,attr,omitempty"`
+	Base       string               `xml:"xml:base,attr,omitempty"`
+	ID         string               `xml:"id"`
+	Category   FeedEntryCategory    `xml:"category"`
+	Links      []FeedEntryLink      `xml:"link"`
+	Title      TypedValue[string]   `xml:"title"`
+	Updated    time.Time            `xml:"updated"`
+	Author     string               `xml:"author>name"`
+	Summary    string               `xml:"summary"`
+	Properties *FeedEntryProperties `xml:"m:properties"`
+	Content    string               `xml:",innerxml"`
+}
+
+type FeedResponse struct {
+	XMLName xml.Name           `xml:"feed"`
+	Xmlns   string             `xml:"xmlns,attr,omitempty"`
+	XmlnsD  string             `xml:"xmlns:d,attr,omitempty"`
+	XmlnsM  string             `xml:"xmlns:m,attr,omitempty"`
+	Base    string             `xml:"xml:base,attr,omitempty"`
+	ID      string             `xml:"id"`
+	Title   TypedValue[string] `xml:"title"`
+	Updated time.Time          `xml:"updated"`
+	Link    FeedEntryLink      `xml:"link"`
+	Entries []*FeedEntry       `xml:"entry"`
+	Count   int64              `xml:"m:count"`
+}
+
+func createFeedResponse(l *linkBuilder, totalEntries int64, pds []*packages_model.PackageDescriptor) *FeedResponse {
+	entries := make([]*FeedEntry, 0, len(pds))
+	for _, pd := range pds {
+		entries = append(entries, createEntry(l, pd, false))
+	}
+
+	return &FeedResponse{
+		Xmlns:   "http://www.w3.org/2005/Atom",
+		Base:    l.Base,
+		XmlnsD:  "http://schemas.microsoft.com/ado/2007/08/dataservices",
+		XmlnsM:  "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
+		ID:      "http://schemas.datacontract.org/2004/07/",
+		Updated: time.Now(),
+		Link:    FeedEntryLink{Rel: "self", Href: l.Base},
+		Count:   totalEntries,
+		Entries: entries,
+	}
+}
+
+func createEntryResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *FeedEntry {
+	return createEntry(l, pd, true)
+}
+
+func createEntry(l *linkBuilder, pd *packages_model.PackageDescriptor, withNamespace bool) *FeedEntry {
+	metadata := pd.Metadata.(*nuget_module.Metadata)
+
+	id := l.GetPackageMetadataURL(pd.Package.Name, pd.Version.Version)
+
+	// Workaround to force a self-closing tag to satisfy XmlReader.IsEmptyElement used by the NuGet client.
+	// https://learn.microsoft.com/en-us/dotnet/api/system.xml.xmlreader.isemptyelement
+	content := `<content type="application/zip" src="` + l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version) + `"/>`
+
+	createdValue := TypedValue[time.Time]{
+		Type:  "Edm.DateTime",
+		Value: pd.Version.CreatedUnix.AsLocalTime(),
+	}
+
+	entry := &FeedEntry{
+		ID:       id,
+		Category: FeedEntryCategory{Term: "NuGetGallery.OData.V2FeedPackage", Scheme: "http://schemas.microsoft.com/ado/2007/08/dataservices/scheme"},
+		Links: []FeedEntryLink{
+			{Rel: "self", Href: id},
+			{Rel: "edit", Href: id},
+		},
+		Title:   TypedValue[string]{Type: "text", Value: pd.Package.Name},
+		Updated: pd.Version.CreatedUnix.AsLocalTime(),
+		Author:  metadata.Authors,
+		Content: content,
+		Properties: &FeedEntryProperties{
+			Version:                  pd.Version.Version,
+			NormalizedVersion:        normalizeVersion(pd.SemVer),
+			Authors:                  metadata.Authors,
+			Dependencies:             buildDependencyString(metadata),
+			Description:              metadata.Description,
+			VersionDownloadCount:     TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
+			DownloadCount:            TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
+			PackageSize:              TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()},
+			Created:                  createdValue,
+			LastUpdated:              createdValue,
+			Published:                createdValue,
+			ProjectURL:               metadata.ProjectURL,
+			ReleaseNotes:             metadata.ReleaseNotes,
+			RequireLicenseAcceptance: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.RequireLicenseAcceptance},
+			Title:                    pd.Package.Name,
+		},
+	}
+
+	if withNamespace {
+		entry.Xmlns = "http://www.w3.org/2005/Atom"
+		entry.Base = l.Base
+		entry.XmlnsD = "http://schemas.microsoft.com/ado/2007/08/dataservices"
+		entry.XmlnsM = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"
+	}
+
+	return entry
+}
+
+func buildDependencyString(metadata *nuget_module.Metadata) string {
+	var b strings.Builder
+	first := true
+	for group, deps := range metadata.Dependencies {
+		for _, dep := range deps {
+			if !first {
+				b.WriteByte('|')
+			}
+			first = false
+
+			b.WriteString(dep.ID)
+			b.WriteByte(':')
+			b.WriteString(dep.Version)
+			b.WriteByte(':')
+			b.WriteString(group)
+		}
+	}
+	return b.String()
+}
diff --git a/routers/api/packages/nuget/api.go b/routers/api/packages/nuget/api_v3.go
similarity index 79%
rename from routers/api/packages/nuget/api.go
rename to routers/api/packages/nuget/api_v3.go
index 964e05f926..552054f26b 100644
--- a/routers/api/packages/nuget/api.go
+++ b/routers/api/packages/nuget/api_v3.go
@@ -16,36 +16,19 @@ import (
 	"github.com/hashicorp/go-version"
 )
 
-// ServiceIndexResponse https://docs.microsoft.com/en-us/nuget/api/service-index#resources
-type ServiceIndexResponse struct {
+// https://docs.microsoft.com/en-us/nuget/api/service-index#resources
+type ServiceIndexResponseV3 struct {
 	Version   string            `json:"version"`
 	Resources []ServiceResource `json:"resources"`
 }
 
-// ServiceResource https://docs.microsoft.com/en-us/nuget/api/service-index#resource
+// https://docs.microsoft.com/en-us/nuget/api/service-index#resource
 type ServiceResource struct {
 	ID   string `json:"@id"`
 	Type string `json:"@type"`
 }
 
-func createServiceIndexResponse(root string) *ServiceIndexResponse {
-	return &ServiceIndexResponse{
-		Version: "3.0.0",
-		Resources: []ServiceResource{
-			{ID: root + "/query", Type: "SearchQueryService"},
-			{ID: root + "/query", Type: "SearchQueryService/3.0.0-beta"},
-			{ID: root + "/query", Type: "SearchQueryService/3.0.0-rc"},
-			{ID: root + "/registration", Type: "RegistrationsBaseUrl"},
-			{ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-beta"},
-			{ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-rc"},
-			{ID: root + "/package", Type: "PackageBaseAddress/3.0.0"},
-			{ID: root, Type: "PackagePublish/2.0.0"},
-			{ID: root + "/symbolpackage", Type: "SymbolPackagePublish/4.9.0"},
-		},
-	}
-}
-
-// RegistrationIndexResponse https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#response
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#response
 type RegistrationIndexResponse struct {
 	RegistrationIndexURL string                   `json:"@id"`
 	Type                 []string                 `json:"@type"`
@@ -53,7 +36,7 @@ type RegistrationIndexResponse struct {
 	Pages                []*RegistrationIndexPage `json:"items"`
 }
 
-// RegistrationIndexPage https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-page-object
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-page-object
 type RegistrationIndexPage struct {
 	RegistrationPageURL string                       `json:"@id"`
 	Lower               string                       `json:"lower"`
@@ -62,14 +45,14 @@ type RegistrationIndexPage struct {
 	Items               []*RegistrationIndexPageItem `json:"items"`
 }
 
-// RegistrationIndexPageItem https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf-object-in-a-page
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf-object-in-a-page
 type RegistrationIndexPageItem struct {
 	RegistrationLeafURL string        `json:"@id"`
 	PackageContentURL   string        `json:"packageContent"`
 	CatalogEntry        *CatalogEntry `json:"catalogEntry"`
 }
 
-// CatalogEntry https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry
 type CatalogEntry struct {
 	CatalogLeafURL           string                    `json:"@id"`
 	PackageContentURL        string                    `json:"packageContent"`
@@ -83,13 +66,13 @@ type CatalogEntry struct {
 	DependencyGroups         []*PackageDependencyGroup `json:"dependencyGroups"`
 }
 
-// PackageDependencyGroup https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group
 type PackageDependencyGroup struct {
 	TargetFramework string               `json:"targetFramework"`
 	Dependencies    []*PackageDependency `json:"dependencies"`
 }
 
-// PackageDependency https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency
 type PackageDependency struct {
 	ID    string `json:"id"`
 	Range string `json:"range"`
@@ -162,7 +145,7 @@ func createDependencyGroups(pd *packages_model.PackageDescriptor) []*PackageDepe
 	return dependencyGroups
 }
 
-// RegistrationLeafResponse https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
 type RegistrationLeafResponse struct {
 	RegistrationLeafURL  string    `json:"@id"`
 	Type                 []string  `json:"@type"`
@@ -183,7 +166,7 @@ func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDe
 	}
 }
 
-// PackageVersionsResponse https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#response
+// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#response
 type PackageVersionsResponse struct {
 	Versions []string `json:"versions"`
 }
@@ -199,13 +182,13 @@ func createPackageVersionsResponse(pds []*packages_model.PackageDescriptor) *Pac
 	}
 }
 
-// SearchResultResponse https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#response
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#response
 type SearchResultResponse struct {
 	TotalHits int64           `json:"totalHits"`
 	Data      []*SearchResult `json:"data"`
 }
 
-// SearchResult https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
 type SearchResult struct {
 	ID                   string                 `json:"id"`
 	Version              string                 `json:"version"`
@@ -216,7 +199,7 @@ type SearchResult struct {
 	RegistrationIndexURL string                 `json:"registration"`
 }
 
-// SearchResultVersion https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
 type SearchResultVersion struct {
 	RegistrationLeafURL string `json:"@id"`
 	Version             string `json:"version"`
diff --git a/routers/api/packages/nuget/links.go b/routers/api/packages/nuget/links.go
index f782c7f2cb..618b54ae8d 100644
--- a/routers/api/packages/nuget/links.go
+++ b/routers/api/packages/nuget/links.go
@@ -26,3 +26,8 @@ func (l *linkBuilder) GetRegistrationLeafURL(id, version string) string {
 func (l *linkBuilder) GetPackageDownloadURL(id, version string) string {
 	return fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", l.Base, id, version, id, version)
 }
+
+// GetPackageMetadataURL builds the package metadata url
+func (l *linkBuilder) GetPackageMetadataURL(id, version string) string {
+	return fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", l.Base, id, version)
+}
diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go
index 3c61ae28bb..e84aef3160 100644
--- a/routers/api/packages/nuget/nuget.go
+++ b/routers/api/packages/nuget/nuget.go
@@ -5,15 +5,18 @@
 package nuget
 
 import (
+	"encoding/xml"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"regexp"
 	"strings"
 
 	"code.gitea.io/gitea/models/db"
 	packages_model "code.gitea.io/gitea/models/packages"
 	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/log"
 	packages_module "code.gitea.io/gitea/modules/packages"
 	nuget_module "code.gitea.io/gitea/modules/packages/nuget"
 	"code.gitea.io/gitea/modules/setting"
@@ -30,15 +33,121 @@ func apiError(ctx *context.Context, status int, obj interface{}) {
 	})
 }
 
-// ServiceIndex https://docs.microsoft.com/en-us/nuget/api/service-index
-func ServiceIndex(ctx *context.Context) {
-	resp := createServiceIndexResponse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget")
-
-	ctx.JSON(http.StatusOK, resp)
+func xmlResponse(ctx *context.Context, status int, obj interface{}) {
+	ctx.Resp.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
+	ctx.Resp.WriteHeader(status)
+	if _, err := ctx.Resp.Write([]byte(xml.Header)); err != nil {
+		log.Error("Write failed: %v", err)
+	}
+	if err := xml.NewEncoder(ctx.Resp).Encode(obj); err != nil {
+		log.Error("XML encode failed: %v", err)
+	}
 }
 
-// SearchService https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-for-packages
-func SearchService(ctx *context.Context) {
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func ServiceIndexV2(ctx *context.Context) {
+	base := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"
+
+	xmlResponse(ctx, http.StatusOK, &ServiceIndexResponseV2{
+		Base:      base,
+		Xmlns:     "http://www.w3.org/2007/app",
+		XmlnsAtom: "http://www.w3.org/2005/Atom",
+		Workspace: ServiceWorkspace{
+			Title: AtomTitle{
+				Type: "text",
+				Text: "Default",
+			},
+			Collection: ServiceCollection{
+				Href: "Packages",
+				Title: AtomTitle{
+					Type: "text",
+					Text: "Packages",
+				},
+			},
+		},
+	})
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/service-index
+func ServiceIndexV3(ctx *context.Context) {
+	root := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"
+
+	ctx.JSON(http.StatusOK, &ServiceIndexResponseV3{
+		Version: "3.0.0",
+		Resources: []ServiceResource{
+			{ID: root + "/query", Type: "SearchQueryService"},
+			{ID: root + "/query", Type: "SearchQueryService/3.0.0-beta"},
+			{ID: root + "/query", Type: "SearchQueryService/3.0.0-rc"},
+			{ID: root + "/registration", Type: "RegistrationsBaseUrl"},
+			{ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-beta"},
+			{ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-rc"},
+			{ID: root + "/package", Type: "PackageBaseAddress/3.0.0"},
+			{ID: root, Type: "PackagePublish/2.0.0"},
+			{ID: root + "/symbolpackage", Type: "SymbolPackagePublish/4.9.0"},
+		},
+	})
+}
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/LegacyFeedCapabilityResourceV2Feed.cs
+func FeedCapabilityResource(ctx *context.Context) {
+	xmlResponse(ctx, http.StatusOK, Metadata)
+}
+
+var searchTermExtract = regexp.MustCompile(`'([^']+)'`)
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func SearchServiceV2(ctx *context.Context) {
+	searchTerm := strings.Trim(ctx.FormTrim("searchTerm"), "'")
+	if searchTerm == "" {
+		// $filter contains a query like:
+		// (((Id ne null) and substringof('microsoft',tolower(Id)))
+		// We don't support these queries, just extract the search term.
+		match := searchTermExtract.FindStringSubmatch(ctx.FormTrim("$filter"))
+		if len(match) == 2 {
+			searchTerm = strings.TrimSpace(match[1])
+		}
+	}
+
+	skip, take := ctx.FormInt("skip"), ctx.FormInt("take")
+	if skip == 0 {
+		skip = ctx.FormInt("$skip")
+	}
+	if take == 0 {
+		take = ctx.FormInt("$top")
+	}
+
+	pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+		OwnerID:    ctx.Package.Owner.ID,
+		Type:       packages_model.TypeNuGet,
+		Name:       packages_model.SearchValue{Value: searchTerm},
+		IsInternal: util.OptionalBoolFalse,
+		Paginator: db.NewAbsoluteListOptions(
+			skip,
+			take,
+		),
+	})
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	resp := createFeedResponse(
+		&linkBuilder{setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+		total,
+		pds,
+	)
+
+	xmlResponse(ctx, http.StatusOK, resp)
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-for-packages
+func SearchServiceV3(ctx *context.Context) {
 	pvs, count, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
 		OwnerID:    ctx.Package.Owner.ID,
 		Type:       packages_model.TypeNuGet,
@@ -69,7 +178,7 @@ func SearchService(ctx *context.Context) {
 	ctx.JSON(http.StatusOK, resp)
 }
 
-// RegistrationIndex https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-index
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-index
 func RegistrationIndex(ctx *context.Context) {
 	packageName := ctx.Params("id")
 
@@ -97,8 +206,37 @@ func RegistrationIndex(ctx *context.Context) {
 	ctx.JSON(http.StatusOK, resp)
 }
 
-// RegistrationLeaf https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
-func RegistrationLeaf(ctx *context.Context) {
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func RegistrationLeafV2(ctx *context.Context) {
+	packageName := ctx.Params("id")
+	packageVersion := ctx.Params("version")
+
+	pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
+	if err != nil {
+		if err == packages_model.ErrPackageNotExist {
+			apiError(ctx, http.StatusNotFound, err)
+			return
+		}
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	resp := createEntryResponse(
+		&linkBuilder{setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+		pd,
+	)
+
+	xmlResponse(ctx, http.StatusOK, resp)
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
+func RegistrationLeafV3(ctx *context.Context) {
 	packageName := ctx.Params("id")
 	packageVersion := strings.TrimSuffix(ctx.Params("version"), ".json")
 
@@ -126,8 +264,33 @@ func RegistrationLeaf(ctx *context.Context) {
 	ctx.JSON(http.StatusOK, resp)
 }
 
-// EnumeratePackageVersions https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#enumerate-package-versions
-func EnumeratePackageVersions(ctx *context.Context) {
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func EnumeratePackageVersionsV2(ctx *context.Context) {
+	packageName := strings.Trim(ctx.FormTrim("id"), "'")
+
+	pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	resp := createFeedResponse(
+		&linkBuilder{setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+		int64(len(pds)),
+		pds,
+	)
+
+	xmlResponse(ctx, http.StatusOK, resp)
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#enumerate-package-versions
+func EnumeratePackageVersionsV3(ctx *context.Context) {
 	packageName := ctx.Params("id")
 
 	pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName)
@@ -151,7 +314,7 @@ func EnumeratePackageVersions(ctx *context.Context) {
 	ctx.JSON(http.StatusOK, resp)
 }
 
-// DownloadPackageFile https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
+// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
 func DownloadPackageFile(ctx *context.Context) {
 	packageName := ctx.Params("id")
 	packageVersion := ctx.Params("version")
@@ -350,7 +513,7 @@ func processUploadedFile(ctx *context.Context, expectedType nuget_module.Package
 	return np, buf, closables
 }
 
-// DownloadSymbolFile https://github.com/dotnet/symstore/blob/main/docs/specs/Simple_Symbol_Query_Protocol.md#request
+// https://github.com/dotnet/symstore/blob/main/docs/specs/Simple_Symbol_Query_Protocol.md#request
 func DownloadSymbolFile(ctx *context.Context) {
 	filename := ctx.Params("filename")
 	guid := ctx.Params("guid")[:32]
diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go
index 8d5a5c7c82..f1f8a950c6 100644
--- a/tests/integration/api_packages_nuget_test.go
+++ b/tests/integration/api_packages_nuget_test.go
@@ -8,10 +8,13 @@ import (
 	"archive/zip"
 	"bytes"
 	"encoding/base64"
+	"encoding/xml"
 	"fmt"
 	"io"
 	"net/http"
+	"net/http/httptest"
 	"testing"
+	"time"
 
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/packages"
@@ -31,9 +34,45 @@ func addNuGetAPIKeyHeader(request *http.Request, token string) *http.Request {
 	return request
 }
 
+func decodeXML(t testing.TB, resp *httptest.ResponseRecorder, v interface{}) {
+	t.Helper()
+
+	assert.NoError(t, xml.NewDecoder(resp.Body).Decode(v))
+}
+
 func TestPackageNuGet(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
 
+	type FeedEntryProperties struct {
+		Version                  string                      `xml:"Version"`
+		NormalizedVersion        string                      `xml:"NormalizedVersion"`
+		Authors                  string                      `xml:"Authors"`
+		Dependencies             string                      `xml:"Dependencies"`
+		Description              string                      `xml:"Description"`
+		VersionDownloadCount     nuget.TypedValue[int64]     `xml:"VersionDownloadCount"`
+		DownloadCount            nuget.TypedValue[int64]     `xml:"DownloadCount"`
+		PackageSize              nuget.TypedValue[int64]     `xml:"PackageSize"`
+		Created                  nuget.TypedValue[time.Time] `xml:"Created"`
+		LastUpdated              nuget.TypedValue[time.Time] `xml:"LastUpdated"`
+		Published                nuget.TypedValue[time.Time] `xml:"Published"`
+		ProjectURL               string                      `xml:"ProjectUrl,omitempty"`
+		ReleaseNotes             string                      `xml:"ReleaseNotes,omitempty"`
+		RequireLicenseAcceptance nuget.TypedValue[bool]      `xml:"RequireLicenseAcceptance"`
+		Title                    string                      `xml:"Title"`
+	}
+
+	type FeedEntry struct {
+		XMLName    xml.Name             `xml:"entry"`
+		Properties *FeedEntryProperties `xml:"properties"`
+		Content    string               `xml:",innerxml"`
+	}
+
+	type FeedResponse struct {
+		XMLName xml.Name     `xml:"feed"`
+		Entries []*FeedEntry `xml:"entry"`
+		Count   int64        `xml:"count"`
+	}
+
 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 	token := getUserToken(t, user.Name)
 
@@ -54,9 +93,11 @@ func TestPackageNuGet(t *testing.T) {
 		<version>` + packageVersion + `</version>
 		<authors>` + packageAuthors + `</authors>
 		<description>` + packageDescription + `</description>
-		<group targetFramework=".NETStandard2.0">
-			<dependency id="Microsoft.CSharp" version="4.5.0" />
-		</group>
+		<dependencies>
+			<group targetFramework=".NETStandard2.0">
+				<dependency id="Microsoft.CSharp" version="4.5.0" />
+			</group>
+		</dependencies>
 	  </metadata>
 	</package>`))
 	archive.Close()
@@ -67,60 +108,101 @@ func TestPackageNuGet(t *testing.T) {
 	t.Run("ServiceIndex", func(t *testing.T) {
 		defer tests.PrintCurrentTest(t)()
 
-		privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate})
+		t.Run("v2", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
 
-		cases := []struct {
-			Owner        string
-			UseBasicAuth bool
-			UseTokenAuth bool
-		}{
-			{privateUser.Name, false, false},
-			{privateUser.Name, true, false},
-			{privateUser.Name, false, true},
-			{user.Name, false, false},
-			{user.Name, true, false},
-			{user.Name, false, true},
-		}
+			privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate})
 
-		for _, c := range cases {
-			url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner)
-
-			req := NewRequest(t, "GET", fmt.Sprintf("%s/index.json", url))
-			if c.UseBasicAuth {
-				req = AddBasicAuthHeader(req, user.Name)
-			} else if c.UseTokenAuth {
-				req = addNuGetAPIKeyHeader(req, token)
+			cases := []struct {
+				Owner        string
+				UseBasicAuth bool
+				UseTokenAuth bool
+			}{
+				{privateUser.Name, false, false},
+				{privateUser.Name, true, false},
+				{privateUser.Name, false, true},
+				{user.Name, false, false},
+				{user.Name, true, false},
+				{user.Name, false, true},
 			}
-			resp := MakeRequest(t, req, http.StatusOK)
 
-			var result nuget.ServiceIndexResponse
-			DecodeJSON(t, resp, &result)
+			for _, c := range cases {
+				url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner)
 
-			assert.Equal(t, "3.0.0", result.Version)
-			assert.NotEmpty(t, result.Resources)
+				req := NewRequest(t, "GET", url)
+				if c.UseBasicAuth {
+					req = AddBasicAuthHeader(req, user.Name)
+				} else if c.UseTokenAuth {
+					req = addNuGetAPIKeyHeader(req, token)
+				}
+				resp := MakeRequest(t, req, http.StatusOK)
 
-			root := setting.AppURL + url[1:]
-			for _, r := range result.Resources {
-				switch r.Type {
-				case "SearchQueryService":
-					fallthrough
-				case "SearchQueryService/3.0.0-beta":
-					fallthrough
-				case "SearchQueryService/3.0.0-rc":
-					assert.Equal(t, root+"/query", r.ID)
-				case "RegistrationsBaseUrl":
-					fallthrough
-				case "RegistrationsBaseUrl/3.0.0-beta":
-					fallthrough
-				case "RegistrationsBaseUrl/3.0.0-rc":
-					assert.Equal(t, root+"/registration", r.ID)
-				case "PackageBaseAddress/3.0.0":
-					assert.Equal(t, root+"/package", r.ID)
-				case "PackagePublish/2.0.0":
-					assert.Equal(t, root, r.ID)
+				var result nuget.ServiceIndexResponseV2
+				decodeXML(t, resp, &result)
+
+				assert.Equal(t, setting.AppURL+url[1:], result.Base)
+				assert.Equal(t, "Packages", result.Workspace.Collection.Href)
+			}
+		})
+
+		t.Run("v3", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate})
+
+			cases := []struct {
+				Owner        string
+				UseBasicAuth bool
+				UseTokenAuth bool
+			}{
+				{privateUser.Name, false, false},
+				{privateUser.Name, true, false},
+				{privateUser.Name, false, true},
+				{user.Name, false, false},
+				{user.Name, true, false},
+				{user.Name, false, true},
+			}
+
+			for _, c := range cases {
+				url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner)
+
+				req := NewRequest(t, "GET", fmt.Sprintf("%s/index.json", url))
+				if c.UseBasicAuth {
+					req = AddBasicAuthHeader(req, user.Name)
+				} else if c.UseTokenAuth {
+					req = addNuGetAPIKeyHeader(req, token)
+				}
+				resp := MakeRequest(t, req, http.StatusOK)
+
+				var result nuget.ServiceIndexResponseV3
+				DecodeJSON(t, resp, &result)
+
+				assert.Equal(t, "3.0.0", result.Version)
+				assert.NotEmpty(t, result.Resources)
+
+				root := setting.AppURL + url[1:]
+				for _, r := range result.Resources {
+					switch r.Type {
+					case "SearchQueryService":
+						fallthrough
+					case "SearchQueryService/3.0.0-beta":
+						fallthrough
+					case "SearchQueryService/3.0.0-rc":
+						assert.Equal(t, root+"/query", r.ID)
+					case "RegistrationsBaseUrl":
+						fallthrough
+					case "RegistrationsBaseUrl/3.0.0-beta":
+						fallthrough
+					case "RegistrationsBaseUrl/3.0.0-rc":
+						assert.Equal(t, root+"/registration", r.ID)
+					case "PackageBaseAddress/3.0.0":
+						assert.Equal(t, root+"/package", r.ID)
+					case "PackagePublish/2.0.0":
+						assert.Equal(t, root, r.ID)
+					}
 				}
 			}
-		}
+		})
 	})
 
 	t.Run("Upload", func(t *testing.T) {
@@ -305,17 +387,57 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
 			{"test", 1, 10, 1, 0},
 		}
 
-		for i, c := range cases {
-			req := NewRequest(t, "GET", fmt.Sprintf("%s/query?q=%s&skip=%d&take=%d", url, c.Query, c.Skip, c.Take))
-			req = AddBasicAuthHeader(req, user.Name)
-			resp := MakeRequest(t, req, http.StatusOK)
+		t.Run("v2", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
 
-			var result nuget.SearchResultResponse
-			DecodeJSON(t, resp, &result)
+			t.Run("Search()", func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
 
-			assert.Equal(t, c.ExpectedTotal, result.TotalHits, "case %d: unexpected total hits", i)
-			assert.Len(t, result.Data, c.ExpectedResults, "case %d: unexpected result count", i)
-		}
+				for i, c := range cases {
+					req := NewRequest(t, "GET", fmt.Sprintf("%s/Search()?searchTerm='%s'&skip=%d&take=%d", url, c.Query, c.Skip, c.Take))
+					req = AddBasicAuthHeader(req, user.Name)
+					resp := MakeRequest(t, req, http.StatusOK)
+
+					var result FeedResponse
+					decodeXML(t, resp, &result)
+
+					assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i)
+					assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i)
+				}
+			})
+
+			t.Run("Packages()", func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
+
+				for i, c := range cases {
+					req := NewRequest(t, "GET", fmt.Sprintf("%s/Search()?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take))
+					req = AddBasicAuthHeader(req, user.Name)
+					resp := MakeRequest(t, req, http.StatusOK)
+
+					var result FeedResponse
+					decodeXML(t, resp, &result)
+
+					assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i)
+					assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i)
+				}
+			})
+		})
+
+		t.Run("v3", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			for i, c := range cases {
+				req := NewRequest(t, "GET", fmt.Sprintf("%s/query?q=%s&skip=%d&take=%d", url, c.Query, c.Skip, c.Take))
+				req = AddBasicAuthHeader(req, user.Name)
+				resp := MakeRequest(t, req, http.StatusOK)
+
+				var result nuget.SearchResultResponse
+				DecodeJSON(t, resp, &result)
+
+				assert.Equal(t, c.ExpectedTotal, result.TotalHits, "case %d: unexpected total hits", i)
+				assert.Len(t, result.Data, c.ExpectedResults, "case %d: unexpected result count", i)
+			}
+		})
 	})
 
 	t.Run("RegistrationService", func(t *testing.T) {
@@ -352,31 +474,70 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
 		t.Run("RegistrationLeaf", func(t *testing.T) {
 			defer tests.PrintCurrentTest(t)()
 
-			req := NewRequest(t, "GET", fmt.Sprintf("%s/registration/%s/%s.json", url, packageName, packageVersion))
-			req = AddBasicAuthHeader(req, user.Name)
-			resp := MakeRequest(t, req, http.StatusOK)
+			t.Run("v2", func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
 
-			var result nuget.RegistrationLeafResponse
-			DecodeJSON(t, resp, &result)
+				req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", url, packageName, packageVersion))
+				req = AddBasicAuthHeader(req, user.Name)
+				resp := MakeRequest(t, req, http.StatusOK)
 
-			assert.Equal(t, leafURL, result.RegistrationLeafURL)
-			assert.Equal(t, contentURL, result.PackageContentURL)
-			assert.Equal(t, indexURL, result.RegistrationIndexURL)
+				var result FeedEntry
+				decodeXML(t, resp, &result)
+
+				assert.Equal(t, packageName, result.Properties.Title)
+				assert.Equal(t, packageVersion, result.Properties.Version)
+				assert.Equal(t, packageAuthors, result.Properties.Authors)
+				assert.Equal(t, packageDescription, result.Properties.Description)
+				assert.Equal(t, "Microsoft.CSharp:4.5.0:.NETStandard2.0", result.Properties.Dependencies)
+			})
+
+			t.Run("v3", func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
+
+				req := NewRequest(t, "GET", fmt.Sprintf("%s/registration/%s/%s.json", url, packageName, packageVersion))
+				req = AddBasicAuthHeader(req, user.Name)
+				resp := MakeRequest(t, req, http.StatusOK)
+
+				var result nuget.RegistrationLeafResponse
+				DecodeJSON(t, resp, &result)
+
+				assert.Equal(t, leafURL, result.RegistrationLeafURL)
+				assert.Equal(t, contentURL, result.PackageContentURL)
+				assert.Equal(t, indexURL, result.RegistrationIndexURL)
+			})
 		})
 	})
 
 	t.Run("PackageService", func(t *testing.T) {
 		defer tests.PrintCurrentTest(t)()
 
-		req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/index.json", url, packageName))
-		req = AddBasicAuthHeader(req, user.Name)
-		resp := MakeRequest(t, req, http.StatusOK)
+		t.Run("v2", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
 
-		var result nuget.PackageVersionsResponse
-		DecodeJSON(t, resp, &result)
+			req := NewRequest(t, "GET", fmt.Sprintf("%s/FindPackagesById()?id='%s'", url, packageName))
+			req = AddBasicAuthHeader(req, user.Name)
+			resp := MakeRequest(t, req, http.StatusOK)
 
-		assert.Len(t, result.Versions, 1)
-		assert.Equal(t, packageVersion, result.Versions[0])
+			var result FeedResponse
+			decodeXML(t, resp, &result)
+
+			assert.Len(t, result.Entries, 1)
+			assert.Equal(t, packageVersion, result.Entries[0].Properties.Version)
+		})
+
+		t.Run("v3", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/index.json", url, packageName))
+			req = AddBasicAuthHeader(req, user.Name)
+			resp := MakeRequest(t, req, http.StatusOK)
+
+			var result nuget.PackageVersionsResponse
+			DecodeJSON(t, resp, &result)
+
+			assert.Len(t, result.Versions, 1)
+			assert.Equal(t, packageVersion, result.Versions[0])
+		})
 	})
 
 	t.Run("Delete", func(t *testing.T) {