Skip to content
Merged
Show file tree
Hide file tree
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Repository href format:

npm support queries the `npm_package` table. Each row is one package version (typically one tarball). Pulp stores only `name` and `version` in the database; rich metadata (`description`, `license`, `dependencies`, etc.) lives in the tarball's `package.json` and is not persisted by pulp_npm.

- **`NpmPackageList`** — lists packages in the latest repository version, grouped by `name`, with all versions and `latest_versions` (most recent `pulp_created` per version). Supports optional `Search` prefix filter on `name` (including scoped names like `@scope/pkg`). Pagination is done in SQL.
- **`NpmPackageList`** — lists packages in the latest repository version, grouped by `name`, with all versions and `latest_versions` (most recent `pulp_created` per version). Supports optional `Search` prefix filter on the npm scope (text before `/`) or unscoped package name (text after `/`), similar to Maven `group_id` / `artifact_id` search. Unscoped names match against the full name. Pagination is done in SQL.
- **`NpmBuildList`** — lists builds (`name` + `version` pairs) in the latest repository version, optionally filtered by `name` and `version`. Pagination is done in SQL.
- **`NpmPackageGet`** — returns tarball info (`relative_path`, `filename`, `sha256`, `size`) and timestamps for a given `name` and `version`, plus all other versions available in the repository. Returns `ErrNpmPackageNotFound` when the package version is not in the repository.
- **`NpmPackageVersionsGet`** — returns tarball info for every version of a given `name`, each entry matching `NpmPackageGet` for that version. Returns `ErrNpmPackageNotFound` when the package is not in the repository.
Expand Down
11 changes: 10 additions & 1 deletion internal/test/integration/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ func TestNpmScopedSuite(t *testing.T) {
}

func (n *NpmScopedSuite) TestNpmScopedPackage() {
listResponse, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "@types/"}, tangy.PageOptions{
listResponse, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "@types"}, tangy.PageOptions{
Offset: 0,
Limit: 10,
})
Expand All @@ -354,6 +354,15 @@ func (n *NpmScopedSuite) TestNpmScopedPackage() {
assert.Equal(n.T(), testNpmScopedPackageName, listResponse.Results[0].Name)
assert.Contains(n.T(), listResponse.Results[0].Versions, testNpmScopedVersion)

listResponse, err = n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "is-odd"}, tangy.PageOptions{
Offset: 0,
Limit: 10,
})
require.NoError(n.T(), err)
require.NotEmpty(n.T(), listResponse.Results)
assert.Equal(n.T(), 1, listResponse.Total)
assert.Equal(n.T(), testNpmScopedPackageName, listResponse.Results[0].Name)

detail, err := n.tangy.NpmPackageGet(
context.Background(),
n.repositoryHref,
Expand Down
41 changes: 40 additions & 1 deletion pkg/tangy/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func (t *tangyImpl) NpmPackageList(ctx context.Context, repositoryHref string, f
searchFilter := ""
if filterOpts.Search != "" {
args["searchFilter"] = filterOpts.Search
searchFilter = ` AND rp.name ILIKE CONCAT(@searchFilter::text, '%')`
searchFilter = npmPackageListSearchFilter()
}
innerUnion, err := contentIdsInVersions(ctx, conn, repoVerMap, &args)
if err != nil {
Expand Down Expand Up @@ -525,6 +525,45 @@ func assembleNpmPackageListFromRows(rows []npmPackageVersionRow) []NpmPackageLis
return append(results, current)
}

// npmPackageListSearchFilter returns SQL that prefix-matches the search term against
// the npm scope (text before the first '/') and the unscoped package name (text after
// the first '/'), similar to MavenPackageList matching group_id OR artifact_id.
// Unscoped packages only have a scope segment (the full name).
func npmPackageListSearchFilter() string {
return ` AND (
split_part(rp.name, '/', 1) ILIKE CONCAT(@searchFilter::text, '%')
OR (
POSITION('/' IN rp.name) > 0
AND split_part(rp.name, '/', 2) ILIKE CONCAT(@searchFilter::text, '%')
)
)`
}

// splitNpmPackageName splits a package name into scope and unscoped name segments.
func splitNpmPackageName(name string) (scope, unscopedName string, scoped bool) {
if i := strings.Index(name, "/"); i >= 0 {
return name[:i], name[i+1:], true
}
return name, "", false
}

// npmPackageMatchesSearch mirrors npmPackageListSearchFilter for unit tests.
func npmPackageMatchesSearch(name, search string) bool {
if search == "" {
return true
}

searchLower := strings.ToLower(search)
scope, unscopedName, scoped := splitNpmPackageName(name)
if strings.HasPrefix(strings.ToLower(scope), searchLower) {
return true
}
if scoped && strings.HasPrefix(strings.ToLower(unscopedName), searchLower) {
return true
}
return false
}

// parseNpmRepositoryHref extracts the repository UUID from an npm repository href.
// Example: /api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/
func parseNpmRepositoryHref(href string) (string, error) {
Expand Down
67 changes: 67 additions & 0 deletions pkg/tangy/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,73 @@ func TestParseNpmLatestVersionsJSON(t *testing.T) {
require.Error(t, err)
}

func TestNpmPackageMatchesSearch(t *testing.T) {
t.Parallel()

tests := []struct {
name string
pkg string
search string
want bool
}{
{
name: "unscoped package name prefix",
pkg: "is-odd",
search: "is-odd",
want: true,
},
{
name: "unscoped package name shorter prefix",
pkg: "is-odd",
search: "is",
want: true,
},
{
name: "scoped package scope prefix",
pkg: "@types/is-odd",
search: "@types",
want: true,
},
{
name: "scoped package unscoped name prefix",
pkg: "@types/is-odd",
search: "is-odd",
want: true,
},
{
name: "scoped package unscoped name shorter prefix",
pkg: "@types/is-odd",
search: "is",
want: true,
},
{
name: "scoped package full name prefix does not match unscoped segment only",
pkg: "@types/is-odd",
search: "@types/",
want: false,
},
{
name: "scoped package search does not match unrelated scope",
pkg: "@types/is-odd",
search: "lodash",
want: false,
},
{
name: "empty search matches everything",
pkg: "@types/is-odd",
search: "",
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, npmPackageMatchesSearch(tt.pkg, tt.search))
})
}
}

func TestMockTangyNpmPackageList(t *testing.T) {
t.Parallel()

Expand Down
Loading