diff --git a/docs/content/doc/packages/nuget.en-us.md b/docs/content/doc/packages/nuget.en-us.md index 6c8aaa70af1d3..670abca7fd594 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 797bff45ac632..2b555e47e95cf 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 a54add0621ed1..f6ab961f5ec5d 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 0000000000000..60a5d9c0e4886 --- /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 := `` + + 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 964e05f9269c0..552054f26b2e3 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 f782c7f2cbc1c..618b54ae8dee9 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 3c61ae28bb18f..e84aef3160f02 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") +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) + } +} - ctx.JSON(http.StatusOK, resp) +// 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) } -// SearchService https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-for-packages -func SearchService(ctx *context.Context) { +// 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 8d5a5c7c82371..f1f8a950c605d 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) { ` + packageVersion + ` ` + packageAuthors + ` ` + packageDescription + ` - - - + + + + + `)) 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}) + + 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) + 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) + 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) + + 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) } - resp := MakeRequest(t, req, http.StatusOK) + }) - var result nuget.ServiceIndexResponse - DecodeJSON(t, resp, &result) + 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) - 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) + 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)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/FindPackagesById()?id='%s'", url, packageName)) + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) - var result nuget.PackageVersionsResponse - DecodeJSON(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)() - assert.Len(t, result.Versions, 1) - assert.Equal(t, packageVersion, result.Versions[0]) + 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) {