1# Copyright 2016 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""A library for converting service configs to discovery docs."""
16
17import collections
18import json
19import logging
20import re
21
22import api_exceptions
23import message_parser
24from protorpc import message_types
25from protorpc import messages
26from protorpc import remote
27import resource_container
28import util
29
30
31_PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}'
32
33_MULTICLASS_MISMATCH_ERROR_TEMPLATE = (
34    'Attempting to implement service %s, version %s, with multiple '
35    'classes that are not compatible. See docstring for api() for '
36    'examples how to implement a multi-class API.')
37
38_INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.'
39
40_API_KEY = 'api_key'
41_API_KEY_PARAM = 'key'
42
43CUSTOM_VARIANT_MAP = {
44    messages.Variant.DOUBLE: ('number', 'double'),
45    messages.Variant.FLOAT: ('number', 'float'),
46    messages.Variant.INT64: ('string', 'int64'),
47    messages.Variant.SINT64: ('string', 'int64'),
48    messages.Variant.UINT64: ('string', 'uint64'),
49    messages.Variant.INT32: ('integer', 'int32'),
50    messages.Variant.SINT32: ('integer', 'int32'),
51    messages.Variant.UINT32: ('integer', 'uint32'),
52    messages.Variant.BOOL: ('boolean', None),
53    messages.Variant.STRING: ('string', None),
54    messages.Variant.BYTES: ('string', 'byte'),
55    messages.Variant.ENUM: ('string', None),
56}
57
58
59
60class DiscoveryGenerator(object):
61  """Generates a discovery doc from a ProtoRPC service.
62
63  Example:
64
65    class HelloRequest(messages.Message):
66      my_name = messages.StringField(1, required=True)
67
68    class HelloResponse(messages.Message):
69      hello = messages.StringField(1, required=True)
70
71    class HelloService(remote.Service):
72
73      @remote.method(HelloRequest, HelloResponse)
74      def hello(self, request):
75        return HelloResponse(hello='Hello there, %s!' %
76                             request.my_name)
77
78    api_config = DiscoveryGenerator().pretty_print_config_to_json(HelloService)
79
80  The resulting api_config will be a JSON discovery document describing the API
81  implemented by HelloService.
82  """
83
84  # Constants for categorizing a request method.
85  # __NO_BODY - Request without a request body, such as GET and DELETE methods.
86  # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body.
87  __NO_BODY = 1  # pylint: disable=invalid-name
88  __HAS_BODY = 2  # pylint: disable=invalid-name
89
90  def __init__(self):
91    self.__parser = message_parser.MessageTypeToJsonSchema()
92
93    # Maps method id to the request schema id.
94    self.__request_schema = {}
95
96    # Maps method id to the response schema id.
97    self.__response_schema = {}
98
99  def _get_resource_path(self, method_id):
100    """Return the resource path for a method or an empty array if none."""
101    return method_id.split('.')[1:-1]
102
103  def _get_canonical_method_id(self, method_id):
104    return method_id.split('.')[-1]
105
106  def __get_request_kind(self, method_info):
107    """Categorize the type of the request.
108
109    Args:
110      method_info: _MethodInfo, method information.
111
112    Returns:
113      The kind of request.
114    """
115    if method_info.http_method in ('GET', 'DELETE'):
116      return self.__NO_BODY
117    else:
118      return self.__HAS_BODY
119
120  def __field_to_subfields(self, field):
121    """Fully describes data represented by field, including the nested case.
122
123    In the case that the field is not a message field, we have no fields nested
124    within a message definition, so we can simply return that field. However, in
125    the nested case, we can't simply describe the data with one field or even
126    with one chain of fields.
127
128    For example, if we have a message field
129
130      m_field = messages.MessageField(RefClass, 1)
131
132    which references a class with two fields:
133
134      class RefClass(messages.Message):
135        one = messages.StringField(1)
136        two = messages.IntegerField(2)
137
138    then we would need to include both one and two to represent all the
139    data contained.
140
141    Calling __field_to_subfields(m_field) would return:
142    [
143      [<MessageField "m_field">, <StringField "one">],
144      [<MessageField "m_field">, <StringField "two">],
145    ]
146
147    If the second field was instead a message field
148
149      class RefClass(messages.Message):
150        one = messages.StringField(1)
151        two = messages.MessageField(OtherRefClass, 2)
152
153    referencing another class with two fields
154
155      class OtherRefClass(messages.Message):
156        three = messages.BooleanField(1)
157        four = messages.FloatField(2)
158
159    then we would need to recurse one level deeper for two.
160
161    With this change, calling __field_to_subfields(m_field) would return:
162    [
163      [<MessageField "m_field">, <StringField "one">],
164      [<MessageField "m_field">, <StringField "two">, <StringField "three">],
165      [<MessageField "m_field">, <StringField "two">, <StringField "four">],
166    ]
167
168    Args:
169      field: An instance of a subclass of messages.Field.
170
171    Returns:
172      A list of lists, where each sublist is a list of fields.
173    """
174    # Termination condition
175    if not isinstance(field, messages.MessageField):
176      return [[field]]
177
178    result = []
179    for subfield in sorted(field.message_type.all_fields(),
180                           key=lambda f: f.number):
181      subfield_results = self.__field_to_subfields(subfield)
182      for subfields_list in subfield_results:
183        subfields_list.insert(0, field)
184        result.append(subfields_list)
185    return result
186
187  def __field_to_parameter_type_and_format(self, field):
188    """Converts the field variant type into a tuple describing the parameter.
189
190    Args:
191      field: An instance of a subclass of messages.Field.
192
193    Returns:
194      A tuple with the type and format of the field, respectively.
195
196    Raises:
197      TypeError: if the field variant is a message variant.
198    """
199    # We use lowercase values for types (e.g. 'string' instead of 'STRING').
200    variant = field.variant
201    if variant == messages.Variant.MESSAGE:
202      raise TypeError('A message variant cannot be used in a parameter.')
203
204    # Note that the 64-bit integers are marked as strings -- this is to
205    # accommodate JavaScript, which would otherwise demote them to 32-bit
206    # integers.
207
208    return CUSTOM_VARIANT_MAP.get(variant) or (variant.name.lower(), None)
209
210  def __get_path_parameters(self, path):
211    """Parses path paremeters from a URI path and organizes them by parameter.
212
213    Some of the parameters may correspond to message fields, and so will be
214    represented as segments corresponding to each subfield; e.g. first.second if
215    the field "second" in the message field "first" is pulled from the path.
216
217    The resulting dictionary uses the first segments as keys and each key has as
218    value the list of full parameter values with first segment equal to the key.
219
220    If the match path parameter is null, that part of the path template is
221    ignored; this occurs if '{}' is used in a template.
222
223    Args:
224      path: String; a URI path, potentially with some parameters.
225
226    Returns:
227      A dictionary with strings as keys and list of strings as values.
228    """
229    path_parameters_by_segment = {}
230    for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path):
231      first_segment = format_var_name.split('.', 1)[0]
232      matches = path_parameters_by_segment.setdefault(first_segment, [])
233      matches.append(format_var_name)
234
235    return path_parameters_by_segment
236
237  def __validate_simple_subfield(self, parameter, field, segment_list,
238                                 segment_index=0):
239    """Verifies that a proposed subfield actually exists and is a simple field.
240
241    Here, simple means it is not a MessageField (nested).
242
243    Args:
244      parameter: String; the '.' delimited name of the current field being
245          considered. This is relative to some root.
246      field: An instance of a subclass of messages.Field. Corresponds to the
247          previous segment in the path (previous relative to _segment_index),
248          since this field should be a message field with the current segment
249          as a field in the message class.
250      segment_list: The full list of segments from the '.' delimited subfield
251          being validated.
252      segment_index: Integer; used to hold the position of current segment so
253          that segment_list can be passed as a reference instead of having to
254          copy using segment_list[1:] at each step.
255
256    Raises:
257      TypeError: If the final subfield (indicated by _segment_index relative
258        to the length of segment_list) is a MessageField.
259      TypeError: If at any stage the lookup at a segment fails, e.g if a.b
260        exists but a.b.c does not exist. This can happen either if a.b is not
261        a message field or if a.b.c is not a property on the message class from
262        a.b.
263    """
264    if segment_index >= len(segment_list):
265      # In this case, the field is the final one, so should be simple type
266      if isinstance(field, messages.MessageField):
267        field_class = field.__class__.__name__
268        raise TypeError('Can\'t use messages in path. Subfield %r was '
269                        'included but is a %s.' % (parameter, field_class))
270      return
271
272    segment = segment_list[segment_index]
273    parameter += '.' + segment
274    try:
275      field = field.type.field_by_name(segment)
276    except (AttributeError, KeyError):
277      raise TypeError('Subfield %r from path does not exist.' % (parameter,))
278
279    self.__validate_simple_subfield(parameter, field, segment_list,
280                                    segment_index=segment_index + 1)
281
282  def __validate_path_parameters(self, field, path_parameters):
283    """Verifies that all path parameters correspond to an existing subfield.
284
285    Args:
286      field: An instance of a subclass of messages.Field. Should be the root
287          level property name in each path parameter in path_parameters. For
288          example, if the field is called 'foo', then each path parameter should
289          begin with 'foo.'.
290      path_parameters: A list of Strings representing URI parameter variables.
291
292    Raises:
293      TypeError: If one of the path parameters does not start with field.name.
294    """
295    for param in path_parameters:
296      segment_list = param.split('.')
297      if segment_list[0] != field.name:
298        raise TypeError('Subfield %r can\'t come from field %r.'
299                        % (param, field.name))
300      self.__validate_simple_subfield(field.name, field, segment_list[1:])
301
302  def __parameter_default(self, field):
303    """Returns default value of field if it has one.
304
305    Args:
306      field: A simple field.
307
308    Returns:
309      The default value of the field, if any exists, with the exception of an
310          enum field, which will have its value cast to a string.
311    """
312    if field.default:
313      if isinstance(field, messages.EnumField):
314        return field.default.name
315      else:
316        return field.default
317
318  def __parameter_enum(self, param):
319    """Returns enum descriptor of a parameter if it is an enum.
320
321    An enum descriptor is a list of keys.
322
323    Args:
324      param: A simple field.
325
326    Returns:
327      The enum descriptor for the field, if it's an enum descriptor, else
328          returns None.
329    """
330    if isinstance(param, messages.EnumField):
331      return [enum_entry[0] for enum_entry in sorted(
332          param.type.to_dict().items(), key=lambda v: v[1])]
333
334  def __parameter_descriptor(self, param):
335    """Creates descriptor for a parameter.
336
337    Args:
338      param: The parameter to be described.
339
340    Returns:
341      Dictionary containing a descriptor for the parameter.
342    """
343    descriptor = {}
344
345    param_type, param_format = self.__field_to_parameter_type_and_format(param)
346
347    # Required
348    if param.required:
349      descriptor['required'] = True
350
351    # Type
352    descriptor['type'] = param_type
353
354    # Format (optional)
355    if param_format:
356      descriptor['format'] = param_format
357
358    # Default
359    default = self.__parameter_default(param)
360    if default is not None:
361      descriptor['default'] = default
362
363    # Repeated
364    if param.repeated:
365      descriptor['repeated'] = True
366
367    # Enum
368    # Note that enumDescriptions are not currently supported using the
369    # framework's annotations, so just insert blank strings.
370    enum_descriptor = self.__parameter_enum(param)
371    if enum_descriptor is not None:
372      descriptor['enum'] = enum_descriptor
373      descriptor['enumDescriptions'] = [''] * len(enum_descriptor)
374
375    return descriptor
376
377  def __add_parameter(self, param, path_parameters, params):
378    """Adds all parameters in a field to a method parameters descriptor.
379
380    Simple fields will only have one parameter, but a message field 'x' that
381    corresponds to a message class with fields 'y' and 'z' will result in
382    parameters 'x.y' and 'x.z', for example. The mapping from field to
383    parameters is mostly handled by __field_to_subfields.
384
385    Args:
386      param: Parameter to be added to the descriptor.
387      path_parameters: A list of parameters matched from a path for this field.
388         For example for the hypothetical 'x' from above if the path was
389         '/a/{x.z}/b/{other}' then this list would contain only the element
390         'x.z' since 'other' does not match to this field.
391      params: List of parameters. Each parameter in the field.
392    """
393    # If this is a simple field, just build the descriptor and append it.
394    # Otherwise, build a schema and assign it to this descriptor
395    descriptor = None
396    if not isinstance(param, messages.MessageField):
397      name = param.name
398      descriptor = self.__parameter_descriptor(param)
399      descriptor['location'] = 'path' if name in path_parameters else 'query'
400
401      if descriptor:
402        params[name] = descriptor
403    else:
404      for subfield_list in self.__field_to_subfields(param):
405        name = '.'.join(subfield.name for subfield in subfield_list)
406        descriptor = self.__parameter_descriptor(subfield_list[-1])
407        if name in path_parameters:
408          descriptor['required'] = True
409          descriptor['location'] = 'path'
410        else:
411          descriptor.pop('required', None)
412          descriptor['location'] = 'query'
413
414        if descriptor:
415          params[name] = descriptor
416
417
418  def __params_descriptor_without_container(self, message_type,
419                                            request_kind, path):
420    """Describe parameters of a method which does not use a ResourceContainer.
421
422    Makes sure that the path parameters are included in the message definition
423    and adds any required fields and URL query parameters.
424
425    This method is to preserve backwards compatibility and will be removed in
426    a future release.
427
428    Args:
429      message_type: messages.Message class, Message with parameters to describe.
430      request_kind: The type of request being made.
431      path: string, HTTP path to method.
432
433    Returns:
434      A list of dicts: Descriptors of the parameters
435    """
436    params = {}
437
438    path_parameter_dict = self.__get_path_parameters(path)
439    for field in sorted(message_type.all_fields(), key=lambda f: f.number):
440      matched_path_parameters = path_parameter_dict.get(field.name, [])
441      self.__validate_path_parameters(field, matched_path_parameters)
442      if matched_path_parameters or request_kind == self.__NO_BODY:
443        self.__add_parameter(field, matched_path_parameters, params)
444
445    return params
446
447  def __params_descriptor(self, message_type, request_kind, path, method_id,
448                          request_params_class):
449    """Describe the parameters of a method.
450
451    If the message_type is not a ResourceContainer, will fall back to
452    __params_descriptor_without_container (which will eventually be deprecated).
453
454    If the message type is a ResourceContainer, then all path/query parameters
455    will come from the ResourceContainer. This method will also make sure all
456    path parameters are covered by the message fields.
457
458    Args:
459      message_type: messages.Message or ResourceContainer class, Message with
460        parameters to describe.
461      request_kind: The type of request being made.
462      path: string, HTTP path to method.
463      method_id: string, Unique method identifier (e.g. 'myapi.items.method')
464      request_params_class: messages.Message, the original params message when
465        using a ResourceContainer. Otherwise, this should be null.
466
467    Returns:
468      A tuple (dict, list of string): Descriptor of the parameters, Order of the
469        parameters.
470    """
471    path_parameter_dict = self.__get_path_parameters(path)
472
473    if request_params_class is None:
474      if path_parameter_dict:
475        logging.warning('Method %s specifies path parameters but you are not '
476                        'using a ResourceContainer. This will fail in future '
477                        'releases; please switch to using ResourceContainer as '
478                        'soon as possible.', method_id)
479      return self.__params_descriptor_without_container(
480          message_type, request_kind, path)
481
482    # From here, we can assume message_type is from a ResourceContainer.
483    message_type = request_params_class
484
485    params = {}
486
487    # Make sure all path parameters are covered.
488    for field_name, matched_path_parameters in path_parameter_dict.iteritems():
489      field = message_type.field_by_name(field_name)
490      self.__validate_path_parameters(field, matched_path_parameters)
491
492    # Add all fields, sort by field.number since we have parameterOrder.
493    for field in sorted(message_type.all_fields(), key=lambda f: f.number):
494      matched_path_parameters = path_parameter_dict.get(field.name, [])
495      self.__add_parameter(field, matched_path_parameters, params)
496
497    return params
498
499  def __params_order_descriptor(self, message_type, path):
500    """Describe the order of path parameters.
501
502    Args:
503      message_type: messages.Message class, Message with parameters to describe.
504      path: string, HTTP path to method.
505
506    Returns:
507      Descriptor list for the parameter order.
508    """
509    descriptor = []
510    path_parameter_dict = self.__get_path_parameters(path)
511
512    for field in sorted(message_type.all_fields(), key=lambda f: f.number):
513      matched_path_parameters = path_parameter_dict.get(field.name, [])
514      if not isinstance(field, messages.MessageField):
515        name = field.name
516        if name in matched_path_parameters:
517          descriptor.append(name)
518      else:
519        for subfield_list in self.__field_to_subfields(field):
520          name = '.'.join(subfield.name for subfield in subfield_list)
521          if name in matched_path_parameters:
522            descriptor.append(name)
523
524    return descriptor
525
526  def __schemas_descriptor(self):
527    """Describes the schemas section of the discovery document.
528
529    Returns:
530      Dictionary describing the schemas of the document.
531    """
532    # Filter out any keys that aren't 'properties', 'type', or 'id'
533    result = {}
534    for schema_key, schema_value in self.__parser.schemas().iteritems():
535      field_keys = schema_value.keys()
536      key_result = {}
537
538      # Some special processing for the properties value
539      if 'properties' in field_keys:
540        key_result['properties'] = schema_value['properties'].copy()
541        # Add in enumDescriptions for any enum properties and strip out
542        # the required tag for consistency with Java framework
543        for prop_key, prop_value in schema_value['properties'].iteritems():
544          if 'enum' in prop_value:
545            num_enums = len(prop_value['enum'])
546            key_result['properties'][prop_key]['enumDescriptions'] = (
547                [''] * num_enums)
548          key_result['properties'][prop_key].pop('required', None)
549
550      for key in ('type', 'id', 'description'):
551        if key in field_keys:
552          key_result[key] = schema_value[key]
553
554      if key_result:
555        result[schema_key] = key_result
556
557    # Add 'type': 'object' to all object properties
558    for schema_value in result.itervalues():
559      for field_value in schema_value.itervalues():
560        if isinstance(field_value, dict):
561          if '$ref' in field_value:
562            field_value['type'] = 'object'
563
564    return result
565
566  def __request_message_descriptor(self, request_kind, message_type, method_id,
567                                   request_body_class):
568    """Describes the parameters and body of the request.
569
570    Args:
571      request_kind: The type of request being made.
572      message_type: messages.Message or ResourceContainer class. The message to
573          describe.
574      method_id: string, Unique method identifier (e.g. 'myapi.items.method')
575      request_body_class: messages.Message of the original body when using
576          a ResourceContainer. Otherwise, this should be null.
577
578    Returns:
579      Dictionary describing the request.
580
581    Raises:
582      ValueError: if the method path and request required fields do not match
583    """
584    if request_body_class:
585      message_type = request_body_class
586
587    if (request_kind != self.__NO_BODY and
588        message_type != message_types.VoidMessage()):
589      self.__request_schema[method_id] = self.__parser.add_message(
590          message_type.__class__)
591      return {
592          '$ref': self.__request_schema[method_id],
593          'parameterName': 'resource',
594      }
595
596  def __response_message_descriptor(self, message_type, method_id):
597    """Describes the response.
598
599    Args:
600      message_type: messages.Message class, The message to describe.
601      method_id: string, Unique method identifier (e.g. 'myapi.items.method')
602
603    Returns:
604      Dictionary describing the response.
605    """
606    if message_type != message_types.VoidMessage():
607      self.__parser.add_message(message_type.__class__)
608      self.__response_schema[method_id] = self.__parser.ref_for_message_type(
609          message_type.__class__)
610      return {'$ref': self.__response_schema[method_id]}
611    else:
612      return None
613
614  def __method_descriptor(self, service, method_info,
615                          protorpc_method_info):
616    """Describes a method.
617
618    Args:
619      service: endpoints.Service, Implementation of the API as a service.
620      method_info: _MethodInfo, Configuration for the method.
621      protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC
622        description of the method.
623
624    Returns:
625      Dictionary describing the method.
626    """
627    descriptor = {}
628
629    request_message_type = (resource_container.ResourceContainer.
630                            get_request_message(protorpc_method_info.remote))
631    request_kind = self.__get_request_kind(method_info)
632    remote_method = protorpc_method_info.remote
633
634    method_id = method_info.method_id(service.api_info)
635
636    path = method_info.get_path(service.api_info)
637
638    description = protorpc_method_info.remote.method.__doc__
639
640    descriptor['id'] = method_id
641    descriptor['path'] = path
642    descriptor['httpMethod'] = method_info.http_method
643
644    if description:
645      descriptor['description'] = description
646
647    descriptor['scopes'] = [
648        'https://www.googleapis.com/auth/userinfo.email'
649    ]
650
651    parameters = self.__params_descriptor(
652        request_message_type, request_kind, path, method_id,
653        method_info.request_params_class)
654    if parameters:
655      descriptor['parameters'] = parameters
656
657    if method_info.request_params_class:
658      parameter_order = self.__params_order_descriptor(
659        method_info.request_params_class, path)
660    else:
661      parameter_order = self.__params_order_descriptor(
662        request_message_type, path)
663    if parameter_order:
664      descriptor['parameterOrder'] = parameter_order
665
666    request_descriptor = self.__request_message_descriptor(
667        request_kind, request_message_type, method_id,
668        method_info.request_body_class)
669    if request_descriptor is not None:
670      descriptor['request'] = request_descriptor
671
672    response_descriptor = self.__response_message_descriptor(
673        remote_method.response_type(), method_info.method_id(service.api_info))
674    if response_descriptor is not None:
675      descriptor['response'] = response_descriptor
676
677    return descriptor
678
679  def __resource_descriptor(self, resource_path, methods):
680    """Describes a resource.
681
682    Args:
683      resource_path: string, the path of the resource (e.g., 'entries.items')
684      methods: list of tuples of type
685        (endpoints.Service, protorpc.remote._RemoteMethodInfo), the methods
686        that serve this resource.
687
688    Returns:
689      Dictionary describing the resource.
690    """
691    descriptor = {}
692    method_map = {}
693    sub_resource_index = collections.defaultdict(list)
694    sub_resource_map = {}
695
696    resource_path_tokens = resource_path.split('.')
697    for service, protorpc_meth_info in methods:
698      method_info = getattr(protorpc_meth_info, 'method_info', None)
699      path = method_info.get_path(service.api_info)
700      method_id = method_info.method_id(service.api_info)
701      canonical_method_id = self._get_canonical_method_id(method_id)
702
703      current_resource_path = self._get_resource_path(method_id)
704
705      # Sanity-check that this method belongs to the resource path
706      if (current_resource_path[:len(resource_path_tokens)] !=
707          resource_path_tokens):
708        raise api_exceptions.ToolError(
709            'Internal consistency error in resource path {0}'.format(
710                current_resource_path))
711
712      # Remove the portion of the current method's resource path that's already
713      # part of the resource path at this level.
714      effective_resource_path = current_resource_path[
715          len(resource_path_tokens):]
716
717      # If this method is part of a sub-resource, note it and skip it for now
718      if effective_resource_path:
719        sub_resource_name = effective_resource_path[0]
720        new_resource_path = '.'.join([resource_path, sub_resource_name])
721        sub_resource_index[new_resource_path].append(
722            (service, protorpc_meth_info))
723      else:
724        method_map[canonical_method_id] = self.__method_descriptor(
725            service, method_info, protorpc_meth_info)
726
727    # Process any sub-resources
728    for sub_resource, sub_resource_methods in sub_resource_index.items():
729      sub_resource_name = sub_resource.split('.')[-1]
730      sub_resource_map[sub_resource_name] = self.__resource_descriptor(
731          sub_resource, sub_resource_methods)
732
733    if method_map:
734      descriptor['methods'] = method_map
735
736    if sub_resource_map:
737      descriptor['resources'] = sub_resource_map
738
739    return descriptor
740
741  def __standard_parameters_descriptor(self):
742    return {
743        'alt': {
744            'type': 'string',
745            'description': 'Data format for the response.',
746            'default': 'json',
747            'enum': ['json'],
748            'enumDescriptions': [
749                'Responses with Content-Type of application/json'
750            ],
751            'location': 'query',
752        },
753        'fields': {
754          'type': 'string',
755          'description': 'Selector specifying which fields to include in a '
756                         'partial response.',
757          'location': 'query',
758        },
759        'key': {
760            'type': 'string',
761            'description': 'API key. Your API key identifies your project and '
762                           'provides you with API access, quota, and reports. '
763                           'Required unless you provide an OAuth 2.0 token.',
764            'location': 'query',
765        },
766        'oauth_token': {
767            'type': 'string',
768            'description': 'OAuth 2.0 token for the current user.',
769            'location': 'query',
770        },
771        'prettyPrint': {
772            'type': 'boolean',
773            'description': 'Returns response with indentations and line '
774                           'breaks.',
775            'default': 'true',
776            'location': 'query',
777        },
778        'quotaUser': {
779            'type': 'string',
780            'description': 'Available to use for quota purposes for '
781                           'server-side applications. Can be any arbitrary '
782                           'string assigned to a user, but should not exceed '
783                           '40 characters. Overrides userIp if both are '
784                           'provided.',
785            'location': 'query',
786        },
787        'userIp': {
788            'type': 'string',
789            'description': 'IP address of the site where the request '
790                           'originates. Use this if you want to enforce '
791                           'per-user limits.',
792            'location': 'query',
793        },
794    }
795
796  def __standard_auth_descriptor(self):
797    return {
798        'oauth2': {
799            'scopes': {
800                'https://www.googleapis.com/auth/userinfo.email': {
801                    'description': 'View your email address'
802                }
803            }
804        }
805    }
806
807  def __get_merged_api_info(self, services):
808    """Builds a description of an API.
809
810    Args:
811      services: List of protorpc.remote.Service instances implementing an
812        api/version.
813
814    Returns:
815      The _ApiInfo object to use for the API that the given services implement.
816
817    Raises:
818      ApiConfigurationError: If there's something wrong with the API
819        configuration, such as a multiclass API decorated with different API
820        descriptors (see the docstring for api()).
821    """
822    merged_api_info = services[0].api_info
823
824    # Verify that, if there are multiple classes here, they're allowed to
825    # implement the same API.
826    for service in services[1:]:
827      if not merged_api_info.is_same_api(service.api_info):
828        raise api_exceptions.ApiConfigurationError(
829            _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name,
830                                                   service.api_info.version))
831
832    return merged_api_info
833
834  def __discovery_doc_descriptor(self, services, hostname=None):
835    """Builds a discovery doc for an API.
836
837    Args:
838      services: List of protorpc.remote.Service instances implementing an
839        api/version.
840      hostname: string, Hostname of the API, to override the value set on the
841        current service. Defaults to None.
842
843    Returns:
844      A dictionary that can be deserialized into JSON in discovery doc format.
845
846    Raises:
847      ApiConfigurationError: If there's something wrong with the API
848        configuration, such as a multiclass API decorated with different API
849        descriptors (see the docstring for api()), or a repeated method
850        signature.
851    """
852    merged_api_info = self.__get_merged_api_info(services)
853    descriptor = self.get_descriptor_defaults(merged_api_info,
854                                              hostname=hostname)
855
856    description = merged_api_info.description
857    if not description and len(services) == 1:
858      description = services[0].__doc__
859    if description:
860      descriptor['description'] = description
861
862    descriptor['parameters'] = self.__standard_parameters_descriptor()
863    descriptor['auth'] = self.__standard_auth_descriptor()
864
865    method_map = {}
866    method_collision_tracker = {}
867    rest_collision_tracker = {}
868
869    resource_index = collections.defaultdict(list)
870    resource_map = {}
871
872    # For the first pass, only process top-level methods (that is, those methods
873    # that are unattached to a resource).
874    for service in services:
875      remote_methods = service.all_remote_methods()
876
877      for protorpc_meth_name, protorpc_meth_info in remote_methods.iteritems():
878        method_info = getattr(protorpc_meth_info, 'method_info', None)
879        # Skip methods that are not decorated with @method
880        if method_info is None:
881          continue
882        path = method_info.get_path(service.api_info)
883        method_id = method_info.method_id(service.api_info)
884        canonical_method_id = self._get_canonical_method_id(method_id)
885        resource_path = self._get_resource_path(method_id)
886
887        # Make sure the same method name isn't repeated.
888        if method_id in method_collision_tracker:
889          raise api_exceptions.ApiConfigurationError(
890              'Method %s used multiple times, in classes %s and %s' %
891              (method_id, method_collision_tracker[method_id],
892               service.__name__))
893        else:
894          method_collision_tracker[method_id] = service.__name__
895
896        # Make sure the same HTTP method & path aren't repeated.
897        rest_identifier = (method_info.http_method, path)
898        if rest_identifier in rest_collision_tracker:
899          raise api_exceptions.ApiConfigurationError(
900              '%s path "%s" used multiple times, in classes %s and %s' %
901              (method_info.http_method, path,
902               rest_collision_tracker[rest_identifier],
903               service.__name__))
904        else:
905          rest_collision_tracker[rest_identifier] = service.__name__
906
907        # If this method is part of a resource, note it and skip it for now
908        if resource_path:
909          resource_index[resource_path[0]].append((service, protorpc_meth_info))
910        else:
911          method_map[canonical_method_id] = self.__method_descriptor(
912              service, method_info, protorpc_meth_info)
913
914    # Do another pass for methods attached to resources
915    for resource, resource_methods in resource_index.items():
916      resource_map[resource] = self.__resource_descriptor(resource,
917          resource_methods)
918
919    if method_map:
920      descriptor['methods'] = method_map
921
922    if resource_map:
923      descriptor['resources'] = resource_map
924
925    # Add schemas, if any
926    schemas = self.__schemas_descriptor()
927    if schemas:
928      descriptor['schemas'] = schemas
929
930    return descriptor
931
932  def get_descriptor_defaults(self, api_info, hostname=None):
933    """Gets a default configuration for a service.
934
935    Args:
936      api_info: _ApiInfo object for this service.
937      hostname: string, Hostname of the API, to override the value set on the
938        current service. Defaults to None.
939
940    Returns:
941      A dictionary with the default configuration.
942    """
943    hostname = (hostname or util.get_app_hostname() or
944                api_info.hostname)
945    protocol = 'http' if ((hostname and hostname.startswith('localhost')) or
946                          util.is_running_on_devserver()) else 'https'
947    full_base_path = '{0}{1}/{2}/'.format(api_info.base_path,
948                                          api_info.name,
949                                          api_info.version)
950    base_url = '{0}://{1}{2}'.format(protocol, hostname, full_base_path)
951    root_url = '{0}://{1}{2}'.format(protocol, hostname, api_info.base_path)
952    defaults = {
953        'kind': 'discovery#restDescription',
954        'discoveryVersion': 'v1',
955        'id': '{0}:{1}'.format(api_info.name, api_info.version),
956        'name': api_info.name,
957        'version': api_info.version,
958        'icons': {
959            'x16': 'http://www.google.com/images/icons/product/search-16.gif',
960            'x32': 'http://www.google.com/images/icons/product/search-32.gif'
961        },
962        'protocol': 'rest',
963        'servicePath': '{0}/{1}/'.format(api_info.name, api_info.version),
964        'batchPath': 'batch',
965        'basePath': full_base_path,
966        'rootUrl': root_url,
967        'baseUrl': base_url,
968    }
969
970    return defaults
971
972  def get_discovery_doc(self, services, hostname=None):
973    """JSON dict description of a protorpc.remote.Service in discovery format.
974
975    Args:
976      services: Either a single protorpc.remote.Service or a list of them
977        that implements an api/version.
978      hostname: string, Hostname of the API, to override the value set on the
979        current service. Defaults to None.
980
981    Returns:
982      dict, The discovery document as a JSON dict.
983    """
984
985    if not isinstance(services, (tuple, list)):
986      services = [services]
987
988    # The type of a class that inherits from remote.Service is actually
989    # remote._ServiceClass, thanks to metaclass strangeness.
990    # pylint: disable=protected-access
991    util.check_list_type(services, remote._ServiceClass, 'services',
992                         allow_none=False)
993
994    return self.__discovery_doc_descriptor(services, hostname=hostname)
995
996  def pretty_print_config_to_json(self, services, hostname=None):
997    """JSON string description of a protorpc.remote.Service in a discovery doc.
998
999    Args:
1000      services: Either a single protorpc.remote.Service or a list of them
1001        that implements an api/version.
1002      hostname: string, Hostname of the API, to override the value set on the
1003        current service. Defaults to None.
1004
1005    Returns:
1006      string, The discovery doc descriptor document as a JSON string.
1007    """
1008    descriptor = self.get_discovery_doc(services, hostname)
1009    return json.dumps(descriptor, sort_keys=True, indent=2,
1010                      separators=(',', ': '))
1011