From f88800d54505f30dbae089d446e292f176222d99 Mon Sep 17 00:00:00 2001 From: Scion Date: Mon, 7 Jul 2025 03:43:58 -0700 Subject: [PATCH] Improve NuGet API Parity (#21291) (#34940) Fixes #21291, allowing icons and other missing attributes to appear for NuGet packages from inside Visual Studio like they do with GitHub Nuget packages. Adds additional NuGet package information, particularly `IconURL`, to bring the Gitea NuGet API more in-line with GitHub's NuGet API. ref: https://learn.microsoft.com/en-us/nuget/api/search-query-service-resource --- models/packages/package_version.go | 8 ++ modules/packages/nuget/metadata.go | 3 + routers/api/packages/nuget/api_v3.go | 144 +++++++++++++------ tests/integration/api_packages_nuget_test.go | 88 +++++++++--- 4 files changed, 184 insertions(+), 59 deletions(-) diff --git a/models/packages/package_version.go b/models/packages/package_version.go index 5672e0efbf..0a478c0323 100644 --- a/models/packages/package_version.go +++ b/models/packages/package_version.go @@ -37,6 +37,14 @@ type PackageVersion struct { DownloadCount int64 `xorm:"NOT NULL DEFAULT 0"` } +// IsPrerelease checks if the version is a prerelease version according to semantic versioning +func (pv *PackageVersion) IsPrerelease() bool { + if pv == nil || pv.Version == "" { + return false + } + return strings.Contains(pv.Version, "-") +} + // GetOrInsertVersion inserts a version. If the same version exist already ErrDuplicatePackageVersion is returned func GetOrInsertVersion(ctx context.Context, pv *PackageVersion) (*PackageVersion, error) { e := db.GetEngine(ctx) diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go index a122590bf1..513b4dd2b9 100644 --- a/modules/packages/nuget/metadata.go +++ b/modules/packages/nuget/metadata.go @@ -71,6 +71,7 @@ type Metadata struct { ReleaseNotes string `json:"release_notes,omitempty"` RepositoryURL string `json:"repository_url,omitempty"` RequireLicenseAcceptance bool `json:"require_license_acceptance"` + Summary string `json:"summary,omitempty"` Tags string `json:"tags,omitempty"` Title string `json:"title,omitempty"` @@ -105,6 +106,7 @@ type nuspecPackage struct { Readme string `xml:"readme"` ReleaseNotes string `xml:"releaseNotes"` RequireLicenseAcceptance bool `xml:"requireLicenseAcceptance"` + Summary string `xml:"summary"` Tags string `xml:"tags"` Title string `xml:"title"` @@ -204,6 +206,7 @@ func ParseNuspecMetaData(archive *zip.Reader, r io.Reader) (*Package, error) { ReleaseNotes: p.Metadata.ReleaseNotes, RepositoryURL: p.Metadata.Repository.URL, RequireLicenseAcceptance: p.Metadata.RequireLicenseAcceptance, + Summary: p.Metadata.Summary, Tags: p.Metadata.Tags, Title: p.Metadata.Title, diff --git a/routers/api/packages/nuget/api_v3.go b/routers/api/packages/nuget/api_v3.go index 2fe25dc0f8..3262f2d9af 100644 --- a/routers/api/packages/nuget/api_v3.go +++ b/routers/api/packages/nuget/api_v3.go @@ -53,15 +53,23 @@ type RegistrationIndexPageItem struct { // 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"` - ID string `json:"id"` - Version string `json:"version"` - Description string `json:"description"` - ReleaseNotes string `json:"releaseNotes"` Authors string `json:"authors"` - RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` - ProjectURL string `json:"projectURL"` + Copyright string `json:"copyright"` DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"` + Description string `json:"description"` + IconURL string `json:"iconUrl"` + ID string `json:"id"` + IsPrerelease bool `json:"isPrerelease"` + Language string `json:"language"` + LicenseURL string `json:"licenseUrl"` + PackageContentURL string `json:"packageContent"` + ProjectURL string `json:"projectUrl"` + RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` + Summary string `json:"summary"` + Tags string `json:"tags"` + Version string `json:"version"` + ReleaseNotes string `json:"releaseNotes"` + Published time.Time `json:"published"` } // https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group @@ -109,15 +117,24 @@ func createRegistrationIndexPageItem(l *linkBuilder, pd *packages_model.PackageD RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), CatalogEntry: &CatalogEntry{ - CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), - PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), - ID: pd.Package.Name, - Version: pd.Version.Version, - Description: metadata.Description, - ReleaseNotes: metadata.ReleaseNotes, - Authors: metadata.Authors, - ProjectURL: metadata.ProjectURL, - DependencyGroups: createDependencyGroups(pd), + CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), + Authors: metadata.Authors, + Copyright: metadata.Copyright, + DependencyGroups: createDependencyGroups(pd), + Description: metadata.Description, + IconURL: metadata.IconURL, + ID: pd.Package.Name, + IsPrerelease: pd.Version.IsPrerelease(), + Language: metadata.Language, + LicenseURL: metadata.LicenseURL, + PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + ProjectURL: metadata.ProjectURL, + RequireLicenseAcceptance: metadata.RequireLicenseAcceptance, + Summary: metadata.Summary, + Tags: metadata.Tags, + Version: pd.Version.Version, + ReleaseNotes: metadata.ReleaseNotes, + Published: pd.Version.CreatedUnix.AsLocalTime(), }, } } @@ -145,22 +162,42 @@ func createDependencyGroups(pd *packages_model.PackageDescriptor) []*PackageDepe // 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"` - Listed bool `json:"listed"` - PackageContentURL string `json:"packageContent"` - Published time.Time `json:"published"` - RegistrationIndexURL string `json:"registration"` + RegistrationLeafURL string `json:"@id"` + Type []string `json:"@type"` + PackageContentURL string `json:"packageContent"` + RegistrationIndexURL string `json:"registration"` + CatalogEntry CatalogEntry `json:"catalogEntry"` } func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationLeafResponse { + registrationLeafURL := l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version) + packageDownloadURL := l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version) + metadata := pd.Metadata.(*nuget_module.Metadata) return &RegistrationLeafResponse{ - Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"}, - Listed: true, - Published: pd.Version.CreatedUnix.AsLocalTime(), - RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), - PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + RegistrationLeafURL: registrationLeafURL, RegistrationIndexURL: l.GetRegistrationIndexURL(pd.Package.Name), + PackageContentURL: packageDownloadURL, + Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"}, + CatalogEntry: CatalogEntry{ + CatalogLeafURL: registrationLeafURL, + Authors: metadata.Authors, + Copyright: metadata.Copyright, + DependencyGroups: createDependencyGroups(pd), + Description: metadata.Description, + IconURL: metadata.IconURL, + ID: pd.Package.Name, + IsPrerelease: pd.Version.IsPrerelease(), + Language: metadata.Language, + LicenseURL: metadata.LicenseURL, + PackageContentURL: packageDownloadURL, + ProjectURL: metadata.ProjectURL, + RequireLicenseAcceptance: metadata.RequireLicenseAcceptance, + Summary: metadata.Summary, + Tags: metadata.Tags, + Version: pd.Version.Version, + ReleaseNotes: metadata.ReleaseNotes, + Published: pd.Version.CreatedUnix.AsLocalTime(), + }, } } @@ -188,13 +225,24 @@ type SearchResultResponse struct { // 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"` - Versions []*SearchResultVersion `json:"versions"` - Description string `json:"description"` - Authors string `json:"authors"` - ProjectURL string `json:"projectURL"` - RegistrationIndexURL string `json:"registration"` + Authors string `json:"authors"` + Copyright string `json:"copyright"` + DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"` + Description string `json:"description"` + IconURL string `json:"iconUrl"` + ID string `json:"id"` + IsPrerelease bool `json:"isPrerelease"` + Language string `json:"language"` + LicenseURL string `json:"licenseUrl"` + ProjectURL string `json:"projectUrl"` + RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` + Summary string `json:"summary"` + Tags string `json:"tags"` + Title string `json:"title"` + TotalDownloads int64 `json:"totalDownloads"` + Version string `json:"version"` + Versions []*SearchResultVersion `json:"versions"` + RegistrationIndexURL string `json:"registration"` } // https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result @@ -230,11 +278,12 @@ func createSearchResultResponse(l *linkBuilder, totalHits int64, pds []*packages func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) *SearchResult { latest := pds[0] versions := make([]*SearchResultVersion, 0, len(pds)) + totalDownloads := int64(0) for _, pd := range pds { if latest.SemVer.LessThan(pd.SemVer) { latest = pd } - + totalDownloads += pd.Version.DownloadCount versions = append(versions, &SearchResultVersion{ RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), Version: pd.Version.Version, @@ -244,12 +293,23 @@ func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) metadata := latest.Metadata.(*nuget_module.Metadata) return &SearchResult{ - ID: latest.Package.Name, - Version: latest.Version.Version, - Versions: versions, - Description: metadata.Description, - Authors: metadata.Authors, - ProjectURL: metadata.ProjectURL, - RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name), + Authors: metadata.Authors, + Copyright: metadata.Copyright, + Description: metadata.Description, + DependencyGroups: createDependencyGroups(latest), + IconURL: metadata.IconURL, + ID: latest.Package.Name, + IsPrerelease: latest.Version.IsPrerelease(), + Language: metadata.Language, + LicenseURL: metadata.LicenseURL, + ProjectURL: metadata.ProjectURL, + RequireLicenseAcceptance: metadata.RequireLicenseAcceptance, + Summary: metadata.Summary, + Tags: metadata.Tags, + Title: metadata.Title, + TotalDownloads: totalDownloads, + Version: latest.Version.Version, + Versions: versions, + RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name), } } diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go index 65b1b9845a..529b540062 100644 --- a/tests/integration/api_packages_nuget_test.go +++ b/tests/integration/api_packages_nuget_test.go @@ -14,6 +14,7 @@ import ( "net/http/httptest" neturl "net/url" "strconv" + "strings" "testing" "time" @@ -100,6 +101,7 @@ func TestPackageNuGet(t *testing.T) { packageVersion := "1.0.3" packageAuthors := "KN4CK3R" packageDescription := "Gitea Test Package" + isPrerelease := strings.Contains(packageVersion, "-") symbolFilename := "test.pdb" symbolID := "d910bb6948bd4c6cb40155bcf52c3c94" @@ -112,11 +114,17 @@ func TestPackageNuGet(t *testing.T) { packageOwners := "Package Owners" packageProjectURL := "https://gitea.io" packageReleaseNotes := "Package Release Notes" + summary := "This is a test package." packageTags := "tag_1 tag_2 tag_3" packageTitle := "Package Title" packageDevelopmentDependency := true packageRequireLicenseAcceptance := true + dependencyCount := 1 + dependencyTargetFramework := ".NETStandard2.0" + dependencyID := "Microsoft.CSharp" + dependencyVersion := "4.5.0" + createNuspec := func(id, version string) string { return ` @@ -133,12 +141,13 @@ func TestPackageNuGet(t *testing.T) { ` + packageProjectURL + ` ` + packageReleaseNotes + ` true + ` + summary + ` ` + packageTags + ` ` + packageTitle + ` ` + version + ` - - + + @@ -428,7 +437,7 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) assert.NoError(t, err) - assert.Equal(t, int64(610), pb.Size) + assert.Equal(t, int64(633), pb.Size) case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion): assert.False(t, pf.IsLead) @@ -440,7 +449,7 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) assert.NoError(t, err) - assert.Equal(t, int64(996), pb.Size) + assert.Equal(t, int64(1043), pb.Size) case symbolFilename: assert.False(t, pf.IsLead) @@ -747,17 +756,39 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) assert.Equal(t, indexURL, result.RegistrationIndexURL) assert.Equal(t, 1, result.Count) assert.Len(t, result.Pages, 1) - assert.Equal(t, indexURL, result.Pages[0].RegistrationPageURL) - assert.Equal(t, packageVersion, result.Pages[0].Lower) - assert.Equal(t, packageVersion, result.Pages[0].Upper) - assert.Equal(t, 1, result.Pages[0].Count) - assert.Len(t, result.Pages[0].Items, 1) - assert.Equal(t, packageName, result.Pages[0].Items[0].CatalogEntry.ID) - assert.Equal(t, packageVersion, result.Pages[0].Items[0].CatalogEntry.Version) - assert.Equal(t, packageAuthors, result.Pages[0].Items[0].CatalogEntry.Authors) - assert.Equal(t, packageDescription, result.Pages[0].Items[0].CatalogEntry.Description) - assert.Equal(t, leafURL, result.Pages[0].Items[0].CatalogEntry.CatalogLeafURL) - assert.Equal(t, contentURL, result.Pages[0].Items[0].CatalogEntry.PackageContentURL) + + page := result.Pages[0] + assert.Equal(t, indexURL, page.RegistrationPageURL) + assert.Equal(t, packageVersion, page.Lower) + assert.Equal(t, packageVersion, page.Upper) + assert.Equal(t, 1, page.Count) + assert.Len(t, page.Items, 1) + + item := page.Items[0] + assert.Equal(t, packageName, item.CatalogEntry.ID) + assert.Equal(t, packageVersion, item.CatalogEntry.Version) + assert.Equal(t, packageAuthors, item.CatalogEntry.Authors) + assert.Equal(t, packageDescription, item.CatalogEntry.Description) + assert.Equal(t, leafURL, item.CatalogEntry.CatalogLeafURL) + assert.Equal(t, contentURL, item.CatalogEntry.PackageContentURL) + assert.Equal(t, packageIconURL, item.CatalogEntry.IconURL) + assert.Equal(t, packageLanguage, item.CatalogEntry.Language) + assert.Equal(t, packageLicenseURL, item.CatalogEntry.LicenseURL) + assert.Equal(t, packageProjectURL, item.CatalogEntry.ProjectURL) + assert.Equal(t, packageReleaseNotes, item.CatalogEntry.ReleaseNotes) + assert.Equal(t, packageRequireLicenseAcceptance, item.CatalogEntry.RequireLicenseAcceptance) + assert.Equal(t, packageTags, item.CatalogEntry.Tags) + assert.Equal(t, summary, item.CatalogEntry.Summary) + assert.Equal(t, isPrerelease, item.CatalogEntry.IsPrerelease) + assert.Len(t, item.CatalogEntry.DependencyGroups, dependencyCount) + + dependencyGroup := item.CatalogEntry.DependencyGroups[0] + assert.Equal(t, dependencyTargetFramework, dependencyGroup.TargetFramework) + assert.Len(t, dependencyGroup.Dependencies, dependencyCount) + + dependency := dependencyGroup.Dependencies[0] + assert.Equal(t, dependencyID, dependency.ID) + assert.Equal(t, dependencyVersion, dependency.Range) }) t.Run("RegistrationLeaf", func(t *testing.T) { @@ -789,7 +820,8 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) assert.Equal(t, packageTags, result.Properties.Tags) assert.Equal(t, packageTitle, result.Properties.Title) - assert.Equal(t, "Microsoft.CSharp:4.5.0:.NETStandard2.0", result.Properties.Dependencies) + packageVersion := strings.Join([]string{dependencyID, dependencyVersion, dependencyTargetFramework}, ":") + assert.Equal(t, packageVersion, result.Properties.Dependencies) }) t.Run("v3", func(t *testing.T) { @@ -803,8 +835,30 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) DecodeJSON(t, resp, &result) assert.Equal(t, leafURL, result.RegistrationLeafURL) - assert.Equal(t, contentURL, result.PackageContentURL) assert.Equal(t, indexURL, result.RegistrationIndexURL) + assert.Equal(t, packageAuthors, result.CatalogEntry.Authors) + assert.Equal(t, packageCopyright, result.CatalogEntry.Copyright) + + dependencyGroup := result.CatalogEntry.DependencyGroups[0] + assert.Equal(t, dependencyTargetFramework, dependencyGroup.TargetFramework) + assert.Len(t, dependencyGroup.Dependencies, dependencyCount) + + dependency := dependencyGroup.Dependencies[0] + assert.Equal(t, dependencyID, dependency.ID) + assert.Equal(t, dependencyVersion, dependency.Range) + + assert.Equal(t, packageDescription, result.CatalogEntry.Description) + assert.Equal(t, packageID, result.CatalogEntry.ID) + assert.Equal(t, packageIconURL, result.CatalogEntry.IconURL) + assert.Equal(t, isPrerelease, result.CatalogEntry.IsPrerelease) + assert.Equal(t, packageLanguage, result.CatalogEntry.Language) + assert.Equal(t, packageLicenseURL, result.CatalogEntry.LicenseURL) + assert.Equal(t, contentURL, result.PackageContentURL) + assert.Equal(t, packageProjectURL, result.CatalogEntry.ProjectURL) + assert.Equal(t, packageRequireLicenseAcceptance, result.CatalogEntry.RequireLicenseAcceptance) + assert.Equal(t, summary, result.CatalogEntry.Summary) + assert.Equal(t, packageTags, result.CatalogEntry.Tags) + assert.Equal(t, packageVersion, result.CatalogEntry.Version) }) }) })