Skip to content

Add support for Chocolatey/NuGet v2 API #21393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 13, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/doc/packages/nuget.en-us.md
Original file line number Diff line number Diff line change
@@ -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**

26 changes: 14 additions & 12 deletions modules/packages/nuget/metadata.go
Original file line number Diff line number Diff line change
@@ -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 {
16 changes: 12 additions & 4 deletions routers/api/packages/api.go
Original file line number Diff line number Diff line change
@@ -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() {
393 changes: 393 additions & 0 deletions routers/api/packages/nuget/api_v2.go
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -16,44 +16,27 @@ 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"`
Count int `json:"count"`
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"`
5 changes: 5 additions & 0 deletions routers/api/packages/nuget/links.go
Original file line number Diff line number Diff line change
@@ -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)
}
189 changes: 176 additions & 13 deletions routers/api/packages/nuget/nuget.go
Original file line number Diff line number Diff line change
@@ -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]
307 changes: 234 additions & 73 deletions tests/integration/api_packages_nuget_test.go
Original file line number Diff line number Diff line change
@@ -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})

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) {