feat:[AH-1093]: NPM search package support (#3711)

* feat:[AH-1093]: NPM search package support
* Merge branch 'main' of https://git0.harness.io/l7B_kbSEQD2wjrM7PShm5w/PROD/Harness_Commons/gitness
* Merge branch 'main' of https://git0.harness.io/l7B_kbSEQD2wjrM7PShm5w/PROD/Harness_Commons/gitness
* Merge branch 'main' of https://git0.harness.io/l7B_kbSEQD2wjrM7PShm5w/PROD/Harness_Commons/gitness
* Merge branch 'release/registry-api_1.18.0' of https://git0.harness.io/l7B_kbSEQD2wjrM7PShm5w/PROD/Harness_Commons/gitness
* feat:[AH-1083]: url fix (#3669)

* feat:[AH-1083]: url fix
This commit is contained in:
Sourabh Awashti 2025-04-28 06:53:28 +00:00 committed by Harness
parent 3e07a681a1
commit ca888e532b
12 changed files with 356 additions and 7 deletions

View File

@ -80,6 +80,12 @@ type Controller interface {
ctx context.Context,
info npm.ArtifactInfo,
) *DeleteEntityResponse
SearchPackage(
ctx context.Context,
info npm.ArtifactInfo,
limit int, offset int,
) *SearchArtifactResponse
}
// NewController creates a new PyPI controller.

View File

@ -81,3 +81,12 @@ type DeleteEntityResponse struct {
func (r *DeleteEntityResponse) GetError() error {
return r.Error
}
type SearchArtifactResponse struct {
BaseResponse
Artifacts *npm2.PackageSearch
}
func (r *SearchArtifactResponse) GetError() error {
return r.Error
}

View File

@ -0,0 +1,66 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package npm
import (
"context"
"fmt"
"github.com/harness/gitness/registry/app/pkg"
"github.com/harness/gitness/registry/app/pkg/base"
"github.com/harness/gitness/registry/app/pkg/commons"
npm2 "github.com/harness/gitness/registry/app/pkg/npm"
"github.com/harness/gitness/registry/app/pkg/response"
"github.com/harness/gitness/registry/app/pkg/types/npm"
"github.com/harness/gitness/registry/types"
)
func (c *controller) SearchPackage(
ctx context.Context,
info npm.ArtifactInfo,
limit int, offset int,
) *SearchArtifactResponse {
f := func(registry types.Registry, a pkg.Artifact) response.Response {
info.RegIdentifier = registry.Name
info.RegistryID = registry.ID
info.ParentID = registry.ParentID
info.Registry = registry
npmRegistry, ok := a.(npm2.Registry)
if !ok {
return &GetMetadataResponse{
BaseResponse: BaseResponse{Error: fmt.Errorf("invalid registry type: expected npm.Registry")},
}
}
artifacts, err := npmRegistry.SearchPackage(ctx, info, limit, offset)
return &SearchArtifactResponse{
BaseResponse: BaseResponse{Error: err},
Artifacts: artifacts,
}
}
result, err := base.NoProxyWrapper(ctx, c.registryDao, f, info)
if !commons.IsEmpty(err) {
return &SearchArtifactResponse{
BaseResponse: BaseResponse{Error: err},
}
}
metadataResponse, ok := result.(*SearchArtifactResponse)
if !ok {
return &SearchArtifactResponse{
BaseResponse: BaseResponse{Error: fmt.Errorf("invalid response type: expected GetMetadataResponse, got %T", result)},
}
}
return metadataResponse
}

View File

@ -56,6 +56,7 @@ type Handler interface {
DeletePackage(w http.ResponseWriter, r *http.Request)
DeleteVersion(w http.ResponseWriter, r *http.Request)
DeletePreview(w http.ResponseWriter, r *http.Request)
SearchPackage(w http.ResponseWriter, r *http.Request)
}
type handler struct {

View File

@ -0,0 +1,66 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package npm
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
errors2 "github.com/harness/gitness/errors"
"github.com/harness/gitness/registry/app/pkg/commons"
npm2 "github.com/harness/gitness/registry/app/pkg/types/npm"
"github.com/harness/gitness/registry/request"
"github.com/rs/zerolog/log"
)
func (h *handler) SearchPackage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
info, ok := request.ArtifactInfoFrom(ctx).(npm2.ArtifactInfo)
if !ok {
log.Ctx(ctx).Error().Msg("Failed to get npm artifact info from context")
h.HandleErrors(r.Context(), []error{fmt.Errorf("failed to fetch npm artifact info from context")}, w)
return
}
info.Image = r.FormValue("text")
limit, err := strconv.Atoi(r.FormValue("size"))
if err != nil {
limit = 20
}
offset, err := strconv.Atoi(r.FormValue("from"))
if err != nil {
offset = 0
}
response := h.controller.SearchPackage(ctx, info, limit, offset)
if !commons.IsEmpty(response.GetError()) {
if errors2.IsNotFound(response.GetError()) {
http.Error(w, response.GetError().Error(), http.StatusNotFound)
return
}
http.Error(w, response.GetError().Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(response.Artifacts)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

View File

@ -197,6 +197,12 @@ func NewRouter(
r.Route("/{id}/-rev/{revision}", func(r chi.Router) {
registerRevisionRoutes(r, npmHandler, packageHandler)
})
r.Route("/-/v1/search", func(r chi.Router) {
r.With(middleware.StoreArtifactInfo(npmHandler)).
With(middleware.RequestPackageAccess(packageHandler, enum.PermissionArtifactsDownload)).
Get("/", npmHandler.SearchPackage)
})
})
r.Route("/rpm", func(r chi.Router) {
r.Use(middlewareauthn.Attempt(packageHandler.GetAuthenticator()))

View File

@ -110,12 +110,12 @@ type PackageSearchPackage struct {
Scope string `json:"scope"`
Name string `json:"name"`
Version string `json:"version"`
Date time.Time `json:"date"`
Description string `json:"description"`
Author User `json:"author"`
Publisher User `json:"publisher"`
Maintainers []User `json:"maintainers"`
Keywords []string `json:"keywords,omitempty"`
Date interface{} `json:"date"`
Description interface{} `json:"description"`
Author interface{} `json:"author"`
Publisher interface{} `json:"publisher"`
Maintainers interface{} `json:"maintainers"`
Keywords interface{} `json:"keywords,omitempty"`
Links *PackageSearchPackageLinks `json:"links"`
}
@ -132,7 +132,7 @@ type User struct {
URL string `json:"url,omitempty"`
}
// PythonMetadata represents the metadata for a Python package.
// NpmMetadata represents the metadata for a Python package.
//
//nolint:revive
type NpmMetadata struct {

View File

@ -19,6 +19,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/harness/gitness/app/api/usererror"
urlprovider "github.com/harness/gitness/app/url"
@ -165,6 +166,98 @@ func (c *localRegistry) GetPackageMetadata(ctx context.Context, info npm.Artifac
return packageMetadata, nil
}
func (c *localRegistry) SearchPackage(ctx context.Context, info npm.ArtifactInfo,
limit int, offset int) (*npm2.PackageSearch, error) {
metadataList, err := c.artifactDao.SearchLatestByName(ctx, info.RegistryID, info.Image, limit, offset)
if err != nil {
log.Err(err).Msgf("Failed to search package for search term: [%s]", info.Image)
return &npm2.PackageSearch{}, err
}
count, err := c.artifactDao.CountLatestByName(ctx, info.RegistryID, info.Image)
if err != nil {
log.Err(err).Msgf("Failed to search package for search term: [%s]", info.Image)
return &npm2.PackageSearch{}, err
}
psList := make([]*npm2.PackageSearchObject, 0)
registryURL := c.urlProvider.PackageURL(ctx,
info.BaseArtifactInfo().RootIdentifier+"/"+info.BaseArtifactInfo().RegIdentifier, "npm")
for _, metadata := range *metadataList {
pso, err := mapToPackageSearch(metadata, registryURL)
if err != nil {
log.Err(err).Msgf("Failed to map search package results: [%s]", info.Image)
return &npm2.PackageSearch{}, err
}
psList = append(psList, pso)
}
return &npm2.PackageSearch{
Objects: psList,
Total: count,
}, nil
}
func mapToPackageSearch(metadata types.Artifact, registryURL string) (*npm2.PackageSearchObject, error) {
var art *npm2.NpmMetadata
if err := json.Unmarshal(metadata.Metadata, &art); err != nil {
return &npm2.PackageSearchObject{}, err
}
for _, version := range art.Versions {
var author npm2.User
if version.Author != nil {
data, err := json.Marshal(version.Author)
if err != nil {
log.Err(err).Msgf("Failed to marshal search package results: [%s]", art.Name)
return &npm2.PackageSearchObject{}, err
}
err = json.Unmarshal(data, &author)
if err != nil {
log.Err(err).Msgf("Failed to unmarshal search package results: [%s]", art.Name)
return &npm2.PackageSearchObject{}, err
}
}
return &npm2.PackageSearchObject{
Package: &npm2.PackageSearchPackage{
Name: version.Name,
Version: version.Version,
Description: version.Description,
Date: metadata.CreatedAt,
Scope: getScope(art.Name),
Author: npm2.User{Username: author.Name},
Publisher: npm2.User{Username: author.Name},
Maintainers: getValueOrDefault(version.Maintainers, []npm2.User{}), // npm cli needs this field
Keywords: getValueOrDefault(version.Keywords, []string{}),
Links: &npm2.PackageSearchPackageLinks{
Registry: registryURL,
Homepage: registryURL,
Repository: registryURL,
},
},
}, nil
}
return &npm2.PackageSearchObject{}, fmt.Errorf("no version found in the metadata for image:[%s]", art.Name)
}
func getValueOrDefault(value interface{}, defaultValue interface{}) interface{} {
if value != nil {
return value
}
return defaultValue
}
func getScope(name string) string {
if strings.HasPrefix(name, "@") {
if i := strings.Index(name, "/"); i != -1 {
return name[1:i] // Strip @ and return only the scope
}
}
return "unscoped"
}
func CreatePackageMetadataVersion(registryURL string,
metadata *npm2.PackageMetadataVersion) *npm2.PackageMetadataVersion {
return &npm2.PackageMetadataVersion{

View File

@ -53,6 +53,10 @@ type proxy struct {
localRegistryHelper LocalRegistryHelper
}
func (r *proxy) SearchPackage(_ context.Context, _ npm2.ArtifactInfo, _ int, _ int) (*npm.PackageSearch, error) {
return nil, commons.ErrNotSupported
}
func (r *proxy) UploadPackageFileReader(_ context.Context,
_ npm2.ArtifactInfo) (*commons.ResponseHeaders, string, error) {
return nil, " ", commons.ErrNotSupported

View File

@ -53,4 +53,6 @@ type Registry interface {
DeletePackage(ctx context.Context, info npm.ArtifactInfo) error
DeleteVersion(ctx context.Context, info npm.ArtifactInfo) error
SearchPackage(ctx context.Context, info npm.ArtifactInfo, limit int, offset int) (*npm3.PackageSearch, error)
}

View File

@ -499,6 +499,14 @@ type ArtifactRepository interface {
GetAllArtifactsByRepo(
ctx context.Context, registryID int64, batchSize int, artifactID int64,
) (*[]types.ArtifactMetadata, error)
SearchLatestByName(
ctx context.Context, regID int64, name string, limit int, offset int,
) (*[]types.Artifact, error)
CountLatestByName(
ctx context.Context, regID int64, name string,
) (int64, error)
}
type DownloadStatRepository interface {

View File

@ -292,6 +292,80 @@ func (a ArtifactDao) mapToArtifact(_ context.Context, dst *artifactDB) (*types.A
}, nil
}
func (a ArtifactDao) SearchLatestByName(
ctx context.Context, regID int64, name string, limit int, offset int,
) (*[]types.Artifact, error) {
subQuery := `
SELECT artifact_image_id, MAX(artifact_created_at) AS max_created_at
FROM artifacts
GROUP BY artifact_image_id`
q := databaseg.Builder.
Select("a.artifact_metadata,"+
"a.artifact_created_at").
From("artifacts a").
Join("images i ON a.artifact_image_id = i.image_id").
Join(fmt.Sprintf(`(%s) latest
ON a.artifact_image_id = latest.artifact_image_id
AND a.artifact_created_at = latest.max_created_at
`, subQuery)).
Where("i.image_name LIKE ? AND i.image_registry_id = ?", "%"+name+"%", regID).
Limit(util.SafeIntToUInt64(limit)).
Offset(util.SafeIntToUInt64(offset))
sql, args, err := q.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to build SQL for latest artifact metadata with pagination")
}
db := dbtx.GetAccessor(ctx, a.db)
var metadataList []*artifactDB
if err := db.SelectContext(ctx, &metadataList, sql, args...); err != nil {
return nil, databaseg.ProcessSQLErrorf(ctx, err, "Failed to get artifact metadata")
}
artifactList, err := a.mapArtifactToArtifactMetadataList(ctx, metadataList)
if err != nil {
return nil, errors.Wrap(err, "Failed to map artifact metadata")
}
return artifactList, nil
}
func (a ArtifactDao) CountLatestByName(
ctx context.Context, regID int64, name string,
) (int64, error) {
subQuery := `
SELECT artifact_image_id, MAX(artifact_created_at) AS max_created_at
FROM artifacts
GROUP BY artifact_image_id`
// Main count query
q := databaseg.Builder.
Select("COUNT(*)").
From("artifacts a").
Join("images i ON a.artifact_image_id = i.image_id").
Join(fmt.Sprintf(`(%s) latest
ON a.artifact_image_id = latest.artifact_image_id
AND a.artifact_created_at = latest.max_created_at
`, subQuery)).
Where("i.image_name LIKE ? AND i.image_registry_id = ?", "%"+name+"%", regID)
sql, args, err := q.ToSql()
if err != nil {
return 0, errors.Wrap(err, "Failed to build count SQL")
}
db := dbtx.GetAccessor(ctx, a.db)
var count int64
if err := db.GetContext(ctx, &count, sql, args...); err != nil {
return 0, databaseg.ProcessSQLErrorf(ctx, err, "Failed to count artifact metadata")
}
return count, nil
}
func (a ArtifactDao) GetAllArtifactsByParentID(
ctx context.Context,
parentID int64,
@ -610,6 +684,20 @@ func (a ArtifactDao) GetLatestArtifactMetadata(
return a.mapToArtifactMetadata(dst)
}
func (a ArtifactDao) mapArtifactToArtifactMetadataList(ctx context.Context,
dst []*artifactDB,
) (*[]types.Artifact, error) {
artifacts := make([]types.Artifact, 0, len(dst))
for _, d := range dst {
artifact, err := a.mapToArtifact(ctx, d)
if err != nil {
return nil, err
}
artifacts = append(artifacts, *artifact)
}
return &artifacts, nil
}
func (a ArtifactDao) mapToArtifactMetadataList(
dst []*artifactMetadataDB,
) (*[]types.ArtifactMetadata, error) {