blob: 058bf8d4d79be605e26cad96d67d21bb1f661f21 [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 OpenAPI (Swagger) specs."""
16from __future__ import absolute_import
17
18import hashlib
19import json
20import logging
21import re
22
23from . import api_exceptions
24from . import message_parser
25from . import message_types
26from . import messages
27from . import remote
28from . import resource_container
29from . import util
30
31_logger = logging.getLogger(__name__)
32
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 aren\'t 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_DEFAULT_SECURITY_DEFINITION = 'google_id_token'
45
46
47_VALID_API_NAME = re.compile('^[a-z][a-z0-9]{0,39}$')
48
49
50def _validate_api_name(name):
51 valid = (_VALID_API_NAME.match(name) is not None)
52 if not valid:
53 raise api_exceptions.InvalidApiNameException(
54 'The API name must match the regular expression {}'.format(
55 _VALID_API_NAME.pattern[1:-1]))
56 return name
57
58
59class OpenApiGenerator(object):
60 """Generates an OpenAPI spec from a ProtoRPC service.
61
62 Example:
63
64 class HelloRequest(messages.Message):
65 my_name = messages.StringField(1, required=True)
66
67 class HelloResponse(messages.Message):
68 hello = messages.StringField(1, required=True)
69
70 class HelloService(remote.Service):
71
72 @remote.method(HelloRequest, HelloResponse)
73 def hello(self, request):
74 return HelloResponse(hello='Hello there, %s!' %
75 request.my_name)
76
77 api_config = OpenApiGenerator().pretty_print_config_to_json(HelloService)
78
79 The resulting api_config will be a JSON OpenAPI document describing the API
80 implemented by HelloService.
81 """
82
83 # Constants for categorizing a request method.
84 # __NO_BODY - Request without a request body, such as GET and DELETE methods.
85 # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body.
86 __NO_BODY = 1 # pylint: disable=invalid-name
87 __HAS_BODY = 2 # pylint: disable=invalid-name
88
89 def __init__(self):
90 self.__parser = message_parser.MessageTypeToJsonSchema()
91
92 # Maps method id to the request schema id.
93 self.__request_schema = {}
94
95 # Maps method id to the response schema id.
96 self.__response_schema = {}
97
98 def _add_def_paths(self, prop_dict):
99 """Recursive method to add relative paths for any $ref objects.
100
101 Args:
102 prop_dict: The property dict to alter.
103
104 Side Effects:
105 Alters prop_dict in-place.
106 """
107 for prop_key, prop_value in prop_dict.items():
108 if prop_key == '$ref' and not 'prop_value'.startswith('#'):
109 prop_dict[prop_key] = '#/definitions/' + prop_dict[prop_key]
110 elif isinstance(prop_value, dict):
111 self._add_def_paths(prop_value)
112
113 def _construct_operation_id(self, service_name, protorpc_method_name):
114 """Return an operation id for a service method.
115
116 Args:
117 service_name: The name of the service.
118 protorpc_method_name: The ProtoRPC method name.
119
120 Returns:
121 A string representing the operation id.
122 """
123
124 # camelCase the ProtoRPC method name
125 method_name_camel = util.snake_case_to_headless_camel_case(
126 protorpc_method_name)
127
128 return '{0}_{1}'.format(service_name, method_name_camel)
129
130 def __get_request_kind(self, method_info):
131 """Categorize the type of the request.
132
133 Args:
134 method_info: _MethodInfo, method information.
135
136 Returns:
137 The kind of request.
138 """
139 if method_info.http_method in ('GET', 'DELETE'):
140 return self.__NO_BODY
141 else:
142 return self.__HAS_BODY
143
144 def __field_to_subfields(self, field):
145 """Fully describes data represented by field, including the nested case.
146
147 In the case that the field is not a message field, we have no fields nested
148 within a message definition, so we can simply return that field. However, in
149 the nested case, we can't simply describe the data with one field or even
150 with one chain of fields.
151
152 For example, if we have a message field
153
154 m_field = messages.MessageField(RefClass, 1)
155
156 which references a class with two fields:
157
158 class RefClass(messages.Message):
159 one = messages.StringField(1)
160 two = messages.IntegerField(2)
161
162 then we would need to include both one and two to represent all the
163 data contained.
164
165 Calling __field_to_subfields(m_field) would return:
166 [
167 [<MessageField "m_field">, <StringField "one">],
168 [<MessageField "m_field">, <StringField "two">],
169 ]
170
171 If the second field was instead a message field
172
173 class RefClass(messages.Message):
174 one = messages.StringField(1)
175 two = messages.MessageField(OtherRefClass, 2)
176
177 referencing another class with two fields
178
179 class OtherRefClass(messages.Message):
180 three = messages.BooleanField(1)
181 four = messages.FloatField(2)
182
183 then we would need to recurse one level deeper for two.
184
185 With this change, calling __field_to_subfields(m_field) would return:
186 [
187 [<MessageField "m_field">, <StringField "one">],
188 [<MessageField "m_field">, <StringField "two">, <StringField "three">],
189 [<MessageField "m_field">, <StringField "two">, <StringField "four">],
190 ]
191
192 Args:
193 field: An instance of a subclass of messages.Field.
194
195 Returns:
196 A list of lists, where each sublist is a list of fields.
197 """
198 # Termination condition
199 if not isinstance(field, messages.MessageField):
200 return [[field]]
201
202 result = []
203 for subfield in sorted(field.message_type.all_fields(),
204 key=lambda f: f.number):
205 subfield_results = self.__field_to_subfields(subfield)
206 for subfields_list in subfield_results:
207 subfields_list.insert(0, field)
208 result.append(subfields_list)
209 return result
210
211 def __field_to_parameter_type_and_format(self, field):
212 """Converts the field variant type into a tuple describing the parameter.
213
214 Args:
215 field: An instance of a subclass of messages.Field.
216
217 Returns:
218 A tuple with the type and format of the field, respectively.
219
220 Raises:
221 TypeError: if the field variant is a message variant.
222 """
223 # We use lowercase values for types (e.g. 'string' instead of 'STRING').
224 variant = field.variant
225 if variant == messages.Variant.MESSAGE:
226 raise TypeError('A message variant can\'t be used in a parameter.')
227
228 # Note that the 64-bit integers are marked as strings -- this is to
229 # accommodate JavaScript, which would otherwise demote them to 32-bit
230 # integers.
231
232 custom_variant_map = {
233 messages.Variant.DOUBLE: ('number', 'double'),
234 messages.Variant.FLOAT: ('number', 'float'),
235 messages.Variant.INT64: ('string', 'int64'),
236 messages.Variant.SINT64: ('string', 'int64'),
237 messages.Variant.UINT64: ('string', 'uint64'),
238 messages.Variant.INT32: ('integer', 'int32'),
239 messages.Variant.SINT32: ('integer', 'int32'),
240 messages.Variant.UINT32: ('integer', 'uint32'),
241 messages.Variant.BOOL: ('boolean', None),
242 messages.Variant.STRING: ('string', None),
243 messages.Variant.BYTES: ('string', 'byte'),
244 messages.Variant.ENUM: ('string', None),
245 }
246 return custom_variant_map.get(variant) or (variant.name.lower(), None)
247
248 def __get_path_parameters(self, path):
249 """Parses path paremeters from a URI path and organizes them by parameter.
250
251 Some of the parameters may correspond to message fields, and so will be
252 represented as segments corresponding to each subfield; e.g. first.second if
253 the field "second" in the message field "first" is pulled from the path.
254
255 The resulting dictionary uses the first segments as keys and each key has as
256 value the list of full parameter values with first segment equal to the key.
257
258 If the match path parameter is null, that part of the path template is
259 ignored; this occurs if '{}' is used in a template.
260
261 Args:
262 path: String; a URI path, potentially with some parameters.
263
264 Returns:
265 A dictionary with strings as keys and list of strings as values.
266 """
267 path_parameters_by_segment = {}
268 for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path):
269 first_segment = format_var_name.split('.', 1)[0]
270 matches = path_parameters_by_segment.setdefault(first_segment, [])
271 matches.append(format_var_name)
272
273 return path_parameters_by_segment
274
275 def __validate_simple_subfield(self, parameter, field, segment_list,
276 segment_index=0):
277 """Verifies that a proposed subfield actually exists and is a simple field.
278
279 Here, simple means it is not a MessageField (nested).
280
281 Args:
282 parameter: String; the '.' delimited name of the current field being
283 considered. This is relative to some root.
284 field: An instance of a subclass of messages.Field. Corresponds to the
285 previous segment in the path (previous relative to _segment_index),
286 since this field should be a message field with the current segment
287 as a field in the message class.
288 segment_list: The full list of segments from the '.' delimited subfield
289 being validated.
290 segment_index: Integer; used to hold the position of current segment so
291 that segment_list can be passed as a reference instead of having to
292 copy using segment_list[1:] at each step.
293
294 Raises:
295 TypeError: If the final subfield (indicated by _segment_index relative
296 to the length of segment_list) is a MessageField.
297 TypeError: If at any stage the lookup at a segment fails, e.g if a.b
298 exists but a.b.c does not exist. This can happen either if a.b is not
299 a message field or if a.b.c is not a property on the message class from
300 a.b.
301 """
302 if segment_index >= len(segment_list):
303 # In this case, the field is the final one, so should be simple type
304 if isinstance(field, messages.MessageField):
305 field_class = field.__class__.__name__
306 raise TypeError('Can\'t use messages in path. Subfield %r was '
307 'included but is a %s.' % (parameter, field_class))
308 return
309
310 segment = segment_list[segment_index]
311 parameter += '.' + segment
312 try:
313 field = field.type.field_by_name(segment)
314 except (AttributeError, KeyError):
315 raise TypeError('Subfield %r from path does not exist.' % (parameter,))
316
317 self.__validate_simple_subfield(parameter, field, segment_list,
318 segment_index=segment_index + 1)
319
320 def __validate_path_parameters(self, field, path_parameters):
321 """Verifies that all path parameters correspond to an existing subfield.
322
323 Args:
324 field: An instance of a subclass of messages.Field. Should be the root
325 level property name in each path parameter in path_parameters. For
326 example, if the field is called 'foo', then each path parameter should
327 begin with 'foo.'.
328 path_parameters: A list of Strings representing URI parameter variables.
329
330 Raises:
331 TypeError: If one of the path parameters does not start with field.name.
332 """
333 for param in path_parameters:
334 segment_list = param.split('.')
335 if segment_list[0] != field.name:
336 raise TypeError('Subfield %r can\'t come from field %r.'
337 % (param, field.name))
338 self.__validate_simple_subfield(field.name, field, segment_list[1:])
339
340 def __parameter_default(self, field):
341 """Returns default value of field if it has one.
342
343 Args:
344 field: A simple field.
345
346 Returns:
347 The default value of the field, if any exists, with the exception of an
348 enum field, which will have its value cast to a string.
349 """
350 if field.default:
351 if isinstance(field, messages.EnumField):
352 return field.default.name
353 else:
354 return field.default
355
356 def __parameter_enum(self, param):
357 """Returns enum descriptor of a parameter if it is an enum.
358
359 An enum descriptor is a list of keys.
360
361 Args:
362 param: A simple field.
363
364 Returns:
365 The enum descriptor for the field, if it's an enum descriptor, else
366 returns None.
367 """
368 if isinstance(param, messages.EnumField):
369 return [enum_entry[0] for enum_entry in sorted(
370 param.type.to_dict().items(), key=lambda v: v[1])]
371
372 def __body_parameter_descriptor(self, method_id):
373 return {
374 'name': 'body',
375 'in': 'body',
376 'required': True,
377 'schema': {
378 '$ref': '#/definitions/{0}'.format(
379 self.__request_schema[method_id])
380 }
381 }
382
383 def __non_body_parameter_descriptor(self, param):
384 """Creates descriptor for a parameter.
385
386 Args:
387 param: The parameter to be described.
388
389 Returns:
390 Dictionary containing a descriptor for the parameter.
391 """
392 descriptor = {}
393
394 descriptor['name'] = param.name
395
396 param_type, param_format = self.__field_to_parameter_type_and_format(param)
397
398 # Required
399 if param.required:
400 descriptor['required'] = True
401
402 # Type
403 descriptor['type'] = param_type
404
405 # Format (optional)
406 if param_format:
407 descriptor['format'] = param_format
408
409 # Default
410 default = self.__parameter_default(param)
411 if default is not None:
412 descriptor['default'] = default
413
414 # Repeated
415 if param.repeated:
416 descriptor['repeated'] = True
417
418 # Enum
419 enum_descriptor = self.__parameter_enum(param)
420 if enum_descriptor is not None:
421 descriptor['enum'] = enum_descriptor
422
423 return descriptor
424
425 def __path_parameter_descriptor(self, param):
426 descriptor = self.__non_body_parameter_descriptor(param)
427 descriptor['required'] = True
428 descriptor['in'] = 'path'
429
430 return descriptor
431
432 def __query_parameter_descriptor(self, param):
433 descriptor = self.__non_body_parameter_descriptor(param)
434 descriptor['in'] = 'query'
435
436 # If this is a repeated field, convert it to the collectionFormat: multi
437 # style.
438 if param.repeated:
439 descriptor['collectionFormat'] = 'multi'
440 descriptor['items'] = {
441 'type': descriptor['type']
442 }
443 descriptor['type'] = 'array'
444 descriptor.pop('repeated', None)
445
446 return descriptor
447
448 def __add_parameter(self, param, path_parameters, params):
449 """Adds all parameters in a field to a method parameters descriptor.
450
451 Simple fields will only have one parameter, but a message field 'x' that
452 corresponds to a message class with fields 'y' and 'z' will result in
453 parameters 'x.y' and 'x.z', for example. The mapping from field to
454 parameters is mostly handled by __field_to_subfields.
455
456 Args:
457 param: Parameter to be added to the descriptor.
458 path_parameters: A list of parameters matched from a path for this field.
459 For example for the hypothetical 'x' from above if the path was
460 '/a/{x.z}/b/{other}' then this list would contain only the element
461 'x.z' since 'other' does not match to this field.
462 params: List of parameters. Each parameter in the field.
463 """
464 # If this is a simple field, just build the descriptor and append it.
465 # Otherwise, build a schema and assign it to this descriptor
466 if not isinstance(param, messages.MessageField):
467 if param.name in path_parameters:
468 descriptor = self.__path_parameter_descriptor(param)
469 else:
470 descriptor = self.__query_parameter_descriptor(param)
471
472 params.append(descriptor)
473 else:
474 # If a subfield of a MessageField is found in the path, build a descriptor
475 # for the path parameter.
476 for subfield_list in self.__field_to_subfields(param):
477 qualified_name = '.'.join(subfield.name for subfield in subfield_list)
478 if qualified_name in path_parameters:
479 descriptor = self.__path_parameter_descriptor(subfield_list[-1])
480 descriptor['required'] = True
481
482 params.append(descriptor)
483
484 def __params_descriptor_without_container(self, message_type,
485 request_kind, method_id, path):
486 """Describe parameters of a method which does not use a ResourceContainer.
487
488 Makes sure that the path parameters are included in the message definition
489 and adds any required fields and URL query parameters.
490
491 This method is to preserve backwards compatibility and will be removed in
492 a future release.
493
494 Args:
495 message_type: messages.Message class, Message with parameters to describe.
496 request_kind: The type of request being made.
497 method_id: string, Unique method identifier (e.g. 'myapi.items.method')
498 path: string, HTTP path to method.
499
500 Returns:
501 A list of dicts: Descriptors of the parameters
502 """
503 params = []
504
505 path_parameter_dict = self.__get_path_parameters(path)
506 for field in sorted(message_type.all_fields(), key=lambda f: f.number):
507 matched_path_parameters = path_parameter_dict.get(field.name, [])
508 self.__validate_path_parameters(field, matched_path_parameters)
509
510 if matched_path_parameters or request_kind == self.__NO_BODY:
511 self.__add_parameter(field, matched_path_parameters, params)
512
513 # If the request has a body, add the body parameter
514 if (message_type != message_types.VoidMessage() and
515 request_kind == self.__HAS_BODY):
516 params.append(self.__body_parameter_descriptor(method_id))
517
518 return params
519
520 def __params_descriptor(self, message_type, request_kind, path, method_id):
521 """Describe the parameters of a method.
522
523 If the message_type is not a ResourceContainer, will fall back to
524 __params_descriptor_without_container (which will eventually be deprecated).
525
526 If the message type is a ResourceContainer, then all path/query parameters
527 will come from the ResourceContainer. This method will also make sure all
528 path parameters are covered by the message fields.
529
530 Args:
531 message_type: messages.Message or ResourceContainer class, Message with
532 parameters to describe.
533 request_kind: The type of request being made.
534 path: string, HTTP path to method.
535 method_id: string, Unique method identifier (e.g. 'myapi.items.method')
536
537 Returns:
538 A tuple (dict, list of string): Descriptor of the parameters, Order of the
539 parameters.
540 """
541 path_parameter_dict = self.__get_path_parameters(path)
542
543 if not isinstance(message_type, resource_container.ResourceContainer):
544 if path_parameter_dict:
545 _logger.warning('Method %s specifies path parameters but you are not '
546 'using a ResourceContainer; instead, you are using %r. '
547 'This will fail in future releases; please switch to '
548 'using ResourceContainer as soon as possible.',
549 method_id, type(message_type))
550 return self.__params_descriptor_without_container(
551 message_type, request_kind, method_id, path)
552
553 # From here, we can assume message_type is a ResourceContainer.
554 params = []
555
556 # Process body parameter, if any
557 if message_type.body_message_class != message_types.VoidMessage:
558 params.append(self.__body_parameter_descriptor(method_id))
559
560 # Process path/querystring parameters
561 params_message_type = message_type.parameters_message_class()
562
563 # Make sure all path parameters are covered.
564 for field_name, matched_path_parameters in path_parameter_dict.items():
565 field = params_message_type.field_by_name(field_name)
566 self.__validate_path_parameters(field, matched_path_parameters)
567
568 # Add all fields, sort by field.number since we have parameterOrder.
569 for field in sorted(params_message_type.all_fields(),
570 key=lambda f: f.number):
571 matched_path_parameters = path_parameter_dict.get(field.name, [])
572 self.__add_parameter(field, matched_path_parameters, params)
573
574 return params
575
576 def __request_message_descriptor(self, request_kind, message_type, method_id,
577 path):
578 """Describes the parameters and body of the request.
579
580 Args:
581 request_kind: The type of request being made.
582 message_type: messages.Message or ResourceContainer class. The message to
583 describe.
584 method_id: string, Unique method identifier (e.g. 'myapi.items.method')
585 path: string, HTTP path to method.
586
587 Returns:
588 Dictionary describing the request.
589
590 Raises:
591 ValueError: if the method path and request required fields do not match
592 """
593 if isinstance(message_type, resource_container.ResourceContainer):
594 base_message_type = message_type.body_message_class()
595 if (request_kind == self.__NO_BODY and
596 base_message_type != message_types.VoidMessage()):
597 msg = ('Method %s specifies a body message in its ResourceContainer, but '
598 'is a HTTP method type that cannot accept a body.') % method_id
599 raise api_exceptions.ApiConfigurationError(msg)
600 else:
601 base_message_type = message_type
602
603 if (request_kind != self.__NO_BODY and
604 base_message_type != message_types.VoidMessage()):
605 self.__request_schema[method_id] = self.__parser.add_message(
606 base_message_type.__class__)
607
608 params = self.__params_descriptor(message_type, request_kind, path,
609 method_id)
610
611 return params
612
613 def __definitions_descriptor(self):
614 """Describes the definitions section of the OpenAPI spec.
615
616 Returns:
617 Dictionary describing the definitions of the spec.
618 """
619 # Filter out any keys that aren't 'properties' or 'type'
620 result = {}
621 for def_key, def_value in self.__parser.schemas().items():
622 if 'properties' in def_value or 'type' in def_value:
623 key_result = {}
624 required_keys = set()
625 if 'type' in def_value:
626 key_result['type'] = def_value['type']
627 if 'properties' in def_value:
628 for prop_key, prop_value in def_value['properties'].items():
629 if isinstance(prop_value, dict) and 'required' in prop_value:
630 required_keys.add(prop_key)
631 del prop_value['required']
632 key_result['properties'] = def_value['properties']
633 # Add in the required fields, if any
634 if required_keys:
635 key_result['required'] = sorted(required_keys)
636 result[def_key] = key_result
637
638 # Add 'type': 'object' to all object properties
639 # Also, recursively add relative path to all $ref values
640 for def_value in result.values():
641 for prop_value in def_value.values():
642 if isinstance(prop_value, dict):
643 if '$ref' in prop_value:
644 prop_value['type'] = 'object'
645 self._add_def_paths(prop_value)
646
647 return result
648
649 def __response_message_descriptor(self, message_type, method_id):
650 """Describes the response.
651
652 Args:
653 message_type: messages.Message class, The message to describe.
654 method_id: string, Unique method identifier (e.g. 'myapi.items.method')
655
656 Returns:
657 Dictionary describing the response.
658 """
659
660 # Skeleton response descriptor, common to all response objects
661 descriptor = {'200': {'description': 'A successful response'}}
662
663 if message_type != message_types.VoidMessage():
664 self.__parser.add_message(message_type.__class__)
665 self.__response_schema[method_id] = self.__parser.ref_for_message_type(
666 message_type.__class__)
667 descriptor['200']['schema'] = {'$ref': '#/definitions/{0}'.format(
668 self.__response_schema[method_id])}
669
670 return dict(descriptor)
671
672 def __x_google_quota_descriptor(self, metric_costs):
673 """Describes the metric costs for a call.
674
675 Args:
676 metric_costs: Dict of metric definitions to the integer cost value against
677 that metric.
678
679 Returns:
680 A dict descriptor describing the Quota limits for the endpoint.
681 """
682 return {
683 'metricCosts': {
684 metric: cost for (metric, cost) in metric_costs.items()
685 }
686 } if metric_costs else None
687
688 def __x_google_quota_definitions_descriptor(self, limit_definitions):
689 """Describes the quota limit definitions for an API.
690
691 Args:
692 limit_definitions: List of endpoints.LimitDefinition tuples
693
694 Returns:
695 A dict descriptor of the API's quota limit definitions.
696 """
697 if not limit_definitions:
698 return None
699
700 definitions_list = [{
701 'name': ld.metric_name,
702 'metric': ld.metric_name,
703 'unit': '1/min/{project}',
704 'values': {'STANDARD': ld.default_limit},
705 'displayName': ld.display_name,
706 } for ld in limit_definitions]
707
708 metrics = [{
709 'name': ld.metric_name,
710 'valueType': 'INT64',
711 'metricKind': 'GAUGE',
712 } for ld in limit_definitions]
713
714 return {
715 'quota': {'limits': definitions_list},
716 'metrics': metrics,
717 }
718
719 def __method_descriptor(self, service, method_info, operation_id,
720 protorpc_method_info, security_definitions):
721 """Describes a method.
722
723 Args:
724 service: endpoints.Service, Implementation of the API as a service.
725 method_info: _MethodInfo, Configuration for the method.
726 operation_id: string, Operation ID of the method
727 protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC
728 description of the method.
729 security_definitions: list of dicts, security definitions for the API.
730
731 Returns:
732 Dictionary describing the method.
733 """
734 descriptor = {}
735
736 request_message_type = (resource_container.ResourceContainer.
737 get_request_message(protorpc_method_info.remote))
738 request_kind = self.__get_request_kind(method_info)
739 remote_method = protorpc_method_info.remote
740
741 path = method_info.get_path(service.api_info)
742
743 descriptor['parameters'] = self.__request_message_descriptor(
744 request_kind, request_message_type,
745 method_info.method_id(service.api_info),
746 path)
747 descriptor['responses'] = self.__response_message_descriptor(
748 remote_method.response_type(), method_info.method_id(service.api_info))
749 descriptor['operationId'] = operation_id
750
751 # Insert the auth audiences, if any
752 api_key_required = method_info.is_api_key_required(service.api_info)
753 if method_info.audiences is not None:
754 descriptor['security'] = self.__security_descriptor(
755 method_info.audiences, security_definitions,
756 api_key_required=api_key_required)
757 elif service.api_info.audiences is not None or api_key_required:
758 descriptor['security'] = self.__security_descriptor(
759 service.api_info.audiences, security_definitions,
760 api_key_required=api_key_required)
761
762 # Insert the metric costs, if any
763 if method_info.metric_costs:
764 descriptor['x-google-quota'] = self.__x_google_quota_descriptor(
765 method_info.metric_costs)
766
767 return descriptor
768
769 def __security_descriptor(self, audiences, security_definitions,
770 api_key_required=False):
771 if not audiences:
772 if not api_key_required:
773 # no security
774 return []
775 # api key only
776 return [{_API_KEY: []}]
777
778 if isinstance(audiences, (tuple, list)):
779 # security_definitions includes not just the base issuers, but also the
780 # hash-appended versions, so we need to filter them out
781 security_issuers = set()
782 for definition_key in security_definitions.keys():
783 if definition_key == _API_KEY:
784 # API key definitions don't count for these purposes
785 continue
786 if '-' in definition_key:
787 split_key = definition_key.rsplit('-', 1)[0]
788 if split_key in security_definitions:
789 continue
790 security_issuers.add(definition_key)
791
792 if security_issuers != {_DEFAULT_SECURITY_DEFINITION}:
793 raise api_exceptions.ApiConfigurationError(
794 'audiences must be a dict when third-party issuers '
795 '(auth0, firebase, etc) are in use.'
796 )
797 audiences = {_DEFAULT_SECURITY_DEFINITION: audiences}
798
799 results = []
800 for issuer, issuer_audiences in audiences.items():
801 result_dict = {}
802 if issuer not in security_definitions:
803 raise TypeError('Missing issuer {}'.format(issuer))
804 audience_string = ','.join(sorted(issuer_audiences))
805 audience_hash = hashfunc(audience_string)
806 full_definition_key = '-'.join([issuer, audience_hash])
807 result_dict[full_definition_key] = []
808 if api_key_required:
809 result_dict[_API_KEY] = []
810 if full_definition_key not in security_definitions:
811 new_definition = dict(security_definitions[issuer])
812 new_definition['x-google-audiences'] = audience_string
813 security_definitions[full_definition_key] = new_definition
814 results.append(result_dict)
815
816 return results
817
818 def __security_definitions_descriptor(self, issuers):
819 """Create a descriptor for the security definitions.
820
821 Args:
822 issuers: dict, mapping issuer names to Issuer tuples
823
824 Returns:
825 The dict representing the security definitions descriptor.
826 """
827 if not issuers:
828 result = {
829 _DEFAULT_SECURITY_DEFINITION: {
830 'authorizationUrl': '',
831 'flow': 'implicit',
832 'type': 'oauth2',
833 'x-google-issuer': 'https://accounts.google.com',
834 'x-google-jwks_uri': 'https://www.googleapis.com/oauth2/v3/certs',
835 }
836 }
837 return result
838
839 result = {}
840
841 for issuer_key, issuer_value in issuers.items():
842 result[issuer_key] = {
843 'authorizationUrl': '',
844 'flow': 'implicit',
845 'type': 'oauth2',
846 'x-google-issuer': issuer_value.issuer,
847 }
848
849 # If jwks_uri is omitted, the auth library will use OpenID discovery
850 # to find it. Otherwise, include it in the descriptor explicitly.
851 if issuer_value.jwks_uri:
852 result[issuer_key]['x-google-jwks_uri'] = issuer_value.jwks_uri
853
854 return result
855
856 def __get_merged_api_info(self, services):
857 """Builds a description of an API.
858
859 Args:
860 services: List of protorpc.remote.Service instances implementing an
861 api/version.
862
863 Returns:
864 The _ApiInfo object to use for the API that the given services implement.
865
866 Raises:
867 ApiConfigurationError: If there's something wrong with the API
868 configuration, such as a multiclass API decorated with different API
869 descriptors (see the docstring for api()).
870 """
871 merged_api_info = services[0].api_info
872
873 # Verify that, if there are multiple classes here, they're allowed to
874 # implement the same API.
875 for service in services[1:]:
876 if not merged_api_info.is_same_api(service.api_info):
877 raise api_exceptions.ApiConfigurationError(
878 _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name,
879 service.api_info.api_version))
880
881 return merged_api_info
882
883 def __api_openapi_descriptor(self, services, hostname=None, x_google_api_name=False):
884 """Builds an OpenAPI description of an API.
885
886 Args:
887 services: List of protorpc.remote.Service instances implementing an
888 api/version.
889 hostname: string, Hostname of the API, to override the value set on the
890 current service. Defaults to None.
891
892 Returns:
893 A dictionary that can be deserialized into JSON and stored as an API
894 description document in OpenAPI format.
895
896 Raises:
897 ApiConfigurationError: If there's something wrong with the API
898 configuration, such as a multiclass API decorated with different API
899 descriptors (see the docstring for api()), or a repeated method
900 signature.
901 """
902 merged_api_info = self.__get_merged_api_info(services)
903 descriptor = self.get_descriptor_defaults(merged_api_info,
904 hostname=hostname,
905 x_google_api_name=x_google_api_name)
906
907 description = merged_api_info.description
908 if not description and len(services) == 1:
909 description = services[0].__doc__
910 if description:
911 descriptor['info']['description'] = description
912
913 security_definitions = self.__security_definitions_descriptor(
914 merged_api_info.issuers)
915
916 method_map = {}
917 method_collision_tracker = {}
918 rest_collision_tracker = {}
919
920 for service in services:
921 remote_methods = service.all_remote_methods()
922
923 for protorpc_meth_name in sorted(remote_methods.keys()):
924 protorpc_meth_info = remote_methods[protorpc_meth_name]
925 method_info = getattr(protorpc_meth_info, 'method_info', None)
926 # Skip methods that are not decorated with @method
927 if method_info is None:
928 continue
929 method_id = method_info.method_id(service.api_info)
930 is_api_key_required = method_info.is_api_key_required(service.api_info)
931 path = '/{0}/{1}/{2}'.format(merged_api_info.name,
932 merged_api_info.path_version,
933 method_info.get_path(service.api_info))
934 verb = method_info.http_method.lower()
935
936 if path not in method_map:
937 method_map[path] = {}
938
939 # If an API key is required and the security definitions don't already
940 # have the apiKey issuer, add the appropriate notation now
941 if is_api_key_required and _API_KEY not in security_definitions:
942 security_definitions[_API_KEY] = {
943 'type': 'apiKey',
944 'name': _API_KEY_PARAM,
945 'in': 'query'
946 }
947
948 # Derive an OperationId from the method name data
949 operation_id = self._construct_operation_id(
950 service.__name__, protorpc_meth_name)
951
952 method_map[path][verb] = self.__method_descriptor(
953 service, method_info, operation_id, protorpc_meth_info,
954 security_definitions)
955
956 # Make sure the same method name isn't repeated.
957 if method_id in method_collision_tracker:
958 raise api_exceptions.ApiConfigurationError(
959 'Method %s used multiple times, in classes %s and %s' %
960 (method_id, method_collision_tracker[method_id],
961 service.__name__))
962 else:
963 method_collision_tracker[method_id] = service.__name__
964
965 # Make sure the same HTTP method & path aren't repeated.
966 rest_identifier = (method_info.http_method,
967 method_info.get_path(service.api_info))
968 if rest_identifier in rest_collision_tracker:
969 raise api_exceptions.ApiConfigurationError(
970 '%s path "%s" used multiple times, in classes %s and %s' %
971 (method_info.http_method, method_info.get_path(service.api_info),
972 rest_collision_tracker[rest_identifier],
973 service.__name__))
974 else:
975 rest_collision_tracker[rest_identifier] = service.__name__
976
977 if method_map:
978 descriptor['paths'] = method_map
979
980 # Add request and/or response definitions, if any
981 definitions = self.__definitions_descriptor()
982 if definitions:
983 descriptor['definitions'] = definitions
984
985 descriptor['securityDefinitions'] = security_definitions
986
987 # Add quota limit metric definitions, if any
988 limit_definitions = self.__x_google_quota_definitions_descriptor(
989 merged_api_info.limit_definitions)
990 if limit_definitions:
991 descriptor['x-google-management'] = limit_definitions
992
993 return descriptor
994
995 def get_descriptor_defaults(self, api_info, hostname=None, x_google_api_name=False):
996 """Gets a default configuration for a service.
997
998 Args:
999 api_info: _ApiInfo object for this service.
1000 hostname: string, Hostname of the API, to override the value set on the
1001 current service. Defaults to None.
1002
1003 Returns:
1004 A dictionary with the default configuration.
1005 """
1006 hostname = (hostname or util.get_app_hostname() or
1007 api_info.hostname)
1008 protocol = 'http' if ((hostname and hostname.startswith('localhost')) or
1009 util.is_running_on_devserver()) else 'https'
1010 base_path = api_info.base_path
1011 if base_path != '/':
1012 base_path = base_path.rstrip('/')
1013 defaults = {
1014 'swagger': '2.0',
1015 'info': {
1016 'version': api_info.api_version,
1017 'title': api_info.name
1018 },
1019 'host': hostname,
1020 'consumes': ['application/json'],
1021 'produces': ['application/json'],
1022 'schemes': [protocol],
1023 'basePath': base_path,
1024 }
1025
1026 if x_google_api_name:
1027 defaults['x-google-api-name'] = _validate_api_name(api_info.name)
1028
1029 return defaults
1030
1031 def get_openapi_dict(self, services, hostname=None, x_google_api_name=False):
1032 """JSON dict description of a protorpc.remote.Service in OpenAPI format.
1033
1034 Args:
1035 services: Either a single protorpc.remote.Service or a list of them
1036 that implements an api/version.
1037 hostname: string, Hostname of the API, to override the value set on the
1038 current service. Defaults to None.
1039
1040 Returns:
1041 dict, The OpenAPI descriptor document as a JSON dict.
1042 """
1043
1044 if not isinstance(services, (tuple, list)):
1045 services = [services]
1046
1047 # The type of a class that inherits from remote.Service is actually
1048 # remote._ServiceClass, thanks to metaclass strangeness.
1049 # pylint: disable=protected-access
1050 util.check_list_type(services, remote._ServiceClass, 'services',
1051 allow_none=False)
1052
1053 return self.__api_openapi_descriptor(services, hostname=hostname, x_google_api_name=x_google_api_name)
1054
1055 def pretty_print_config_to_json(self, services, hostname=None, x_google_api_name=False):
1056 """JSON string description of a protorpc.remote.Service in OpenAPI format.
1057
1058 Args:
1059 services: Either a single protorpc.remote.Service or a list of them
1060 that implements an api/version.
1061 hostname: string, Hostname of the API, to override the value set on the
1062 current service. Defaults to None.
1063
1064 Returns:
1065 string, The OpenAPI descriptor document as a JSON string.
1066 """
1067 descriptor = self.get_openapi_dict(services, hostname, x_google_api_name=x_google_api_name)
1068 return json.dumps(descriptor, sort_keys=True, indent=2,
1069 separators=(',', ': '))
1070
1071
1072def hashfunc(string):
1073 return hashlib.md5(string).hexdigest()[:8]