| # Copyright 2016 Google Inc. All Rights Reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """A library for converting service configs to OpenAPI (Swagger) specs.""" |
| from __future__ import absolute_import |
| |
| import hashlib |
| import json |
| import logging |
| import re |
| |
| from . import api_exceptions |
| from . import message_parser |
| from . import message_types |
| from . import messages |
| from . import remote |
| from . import resource_container |
| from . import util |
| |
| _logger = logging.getLogger(__name__) |
| |
| _PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}' |
| |
| _MULTICLASS_MISMATCH_ERROR_TEMPLATE = ( |
| 'Attempting to implement service %s, version %s, with multiple ' |
| 'classes that aren\'t compatible. See docstring for api() for ' |
| 'examples how to implement a multi-class API.') |
| |
| _INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.' |
| |
| _API_KEY = 'api_key' |
| _API_KEY_PARAM = 'key' |
| _DEFAULT_SECURITY_DEFINITION = 'google_id_token' |
| |
| |
| _VALID_API_NAME = re.compile('^[a-z][a-z0-9]{0,39}$') |
| |
| |
| def _validate_api_name(name): |
| valid = (_VALID_API_NAME.match(name) is not None) |
| if not valid: |
| raise api_exceptions.InvalidApiNameException( |
| 'The API name must match the regular expression {}'.format( |
| _VALID_API_NAME.pattern[1:-1])) |
| return name |
| |
| |
| class OpenApiGenerator(object): |
| """Generates an OpenAPI spec from a ProtoRPC service. |
| |
| Example: |
| |
| class HelloRequest(messages.Message): |
| my_name = messages.StringField(1, required=True) |
| |
| class HelloResponse(messages.Message): |
| hello = messages.StringField(1, required=True) |
| |
| class HelloService(remote.Service): |
| |
| @remote.method(HelloRequest, HelloResponse) |
| def hello(self, request): |
| return HelloResponse(hello='Hello there, %s!' % |
| request.my_name) |
| |
| api_config = OpenApiGenerator().pretty_print_config_to_json(HelloService) |
| |
| The resulting api_config will be a JSON OpenAPI document describing the API |
| implemented by HelloService. |
| """ |
| |
| # Constants for categorizing a request method. |
| # __NO_BODY - Request without a request body, such as GET and DELETE methods. |
| # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body. |
| __NO_BODY = 1 # pylint: disable=invalid-name |
| __HAS_BODY = 2 # pylint: disable=invalid-name |
| |
| def __init__(self): |
| self.__parser = message_parser.MessageTypeToJsonSchema() |
| |
| # Maps method id to the request schema id. |
| self.__request_schema = {} |
| |
| # Maps method id to the response schema id. |
| self.__response_schema = {} |
| |
| def _add_def_paths(self, prop_dict): |
| """Recursive method to add relative paths for any $ref objects. |
| |
| Args: |
| prop_dict: The property dict to alter. |
| |
| Side Effects: |
| Alters prop_dict in-place. |
| """ |
| for prop_key, prop_value in prop_dict.items(): |
| if prop_key == '$ref' and not 'prop_value'.startswith('#'): |
| prop_dict[prop_key] = '#/definitions/' + prop_dict[prop_key] |
| elif isinstance(prop_value, dict): |
| self._add_def_paths(prop_value) |
| |
| def _construct_operation_id(self, service_name, protorpc_method_name): |
| """Return an operation id for a service method. |
| |
| Args: |
| service_name: The name of the service. |
| protorpc_method_name: The ProtoRPC method name. |
| |
| Returns: |
| A string representing the operation id. |
| """ |
| |
| # camelCase the ProtoRPC method name |
| method_name_camel = util.snake_case_to_headless_camel_case( |
| protorpc_method_name) |
| |
| return '{0}_{1}'.format(service_name, method_name_camel) |
| |
| def __get_request_kind(self, method_info): |
| """Categorize the type of the request. |
| |
| Args: |
| method_info: _MethodInfo, method information. |
| |
| Returns: |
| The kind of request. |
| """ |
| if method_info.http_method in ('GET', 'DELETE'): |
| return self.__NO_BODY |
| else: |
| return self.__HAS_BODY |
| |
| def __field_to_subfields(self, field): |
| """Fully describes data represented by field, including the nested case. |
| |
| In the case that the field is not a message field, we have no fields nested |
| within a message definition, so we can simply return that field. However, in |
| the nested case, we can't simply describe the data with one field or even |
| with one chain of fields. |
| |
| For example, if we have a message field |
| |
| m_field = messages.MessageField(RefClass, 1) |
| |
| which references a class with two fields: |
| |
| class RefClass(messages.Message): |
| one = messages.StringField(1) |
| two = messages.IntegerField(2) |
| |
| then we would need to include both one and two to represent all the |
| data contained. |
| |
| Calling __field_to_subfields(m_field) would return: |
| [ |
| [<MessageField "m_field">, <StringField "one">], |
| [<MessageField "m_field">, <StringField "two">], |
| ] |
| |
| If the second field was instead a message field |
| |
| class RefClass(messages.Message): |
| one = messages.StringField(1) |
| two = messages.MessageField(OtherRefClass, 2) |
| |
| referencing another class with two fields |
| |
| class OtherRefClass(messages.Message): |
| three = messages.BooleanField(1) |
| four = messages.FloatField(2) |
| |
| then we would need to recurse one level deeper for two. |
| |
| With this change, calling __field_to_subfields(m_field) would return: |
| [ |
| [<MessageField "m_field">, <StringField "one">], |
| [<MessageField "m_field">, <StringField "two">, <StringField "three">], |
| [<MessageField "m_field">, <StringField "two">, <StringField "four">], |
| ] |
| |
| Args: |
| field: An instance of a subclass of messages.Field. |
| |
| Returns: |
| A list of lists, where each sublist is a list of fields. |
| """ |
| # Termination condition |
| if not isinstance(field, messages.MessageField): |
| return [[field]] |
| |
| result = [] |
| for subfield in sorted(field.message_type.all_fields(), |
| key=lambda f: f.number): |
| subfield_results = self.__field_to_subfields(subfield) |
| for subfields_list in subfield_results: |
| subfields_list.insert(0, field) |
| result.append(subfields_list) |
| return result |
| |
| def __field_to_parameter_type_and_format(self, field): |
| """Converts the field variant type into a tuple describing the parameter. |
| |
| Args: |
| field: An instance of a subclass of messages.Field. |
| |
| Returns: |
| A tuple with the type and format of the field, respectively. |
| |
| Raises: |
| TypeError: if the field variant is a message variant. |
| """ |
| # We use lowercase values for types (e.g. 'string' instead of 'STRING'). |
| variant = field.variant |
| if variant == messages.Variant.MESSAGE: |
| raise TypeError('A message variant can\'t be used in a parameter.') |
| |
| # Note that the 64-bit integers are marked as strings -- this is to |
| # accommodate JavaScript, which would otherwise demote them to 32-bit |
| # integers. |
| |
| custom_variant_map = { |
| messages.Variant.DOUBLE: ('number', 'double'), |
| messages.Variant.FLOAT: ('number', 'float'), |
| messages.Variant.INT64: ('string', 'int64'), |
| messages.Variant.SINT64: ('string', 'int64'), |
| messages.Variant.UINT64: ('string', 'uint64'), |
| messages.Variant.INT32: ('integer', 'int32'), |
| messages.Variant.SINT32: ('integer', 'int32'), |
| messages.Variant.UINT32: ('integer', 'uint32'), |
| messages.Variant.BOOL: ('boolean', None), |
| messages.Variant.STRING: ('string', None), |
| messages.Variant.BYTES: ('string', 'byte'), |
| messages.Variant.ENUM: ('string', None), |
| } |
| return custom_variant_map.get(variant) or (variant.name.lower(), None) |
| |
| def __get_path_parameters(self, path): |
| """Parses path paremeters from a URI path and organizes them by parameter. |
| |
| Some of the parameters may correspond to message fields, and so will be |
| represented as segments corresponding to each subfield; e.g. first.second if |
| the field "second" in the message field "first" is pulled from the path. |
| |
| The resulting dictionary uses the first segments as keys and each key has as |
| value the list of full parameter values with first segment equal to the key. |
| |
| If the match path parameter is null, that part of the path template is |
| ignored; this occurs if '{}' is used in a template. |
| |
| Args: |
| path: String; a URI path, potentially with some parameters. |
| |
| Returns: |
| A dictionary with strings as keys and list of strings as values. |
| """ |
| path_parameters_by_segment = {} |
| for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path): |
| first_segment = format_var_name.split('.', 1)[0] |
| matches = path_parameters_by_segment.setdefault(first_segment, []) |
| matches.append(format_var_name) |
| |
| return path_parameters_by_segment |
| |
| def __validate_simple_subfield(self, parameter, field, segment_list, |
| segment_index=0): |
| """Verifies that a proposed subfield actually exists and is a simple field. |
| |
| Here, simple means it is not a MessageField (nested). |
| |
| Args: |
| parameter: String; the '.' delimited name of the current field being |
| considered. This is relative to some root. |
| field: An instance of a subclass of messages.Field. Corresponds to the |
| previous segment in the path (previous relative to _segment_index), |
| since this field should be a message field with the current segment |
| as a field in the message class. |
| segment_list: The full list of segments from the '.' delimited subfield |
| being validated. |
| segment_index: Integer; used to hold the position of current segment so |
| that segment_list can be passed as a reference instead of having to |
| copy using segment_list[1:] at each step. |
| |
| Raises: |
| TypeError: If the final subfield (indicated by _segment_index relative |
| to the length of segment_list) is a MessageField. |
| TypeError: If at any stage the lookup at a segment fails, e.g if a.b |
| exists but a.b.c does not exist. This can happen either if a.b is not |
| a message field or if a.b.c is not a property on the message class from |
| a.b. |
| """ |
| if segment_index >= len(segment_list): |
| # In this case, the field is the final one, so should be simple type |
| if isinstance(field, messages.MessageField): |
| field_class = field.__class__.__name__ |
| raise TypeError('Can\'t use messages in path. Subfield %r was ' |
| 'included but is a %s.' % (parameter, field_class)) |
| return |
| |
| segment = segment_list[segment_index] |
| parameter += '.' + segment |
| try: |
| field = field.type.field_by_name(segment) |
| except (AttributeError, KeyError): |
| raise TypeError('Subfield %r from path does not exist.' % (parameter,)) |
| |
| self.__validate_simple_subfield(parameter, field, segment_list, |
| segment_index=segment_index + 1) |
| |
| def __validate_path_parameters(self, field, path_parameters): |
| """Verifies that all path parameters correspond to an existing subfield. |
| |
| Args: |
| field: An instance of a subclass of messages.Field. Should be the root |
| level property name in each path parameter in path_parameters. For |
| example, if the field is called 'foo', then each path parameter should |
| begin with 'foo.'. |
| path_parameters: A list of Strings representing URI parameter variables. |
| |
| Raises: |
| TypeError: If one of the path parameters does not start with field.name. |
| """ |
| for param in path_parameters: |
| segment_list = param.split('.') |
| if segment_list[0] != field.name: |
| raise TypeError('Subfield %r can\'t come from field %r.' |
| % (param, field.name)) |
| self.__validate_simple_subfield(field.name, field, segment_list[1:]) |
| |
| def __parameter_default(self, field): |
| """Returns default value of field if it has one. |
| |
| Args: |
| field: A simple field. |
| |
| Returns: |
| The default value of the field, if any exists, with the exception of an |
| enum field, which will have its value cast to a string. |
| """ |
| if field.default: |
| if isinstance(field, messages.EnumField): |
| return field.default.name |
| else: |
| return field.default |
| |
| def __parameter_enum(self, param): |
| """Returns enum descriptor of a parameter if it is an enum. |
| |
| An enum descriptor is a list of keys. |
| |
| Args: |
| param: A simple field. |
| |
| Returns: |
| The enum descriptor for the field, if it's an enum descriptor, else |
| returns None. |
| """ |
| if isinstance(param, messages.EnumField): |
| return [enum_entry[0] for enum_entry in sorted( |
| param.type.to_dict().items(), key=lambda v: v[1])] |
| |
| def __body_parameter_descriptor(self, method_id): |
| return { |
| 'name': 'body', |
| 'in': 'body', |
| 'required': True, |
| 'schema': { |
| '$ref': '#/definitions/{0}'.format( |
| self.__request_schema[method_id]) |
| } |
| } |
| |
| def __non_body_parameter_descriptor(self, param): |
| """Creates descriptor for a parameter. |
| |
| Args: |
| param: The parameter to be described. |
| |
| Returns: |
| Dictionary containing a descriptor for the parameter. |
| """ |
| descriptor = {} |
| |
| descriptor['name'] = param.name |
| |
| param_type, param_format = self.__field_to_parameter_type_and_format(param) |
| |
| # Required |
| if param.required: |
| descriptor['required'] = True |
| |
| # Type |
| descriptor['type'] = param_type |
| |
| # Format (optional) |
| if param_format: |
| descriptor['format'] = param_format |
| |
| # Default |
| default = self.__parameter_default(param) |
| if default is not None: |
| descriptor['default'] = default |
| |
| # Repeated |
| if param.repeated: |
| descriptor['repeated'] = True |
| |
| # Enum |
| enum_descriptor = self.__parameter_enum(param) |
| if enum_descriptor is not None: |
| descriptor['enum'] = enum_descriptor |
| |
| return descriptor |
| |
| def __path_parameter_descriptor(self, param): |
| descriptor = self.__non_body_parameter_descriptor(param) |
| descriptor['required'] = True |
| descriptor['in'] = 'path' |
| |
| return descriptor |
| |
| def __query_parameter_descriptor(self, param): |
| descriptor = self.__non_body_parameter_descriptor(param) |
| descriptor['in'] = 'query' |
| |
| # If this is a repeated field, convert it to the collectionFormat: multi |
| # style. |
| if param.repeated: |
| descriptor['collectionFormat'] = 'multi' |
| descriptor['items'] = { |
| 'type': descriptor['type'] |
| } |
| descriptor['type'] = 'array' |
| descriptor.pop('repeated', None) |
| |
| return descriptor |
| |
| def __add_parameter(self, param, path_parameters, params): |
| """Adds all parameters in a field to a method parameters descriptor. |
| |
| Simple fields will only have one parameter, but a message field 'x' that |
| corresponds to a message class with fields 'y' and 'z' will result in |
| parameters 'x.y' and 'x.z', for example. The mapping from field to |
| parameters is mostly handled by __field_to_subfields. |
| |
| Args: |
| param: Parameter to be added to the descriptor. |
| path_parameters: A list of parameters matched from a path for this field. |
| For example for the hypothetical 'x' from above if the path was |
| '/a/{x.z}/b/{other}' then this list would contain only the element |
| 'x.z' since 'other' does not match to this field. |
| params: List of parameters. Each parameter in the field. |
| """ |
| # If this is a simple field, just build the descriptor and append it. |
| # Otherwise, build a schema and assign it to this descriptor |
| if not isinstance(param, messages.MessageField): |
| if param.name in path_parameters: |
| descriptor = self.__path_parameter_descriptor(param) |
| else: |
| descriptor = self.__query_parameter_descriptor(param) |
| |
| params.append(descriptor) |
| else: |
| # If a subfield of a MessageField is found in the path, build a descriptor |
| # for the path parameter. |
| for subfield_list in self.__field_to_subfields(param): |
| qualified_name = '.'.join(subfield.name for subfield in subfield_list) |
| if qualified_name in path_parameters: |
| descriptor = self.__path_parameter_descriptor(subfield_list[-1]) |
| descriptor['required'] = True |
| |
| params.append(descriptor) |
| |
| def __params_descriptor_without_container(self, message_type, |
| request_kind, method_id, path): |
| """Describe parameters of a method which does not use a ResourceContainer. |
| |
| Makes sure that the path parameters are included in the message definition |
| and adds any required fields and URL query parameters. |
| |
| This method is to preserve backwards compatibility and will be removed in |
| a future release. |
| |
| Args: |
| message_type: messages.Message class, Message with parameters to describe. |
| request_kind: The type of request being made. |
| method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| path: string, HTTP path to method. |
| |
| Returns: |
| A list of dicts: Descriptors of the parameters |
| """ |
| params = [] |
| |
| path_parameter_dict = self.__get_path_parameters(path) |
| for field in sorted(message_type.all_fields(), key=lambda f: f.number): |
| matched_path_parameters = path_parameter_dict.get(field.name, []) |
| self.__validate_path_parameters(field, matched_path_parameters) |
| |
| if matched_path_parameters or request_kind == self.__NO_BODY: |
| self.__add_parameter(field, matched_path_parameters, params) |
| |
| # If the request has a body, add the body parameter |
| if (message_type != message_types.VoidMessage() and |
| request_kind == self.__HAS_BODY): |
| params.append(self.__body_parameter_descriptor(method_id)) |
| |
| return params |
| |
| def __params_descriptor(self, message_type, request_kind, path, method_id): |
| """Describe the parameters of a method. |
| |
| If the message_type is not a ResourceContainer, will fall back to |
| __params_descriptor_without_container (which will eventually be deprecated). |
| |
| If the message type is a ResourceContainer, then all path/query parameters |
| will come from the ResourceContainer. This method will also make sure all |
| path parameters are covered by the message fields. |
| |
| Args: |
| message_type: messages.Message or ResourceContainer class, Message with |
| parameters to describe. |
| request_kind: The type of request being made. |
| path: string, HTTP path to method. |
| method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| |
| Returns: |
| A tuple (dict, list of string): Descriptor of the parameters, Order of the |
| parameters. |
| """ |
| path_parameter_dict = self.__get_path_parameters(path) |
| |
| if not isinstance(message_type, resource_container.ResourceContainer): |
| if path_parameter_dict: |
| _logger.warning('Method %s specifies path parameters but you are not ' |
| 'using a ResourceContainer; instead, you are using %r. ' |
| 'This will fail in future releases; please switch to ' |
| 'using ResourceContainer as soon as possible.', |
| method_id, type(message_type)) |
| return self.__params_descriptor_without_container( |
| message_type, request_kind, method_id, path) |
| |
| # From here, we can assume message_type is a ResourceContainer. |
| params = [] |
| |
| # Process body parameter, if any |
| if message_type.body_message_class != message_types.VoidMessage: |
| params.append(self.__body_parameter_descriptor(method_id)) |
| |
| # Process path/querystring parameters |
| params_message_type = message_type.parameters_message_class() |
| |
| # Make sure all path parameters are covered. |
| for field_name, matched_path_parameters in path_parameter_dict.items(): |
| field = params_message_type.field_by_name(field_name) |
| self.__validate_path_parameters(field, matched_path_parameters) |
| |
| # Add all fields, sort by field.number since we have parameterOrder. |
| for field in sorted(params_message_type.all_fields(), |
| key=lambda f: f.number): |
| matched_path_parameters = path_parameter_dict.get(field.name, []) |
| self.__add_parameter(field, matched_path_parameters, params) |
| |
| return params |
| |
| def __request_message_descriptor(self, request_kind, message_type, method_id, |
| path): |
| """Describes the parameters and body of the request. |
| |
| Args: |
| request_kind: The type of request being made. |
| message_type: messages.Message or ResourceContainer class. The message to |
| describe. |
| method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| path: string, HTTP path to method. |
| |
| Returns: |
| Dictionary describing the request. |
| |
| Raises: |
| ValueError: if the method path and request required fields do not match |
| """ |
| if isinstance(message_type, resource_container.ResourceContainer): |
| base_message_type = message_type.body_message_class() |
| if (request_kind == self.__NO_BODY and |
| base_message_type != message_types.VoidMessage()): |
| msg = ('Method %s specifies a body message in its ResourceContainer, but ' |
| 'is a HTTP method type that cannot accept a body.') % method_id |
| raise api_exceptions.ApiConfigurationError(msg) |
| else: |
| base_message_type = message_type |
| |
| if (request_kind != self.__NO_BODY and |
| base_message_type != message_types.VoidMessage()): |
| self.__request_schema[method_id] = self.__parser.add_message( |
| base_message_type.__class__) |
| |
| params = self.__params_descriptor(message_type, request_kind, path, |
| method_id) |
| |
| return params |
| |
| def __definitions_descriptor(self): |
| """Describes the definitions section of the OpenAPI spec. |
| |
| Returns: |
| Dictionary describing the definitions of the spec. |
| """ |
| # Filter out any keys that aren't 'properties' or 'type' |
| result = {} |
| for def_key, def_value in self.__parser.schemas().items(): |
| if 'properties' in def_value or 'type' in def_value: |
| key_result = {} |
| required_keys = set() |
| if 'type' in def_value: |
| key_result['type'] = def_value['type'] |
| if 'properties' in def_value: |
| for prop_key, prop_value in def_value['properties'].items(): |
| if isinstance(prop_value, dict) and 'required' in prop_value: |
| required_keys.add(prop_key) |
| del prop_value['required'] |
| key_result['properties'] = def_value['properties'] |
| # Add in the required fields, if any |
| if required_keys: |
| key_result['required'] = sorted(required_keys) |
| result[def_key] = key_result |
| |
| # Add 'type': 'object' to all object properties |
| # Also, recursively add relative path to all $ref values |
| for def_value in result.values(): |
| for prop_value in def_value.values(): |
| if isinstance(prop_value, dict): |
| if '$ref' in prop_value: |
| prop_value['type'] = 'object' |
| self._add_def_paths(prop_value) |
| |
| return result |
| |
| def __response_message_descriptor(self, message_type, method_id): |
| """Describes the response. |
| |
| Args: |
| message_type: messages.Message class, The message to describe. |
| method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| |
| Returns: |
| Dictionary describing the response. |
| """ |
| |
| # Skeleton response descriptor, common to all response objects |
| descriptor = {'200': {'description': 'A successful response'}} |
| |
| if message_type != message_types.VoidMessage(): |
| self.__parser.add_message(message_type.__class__) |
| self.__response_schema[method_id] = self.__parser.ref_for_message_type( |
| message_type.__class__) |
| descriptor['200']['schema'] = {'$ref': '#/definitions/{0}'.format( |
| self.__response_schema[method_id])} |
| |
| return dict(descriptor) |
| |
| def __x_google_quota_descriptor(self, metric_costs): |
| """Describes the metric costs for a call. |
| |
| Args: |
| metric_costs: Dict of metric definitions to the integer cost value against |
| that metric. |
| |
| Returns: |
| A dict descriptor describing the Quota limits for the endpoint. |
| """ |
| return { |
| 'metricCosts': { |
| metric: cost for (metric, cost) in metric_costs.items() |
| } |
| } if metric_costs else None |
| |
| def __x_google_quota_definitions_descriptor(self, limit_definitions): |
| """Describes the quota limit definitions for an API. |
| |
| Args: |
| limit_definitions: List of endpoints.LimitDefinition tuples |
| |
| Returns: |
| A dict descriptor of the API's quota limit definitions. |
| """ |
| if not limit_definitions: |
| return None |
| |
| definitions_list = [{ |
| 'name': ld.metric_name, |
| 'metric': ld.metric_name, |
| 'unit': '1/min/{project}', |
| 'values': {'STANDARD': ld.default_limit}, |
| 'displayName': ld.display_name, |
| } for ld in limit_definitions] |
| |
| metrics = [{ |
| 'name': ld.metric_name, |
| 'valueType': 'INT64', |
| 'metricKind': 'GAUGE', |
| } for ld in limit_definitions] |
| |
| return { |
| 'quota': {'limits': definitions_list}, |
| 'metrics': metrics, |
| } |
| |
| def __method_descriptor(self, service, method_info, operation_id, |
| protorpc_method_info, security_definitions): |
| """Describes a method. |
| |
| Args: |
| service: endpoints.Service, Implementation of the API as a service. |
| method_info: _MethodInfo, Configuration for the method. |
| operation_id: string, Operation ID of the method |
| protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC |
| description of the method. |
| security_definitions: list of dicts, security definitions for the API. |
| |
| Returns: |
| Dictionary describing the method. |
| """ |
| descriptor = {} |
| |
| request_message_type = (resource_container.ResourceContainer. |
| get_request_message(protorpc_method_info.remote)) |
| request_kind = self.__get_request_kind(method_info) |
| remote_method = protorpc_method_info.remote |
| |
| path = method_info.get_path(service.api_info) |
| |
| descriptor['parameters'] = self.__request_message_descriptor( |
| request_kind, request_message_type, |
| method_info.method_id(service.api_info), |
| path) |
| descriptor['responses'] = self.__response_message_descriptor( |
| remote_method.response_type(), method_info.method_id(service.api_info)) |
| descriptor['operationId'] = operation_id |
| |
| # Insert the auth audiences, if any |
| api_key_required = method_info.is_api_key_required(service.api_info) |
| if method_info.audiences is not None: |
| descriptor['security'] = self.__security_descriptor( |
| method_info.audiences, security_definitions, |
| api_key_required=api_key_required) |
| elif service.api_info.audiences is not None or api_key_required: |
| descriptor['security'] = self.__security_descriptor( |
| service.api_info.audiences, security_definitions, |
| api_key_required=api_key_required) |
| |
| # Insert the metric costs, if any |
| if method_info.metric_costs: |
| descriptor['x-google-quota'] = self.__x_google_quota_descriptor( |
| method_info.metric_costs) |
| |
| return descriptor |
| |
| def __security_descriptor(self, audiences, security_definitions, |
| api_key_required=False): |
| if not audiences: |
| if not api_key_required: |
| # no security |
| return [] |
| # api key only |
| return [{_API_KEY: []}] |
| |
| if isinstance(audiences, (tuple, list)): |
| # security_definitions includes not just the base issuers, but also the |
| # hash-appended versions, so we need to filter them out |
| security_issuers = set() |
| for definition_key in security_definitions.keys(): |
| if definition_key == _API_KEY: |
| # API key definitions don't count for these purposes |
| continue |
| if '-' in definition_key: |
| split_key = definition_key.rsplit('-', 1)[0] |
| if split_key in security_definitions: |
| continue |
| security_issuers.add(definition_key) |
| |
| if security_issuers != {_DEFAULT_SECURITY_DEFINITION}: |
| raise api_exceptions.ApiConfigurationError( |
| 'audiences must be a dict when third-party issuers ' |
| '(auth0, firebase, etc) are in use.' |
| ) |
| audiences = {_DEFAULT_SECURITY_DEFINITION: audiences} |
| |
| results = [] |
| for issuer, issuer_audiences in audiences.items(): |
| result_dict = {} |
| if issuer not in security_definitions: |
| raise TypeError('Missing issuer {}'.format(issuer)) |
| audience_string = ','.join(sorted(issuer_audiences)) |
| audience_hash = hashfunc(audience_string) |
| full_definition_key = '-'.join([issuer, audience_hash]) |
| result_dict[full_definition_key] = [] |
| if api_key_required: |
| result_dict[_API_KEY] = [] |
| if full_definition_key not in security_definitions: |
| new_definition = dict(security_definitions[issuer]) |
| new_definition['x-google-audiences'] = audience_string |
| security_definitions[full_definition_key] = new_definition |
| results.append(result_dict) |
| |
| return results |
| |
| def __security_definitions_descriptor(self, issuers): |
| """Create a descriptor for the security definitions. |
| |
| Args: |
| issuers: dict, mapping issuer names to Issuer tuples |
| |
| Returns: |
| The dict representing the security definitions descriptor. |
| """ |
| if not issuers: |
| result = { |
| _DEFAULT_SECURITY_DEFINITION: { |
| 'authorizationUrl': '', |
| 'flow': 'implicit', |
| 'type': 'oauth2', |
| 'x-google-issuer': 'https://accounts.google.com', |
| 'x-google-jwks_uri': 'https://www.googleapis.com/oauth2/v3/certs', |
| } |
| } |
| return result |
| |
| result = {} |
| |
| for issuer_key, issuer_value in issuers.items(): |
| result[issuer_key] = { |
| 'authorizationUrl': '', |
| 'flow': 'implicit', |
| 'type': 'oauth2', |
| 'x-google-issuer': issuer_value.issuer, |
| } |
| |
| # If jwks_uri is omitted, the auth library will use OpenID discovery |
| # to find it. Otherwise, include it in the descriptor explicitly. |
| if issuer_value.jwks_uri: |
| result[issuer_key]['x-google-jwks_uri'] = issuer_value.jwks_uri |
| |
| return result |
| |
| def __get_merged_api_info(self, services): |
| """Builds a description of an API. |
| |
| Args: |
| services: List of protorpc.remote.Service instances implementing an |
| api/version. |
| |
| Returns: |
| The _ApiInfo object to use for the API that the given services implement. |
| |
| Raises: |
| ApiConfigurationError: If there's something wrong with the API |
| configuration, such as a multiclass API decorated with different API |
| descriptors (see the docstring for api()). |
| """ |
| merged_api_info = services[0].api_info |
| |
| # Verify that, if there are multiple classes here, they're allowed to |
| # implement the same API. |
| for service in services[1:]: |
| if not merged_api_info.is_same_api(service.api_info): |
| raise api_exceptions.ApiConfigurationError( |
| _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name, |
| service.api_info.api_version)) |
| |
| return merged_api_info |
| |
| def __api_openapi_descriptor(self, services, hostname=None, x_google_api_name=False): |
| """Builds an OpenAPI description of an API. |
| |
| Args: |
| services: List of protorpc.remote.Service instances implementing an |
| api/version. |
| hostname: string, Hostname of the API, to override the value set on the |
| current service. Defaults to None. |
| |
| Returns: |
| A dictionary that can be deserialized into JSON and stored as an API |
| description document in OpenAPI format. |
| |
| Raises: |
| ApiConfigurationError: If there's something wrong with the API |
| configuration, such as a multiclass API decorated with different API |
| descriptors (see the docstring for api()), or a repeated method |
| signature. |
| """ |
| merged_api_info = self.__get_merged_api_info(services) |
| descriptor = self.get_descriptor_defaults(merged_api_info, |
| hostname=hostname, |
| x_google_api_name=x_google_api_name) |
| |
| description = merged_api_info.description |
| if not description and len(services) == 1: |
| description = services[0].__doc__ |
| if description: |
| descriptor['info']['description'] = description |
| |
| security_definitions = self.__security_definitions_descriptor( |
| merged_api_info.issuers) |
| |
| method_map = {} |
| method_collision_tracker = {} |
| rest_collision_tracker = {} |
| |
| for service in services: |
| remote_methods = service.all_remote_methods() |
| |
| for protorpc_meth_name in sorted(remote_methods.keys()): |
| protorpc_meth_info = remote_methods[protorpc_meth_name] |
| method_info = getattr(protorpc_meth_info, 'method_info', None) |
| # Skip methods that are not decorated with @method |
| if method_info is None: |
| continue |
| method_id = method_info.method_id(service.api_info) |
| is_api_key_required = method_info.is_api_key_required(service.api_info) |
| path = '/{0}/{1}/{2}'.format(merged_api_info.name, |
| merged_api_info.path_version, |
| method_info.get_path(service.api_info)) |
| verb = method_info.http_method.lower() |
| |
| if path not in method_map: |
| method_map[path] = {} |
| |
| # If an API key is required and the security definitions don't already |
| # have the apiKey issuer, add the appropriate notation now |
| if is_api_key_required and _API_KEY not in security_definitions: |
| security_definitions[_API_KEY] = { |
| 'type': 'apiKey', |
| 'name': _API_KEY_PARAM, |
| 'in': 'query' |
| } |
| |
| # Derive an OperationId from the method name data |
| operation_id = self._construct_operation_id( |
| service.__name__, protorpc_meth_name) |
| |
| method_map[path][verb] = self.__method_descriptor( |
| service, method_info, operation_id, protorpc_meth_info, |
| security_definitions) |
| |
| # Make sure the same method name isn't repeated. |
| if method_id in method_collision_tracker: |
| raise api_exceptions.ApiConfigurationError( |
| 'Method %s used multiple times, in classes %s and %s' % |
| (method_id, method_collision_tracker[method_id], |
| service.__name__)) |
| else: |
| method_collision_tracker[method_id] = service.__name__ |
| |
| # Make sure the same HTTP method & path aren't repeated. |
| rest_identifier = (method_info.http_method, |
| method_info.get_path(service.api_info)) |
| if rest_identifier in rest_collision_tracker: |
| raise api_exceptions.ApiConfigurationError( |
| '%s path "%s" used multiple times, in classes %s and %s' % |
| (method_info.http_method, method_info.get_path(service.api_info), |
| rest_collision_tracker[rest_identifier], |
| service.__name__)) |
| else: |
| rest_collision_tracker[rest_identifier] = service.__name__ |
| |
| if method_map: |
| descriptor['paths'] = method_map |
| |
| # Add request and/or response definitions, if any |
| definitions = self.__definitions_descriptor() |
| if definitions: |
| descriptor['definitions'] = definitions |
| |
| descriptor['securityDefinitions'] = security_definitions |
| |
| # Add quota limit metric definitions, if any |
| limit_definitions = self.__x_google_quota_definitions_descriptor( |
| merged_api_info.limit_definitions) |
| if limit_definitions: |
| descriptor['x-google-management'] = limit_definitions |
| |
| return descriptor |
| |
| def get_descriptor_defaults(self, api_info, hostname=None, x_google_api_name=False): |
| """Gets a default configuration for a service. |
| |
| Args: |
| api_info: _ApiInfo object for this service. |
| hostname: string, Hostname of the API, to override the value set on the |
| current service. Defaults to None. |
| |
| Returns: |
| A dictionary with the default configuration. |
| """ |
| hostname = (hostname or util.get_app_hostname() or |
| api_info.hostname) |
| protocol = 'http' if ((hostname and hostname.startswith('localhost')) or |
| util.is_running_on_devserver()) else 'https' |
| base_path = api_info.base_path |
| if base_path != '/': |
| base_path = base_path.rstrip('/') |
| defaults = { |
| 'swagger': '2.0', |
| 'info': { |
| 'version': api_info.api_version, |
| 'title': api_info.name |
| }, |
| 'host': hostname, |
| 'consumes': ['application/json'], |
| 'produces': ['application/json'], |
| 'schemes': [protocol], |
| 'basePath': base_path, |
| } |
| |
| if x_google_api_name: |
| defaults['x-google-api-name'] = _validate_api_name(api_info.name) |
| |
| return defaults |
| |
| def get_openapi_dict(self, services, hostname=None, x_google_api_name=False): |
| """JSON dict description of a protorpc.remote.Service in OpenAPI format. |
| |
| Args: |
| services: Either a single protorpc.remote.Service or a list of them |
| that implements an api/version. |
| hostname: string, Hostname of the API, to override the value set on the |
| current service. Defaults to None. |
| |
| Returns: |
| dict, The OpenAPI descriptor document as a JSON dict. |
| """ |
| |
| if not isinstance(services, (tuple, list)): |
| services = [services] |
| |
| # The type of a class that inherits from remote.Service is actually |
| # remote._ServiceClass, thanks to metaclass strangeness. |
| # pylint: disable=protected-access |
| util.check_list_type(services, remote._ServiceClass, 'services', |
| allow_none=False) |
| |
| return self.__api_openapi_descriptor(services, hostname=hostname, x_google_api_name=x_google_api_name) |
| |
| def pretty_print_config_to_json(self, services, hostname=None, x_google_api_name=False): |
| """JSON string description of a protorpc.remote.Service in OpenAPI format. |
| |
| Args: |
| services: Either a single protorpc.remote.Service or a list of them |
| that implements an api/version. |
| hostname: string, Hostname of the API, to override the value set on the |
| current service. Defaults to None. |
| |
| Returns: |
| string, The OpenAPI descriptor document as a JSON string. |
| """ |
| descriptor = self.get_openapi_dict(services, hostname, x_google_api_name=x_google_api_name) |
| return json.dumps(descriptor, sort_keys=True, indent=2, |
| separators=(',', ': ')) |
| |
| |
| def hashfunc(string): |
| return hashlib.md5(string).hexdigest()[:8] |