diff options
Diffstat (limited to 'Bugzilla/API/Server.pm')
-rw-r--r-- | Bugzilla/API/Server.pm | 654 |
1 files changed, 654 insertions, 0 deletions
diff --git a/Bugzilla/API/Server.pm b/Bugzilla/API/Server.pm new file mode 100644 index 000000000..c2682ab8a --- /dev/null +++ b/Bugzilla/API/Server.pm @@ -0,0 +1,654 @@ +# 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::Server; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Util qw(trick_taint trim disable_utf8); + +use Digest::MD5 qw(md5_base64); +use File::Spec qw(catfile); +use HTTP::Request; +use HTTP::Response; +use JSON; +use Moo; +use Module::Runtime qw(require_module); +use Scalar::Util qw(blessed); +use Storable qw(freeze); + +############# +# Constants # +############# + +use constant DEFAULT_API_VERSION => '1_0'; +use constant DEFAULT_API_NAMESPACE => 'core'; + +################################# +# Set up basic accessor methods # +################################# + +has api_ext => (is => 'rw', default => 0); +has api_ext_version => (is => 'rw', default => ''); +has api_options => (is => 'rw', default => sub { [] }); +has api_params => (is => 'rw', default => sub { {} }); +has api_path => (is => 'rw', default => ''); +has cgi => (is => 'lazy'); +has content_type => (is => 'lazy'); +has controller => (is => 'rw', default => undef); +has json => (is => 'lazy'); +has load_error => (is => 'rw', default => undef); +has method_name => (is => 'rw', default => ''); +has request => (is => 'lazy'); +has success_code => (is => 'rw', default => 200); + +################## +# Public methods # +################## + +sub server { + my ($class) = @_; + + my $api_namespace = DEFAULT_API_NAMESPACE; + my $api_version = DEFAULT_API_VERSION; + + # First load the default server in case something fails + # we still have something to return. + my $server_class = "Bugzilla::API::${api_version}::Server"; + require_module($server_class); + my $self = $server_class->new; + + my $path_info = Bugzilla->cgi->path_info; + + # If we do not match /<namespace>/<version>/ then we assume legacy calls + # and use the default namespace and version. + if ($path_info =~ m|^/([^/]+)/(\d+\.\d+(?:\.\d+)?)/|) { + # First figure out the namespace we are accessing (core is native) + $api_namespace = $1 if $path_info =~ s|^/([^/]+)||; + $api_namespace = $self->_check_namespace($api_namespace); + + # Figure out which version we are looking for based on path + $api_version = $1 if $path_info =~ s|^/(\d+\.\d+(?:\.\d+)?)(/.*)$|$2|; + $api_version = $self->_check_version($api_version, $api_namespace); + } + + # If the version pulled from the path is different than + # what the server is currently, then reload as the new version. + if ($api_version ne $self->api_version) { + my $server_class = "Bugzilla::API::${api_version}::Server"; + require_module($server_class); + $self = $server_class->new; + } + + # Stuff away for later + $self->api_path($path_info); + + return $self; +} + +sub constants { + my ($self) = @_; + my $api_version = $self->api_version; + + no strict 'refs'; + + my $class = "Bugzilla::API::${api_version}::Constants"; + require_module($class); + + my %constants; + foreach my $constant (@{$class . "::EXPORT"}, @{$class . "::EXPORT_OK"}) { + if (ref $class->$constant) { + $constants{$constant} = $class->$constant; + } + else { + my @list = ($class->$constant); + $constants{$constant} = (scalar(@list) == 1) ? $list[0] : \@list; + } + } + + return \%constants; +} + +sub response_header { + my ($self, $code, $result) = @_; + # The HTTP body needs to be bytes (not a utf8 string) for recent + # versions of HTTP::Message, but JSON::RPC::Server doesn't handle this + # properly. $_[1] is the HTTP body content we're going to be sending. + if (utf8::is_utf8($_[2])) { + utf8::encode($_[2]); + # Since we're going to just be sending raw bytes, we need to + # set STDOUT to not expect utf8. + disable_utf8(); + } + my $h = HTTP::Headers->new; + $h->header('Content-Type' => $self->content_type . '; charset=UTF-8'); + return HTTP::Response->new($code => undef, $h, $result); +} + +################################### +# Public methods to be overridden # +################################### + +sub handle { } +sub response { } +sub print_response { } +sub handle_login { } + +################### +# Utility methods # +################### + +sub return_error { + my ($self, $status_code, $message, $error_code) = @_; + if ($status_code && $message) { + $self->{_return_error} = { + status_code => $status_code, + error => JSON::true, + message => $message + }; + $self->{_return_error}->{code} = $error_code if $error_code; + } + return $self->{_return_error}; +} + +sub callback { + my ($self, $value) = @_; + if (defined $value) { + $value = trim($value); + # We don't use \w because we don't want to allow Unicode here. + if ($value !~ /^[A-Za-z0-9_\.\[\]]+$/) { + ThrowUserError('json_rpc_invalid_callback', { callback => $value }); + } + $self->{_callback} = $value; + # JSONP needs to be parsed by a JS parser, not by a JSON parser. + $self->content_type('text/javascript'); + } + return $self->{_callback}; +} + +# ETag support +sub etag { + my ($self, $data) = @_; + my $cache = Bugzilla->request_cache; + if (defined $data) { + # Serialize the data if passed a reference + local $Storable::canonical = 1; + $data = freeze($data) if ref $data; + + # Wide characters cause md5_base64() to die. + utf8::encode($data) if utf8::is_utf8($data); + + # Append content_type to the end of the data + # string as we want the etag to be unique to + # the content_type. We do not need this for + # XMLRPC as text/xml is always returned. + if (blessed($self) && $self->can('content_type')) { + $data .= $self->content_type if $self->content_type; + } + + $cache->{'_etag'} = md5_base64($data); + } + return $cache->{'_etag'}; +} + +# HACK: Allow error tag checking to work with t/012throwables.t +sub ThrowUserError { + my ($error, $self, $vars) = @_; + $self->load_error({ type => 'user', + error => $error, + vars => $vars }); +} + +sub ThrowCodeError { + my ($error, $self, $vars) = @_; + $self->load_error({ type => 'code', + error => $error, + vars => $vars }); +} + +################### +# Private methods # +################### + +sub _build_cgi { + return Bugzilla->cgi; +} + +sub _build_content_type { + return 'application/json'; +} + +sub _build_json { + # This may seem a little backwards to set utf8(0), but what this really + # means is "don't convert our utf8 into byte strings, just leave it as a + # utf8 string." + return JSON->new->utf8(0) + ->allow_blessed(1) + ->convert_blessed(1); +} + +sub _build_request { + return HTTP::Request->new($_[0]->cgi->request_method, $_[0]->cgi->url); +} + +sub _check_namespace { + my ($self, $namespace) = @_; + + # No need to do anything else if native api + return $namespace if lc($namespace) eq lc(DEFAULT_API_NAMESPACE); + + # Check if namespace matches an extension name + my $found = 0; + foreach my $extension (@{ Bugzilla->extensions }) { + $found = 1 if lc($extension->NAME) eq lc($namespace); + } + # Make sure we have this namespace available + if (!$found) { + ThrowUserError('unknown_api_namespace', $self, + { api_namespace => $namespace }); + return DEFAULT_API_NAMESPACE; + } + + return $namespace; +} + +sub _check_version { + my ($self, $version, $namespace) = @_; + + return DEFAULT_API_VERSION if !defined $version; + + my $old_version = $version; + $version =~ s/\./_/g; + + my $version_dir; + if (lc($namespace) eq 'core') { + $version_dir = File::Spec->catdir('Bugzilla', 'API', $version); + } + else { + $version_dir = File::Spec->catdir(bz_locations()->{extensionsdir}, + $namespace, 'API', $version); + } + + # Make sure we actual have this version installed + if (!-d $version_dir) { + ThrowUserError('unknown_api_version', $self, + { api_version => $old_version, + api_namespace => $namespace }); + return DEFAULT_API_VERSION; + } + + # If we using an extension API, we need to determing which version of + # the Core API it was written for. + if (lc($namespace) ne 'core') { + my $core_api_version; + foreach my $extension (@{ Bugzilla->extensions }) { + next if lc($extension->NAME) ne lc($namespace); + if ($extension->API_VERSION_MAP + && $extension->API_VERSION_MAP->{$version}) + { + $self->api_ext_version($version); + $version = $extension->API_VERSION_MAP->{$version}; + } + } + } + + return $version; +} + +sub _best_content_type { + my ($self, @types) = @_; + my @accept_types = $self->_get_content_prefs(); + # Return the types as-is if no accept header sent, since sorting will be a no-op. + if (!@accept_types) { + return $types[0]; + } + my $score = sub { $self->_score_type(shift, @accept_types) }; + my @scored_types = sort {$score->($b) <=> $score->($a)} @types; + return $scored_types[0] || '*/*'; +} + +sub _score_type { + my ($self, $type, @accept_types) = @_; + my $score = scalar(@accept_types); + for my $accept_type (@accept_types) { + return $score if $type eq $accept_type; + $score--; + } + return 0; +} + +sub _get_content_prefs { + my $self = shift; + my $default_weight = 1; + my @prefs; + + # Parse the Accept header, and save type name, score, and position. + my @accept_types = split /,/, $self->cgi->http('accept') || ''; + my $order = 0; + for my $accept_type (@accept_types) { + my ($weight) = ($accept_type =~ /q=(\d\.\d+|\d+)/); + my ($name) = ($accept_type =~ m#(\S+/[^;]+)#); + next unless $name; + push @prefs, { name => $name, order => $order++}; + if (defined $weight) { + $prefs[-1]->{score} = $weight; + } else { + $prefs[-1]->{score} = $default_weight; + $default_weight -= 0.001; + } + } + + # Sort the types by score, subscore by order, and pull out just the name + @prefs = map {$_->{name}} sort {$b->{score} <=> $a->{score} || + $a->{order} <=> $b->{order}} @prefs; + return @prefs; +} + +#################################### +# Private methods to be overridden # +#################################### + +sub _handle { } +sub _params_check { } +sub _retrieve_json_params { } +sub _find_resource { } + +1; + +__END__ + +=head1 NAME + +Bugzilla::API::Server - The Web Service API interface to Bugzilla + +=head1 DESCRIPTION + +This is the standard API for external programs that want to interact +with Bugzilla. It provides various resources in various modules. + +You interact with this API using L<REST|Bugzilla::API::Server>. + +Full client documentation for the Bugzilla API can be found at +L<https://bugzilla.readthedocs.org/en/latest/api/index.html>. + +=head1 USAGE + +Methodl are grouped into "namespaces", like C<core> for +native Bugzilla API methods. Extensions reside in their own +I<namespaces> such as C<Example>. So, for example: + +GET /example/1.0/bug1 + +calls + +GET /bug/1 + +in the C<Example> namespace. + +The endpoint for the API interface is the C<rest.cgi> script in +your Bugzilla installation. For example, if your Bugzilla is at +C<bugzilla.yourdomain.com>, to access the API and load a bug, +you would use C<http://bugzilla.yourdomain.com/rest.cgi/core/1.0/bug/35>. + +If using Apache and mod_rewrite is installed and enabled, you can +simplify the endpoint by changing /rest.cgi/ to something like /api/ +or something similar. So the same example from above would be: +C<http://bugzilla.yourdomain.com/api/core/1.0/bug/35> which is simpler +to remember. + +Add this to your .htaccess file: + + <IfModule mod_rewrite.c> + RewriteEngine On + RewriteRule ^rest/(.*)$ rest.cgi/$1 [NE] + </IfModule> + +=head1 BROWSING + +If the Accept: header of a request is set to text/html (as it is by an +ordinary web browser) then the API will return the JSON data as a HTML +page which the browser can display. In other words, you can play with the +API using just your browser and see results in a human-readable form. +This is a good way to try out the various GET calls, even if you can't use +it for POST or PUT. + +=head1 DATA FORMAT + +The API only supports JSON input, and either JSON and JSONP output. +So objects sent and received must be in JSON format. + +On every request, you must set both the "Accept" and "Content-Type" HTTP +headers to the MIME type of the data format you are using to communicate with +the API. Content-Type tells the API how to interpret your request, and Accept +tells it how you want your data back. "Content-Type" must be "application/json". +"Accept" can be either that, or "application/javascript" for JSONP - add a "callback" +parameter to name your callback. + +Parameters may also be passed in as part of the query string for non-GET requests +and will override any matching parameters in the request body. + +=head1 AUTHENTICATION + +Along with viewing data as an anonymous user, you may also see private information +if you have a Bugzilla account by providing your login credentials. + +=over + +=item Login name and password + +Pass in as query parameters of any request: + +login=fred@example.com&password=ilovecheese + +Remember to URL encode any special characters, which are often seen in passwords and to +also enable SSL support. + +=item Login token + +By calling GET /login?login=fred@example.com&password=ilovecheese, you get back +a C<token> value which can then be passed to each subsequent call as +authentication. This is useful for third party clients that cannot use cookies +and do not want to store a user's login and password in the client. You can also +pass in "token" as a convenience. + +=item API Key + +You can also authenticate by passing an C<api_key> value as part of the query +parameters which is setup using the I<API Keys> tab in C<userprefs.cgi>. + +=back + +=head1 ERRORS + +When an API error occurs, a data structure is returned with the key C<error> +set to C<true>. + +The error contents look similar to: + + { "error": true, "message": "Some message here", "code": 123 } + +=head1 CONSTANTS + +=over + +=item DEFAULT_API_VERSION + +The default API version that is used by C<server>. +Current default is L<1.0> which is the first version of the API implemented in this way.. + +=item DEFAULT_API_NAMESPACE + +The default API namespace that is used if C<server> is called before C<init_serber>. +Current default is L<core> which is the native API methods (non-extension). + +=back + +=head1 METHODS + +The L<Bugzilla::API::Server> has the following methods used by various +code in Bugzilla. + +=over + +=item server + +Returns a L<Bugzilla::API::Server> object after looking at the cgi path to +determine which version of the API is being requested and which namespace to +load methods from. A new server instance of the proper version is returned. + +=item constants + +A method return a hash containing the constants from the Constants.pm module +in the API version directory. The calling code will not need to know which +version of the API is being used to access the constant values. + +=item json + +Returns a L<JSON> encode/decoder object. + +=item cgi + +Returns a L<Bugzilla::CGI> object. + +=item request + +Returns a L<HTTP::Request> object. + +=item response_header + +Returns a L<HTTP::Response> object with the appropriate content-type set. +Requires that a status code and content data to be passed in. + +=item handle + +Handles the current request by finding the correct resource, setting the parameters, +authentication, executing the resource, and forming an appropriate response. + +=item response + +Encodes the return data in the requested content-type and also does some other +changes such as conversion to JSONP and setting status_code. Also sets the eTag +header values based on the result content. + +=item print_response + +Prints the final response headers and content to STDOUT. + +=item handle_login + +Authenticates the user and performs additional checks. + +=item return_error + +If an error occurs, this method will return a data structure describing the error +with a code and message. + +=item callback + +When calling the API over GET, you can use the "JSONP" method of doing cross-domain +requests, if you want to access the API directly on a web page from another site. +JSONP is described at L<http://bob.pythonmac.org/archives/2005/12/05/remote-json-jsonp/>. + +To use JSONP with Bugzilla's API, simply specify a C<callback> parameter when +using it via GET as described above. For example, here's some HTML you could use +to get the time on a remote Bugzilla website, using JSONP: + + <script type="text/javascript" src="http://bugzilla.example.com/time?callback=foo"> + +That would call the API path for C<time> and pass its value to a function +called C<foo> as the only argument. All the other URL parameters (such as for +passing in arguments to methods) that can be passed during GET requests are also +available, of course. The above is just the simplest possible example. + +The values returned when using JSONP are identical to the values returned +when not using JSONP, so you will also get error messages if there is an +error. + +The C<callback> URL parameter may only contain letters, numbers, periods, and +the underscore (C<_>) character. Including any other characters will cause +Bugzilla to throw an error. (This error will be a normal API response, not JSONP.) + +=item etag + +Using the data structure passed to the subroutine, we convert the data to a string +and then md5 hash the string to creates a value for the eTag header. This allows +a user to include the value in seubsequent requests and only return the full data +if it has changed. + +=item api_ext + +A boolean value signifying if the current request is for an API method is exported +by an extension or is part of the core methods. + +=item api_ext_version + +If the current request is for an extension API method, this is the version of the +extension API that should be used. + +=item api_namespace + +The current namespace of the API method being requested as determined by the +cgi path. If a namespace is not provided, we default to L<core>. + +=item api_options + +Once a resource has been matched to the current request, this the available options +to the client such as GET, PUT, etc. + +=item api_params + +Once a resource has been matched, this is the params that were pulled from the +regex used to match the resource. This could be a resource id or name such as +a bug id, etc. + +=item api_path + +The final cgi path after namespace and version have been removed. This is the +path used to locate a matching resource from the controller modules. + +=item api_version + +The current version of the L<core> API that is being used for processing the +request. Note that this version may be different from C<api_ext_version> if +the client requested a method in an extension's namespace. + +=item content_type + +The content-type of the data that will be returned. The current default is +L<application/json>. If a caller is msking a request using a browser, it will +most likely be L<text/html>. + +=item controller + +Once a resource has been matched, this is the controller module that contains +the method that will be executed. + +=item method_name + +The method in the controller module that will be executed to handle the request. + +=item success_code + +The success code to be used when creating the L<response> object to be returned. +It can be different depending on if the request was successful, a resource was +created, or an error occurred. + +=back + +=head1 B<Methods in need of POD> + +=over + +=item ThrowCodeError + +=item ThrowUserError + +=back + |