def validate_recursively(expression, scope, depth=0):
            first_dot = expression.find(".")
            base = expression[:first_dot] if first_dot > 0 else expression
            match = self.EXPRESSION_ATTR_RE.match(base)
            if not match:
                raise types.BadAttributeLookup(
                    f"Badly formed attribute expression: {expression}")

            name, idxed, mapped = (
                match.groupdict()["attr_name"],
                bool(match.groupdict()["index"]),
                bool(match.groupdict()["key"]),
            )
            field = scope.get(name)

            if not field:
                exception_class = (types.BadAttributeLookup if depth else
                                   types.UndefinedVariableReference)
                raise exception_class(f"No such variable or attribute: {name}")

            # Invalid input
            if (idxed or mapped) and not field.repeated:
                raise types.BadAttributeLookup(
                    f"Collection lookup on non-repeated field: {base}")

            # Can only ignore indexing or mapping in an indexed (or mapped) field
            # if it is the terminal point in the expression.
            if field.repeated and not (idxed or mapped) and first_dot != -1:
                raise types.BadAttributeLookup(
                    ("Accessing attribute on a non-terminal collection without"
                     f"indexing into the collection: {base}"))

            message = field.message
            scope = dict(message.fields) if message else {}
            # Can only map message types, not enums
            if mapped:
                # See https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/descriptor.proto#L496
                # for a better understanding of how map attributes are handled in protobuf
                if not message or not message.options.map_field:
                    raise types.BadAttributeLookup(
                        f"Badly formed mapped field: {base}")

                value_field = message.fields.get("value")
                if not value_field:
                    raise types.BadAttributeLookup(
                        f"Mapped attribute has no value field: {base}")

                value_message = value_field.message
                if not value_message:
                    raise types.BadAttributeLookup(
                        f"Mapped value field is not a message: {base}")

                if first_dot != -1:
                    scope = value_message.fields

            # Terminus of the expression.
            if first_dot == -1:
                return field

            # Enums and primitives are only allowed at the tail of an expression.
            if not message:
                raise types.BadAttributeLookup(
                    f"Non-terminal attribute is not a message: {base}")

            return validate_recursively(expression[first_dot + 1:], scope,
                                        depth + 1)
    def _normal_request_setup(self, base_param_to_attrs, val, request, field):
        """validates and transforms non-resource-based request entries.

        Private method, lifted out to make validate_and_transform_request cleaner.

        Args:
            base_param_to_attrs ({str:RequestEntry}):
            val (str): The value to which the terminal field will be set
                       (only used if the terminus is an enum)
            request (str:str): The request dictionary read in from the config.
            field (str): The value of the "field" parameter in the request entry.

        Returns:
                Tuple[str, AttributeRequestSetup]
        """
        base = self.request_type_
        attr_chain = field.split(".")
        for i, attr_name in enumerate(attr_chain):
            attr = base.fields.get(attr_name)
            if not attr:
                raise types.BadAttributeLookup(
                    "Method request type {} has no attribute: '{}'".format(
                        self.request_type_, attr_name))

            if attr.message:
                base = attr.message
            elif attr.enum:
                # A little bit hacky, but 'values' is a list, and this is the easiest
                # way to verify that the value is a valid enum variant.
                witness = any(e.name == val for e in attr.enum.values)
                if not witness:
                    raise types.InvalidEnumVariant(
                        "Invalid variant for enum {}: '{}'".format(attr, val))
                break
            elif attr.is_primitive:
                # Only valid if this is the last attribute in the chain.
                break
            else:
                raise TypeError(
                    f"Could not handle attribute '{attr_name}' of type: {attr.type}"
                )

        if i != len(attr_chain) - 1:
            # We broke out of the loop after processing an enum or a primitive.
            extra_attrs = ".".join(attr_chain[i:])
            raise types.NonTerminalPrimitiveOrEnum(
                f"Attempted to reference attributes of enum value or primitive type: '{extra_attrs}'"
            )

        if len(attr_chain) > 1:
            request["field"] = ".".join(attr_chain[1:])
        else:
            # Because of the way top level attrs get rendered,
            # there can't be duplicates.
            # This is admittedly a bit of a hack.
            if attr_chain[0] in base_param_to_attrs:
                raise types.InvalidRequestSetup(
                    "Duplicated top level field in request block: '{}'".format(
                        attr_chain[0]))
            del request["field"]

        if isinstance(request["value"], str):
            # Passing value through json is a safe and simple way of
            # making sure strings are properly wrapped and quotes escaped.
            # This statement both wraps enums in quotes and escapes quotes
            # in string values passed as parameters.
            #
            # Python code can set protobuf enums from strings.
            # This is preferable to adding the necessary import statement
            # and requires less munging of the assigned value
            request["value"] = json.dumps(request["value"])

        # Mypy isn't smart enough to handle dictionary unpacking,
        # so disable it for the AttributeRequestSetup ctor call.
        return attr_chain[0], AttributeRequestSetup(**request)  # type: ignore
    def validate_and_transform_request(
            self, calling_form: types.CallingForm,
            request: List[Mapping[str, str]]) -> FullRequest:
        """Validates and transforms the "request" block from a sample config.

           In the initial request, each dict has a "field" key that maps to a dotted
           variable name, e.g. clam.shell.

           The only required keys in each dict are "field" and value".
           Optional keys are "input_parameter", "value_is_file". and "comment".
           All values in the initial request are strings except for the value
           for "value_is_file", which is a bool.

           The TransformedRequest structure of the return value has four fields:
           "base", "body", "single", and "pattern",
           where "base" maps to the top level attribute name,
           "body" maps to a list of subfield assignment definitions, "single"
           maps to a singleton attribute assignment structure with no "field" value,
           and "pattern" is a resource name pattern string if the request describes
           resource name construction.
           The "field" attribute in the requests in a "body" list have their prefix stripped;
           the request in a "single" attribute has no "field" attribute.

           Note: gRPC API methods only take one parameter (ignoring client-side streaming).
                 The reason that GAPIC client library API methods may take multiple parameters
                 is a workaround to provide idiomatic protobuf support within python.
                 The different 'bases' are really attributes for the singular request parameter.

           TODO: properly handle subfields, indexing, and so forth.
           TODO: Add/transform to list repeated element fields.
                 Requires proto/method/message descriptors.

           E.g. [{"field": "clam.shell", "value": "10 kg", "input_parameter": "shell"},
                 {"field": "clam.pearls", "value": "3"},
                 {"field": "squid.mantle", "value": "100 kg"},
                 {"field": "whelk", "value": "speckled"}]
                  ->
                [TransformedRequest(
                     base="clam",
                     body=[AttributeRequestSetup(field="shell",
                                                 value="10 kg",
                                                 input_parameter="shell"),
                           AttributeRequestSetup(field="pearls", value="3")],
                     single=None),
                 TransformedRequest(base="squid",
                                    body=[AttributeRequestSetup(field="mantle",
                                                                value="100 kg")],
                                    single=None),
                 TransformedRequest(base="whelk",
                                    body=None,
                                    single=AttributeRequestSetup(value="speckled))]

           The transformation makes it easier to set up request parameters in jinja
           because it doesn't have to engage in prefix detection, validation,
           or aggregation logic.


        Args:
            request (list[dict{str:str}]): The request body from the sample config

        Returns:
            List[TransformedRequest]: The transformed request block.

        Raises:
            InvalidRequestSetup: If a dict in the request lacks a "field" key,
                                 a "value" key, if there is an unexpected keyword,
                                 or if more than one base parameter is given for
                                 a client-side streaming calling form.
            BadAttributeLookup: If a request field refers to a non-existent field
                                in the request message type.
            ResourceRequestMismatch: If a request attempts to describe both
                                     attribute manipulation and resource name
                                     construction.

        """
        base_param_to_attrs: Dict[str,
                                  RequestEntry] = defaultdict(RequestEntry)
        for r in request:
            r_dup = dict(r)
            val = r_dup.get("value")
            if not val:
                raise types.InvalidRequestSetup(
                    "Missing keyword in request entry: 'value'")

            field = r_dup.get("field")
            if not field:
                raise types.InvalidRequestSetup(
                    "Missing keyword in request entry: 'field'")

            spurious_kwords = set(r_dup.keys()) - self.VALID_REQUEST_KWORDS
            if spurious_kwords:
                raise types.InvalidRequestSetup(
                    "Spurious keyword(s) in request entry: {}".format(
                        ", ".join(f"'{kword}'" for kword in spurious_kwords)))

            input_parameter = r_dup.get("input_parameter")
            if input_parameter:
                self._handle_lvalue(
                    input_parameter,
                    wrappers.Field(
                        field_pb=descriptor_pb2.FieldDescriptorProto()),
                )

            # The percentage sign is used for setting up resource based requests
            percent_idx = field.find("%")
            if percent_idx == -1:
                base_param, attr = self._normal_request_setup(
                    base_param_to_attrs, val, r_dup, field)

                request_entry = base_param_to_attrs.get(base_param)
                if request_entry and request_entry.is_resource_request:
                    raise types.ResourceRequestMismatch(
                        f"Request setup mismatch for base: {base_param}")

                base_param_to_attrs[base_param].attrs.append(attr)
            else:
                # It's a resource based request.
                base_param, resource_attr = (
                    field[:percent_idx],
                    field[percent_idx + 1:],
                )
                request_entry = base_param_to_attrs.get(base_param)
                if request_entry and not request_entry.is_resource_request:
                    raise types.ResourceRequestMismatch(
                        f"Request setup mismatch for base: {base_param}")

                if not self.request_type_.fields.get(base_param):
                    raise types.BadAttributeLookup(
                        "Method request type {} has no attribute: '{}'".format(
                            self.request_type_, base_param))

                r_dup["field"] = resource_attr
                request_entry = base_param_to_attrs[base_param]
                request_entry.is_resource_request = True
                request_entry.attrs.append(
                    AttributeRequestSetup(**r_dup)  # type: ignore
                )

        client_streaming_forms = {
            types.CallingForm.RequestStreamingClient,
            types.CallingForm.RequestStreamingBidi,
        }

        if len(base_param_to_attrs
               ) > 1 and calling_form in client_streaming_forms:
            raise types.InvalidRequestSetup(
                "Too many base parameters for client side streaming form")

        # We can only flatten a collection of request parameters if they're a
        # subset of the flattened fields of the method.
        flattenable = self.flattenable_fields >= set(base_param_to_attrs)
        return FullRequest(
            request_list=[
                TransformedRequest.build(
                    self.request_type_,
                    self.api_schema_,
                    key,
                    val.attrs,
                    val.is_resource_request,
                ) for key, val in base_param_to_attrs.items()
            ],
            flattenable=False,
        )
Exemplo n.º 4
0
    def validate_and_transform_request(
            self, calling_form: types.CallingForm,
            request: List[Mapping[str, str]]) -> List[TransformedRequest]:
        """Validates and transforms the "request" block from a sample config.

           In the initial request, each dict has a "field" key that maps to a dotted
           variable name, e.g. clam.shell.

           The only required keys in each dict are "field" and value".
           Optional keys are "input_parameter", "value_is_file". and "comment".
           All values in the initial request are strings except for the value
           for "value_is_file", which is a bool.

           The TransformedRequest structure of the return value has three fields:
           "base", "body", and "single", where "base" maps to the top level attribute name,
           "body" maps to a list of subfield assignment definitions, and "single"
           maps to a singleton attribute assignment structure with no "field" value.
           The "field" attribute in the requests in a "body" list have their prefix stripped;
           the request in a "single" attribute has no "field" attribute.

           Note: gRPC API methods only take one parameter (ignoring client-side streaming).
                 The reason that GAPIC client library API methods may take multiple parameters
                 is a workaround to provide idiomatic protobuf support within python.
                 The different 'bases' are really attributes for the singular request parameter.

           TODO: properly handle subfields, indexing, and so forth.
           TODO: Add/transform to list repeated element fields.
                 Requires proto/method/message descriptors.

           E.g. [{"field": "clam.shell", "value": "10 kg", "input_parameter": "shell"},
                 {"field": "clam.pearls", "value": "3"},
                 {"field": "squid.mantle", "value": "100 kg"},
                 {"field": "whelk", "value": "speckled"}]
                  ->
                [TransformedRequest(
                     base="clam",
                     body=[AttributeRequestSetup(field="shell",
                                                 value="10 kg",
                                                 input_parameter="shell"),
                           AttributeRequestSetup(field="pearls", value="3")],
                     single=None),
                 TransformedRequest(base="squid",
                                    body=[AttributeRequestSetup(field="mantle",
                                                                value="100 kg")],
                                    single=None),
                 TransformedRequest(base="whelk",
                                    body=None,
                                    single=AttributeRequestSetup(value="speckled))]

           The transformation makes it easier to set up request parameters in jinja
           because it doesn't have to engage in prefix detection, validation,
           or aggregation logic.


        Args:
            request (list[dict{str:str}]): The request body from the sample config

        Returns:
            List[TransformedRequest]: The transformed request block.

        Raises:
            InvalidRequestSetup: If a dict in the request lacks a "field" key,
                                 a "value" key, if there is an unexpected keyword,
                                 or if more than one base parameter is given for
                                 a client-side streaming calling form.
            BadAttributeLookup: If a request field refers to a non-existent field
                                in the request message type.

        """
        base_param_to_attrs: Dict[
            str, List[AttributeRequestSetup]] = defaultdict(list)

        for r in request:
            duplicate = dict(r)
            val = duplicate.get("value")
            if not val:
                raise types.InvalidRequestSetup(
                    "Missing keyword in request entry: 'value'")

            field = duplicate.get("field")
            if not field:
                raise types.InvalidRequestSetup(
                    "Missing keyword in request entry: 'field'")

            spurious_keywords = set(duplicate.keys()) - {
                "value", "field", "value_is_file", "input_parameter", "comment"
            }
            if spurious_keywords:
                raise types.InvalidRequestSetup(
                    "Spurious keyword(s) in request entry: {}".format(
                        ", ".join(f"'{kword}'"
                                  for kword in spurious_keywords)))

            input_parameter = duplicate.get("input_parameter")
            if input_parameter:
                self._handle_lvalue(
                    input_parameter,
                    wrappers.Field(
                        field_pb=descriptor_pb2.FieldDescriptorProto()))

            attr_chain = field.split(".")
            base = self.request_type_
            for i, attr_name in enumerate(attr_chain):
                attr = base.fields.get(attr_name)
                if not attr:
                    raise types.BadAttributeLookup(
                        "Method request type {} has no attribute: '{}'".format(
                            self.request_type_.type, attr_name))

                if attr.message:
                    base = attr.message
                elif attr.enum:
                    # A little bit hacky, but 'values' is a list, and this is the easiest
                    # way to verify that the value is a valid enum variant.
                    witness = any(e.name == val for e in attr.enum.values)
                    if not witness:
                        raise types.InvalidEnumVariant(
                            "Invalid variant for enum {}: '{}'".format(
                                attr, val))
                    # Python code can set protobuf enums from strings.
                    # This is preferable to adding the necessary import statement
                    # and requires less munging of the assigned value
                    duplicate["value"] = f"'{val}'"
                    break
                else:
                    raise TypeError

            if i != len(attr_chain) - 1:
                # We broke out of the loop after processing an enum.
                extra_attrs = ".".join(attr_chain[i:])
                raise types.InvalidEnumVariant(
                    f"Attempted to reference attributes of enum value: '{extra_attrs}'"
                )

            if len(attr_chain) > 1:
                duplicate["field"] = ".".join(attr_chain[1:])
            else:
                # Because of the way top level attrs get rendered,
                # there can't be duplicates.
                # This is admittedly a bit of a hack.
                if attr_chain[0] in base_param_to_attrs:
                    raise types.InvalidRequestSetup(
                        "Duplicated top level field in request block: '{}'".
                        format(attr_chain[0]))
                del duplicate["field"]

            # Mypy isn't smart enough to handle dictionary unpacking,
            # so disable it for the AttributeRequestSetup ctor call.
            base_param_to_attrs[attr_chain[0]].append(
                AttributeRequestSetup(**duplicate))  # type: ignore

        client_streaming_forms = {
            types.CallingForm.RequestStreamingClient,
            types.CallingForm.RequestStreamingBidi,
        }

        if len(base_param_to_attrs
               ) > 1 and calling_form in client_streaming_forms:
            raise types.InvalidRequestSetup(
                "Too many base parameters for client side streaming form")

        return [(TransformedRequest(base=key, body=val, single=None)
                 if val[0].field else TransformedRequest(
                     base=key, body=None, single=val[0]))
                for key, val in base_param_to_attrs.items()]