Commit f23e5876 authored by David Fernandez's avatar David Fernandez

Lift the NPM naming convention constraint

For the project level Package Manager API.

At the instance level, the API will only return packages that follow
the naming convention.

Documentation: https://docs.gitlab.com/ee/user/packages/npm_registry/#package-naming-convention
parent 77ea020f
...@@ -2,29 +2,41 @@ ...@@ -2,29 +2,41 @@
module Packages module Packages
module Npm module Npm
class PackageFinder class PackageFinder
attr_reader :project, :package_name
delegate :find_by_version, to: :execute delegate :find_by_version, to: :execute
delegate :last, to: :execute
def initialize(project, package_name) def initialize(package_name, project: nil, namespace: nil)
@project = project
@package_name = package_name @package_name = package_name
@project = project
@namespace = namespace
end end
def execute def execute
return Packages::Package.none unless project base.npm
.with_name(@package_name)
packages .last_of_each_version
.preload_files
end end
private private
def packages def base
project.packages if @project
.npm packages_for_project
.with_name(package_name) elsif @namespace
.last_of_each_version packages_for_namespace
.preload_files else
::Packages::Package.none
end
end
def packages_for_project
@project.packages
end
def packages_for_namespace
projects = ::Project.in_namespace(@namespace.self_and_descendants.select(:id))
::Packages::Package.for_projects(projects.select(:id))
end end
end end
end end
......
...@@ -40,11 +40,11 @@ class Packages::Package < ApplicationRecord ...@@ -40,11 +40,11 @@ class Packages::Package < ApplicationRecord
validate :unique_debian_package_name, if: :debian_package? validate :unique_debian_package_name, if: :debian_package?
validate :valid_conan_package_recipe, if: :conan? validate :valid_conan_package_recipe, if: :conan?
validate :valid_npm_package_name, if: :npm?
validate :valid_composer_global_name, if: :composer? validate :valid_composer_global_name, if: :composer?
validate :package_already_taken, if: :npm? validate :package_already_taken, if: :npm?
validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic?
validates :name, format: { with: Gitlab::Regex.npm_package_name_regex }, if: :npm?
validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget? validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget?
validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package? validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package?
validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming? validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming?
...@@ -247,14 +247,6 @@ class Packages::Package < ApplicationRecord ...@@ -247,14 +247,6 @@ class Packages::Package < ApplicationRecord
end end
end end
def valid_npm_package_name
return unless project&.root_namespace
unless name =~ %r{\A@#{project.root_namespace.path}/[^/]+\z}
errors.add(:name, 'is not valid')
end
end
def package_already_taken def package_already_taken
return unless project return unless project
......
---
title: Lift the NPM package naming convention for the project level API
merge_request: 53266
author:
type: changed
...@@ -86,7 +86,9 @@ A `package.json` file is created. ...@@ -86,7 +86,9 @@ A `package.json` file is created.
To use the GitLab endpoint for npm packages, choose an option: To use the GitLab endpoint for npm packages, choose an option:
- **Project-level**: Use when you have few npm packages and they are not in - **Project-level**: Use when you have few npm packages and they are not in
the same GitLab group. the same GitLab group. The [package naming convention](#package-naming-convention) is not enforced at this level.
Instead, you should use a [scope](https://docs.npmjs.com/cli/v6/using-npm/scope) for your package.
When you use a scope, the registry URL is [updated](#authenticate-to-the-package-registry) only for that scope.
- **Instance-level**: Use when you have many npm packages in different - **Instance-level**: Use when you have many npm packages in different
GitLab groups or in their own namespace. Be sure to comply with the [package naming convention](#package-naming-convention). GitLab groups or in their own namespace. Be sure to comply with the [package naming convention](#package-naming-convention).
...@@ -204,7 +206,7 @@ Then, you can run `npm publish` either locally or by using GitLab CI/CD. ...@@ -204,7 +206,7 @@ Then, you can run `npm publish` either locally or by using GitLab CI/CD.
## Package naming convention ## Package naming convention
Your npm package name must be in the format of `@scope/package-name`. When you use the [instance-level endpoint](#use-the-gitlab-endpoint-for-npm-packages), only the packages with names in the format of `@scope/package-name` are available.
- The `@scope` is the root namespace of the GitLab project. It must match exactly, including the case. - The `@scope` is the root namespace of the GitLab project. It must match exactly, including the case.
- The `package-name` can be whatever you want. - The `package-name` can be whatever you want.
...@@ -302,8 +304,9 @@ the same version more than once, even if it has been deleted. ...@@ -302,8 +304,9 @@ the same version more than once, even if it has been deleted.
## Install a package ## Install a package
npm packages are commonly-installed by using the `npm` or `yarn` commands npm packages are commonly-installed by using the `npm` or `yarn` commands
in a JavaScript project. You can install a package from the scope of a project, group, in a JavaScript project. You can install a package from the scope of a project or instance.
or instance.
If multiple packages have the same name and version, when you install a package, the most recently-published package is retrieved.
1. Set the URL for scoped packages by running: 1. Set the URL for scoped packages by running:
......
...@@ -41,7 +41,7 @@ module API ...@@ -41,7 +41,7 @@ module API
authorize_read_package!(project) authorize_read_package!(project)
packages = ::Packages::Npm::PackageFinder.new(project, package_name) packages = ::Packages::Npm::PackageFinder.new(package_name, project: project)
.execute .execute
not_found! if packages.empty? not_found! if packages.empty?
...@@ -68,8 +68,7 @@ module API ...@@ -68,8 +68,7 @@ module API
authorize_create_package!(project) authorize_create_package!(project)
package = ::Packages::Npm::PackageFinder package = ::Packages::Npm::PackageFinder.new(package_name, project: project)
.new(project, package_name)
.find_by_version(version) .find_by_version(version)
not_found!('Package') unless package not_found!('Package') unless package
...@@ -112,8 +111,7 @@ module API ...@@ -112,8 +111,7 @@ module API
route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true
get '*package_name', format: false, requirements: ::API::Helpers::Packages::Npm::NPM_ENDPOINT_REQUIREMENTS do get '*package_name', format: false, requirements: ::API::Helpers::Packages::Npm::NPM_ENDPOINT_REQUIREMENTS do
package_name = params[:package_name] package_name = params[:package_name]
packages = ::Packages::Npm::PackageFinder.new(package_name, project: project_or_nil)
packages = ::Packages::Npm::PackageFinder.new(project_or_nil, package_name)
.execute .execute
redirect_request = project_or_nil.blank? || packages.empty? redirect_request = project_or_nil.blank? || packages.empty?
......
...@@ -49,13 +49,34 @@ module API ...@@ -49,13 +49,34 @@ module API
when :project when :project
params[:id] params[:id]
when :instance when :instance
::Packages::Package.npm namespace_path = namespace_path_from_package_name
.with_name(params[:package_name]) next unless namespace_path
.first
&.project_id namespace = namespace_from_path(namespace_path)
next unless namespace
finder = ::Packages::Npm::PackageFinder.new(params[:package_name], namespace: namespace)
finder.last&.project_id
end end
end end
end end
# from "@scope/package-name" return "scope" or nil
def namespace_path_from_package_name
package_name = params[:package_name]
return unless package_name.starts_with?('@')
return unless package_name.include?('/')
package_name.match(Gitlab::Regex.npm_package_name_regex)&.captures&.first
end
def namespace_from_path(path)
group = Group.by_path(path)
return group if group
Namespace.for_user.by_path(path)
end
end end
end end
end end
......
...@@ -61,6 +61,10 @@ module Gitlab ...@@ -61,6 +61,10 @@ module Gitlab
maven_app_name_regex maven_app_name_regex
end end
def npm_package_name_regex
@npm_package_name_regex ||= %r{\A(?:@(#{Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX})/)?[-+\.\_a-zA-Z0-9]+\z}
end
def nuget_package_name_regex def nuget_package_name_regex
@nuget_package_name_regex ||= %r{\A[-+\.\_a-zA-Z0-9]+\z}.freeze @nuget_package_name_regex ||= %r{\A[-+\.\_a-zA-Z0-9]+\z}.freeze
end end
......
...@@ -2,13 +2,43 @@ ...@@ -2,13 +2,43 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe ::Packages::Npm::PackageFinder do RSpec.describe ::Packages::Npm::PackageFinder do
let(:package) { create(:npm_package) } let_it_be_with_reload(:project) { create(:project)}
let_it_be(:package) { create(:npm_package, project: project) }
let(:project) { package.project } let(:project) { package.project }
let(:package_name) { package.name } let(:package_name) { package.name }
describe '#execute!' do shared_examples 'accepting a namespace for' do |example_name|
subject { described_class.new(project, package_name).execute } before do
project.update!(namespace: namespace)
end
context 'that is a group' do
let_it_be(:namespace) { create(:group) }
it_behaves_like example_name
context 'within another group' do
let_it_be(:subgroup) { create(:group, parent: namespace) }
before do
project.update!(namespace: subgroup)
end
it_behaves_like example_name
end
end
context 'that is a user namespace' do
let_it_be(:user) { create(:user) }
let_it_be(:namespace) { user.namespace }
it_behaves_like example_name
end
end
describe '#execute' do
shared_examples 'finding packages by name' do
it { is_expected.to eq([package]) } it { is_expected.to eq([package]) }
context 'with unknown package name' do context 'with unknown package name' do
...@@ -16,19 +46,41 @@ RSpec.describe ::Packages::Npm::PackageFinder do ...@@ -16,19 +46,41 @@ RSpec.describe ::Packages::Npm::PackageFinder do
it { is_expected.to be_empty } it { is_expected.to be_empty }
end end
end
subject { finder.execute }
context 'with nil project' do context 'with a project' do
let(:finder) { described_class.new(package_name, project: project) }
it_behaves_like 'finding packages by name'
context 'set to nil' do
let(:project) { nil } let(:project) { nil }
it { is_expected.to be_empty } it { is_expected.to be_empty }
end end
end end
context 'with a namespace' do
let(:finder) { described_class.new(package_name, namespace: namespace) }
it_behaves_like 'accepting a namespace for', 'finding packages by name'
context 'set to nil' do
let_it_be(:namespace) { nil }
it { is_expected.to be_empty }
end
end
end
describe '#find_by_version' do describe '#find_by_version' do
let(:version) { package.version } let(:version) { package.version }
subject { described_class.new(project, package.name).find_by_version(version) } subject { finder.find_by_version(version) }
shared_examples 'finding packages by version' do
it { is_expected.to eq(package) } it { is_expected.to eq(package) }
context 'with unknown version' do context 'with unknown version' do
...@@ -37,4 +89,52 @@ RSpec.describe ::Packages::Npm::PackageFinder do ...@@ -37,4 +89,52 @@ RSpec.describe ::Packages::Npm::PackageFinder do
it { is_expected.to be_nil } it { is_expected.to be_nil }
end end
end end
context 'with a project' do
let(:finder) { described_class.new(package_name, project: project) }
it_behaves_like 'finding packages by version'
end
context 'with a namespace' do
let(:finder) { described_class.new(package_name, namespace: namespace) }
it_behaves_like 'accepting a namespace for', 'finding packages by version'
end
end
describe '#last' do
subject { finder.last }
shared_examples 'finding package by last' do
it { is_expected.to eq(package) }
end
context 'with a project' do
let(:finder) { described_class.new(package_name, project: project) }
it_behaves_like 'finding package by last'
end
context 'with a namespace' do
let(:finder) { described_class.new(package_name, namespace: namespace) }
it_behaves_like 'accepting a namespace for', 'finding package by last'
context 'with duplicate packages' do
let_it_be(:namespace) { create(:group) }
let_it_be(:subgroup1) { create(:group, parent: namespace) }
let_it_be(:subgroup2) { create(:group, parent: namespace) }
let_it_be(:project2) { create(:project, namespace: subgroup2) }
let_it_be(:package2) { create(:npm_package, name: package.name, project: project2) }
before do
project.update!(namespace: subgroup1)
end
# the most recent one is returned
it { is_expected.to eq(package2) }
end
end
end
end end
...@@ -367,6 +367,35 @@ RSpec.describe Gitlab::Regex do ...@@ -367,6 +367,35 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('%2e%2e%2f1.2.3') } it { is_expected.not_to match('%2e%2e%2f1.2.3') }
end end
describe '.npm_package_name_regex' do
subject { described_class.npm_package_name_regex }
it { is_expected.to match('@scope/package') }
it { is_expected.to match('unscoped-package') }
it { is_expected.not_to match('@first-scope@second-scope/package') }
it { is_expected.not_to match('scope-without-at-symbol/package') }
it { is_expected.not_to match('@not-a-scoped-package') }
it { is_expected.not_to match('@scope/sub/package') }
it { is_expected.not_to match('@scope/../../package') }
it { is_expected.not_to match('@scope%2e%2e%2fpackage') }
it { is_expected.not_to match('@%2e%2e%2f/package') }
context 'capturing group' do
[
['@scope/package', 'scope'],
['unscoped-package', nil],
['@not-a-scoped-package', nil],
['@scope/sub/package', nil],
['@inv@lid-scope/package', nil]
].each do |package_name, extracted_scope_name|
it "extracts the scope name for #{package_name}" do
match = package_name.match(described_class.npm_package_name_regex)
expect(match&.captures&.first).to eq(extracted_scope_name)
end
end
end
end
describe '.nuget_version_regex' do describe '.nuget_version_regex' do
subject { described_class.nuget_version_regex } subject { described_class.nuget_version_regex }
......
...@@ -162,6 +162,18 @@ RSpec.describe Packages::Package, type: :model do ...@@ -162,6 +162,18 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value('../../../my_package').for(:name) } it { is_expected.not_to allow_value('../../../my_package').for(:name) }
it { is_expected.not_to allow_value('%2e%2e%2fmy_package').for(:name) } it { is_expected.not_to allow_value('%2e%2e%2fmy_package').for(:name) }
end end
context 'npm package' do
subject { build_stubbed(:npm_package) }
it { is_expected.to allow_value("@group-1/package").for(:name) }
it { is_expected.to allow_value("@any-scope/package").for(:name) }
it { is_expected.to allow_value("unscoped-package").for(:name) }
it { is_expected.not_to allow_value("@inv@lid-scope/package").for(:name) }
it { is_expected.not_to allow_value("@scope/../../package").for(:name) }
it { is_expected.not_to allow_value("@scope%2e%2e%fpackage").for(:name) }
it { is_expected.not_to allow_value("@scope/sub/package").for(:name) }
end
end end
describe '#version' do describe '#version' do
...@@ -342,16 +354,6 @@ RSpec.describe Packages::Package, type: :model do ...@@ -342,16 +354,6 @@ RSpec.describe Packages::Package, type: :model do
end end
describe '#package_already_taken' do describe '#package_already_taken' do
context 'npm package' do
let!(:package) { create(:npm_package) }
it 'will not allow a package of the same name' do
new_package = build(:npm_package, project: create(:project), name: package.name)
expect(new_package).not_to be_valid
end
end
context 'maven package' do context 'maven package' do
let!(:package) { create(:maven_package) } let!(:package) { create(:maven_package) }
......
...@@ -30,7 +30,7 @@ RSpec.describe API::NpmProjectPackages do ...@@ -30,7 +30,7 @@ RSpec.describe API::NpmProjectPackages do
end end
describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do
let_it_be(:package_file) { package.package_files.first } let(:package_file) { package.package_files.first }
let(:headers) { {} } let(:headers) { {} }
let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") } let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") }
...@@ -127,24 +127,6 @@ RSpec.describe API::NpmProjectPackages do ...@@ -127,24 +127,6 @@ RSpec.describe API::NpmProjectPackages do
context 'when params are correct' do context 'when params are correct' do
context 'invalid package record' do context 'invalid package record' do
context 'unscoped package' do
let(:package_name) { 'my_unscoped_package' }
let(:params) { upload_params(package_name: package_name) }
it_behaves_like 'handling invalid record with 400 error'
context 'with empty versions' do
let(:params) { upload_params(package_name: package_name).merge!(versions: {}) }
it 'throws a 400 error' do
expect { upload_package_with_token(package_name, params) }
.not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
context 'invalid package name' do context 'invalid package name' do
let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" } let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" }
let(:params) { upload_params(package_name: package_name) } let(:params) { upload_params(package_name: package_name) }
...@@ -175,10 +157,10 @@ RSpec.describe API::NpmProjectPackages do ...@@ -175,10 +157,10 @@ RSpec.describe API::NpmProjectPackages do
end end
end end
context 'scoped package' do context 'valid package record' do
let(:package_name) { "@#{group.path}/my_package_name" }
let(:params) { upload_params(package_name: package_name) } let(:params) { upload_params(package_name: package_name) }
shared_examples 'handling upload with different authentications' do
context 'with access token' do context 'with access token' do
subject { upload_package_with_token(package_name, params) } subject { upload_package_with_token(package_name, params) }
...@@ -224,6 +206,25 @@ RSpec.describe API::NpmProjectPackages do ...@@ -224,6 +206,25 @@ RSpec.describe API::NpmProjectPackages do
end end
end end
context 'with a scoped name' do
let(:package_name) { "@#{group.path}/my_package_name" }
it_behaves_like 'handling upload with different authentications'
end
context 'with any scoped name' do
let(:package_name) { "@any_scope/my_package_name" }
it_behaves_like 'handling upload with different authentications'
end
context 'with an unscoped name' do
let(:package_name) { "my_unscoped_package_name" }
it_behaves_like 'handling upload with different authentications'
end
end
context 'package creation fails' do context 'package creation fails' do
let(:package_name) { "@#{group.path}/my_package_name" } let(:package_name) { "@#{group.path}/my_package_name" }
let(:params) { upload_params(package_name: package_name) } let(:params) { upload_params(package_name: package_name) }
......
...@@ -15,7 +15,7 @@ RSpec.describe Packages::Npm::CreatePackageService do ...@@ -15,7 +15,7 @@ RSpec.describe Packages::Npm::CreatePackageService do
end end
let(:override) { {} } let(:override) { {} }
let(:package_name) { "@#{namespace.path}/my-app".freeze } let(:package_name) { "@#{namespace.path}/my-app" }
subject { described_class.new(project, user, params).execute } subject { described_class.new(project, user, params).execute }
...@@ -42,11 +42,6 @@ RSpec.describe Packages::Npm::CreatePackageService do ...@@ -42,11 +42,6 @@ RSpec.describe Packages::Npm::CreatePackageService do
it { expect(subject.name).to eq(package_name) } it { expect(subject.name).to eq(package_name) }
it { expect(subject.version).to eq(version) } it { expect(subject.version).to eq(version) }
end
describe '#execute' do
context 'scoped package' do
it_behaves_like 'valid package'
context 'with build info' do context 'with build info' do
let(:job) { create(:ci_build, user: user) } let(:job) { create(:ci_build, user: user) }
...@@ -61,10 +56,21 @@ RSpec.describe Packages::Npm::CreatePackageService do ...@@ -61,10 +56,21 @@ RSpec.describe Packages::Npm::CreatePackageService do
end end
end end
context 'invalid package name' do describe '#execute' do
let(:package_name) { "@#{namespace.path}/my-group/my-app".freeze } context 'scoped package' do
it_behaves_like 'valid package'
end
it { expect { subject }.to raise_error(ActiveRecord::RecordInvalid) } context 'scoped package not following the naming convention' do
let(:package_name) { '@any-scope/package' }
it_behaves_like 'valid package'
end
context 'unscoped package' do
let(:package_name) { 'unscoped-package' }
it_behaves_like 'valid package'
end end
context 'package already exists' do context 'package already exists' do
...@@ -84,13 +90,20 @@ RSpec.describe Packages::Npm::CreatePackageService do ...@@ -84,13 +90,20 @@ RSpec.describe Packages::Npm::CreatePackageService do
it { expect(subject[:message]).to be 'File is too large.' } it { expect(subject[:message]).to be 'File is too large.' }
end end
context 'with incorrect namespace' do [
let(:package_name) { '@my_other_namespace/my-app' } '@inv@lid_scope/package',
'@scope/sub/group',
'@scope/../../package',
'@scope%2e%2e%2fpackage'
].each do |invalid_package_name|
context "with invalid name #{invalid_package_name}" do
let(:package_name) { invalid_package_name }
it 'raises a RecordInvalid error' do it 'raises a RecordInvalid error' do
expect { subject }.to raise_error(ActiveRecord::RecordInvalid) expect { subject }.to raise_error(ActiveRecord::RecordInvalid)
end end
end end
end
context 'with empty versions' do context 'with empty versions' do
let(:override) { { versions: {} } } let(:override) { { versions: {} } }
......
...@@ -22,6 +22,10 @@ RSpec.shared_context 'set package name from package name type' do ...@@ -22,6 +22,10 @@ RSpec.shared_context 'set package name from package name type' do
case package_name_type case package_name_type
when :scoped_naming_convention when :scoped_naming_convention
"@#{group.path}/scoped-package" "@#{group.path}/scoped-package"
when :scoped_no_naming_convention
'@any-scope/scoped-package'
when :unscoped
'unscoped-package'
when :non_existing when :non_existing
'non-existing-package' 'non-existing-package'
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment