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