From 7e19fc63b3e2ceb360482abaa409f1ed32d81500 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 24 Sep 2026 18:32:35 +0200 Subject: [PATCH 1/2] Bug 2071907 - Migrate Product REST resource to native Mojo API --- Bugzilla/API/V1/Product.pm | 346 ++++++++ Bugzilla/WebService.pm | 2 - Bugzilla/WebService/Bug.pm | 3 +- Bugzilla/WebService/Constants.pm | 1 - Bugzilla/WebService/Product.pm | 798 ------------------ Bugzilla/WebService/Server/REST.pm | 1 - .../Server/REST/Resources/Product.pm | 71 -- qa/t/rest_product.t | 25 + qa/t/rest_product_create.t | 20 + 9 files changed, 392 insertions(+), 875 deletions(-) create mode 100644 Bugzilla/API/V1/Product.pm delete mode 100644 Bugzilla/WebService/Product.pm delete mode 100644 Bugzilla/WebService/Server/REST/Resources/Product.pm diff --git a/Bugzilla/API/V1/Product.pm b/Bugzilla/API/V1/Product.pm new file mode 100644 index 0000000000..d529cac5f3 --- /dev/null +++ b/Bugzilla/API/V1/Product.pm @@ -0,0 +1,346 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::API::V1::Product; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use Mojo::JSON qw(true false); + +use Bugzilla::Constants; +use Bugzilla::Product; +use Bugzilla::Util qw(email_filter); +use Bugzilla::WebService::Util qw(filter filter_wants merge_request_params); + +use constant FIELD_MAP => + {has_unconfirmed => 'allows_unconfirmed', is_open => 'isactive'}; + +sub setup_routes { + my ($class, $r) = @_; + my $routes = $r->under( + '/' => sub { Bugzilla->usage_mode(USAGE_MODE_MOJO_REST); }); + + foreach my $type (qw(accessible enterable selectable)) { + $routes->get("/product_$type") + ->to('V1::Product#get_products_by_type', product_type => $type); + $routes->options("/product_$type") + ->to('V1::Product#options', allow => 'GET'); + } + + $routes->get('/product')->to('V1::Product#get'); + $routes->post('/product')->to('V1::Product#create'); + $routes->get('/product/#id_or_name')->to('V1::Product#get'); + + $routes->options('/product')->to('V1::Product#options', allow => 'GET, POST'); + $routes->options('/product/#id_or_name') + ->to('V1::Product#options', allow => 'GET'); +} + +sub options { + my ($self) = @_; + + my $allow = $self->stash('allow'); + $self->res->headers->header('Allow' => $allow); + $self->res->headers->header('Access-Control-Allow-Methods' => $allow); + + return $self->rendered(200); +} + +# GET /product_accessible, /product_enterable and /product_selectable: the ids +# of the products the user can search and/or enter bugs against. +sub get_products_by_type { + my ($self) = @_; + + my $user = $self->_login // return $self->user_error('login_required'); + my $method = 'get_' . $self->stash('product_type') . '_products'; + + Bugzilla->switch_to_shadow_db(); + return $self->render(json => {ids => [map { 0 + $_->id } @{$user->$method}]}); +} + +# Get a list of actual products, based on list of ids or names +our %FLAG_CACHE; + +sub get { + my ($self) = @_; + + my $user = $self->_login // return $self->user_error('login_required'); + + my @list_params = qw(ids names type include_fields exclude_fields); + my ($params, $error) = merge_request_params($self, \@list_params); + return $self->user_error($error) if $error; + + # A JSON body is merged in as-is, so a single value there is not yet a list; + # legacy validate() coerced it the same way. + for my $field (@list_params) { + $params->{$field} = [$params->{$field}] + if defined $params->{$field} && !ref $params->{$field}; + } + + for my $field (qw(include_fields exclude_fields)) { + $params->{$field} = [map { split(/[\s,]+/) } @{$params->{$field}}] + if exists $params->{$field}; + } + + if (defined(my $id_or_name = $self->stash('id_or_name'))) { + $params->{$id_or_name =~ /^\d+$/ ? 'ids' : 'names'} = [$id_or_name]; + } + + defined $params->{ids} + || defined $params->{names} + || defined $params->{type} + || return $self->code_error('params_required', + {function => 'Product.get', params => ['ids', 'names', 'type']}); + + Bugzilla->switch_to_shadow_db(); + + my $products = []; + if (defined $params->{type}) { + my %product_hash; + foreach my $type (@{$params->{type}}) { + my $result = []; + if ($type eq 'accessible') { + $result = $user->get_accessible_products(); + } + elsif ($type eq 'enterable') { + $result = $user->get_enterable_products(); + } + elsif ($type eq 'selectable') { + $result = $user->get_selectable_products(); + } + else { + return $self->user_error('get_products_invalid_type', {type => $type}); + } + map { $product_hash{$_->id} = $_ } @$result; + } + $products = [values %product_hash]; + } + else { + $products = $user->get_accessible_products; + } + + my @requested_products; + + if (defined $params->{ids}) { + + # Create a hash with the ids the user wants + my %ids = map { $_ => 1 } @{$params->{ids}}; + + # Return the intersection of this, by grepping the ids from + # accessible products. + push(@requested_products, grep { $ids{$_->id} } @$products); + } + + if (defined $params->{names}) { + + # Create a hash with the names the user wants + my %names = map { lc($_) => 1 } @{$params->{names}}; + + # Return the intersection of this, by grepping the names from + # accessible products, union'ed with products found by ID to + # avoid duplicates + foreach my $product (grep { $names{lc $_->name} } @$products) { + next if grep { $_->id == $product->id } @requested_products; + push @requested_products, $product; + } + } + + # If we just requested a specific type of products without + # specifying ids or names, then return the entire list. + if (!defined $params->{ids} && !defined $params->{names}) { + @requested_products = @$products; + } + + # Now create a result entry for each. + local %FLAG_CACHE = (); + my @products = map { $self->_product_to_hash($params, $_) } @requested_products; + return $self->render(json => {products => \@products}); +} + +sub create { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + $user->in_group('editcomponents') + || return $self->user_error('auth_failure', + {group => 'editcomponents', action => 'add', object => 'products'}); + + my ($params, $error) = merge_request_params($self); + return $self->user_error($error) if $error; + + # A JSON body sends booleans as real booleans, but the query string and a + # form body send the literal string "true" or "false", which Perl treats as + # true either way. + foreach my $field (qw(has_unconfirmed is_open create_series)) { + next if !defined $params->{$field} || ref $params->{$field}; + my $value = lc $params->{$field}; + return $self->user_error('invalid_params', + {type_error => "$field must be true or false"}) + if $value !~ /^(?:true|false|1|0)$/; + $params->{$field} = ($value eq 'true' || $value eq '1') ? 1 : 0; + } + + # Create product + my $args = { + name => $params->{name}, + description => $params->{description}, + default_bug_type => $params->{default_bug_type}, + defaultmilestone => $params->{default_milestone}, + + # Accept the old param name `version` for backward compatibility + default_version => $params->{default_version} || $params->{version}, + + # create_series has no default value. + create_series => defined $params->{create_series} + ? $params->{create_series} + : 1 + }; + foreach my $field (qw(has_unconfirmed is_open classification)) { + if (defined $params->{$field}) { + my $name = FIELD_MAP->{$field} || $field; + $args->{$name} = $params->{$field}; + } + } + my $product = Bugzilla::Product->create($args); + return $self->render(json => {id => 0 + $product->id}, status => 201); +} + +sub _product_to_hash { + my ($self, $params, $product) = @_; + + my $field_data = { + id => 0 + $product->id, + name => $product->name, + description => $product->description, + is_active => $product->is_active ? true : false, + default_milestone => $product->default_milestone, + default_version => $product->default_version, + has_unconfirmed => $product->allows_unconfirmed ? true : false, + classification => $product->classification->name, + default_bug_type => $product->default_bug_type, + }; + if (filter_wants($params, 'components')) { + $field_data->{components} + = [map { $self->_component_to_hash($_, $params) } @{$product->components}]; + } + if (filter_wants($params, 'versions')) { + $field_data->{versions} + = [map { $self->_version_to_hash($_, $params) } @{$product->versions}]; + } + if (filter_wants($params, 'milestones')) { + $field_data->{milestones} + = [map { $self->_milestone_to_hash($_, $params) } @{$product->milestones}]; + } + + # BMO - add default hw/os + $field_data->{default_platform} = $product->default_platform; + $field_data->{default_op_sys} = $product->default_op_sys; + + # BMO - add default security group + $field_data->{default_security_group} = $product->default_security_group; + return filter($params, $field_data); +} + +sub _component_to_hash { + my ($self, $component, $params) = @_; + my $field_data = filter $params, { + id => 0 + $component->id, + name => $component->name, + description => $component->description, + default_assigned_to => _email($component->default_assignee->login), + default_qa_contact => _email($component->default_qa_contact->login), + triage_owner => _email($component->triage_owner->login), + sort_key => # sort_key is returned to match Bug.fields + 0, + is_active => $component->is_active ? true : false, + default_bug_type => $component->default_bug_type, + team_name => $component->team_name, + }, + undef, 'components'; + + if (filter_wants($params, 'flag_types', undef, 'components')) { + $field_data->{flag_types} = { + bug => [ + map { $FLAG_CACHE{$_->id} //= $self->_flag_type_to_hash($_) } + @{$component->flag_types->{'bug'}} + ], + attachment => [ + map { $FLAG_CACHE{$_->id} //= $self->_flag_type_to_hash($_) } + @{$component->flag_types->{'attachment'}} + ], + }; + } + + return $field_data; +} + +sub _flag_type_to_hash { + my ($self, $flag_type) = @_; + return { + id => 0 + $flag_type->id, + name => $flag_type->name, + description => $flag_type->description, + cc_list => $flag_type->cc_list, + sort_key => 0 + $flag_type->sortkey, + is_active => $flag_type->is_active ? true : false, + is_requestable => $flag_type->is_requestable ? true : false, + is_requesteeble => $flag_type->is_requesteeble ? true : false, + is_multiplicable => $flag_type->is_multiplicable ? true : false, + grant_group => _int_or_null($flag_type->grant_group_id), + request_group => _int_or_null($flag_type->request_group_id), + }; +} + +sub _version_to_hash { + my ($self, $version, $params) = @_; + return filter $params, { + id => 0 + $version->id, + name => $version->name, + sort_key => # sort_key is returned to match Bug.fields + 0, + is_active => $version->is_active ? true : false, + }, + undef, 'versions'; +} + +sub _milestone_to_hash { + my ($self, $milestone, $params) = @_; + return filter $params, + { + id => 0 + $milestone->id, + name => $milestone->name, + sort_key => 0 + $milestone->sortkey, + is_active => $milestone->is_active ? true : false, + }, + undef, 'milestones'; +} + +# Anonymous access is allowed unless requirelogin is on. The Mojo login helper +# never enforces requirelogin for REST requests (it returns the anonymous +# user), whereas the legacy dispatcher's Bugzilla->login() did. +sub _login { + my ($self) = @_; + my $user = $self->bugzilla->login; + return ($user->id || !Bugzilla->params->{requirelogin}) ? $user : undef; +} + +# Legacy type('email') only filtered when the webservice_email_filter +# parameter is on. +sub _email { + my ($login) = @_; + return Bugzilla->params->{webservice_email_filter} ? email_filter($login) : $login; +} + +# Legacy type('int') rendered undef as JSON null rather than 0. +sub _int_or_null { + my ($value) = @_; + return defined $value ? 0 + $value : undef; +} + +1; diff --git a/Bugzilla/WebService.pm b/Bugzilla/WebService.pm index d20ef76d29..1351e49fc6 100644 --- a/Bugzilla/WebService.pm +++ b/Bugzilla/WebService.pm @@ -417,8 +417,6 @@ objects. =item L -=item L - =item L =back diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index 6a095fef2b..8ba1d13a56 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -2238,8 +2238,7 @@ This is an array of hashes, representing the legal values for select-type (drop-down and multiple-selection) fields. This is also populated for the C, C, C, and C fields, but not for the C field (you must use -L -for that. +C for that). For fields that aren't select-type fields, this will simply be an empty array. diff --git a/Bugzilla/WebService/Constants.pm b/Bugzilla/WebService/Constants.pm index c0f22d8f6f..44737fb5b9 100644 --- a/Bugzilla/WebService/Constants.pm +++ b/Bugzilla/WebService/Constants.pm @@ -316,7 +316,6 @@ sub WS_DISPATCH { 'Bugzilla' => 'Bugzilla::WebService::Bugzilla', 'Bug' => 'Bugzilla::WebService::Bug', 'User' => 'Bugzilla::WebService::User', - 'Product' => 'Bugzilla::WebService::Product', 'Group' => 'Bugzilla::WebService::Group', %hook_dispatch }; diff --git a/Bugzilla/WebService/Product.pm b/Bugzilla/WebService/Product.pm deleted file mode 100644 index ab2ce80aca..0000000000 --- a/Bugzilla/WebService/Product.pm +++ /dev/null @@ -1,798 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::WebService::Product; - -use 5.10.1; -use strict; -use warnings; - -use base qw(Bugzilla::WebService); -use Bugzilla::Product; -use Bugzilla::User; -use Bugzilla::Error; -use Bugzilla::Constants; -use Bugzilla::WebService::Constants; -use Bugzilla::WebService::Util qw(validate filter filter_wants); - -use constant READ_ONLY => qw( - get - get_accessible_products - get_enterable_products - get_selectable_products -); - -use constant PUBLIC_METHODS => qw( - create - get - get_accessible_products - get_enterable_products - get_selectable_products -); - -use constant FIELD_MAP => - {has_unconfirmed => 'allows_unconfirmed', is_open => 'isactive',}; - -################################################## -# Add aliases here for method name compatibility # -################################################## - -BEGIN { *get_products = \&get } - -# Get the ids of the products the user can search -sub get_selectable_products { - Bugzilla->switch_to_shadow_db(); - return {ids => [map { $_->id } @{Bugzilla->user->get_selectable_products}]}; -} - -# Get the ids of the products the user can enter bugs against -sub get_enterable_products { - Bugzilla->switch_to_shadow_db(); - return {ids => [map { $_->id } @{Bugzilla->user->get_enterable_products}]}; -} - -# Get the union of the products the user can search and enter bugs against. -sub get_accessible_products { - Bugzilla->switch_to_shadow_db(); - return {ids => [map { $_->id } @{Bugzilla->user->get_accessible_products}]}; -} - -# Get a list of actual products, based on list of ids or names -our %FLAG_CACHE; - -sub get { - my ($self, $params) = validate(@_, 'ids', 'names', 'type'); - my $user = Bugzilla->user; - - Bugzilla->request_cache->{bz_etag_disable} = 1; - - defined $params->{ids} - || defined $params->{names} - || defined $params->{type} - || ThrowCodeError("params_required", - {function => "Product.get", params => ['ids', 'names', 'type']}); - - Bugzilla->switch_to_shadow_db(); - - my $products = []; - if (defined $params->{type}) { - my %product_hash; - foreach my $type (@{$params->{type}}) { - my $result = []; - if ($type eq 'accessible') { - $result = $user->get_accessible_products(); - } - elsif ($type eq 'enterable') { - $result = $user->get_enterable_products(); - } - elsif ($type eq 'selectable') { - $result = $user->get_selectable_products(); - } - else { - ThrowUserError('get_products_invalid_type', {type => $type}); - } - map { $product_hash{$_->id} = $_ } @$result; - } - $products = [values %product_hash]; - } - else { - $products = $user->get_accessible_products; - } - - my @requested_products; - - if (defined $params->{ids}) { - - # Create a hash with the ids the user wants - my %ids = map { $_ => 1 } @{$params->{ids}}; - - # Return the intersection of this, by grepping the ids from - # accessible products. - push(@requested_products, grep { $ids{$_->id} } @$products); - } - - if (defined $params->{names}) { - - # Create a hash with the names the user wants - my %names = map { lc($_) => 1 } @{$params->{names}}; - - # Return the intersection of this, by grepping the names from - # accessible products, union'ed with products found by ID to - # avoid duplicates - foreach my $product (grep { $names{lc $_->name} } @$products) { - next if grep { $_->id == $product->id } @requested_products; - push @requested_products, $product; - } - } - - # If we just requested a specific type of products without - # specifying ids or names, then return the entire list. - if (!defined $params->{ids} && !defined $params->{names}) { - @requested_products = @$products; - } - - # Now create a result entry for each. - local %FLAG_CACHE = (); - my @products = map { $self->_product_to_hash($params, $_) } @requested_products; - return {products => \@products}; -} - -sub create { - my ($self, $params) = @_; - - Bugzilla->login(LOGIN_REQUIRED); - Bugzilla->user->in_group('editcomponents') - || ThrowUserError("auth_failure", - {group => "editcomponents", action => "add", object => "products"}); - - # Create product - my $args = { - name => $params->{name}, - description => $params->{description}, - default_bug_type => $params->{default_bug_type}, - defaultmilestone => $params->{default_milestone}, - # Accept the old param name `version` for backward compatibility - default_version => $params->{default_version} || $params->{version}, - - # create_series has no default value. - create_series => defined $params->{create_series} - ? $params->{create_series} - : 1 - }; - foreach my $field (qw(has_unconfirmed is_open classification)) { - if (defined $params->{$field}) { - my $name = FIELD_MAP->{$field} || $field; - $args->{$name} = $params->{$field}; - } - } - my $product = Bugzilla::Product->create($args); - return {id => $self->type('int', $product->id)}; -} - -sub _product_to_hash { - my ($self, $params, $product) = @_; - - my $field_data = { - id => $self->type('int', $product->id), - name => $self->type('string', $product->name), - description => $self->type('string', $product->description), - is_active => $self->type('boolean', $product->is_active), - default_milestone => $self->type('string', $product->default_milestone), - default_version => $self->type('string', $product->default_version), - has_unconfirmed => $self->type('boolean', $product->allows_unconfirmed), - classification => $self->type('string', $product->classification->name), - default_bug_type => $self->type('string', $product->default_bug_type), - }; - if (filter_wants($params, 'components')) { - $field_data->{components} - = [map { $self->_component_to_hash($_, $params) } @{$product->components}]; - } - if (filter_wants($params, 'versions')) { - $field_data->{versions} - = [map { $self->_version_to_hash($_, $params) } @{$product->versions}]; - } - if (filter_wants($params, 'milestones')) { - $field_data->{milestones} - = [map { $self->_milestone_to_hash($_, $params) } @{$product->milestones}]; - } - - # BMO - add default hw/os - $field_data->{default_platform} - = $self->type('string', $product->default_platform); - $field_data->{default_op_sys} = $self->type('string', $product->default_op_sys); - - # BMO - add default security group - $field_data->{default_security_group} - = $self->type('string', $product->default_security_group); - return filter($params, $field_data); -} - -sub _component_to_hash { - my ($self, $component, $params) = @_; - my $field_data = filter $params, { - id => $self->type('int', $component->id), - name => $self->type('string', $component->name), - description => $self->type('string', $component->description), - default_assigned_to => - $self->type('email', $component->default_assignee->login), - default_qa_contact => - $self->type('email', $component->default_qa_contact->login), - triage_owner => $self->type('email', $component->triage_owner->login), - sort_key => # sort_key is returned to match Bug.fields - 0, - is_active => $self->type('boolean', $component->is_active), - default_bug_type => $self->type('string', $component->default_bug_type), - team_name => $self->type('string', $component->team_name), - }, - undef, 'components'; - - if (filter_wants($params, 'flag_types', undef, 'components')) { - $field_data->{flag_types} = { - bug => [ - map { $FLAG_CACHE{$_->id} //= $self->_flag_type_to_hash($_) } - @{$component->flag_types->{'bug'}} - ], - attachment => [ - map { $FLAG_CACHE{$_->id} //= $self->_flag_type_to_hash($_) } - @{$component->flag_types->{'attachment'}} - ], - }; - } - - return $field_data; -} - -sub _flag_type_to_hash { - my ($self, $flag_type) = @_; - return { - id => $self->type('int', $flag_type->id), - name => $self->type('string', $flag_type->name), - description => $self->type('string', $flag_type->description), - cc_list => $self->type('string', $flag_type->cc_list), - sort_key => $self->type('int', $flag_type->sortkey), - is_active => $self->type('boolean', $flag_type->is_active), - is_requestable => $self->type('boolean', $flag_type->is_requestable), - is_requesteeble => $self->type('boolean', $flag_type->is_requesteeble), - is_multiplicable => $self->type('boolean', $flag_type->is_multiplicable), - grant_group => $self->type('int', $flag_type->grant_group_id), - request_group => $self->type('int', $flag_type->request_group_id), - }; -} - -sub _version_to_hash { - my ($self, $version, $params) = @_; - return filter $params, { - id => $self->type('int', $version->id), - name => $self->type('string', $version->name), - sort_key => # sort_key is returened to match Bug.fields - 0, - is_active => $self->type('boolean', $version->is_active), - }, - undef, 'versions'; -} - -sub _milestone_to_hash { - my ($self, $milestone, $params) = @_; - return filter $params, - { - id => $self->type('int', $milestone->id), - name => $self->type('string', $milestone->name), - sort_key => $self->type('int', $milestone->sortkey), - is_active => $self->type('boolean', $milestone->is_active), - }, - undef, 'milestones'; -} - -1; - -__END__ - -=head1 NAME - -Bugzilla::Webservice::Product - The Product API - -=head1 DESCRIPTION - -This part of the Bugzilla API allows you to list the available Products and -get information about them. - -=head1 METHODS - -See L for a description of how parameters are passed, -and what B, B, and B mean. - -Although the data input and output is the same for JSON-RPC and REST, -the directions for how to access the data via REST is noted in each method -where applicable. - -=head1 List Products - -=head2 get_selectable_products - -B - -=over - -=item B - -Returns a list of the ids of the products the user can search on. - -=item B - -GET /product_selectable - -the returned data format is same as below. - -=item B (none) - -=item B - -A hash containing one item, C, that contains an array of product -ids. - -=item B (none) - -=item B - -=over - -=item REST API call added in Bugzilla B<5.0>. - -=back - -=back - -=head2 get_enterable_products - -B - -=over - -=item B - -Returns a list of the ids of the products the user can enter bugs -against. - -=item B - -GET /product_enterable - -the returned data format is same as below. - -=item B (none) - -=item B - -A hash containing one item, C, that contains an array of product -ids. - -=item B (none) - -=item B - -=over - -=item REST API call added in Bugzilla B<5.0>. - -=back - -=back - -=head2 get_accessible_products - -B - -=over - -=item B - -Returns a list of the ids of the products the user can search or enter -bugs against. - -=item B - -GET /product_accessible - -the returned data format is same as below. - -=item B (none) - -=item B - -A hash containing one item, C, that contains an array of product -ids. - -=item B (none) - -=item B - -=over - -=item REST API call added in Bugzilla B<5.0>. - -=back - -=back - -=head2 get - -B - -=over - -=item B - -Returns a list of information about the products passed to it. - -Note: Can also be called as "get_products" for compatibility with Bugzilla 3.0 API. - -=item B - -To return information about a specific groups of products such as -C, C, or C: - -GET /product?type=accessible - -To return information about a specific product by C or C: - -GET /product/ - -You can also return information about more than one specific product -by using the following in your query string: - -GET /product?ids=1&ids=2&ids=3 or GET /product?names=ProductOne&names=Product2 - -the returned data format is same as below. - -=item B - -In addition to the parameters below, this method also accepts the -standard L and -L arguments. - -This RPC call supports sub field restrictions. - -=over - -=item C - -An array of product ids - -=item C - -An array of product names - -=item C - -The group of products to return. Valid values are: C (default), -C, and C. C can be a single value or an array -of values if more than one group is needed with duplicates removed. - -=back - -=item B - -A hash containing one item, C, that is an array of -hashes. Each hash describes a product, and has the following items: - -=over - -=item C - -C An integer id uniquely identifying the product in this installation only. - -=item C - -C The name of the product. This is a unique identifier for the -product. - -=item C - -C A description of the product, which may contain HTML. - -=item C - -C A boolean indicating if the product is active. - -=item C - -C The default type for bugs filed under this product. - -=item C - -C The name of the default milestone for the product. - -=item C - -C The name of the default version for the product. - -=item C - -C Indicates whether the UNCONFIRMED bug status is available -for this product. - -=item C - -C The classification name for the product. - -=item C - -C An array of hashes, where each hash describes a component, and has the -following items: - -=over - -=item C - -C An integer id uniquely identifying the component in this installation -only. - -=item C - -C The name of the component. This is a unique identifier for this -component. - -=item C - -C A description of the component, which may contain HTML. - -=item C - -C The login name of the user to whom new bugs will be assigned by -default. - -=item C - -C The default type for bugs filed under this component. - -=item C - -C The login name of the user who will be set as the QA Contact for -new bugs by default. - -=item C - -C The login name of the user who is named as the Triage Owner of the -component. - -=item C - -C The team name that owns the component. - -=item C - -C Components, when displayed in a list, are sorted first by this integer -and then secondly by their name. - -=item C - -C A boolean indicating if the component is active. Inactive -components are not enabled for new bugs. - -=item C - -A hash containing the two items C and C that each contains an -array of hashes, where each hash describes a flagtype, and has the -following items: - -=over - -=item C - -C Returns the ID of the flagtype. - -=item C - -C Returns the name of the flagtype. - -=item C - -C Returns the description of the flagtype. - -=item C - -C Returns the concatenated CC list for the flagtype, as a single string. - -=item C - -C Returns the sortkey of the flagtype. - -=item C - -C Returns whether the flagtype is active or disabled. Flags being -in a disabled flagtype are not deleted. It only prevents you from -adding new flags to it. - -=item C - -C Returns whether you can request for the given flagtype -(i.e. whether the '?' flag is available or not). - -=item C - -C Returns whether you can ask someone specifically or not. - -=item C - -C Returns whether you can have more than one flag for the given -flagtype in a given bug/attachment. - -=item C - -C the group id that is allowed to grant/deny flags of this type. -If the item is not included all users are allowed to grant/deny this -flagtype. - -=item C - -C the group id that is allowed to request the flag if the flag -is of the type requestable. If the item is not included all users -are allowed request this flagtype. - -=back - -=back - -=item C - -C An array of hashes, where each hash describes a version, and has the -following items: C, C and C. - -=item C - -C An array of hashes, where each hash describes a milestone, and has the -following items: C, C and C. - -=back - -Note, that if the user tries to access a product that is not in the -list of accessible products for the user, or a product that does not -exist, that is silently ignored, and no information about that product -is returned. - -=item B (none) - -=item B - -=over - -=item In Bugzilla B<4.2>, C was added as an input parameter. - -=item In Bugzilla B<4.2>, C, C, C, -C, C and C were added to -the fields returned by C as a replacement for C, which has -been removed. - -=item REST API call added in Bugzilla B<5.0>. - -=item In Bugzilla B<4.4>, C was added to the fields returned -by C. - -=item In Bugzilla B<6.0>, C was added to the fields returned -by C. - -=back - -=back - -=head1 Product Creation - -=head2 create - -B - -=over - -=item B - -This allows you to create a new product in Bugzilla. - -=item B - -POST /product - -The params to include in the POST body as well as the returned data format, -are the same as below. - -=item B - -Some params must be set, or an error will be thrown. These params are -marked B. - -=over - -=item C - -B C The name of this product. Must be globally unique -within Bugzilla. - -=item C - -B C A description for this product. Allows some simple HTML. - -=item C - -B C The default version for this product. - -=item C - -C Allow the UNCONFIRMED status to be set on bugs in this product. -Default: true. - -=item C - -C The name of the Classification which contains this product. - -=item C - -C The default type for bugs filed under this product. Each component can -override this value. - -=item C - -C The default milestone for this product. Default: '---'. - -=item C - -C The default version for this product. Default: 'unspecified'. - -=item C - -C True if the product is currently allowing bugs to be entered -into it. Default: true. - -=item C - -C True if you want series for New Charts to be created for this -new product. Default: true. - -=back - -=item B - -A hash with one element, id. This is the id of the newly-filed product. - -=item B - -=over - -=item 51 (Classification does not exist) - -You must specify an existing classification name. - -=item 700 (Product blank name) - -You must specify a non-blank name for this product. - -=item 701 (Product name too long) - -The name specified for this product was longer than the maximum -allowed length. - -=item 702 (Product name already exists) - -You specified the name of a product that already exists. -(Product names must be globally unique in Bugzilla.) - -=item 703 (Product must have description) - -You must specify a description for this product. - -=back - -=item B - -=over - -=item REST API call added in Bugzilla B<5.0>. - -=item The C field was renamed to C in Bugzilla B<6.0>. -The old name is still accepted for backward compatibility. - -=back - -=back diff --git a/Bugzilla/WebService/Server/REST.pm b/Bugzilla/WebService/Server/REST.pm index 5ad30efe09..f90b5c620e 100644 --- a/Bugzilla/WebService/Server/REST.pm +++ b/Bugzilla/WebService/Server/REST.pm @@ -25,7 +25,6 @@ use Bugzilla::WebService::Util qw(fix_credentials set_rest_cors_headers taint_da use Bugzilla::WebService::Server::REST::Resources::Bug; use Bugzilla::WebService::Server::REST::Resources::Bugzilla; use Bugzilla::WebService::Server::REST::Resources::Group; -use Bugzilla::WebService::Server::REST::Resources::Product; use Bugzilla::WebService::Server::REST::Resources::User; use List::MoreUtils qw(uniq); diff --git a/Bugzilla/WebService/Server/REST/Resources/Product.pm b/Bugzilla/WebService/Server/REST/Resources/Product.pm deleted file mode 100644 index 3222642c8d..0000000000 --- a/Bugzilla/WebService/Server/REST/Resources/Product.pm +++ /dev/null @@ -1,71 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::WebService::Server::REST::Resources::Product; - -use 5.10.1; -use strict; -use warnings; - -use Bugzilla::WebService::Constants; -use Bugzilla::WebService::Product; - -use Bugzilla::Error; - -BEGIN { - *Bugzilla::WebService::Product::rest_resources = \&_rest_resources; -} - -sub _rest_resources { - my $rest_resources = [ - qr{^/product_accessible$}, - {GET => {method => 'get_accessible_products'}}, - qr{^/product_enterable$}, - {GET => {method => 'get_enterable_products'}}, - qr{^/product_selectable$}, - {GET => {method => 'get_selectable_products'}}, - qr{^/product$}, - { - GET => {method => 'get'}, - POST => {method => 'create', success_code => STATUS_CREATED} - }, - qr{^/product/([^/]+)$}, - { - GET => { - method => 'get', - params => sub { - my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; - return {$param => [$_[0]]}; - } - }, - PUT => { - method => 'update', - params => sub { - my $param = $_[0] =~ /^\d+$/ ? 'ids' : 'names'; - return {$param => [$_[0]]}; - } - } - }, - ]; - return $rest_resources; -} - -1; - -__END__ - -=head1 NAME - -Bugzilla::Webservice::Server::REST::Resources::Product - The Product REST API - -=head1 DESCRIPTION - -This part of the Bugzilla REST API allows you to list the available Products and -get information about them. - -See L for more details on how to use this part of -the REST API. diff --git a/qa/t/rest_product.t b/qa/t/rest_product.t index 0acb360ccd..f73fe592ac 100644 --- a/qa/t/rest_product.t +++ b/qa/t/rest_product.t @@ -127,4 +127,29 @@ foreach my $user (sort keys %$tests) { } } +my $admin_headers = {'X-Bugzilla-API-Key' => $config->{admin_user_api_key}}; + +# A product can be fetched by id or by name in the path. +$t->get_ok($url . "rest/product/$public" => $admin_headers)->status_is(200) + ->json_is('/products/0/name' => 'Another Product'); +$t->get_ok($url . 'rest/product/Another Product' => $admin_headers) + ->status_is(200)->json_is('/products/0/id' => $public); + +# include_fields accepts a comma-separated list. +$t->get_ok($url . "rest/product/$public?include_fields=id,name" => $admin_headers) + ->status_is(200); +is_deeply([sort keys %{$t->tx->res->json->{products}[0]}], + ['id', 'name'], 'include_fields=id,name returns only those fields'); + +# At least one of ids, names or type is required. +$t->get_ok($url . 'rest/product' => $admin_headers)->status_is(400) + ->json_like('/message' => qr/requires\s+that you set one of the following/); + +# An unknown type is rejected. +$t->get_ok($url . 'rest/product?type=bogus' => $admin_headers) + ->status_is(400)->json_like('/message' => qr/'bogus' is invalid/); + +$t->options_ok($url . 'rest/product')->status_is(200) + ->header_is('Allow' => 'GET, POST'); + done_testing(); diff --git a/qa/t/rest_product_create.t b/qa/t/rest_product_create.t index ff9989b13e..8082ea4970 100644 --- a/qa/t/rest_product_create.t +++ b/qa/t/rest_product_create.t @@ -19,6 +19,7 @@ use lib qw(lib ../../lib ../../local/lib/perl5); use Bugzilla; use QA::Util qw(get_config random_string); +use Mojo::JSON qw(false); use Test::Mojo; use Test::More; @@ -165,4 +166,23 @@ foreach my $test (@tests) { "Product has the correct value for has_unconfirmed: $has_unco"); } +# Booleans passed in the query string arrive as the strings "true"/"false" +# and must not all be treated as true. +my $qs_name = random_string(20); +$t->post_ok($url + . "rest/product?name=$qs_name&description=Created%20via%20query%20string" + . '&version=' . PROD_VERSION + . '&is_open=false&has_unconfirmed=false' => + {'X-Bugzilla-API-Key' => $admin_api_key})->status_is(201); +my $qs_id = $t->tx->res->json->{id}; +$t->get_ok($url . "rest/product/$qs_id" => {'X-Bugzilla-API-Key' => $admin_api_key}) + ->status_is(200)->json_is('/products/0/is_active' => false) + ->json_is('/products/0/has_unconfirmed' => false); + +$t->post_ok($url + . 'rest/product?name=' . random_string(20) + . '&description=x&version=' . PROD_VERSION . '&is_open=maybe' => + {'X-Bugzilla-API-Key' => $admin_api_key})->status_is(400) + ->json_like('/message' => qr/is_open must be true or false/); + done_testing(); From a639956605583c22f722b464d92964dcc46957f6 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 24 Sep 2026 19:41:51 +0200 Subject: [PATCH 2/2] Bug 2071907 - Remove the Update Product section from the REST docs --- docs/en/rst/api/core/v1/product.rst | 117 ---------------------------- 1 file changed, 117 deletions(-) diff --git a/docs/en/rst/api/core/v1/product.rst b/docs/en/rst/api/core/v1/product.rst index 178d0f67fa..f58e445e82 100644 --- a/docs/en/rst/api/core/v1/product.rst +++ b/docs/en/rst/api/core/v1/product.rst @@ -358,120 +358,3 @@ id int ID of the newly-filed product. (Product names must be globally unique in Bugzilla.) * 703 (Product must have description) You must specify a description for this product. - -.. _rest_product_update: - -Update Product --------------- - -This allows you to update a product in Bugzilla. - -**Request** - -.. code-block:: text - - PUT /rest/product/(id_or_name) - -You can edit a single product by passing the ID or name of the product -in the URL. To edit more than one product, you can specify addition IDs or -product names using the ``ids`` or ``names`` parameters respectively. - -.. code-block:: js - - { - "ids" : [123], - "name" : "BarName", - "has_unconfirmed" : false - } - -One of the below must be specified. - -============== ===== ========================================================== -name type description -============== ===== ========================================================== -**id_or_name** mixed Integer product ID or name. -**ids** array Numeric IDs of the products that you wish to update. -**names** array Names of the products that you wish to update. -============== ===== ========================================================== - -The following parameters specify the new values you want to set for the product(s) -you are updating. - -================= ======= ===================================================== -name type description -================= ======= ===================================================== -name string A new name for this product. If you try to set this - while updating more than one product, an error will - occur, as product names must be unique. -default_bug_type string The default type for bugs filed under this product. - Each component can override this value. -default_milestone string When a new bug is filed, what milestone does it - get by default if the user does not choose one? Must - represent a milestone that is valid for this product. -default_version string When a new bug is filed, what version does it - get by default if the user does not choose one? Must - represent a version that is valid for this product. -description string Update the long description for these products to - this value. -has_unconfirmed boolean Allow the UNCONFIRMED status to be set on bugs in - products. -is_open boolean ``true`` if the product is currently allowing bugs - to be entered into it, ``false`` otherwise. -================= ======= ===================================================== - -**Response** - -.. code-block:: js - - { - "products" : [ - { - "id" : 123, - "changes" : { - "name" : { - "removed" : "FooName", - "added" : "BarName" - }, - "has_unconfirmed" : { - "removed" : "1", - "added" : "0" - } - } - } - ] - } - -``products`` (array) Product change objects containing the following items: - -======= ====== ================================================================ -name type description -======= ====== ================================================================ -id int The ID of the product that was updated. -changes object The changes that were actually done on this product. The - keys are the names of the fields that were changed, and the - values are an object with two items: - - * added: (string) The value that this field was changed to. - * removed: (string) The value that was previously set in this - field. -======= ====== ================================================================ - -Booleans will be represented with the strings '1' and '0' for changed values -as they are stored as strings in the database currently. - -**Errors** - -* 700 (Product blank name) - You must specify a non-blank name for this product. -* 701 (Product name too long) - The name specified for this product was longer than the maximum - allowed length. -* 702 (Product name already exists) - You specified the name of a product that already exists. - (Product names must be globally unique in Bugzilla.) -* 703 (Product must have description) - You must specify a description for this product. -* 705 (Product must define a default milestone) - You must define a default milestone. -* 706 (Product must define a default version) - You must define a default version.