Package: grpc-argument-validator
gRPC argument validatorgRPC argument validator is a library that provides decorators to automatically validate arguments in requests to rpc methods.Getting StartedThis is an example of how you may give instructions on setting up your project locally.
To get a local copy up and running follow these simple example steps.InstallationFrom PyPIpipinstallgrpc-argument-validatorFrom sourceInstallpoetryClone repogitclonehttps://github.com/messagebird/python-grpc-argument-validator.gitInstall packagescdpython-grpc-argument-validator&&poetryinstallRun the testscdsrc/tests
poetryrunpython-munittestQuick Examplefromgoogle.protobuf.descriptorimportFieldDescriptorfromgrpc_argument_validatorimportvalidate_argsfromgrpc_argument_validatorimportAbstractArgumentValidator,ValidationResult,ValidationContextclassPathValidator(AbstractArgumentValidator):defcheck(self,name:str,value:Path,field_descriptor:FieldDescriptor,validation_context:ValidationContext)->ValidationResult:iflen(value.points)>5:returnValidationResult(valid=True)returnValidationResult(False,f"path for '{name}' should be at least five points long")classRouteService(RouteCheckerServicer):@validate_args(non_empty=["tags","tags[]","path.points"],validators={"path":PathValidator()},)defCreate(self,request:Route,context:grpc.ServicerContext):returnBoolValue(value=True)DocumentationWe host the full API reference onGitHub pages.Argument field syntaxTo specify which argument field should be validated,grpc-argument-validatorexpects strings that match the field names
as defined in the protobufs. To access nested fields, use a dot (.).Consider the following protobuf definition:syntax="proto3";packagerouteguide;import"google/protobuf/empty.proto";import"google/protobuf/wrappers.proto";messagePoint{int32x=1;int32y=2;google.protobuf.StringValuename=3;}messageRectangle{Pointlo=1;Pointhi=2;}messageArea{Rectanglerectangle=1;google.protobuf.StringValuemessage=2;google.protobuf.BytesValueuuid=3;}messagePath{repeatedPointpoints=1;}enumPlanet{PLANET_INVALID=0;PLANET_EARTH=1;PLANET_MARS=2;}messagePlanetValue{Planetvalue=1;}messageRoute{Pathpath=1;google.protobuf.StringValuename=2;PlanetValueplanet=3;repeatedstringtags=4;}serviceRouteService{rpcCreateRoute(Route)returns(google.protobuf.Empty);rpcCreateArea(Area)returns(google.protobuf.Empty);}If you want to validate the fieldplanetin aRouteproto, simply specify"planet"or equivalently".planet".If you want to validate thevaluefield within thenamefield of aRouteproto, use"name.value"or
equivalently".name.value".If you want to apply a check to each element of arepeatedfield, append[]to the name of the field.If you want to apply a check to the 'root proto' (i.e. the request itself), use"."as the field path.To clarify this, let's say that we know that bothplanetandname.valueshould have non-default values. We can then
decorate a method in our gRPC server as follows:importgrpcfromgoogle.protobuf.empty_pb2importEmptyfromgrpc_argument_validatorimportvalidate_argsfromtests.route_guide_protos.route_guide_pb2importRoutefromtests.route_guide_protos.route_guide_pb2_grpcimportRouteServiceServicerclassRouteServiceImpl(RouteServiceServicer):@validate_args(non_empty=["planet","name.value"])defCreateRoute(self,request:Route,context:grpc.ServicerContext):returnEmpty()Calling the service with a default value for eitherplanetorname.valuewill yield anINVALID_ARGUMENTstatus code
with further details on which fields violate the validation.ValidatorsThere are two kinds of validators you might consider:There are predefined validators which we will cover shortlyAnother option is to define your own validatorsIn the examples below, we have used exactly one validator + field path pervalidate_argsdecorator for clarity.
Fortunately, our API allows you to use multiple validators and fields!'Has' validatorThe simplest of all predefined validators is the 'has' validator which simply checks whether aHasFieldevaluates toTrue. This of course works in combination with nested fields.In the example below, calling theCreateendpoint without settingRoute.namewould result in anINVALID_ARGUMENTstatus.classRouteServiceImpl(RouteServiceServicer):@validate_args(has=["name"])defCreateRoute(self,request:Route,context:grpc.ServicerContext):returnEmpty()Run this on a local machine and make a request with an invalid argument:withgrpc.insecure_channel("127.0.0.1:50051")asc:route_client=RouteServiceStub(channel=c)try:route_client.CreateRoute(Route(tags=["tag"]))exceptgrpc.RpcErrorase:ifisinstance(e,grpc.Call):print(e.details())The following will be printed:must have 'name'UUID validatorAnother common use-case is the validation of UUIDs. You can enlist the fields that should be UUIDs (represented as
16 bytes) with theuuidsargument:classRouteServiceImpl(RouteServiceServicer):@validate_args(uuids=["uuid.value"])defCreateArea(self,request:Area,context:grpc.ServicerContext):returnEmpty()The client side might violate the UUID requirement as follows:withgrpc.insecure_channel("127.0.0.1:50051")asc:route_client=RouteServiceStub(channel=c)try:route_client.CreateArea(Area(uuid=BytesValue(value="not a uuid".encode())))exceptgrpc.RpcErrorase:ifisinstance(e,grpc.Call):print(e.details())This will print'uuid.value' must be a valid UUID.Non-default validatorFor fields that should have a non-default value, such asenums, we have provided thenon_defaultargument:classRouteServiceImpl(RouteServiceServicer):@validate_args(non_default=["planet.value"])defCreateRoute(self,request:Route,context:grpc.ServicerContext):returnEmpty()The client side may violate this as follows:withgrpc.insecure_channel("127.0.0.1:50051")asc:route_client=RouteServiceStub(channel=c)try:route_client.CreateRoute(Route(planet=PlanetValue()))exceptgrpc.RpcErrorase:ifisinstance(e,grpc.Call):print(e.details())Which will print'planet.value' must have non-default value.Non-empty validatorWe provide a 'non-'empty validator which can be used to ensure that arepeatedfield has more than zero elements.classRouteServiceImpl(RouteServiceServicer):@validate_args(non_empty=["tags"])defCreateRoute(self,request:Route,context:grpc.ServicerContext):returnEmpty()Which can be violated as follows:withgrpc.insecure_channel("127.0.0.1:50051")asc:route_client=RouteServiceStub(channel=c)try:route_client.CreateRoute(Route(tags=[]))exceptgrpc.RpcErrorase:ifisinstance(e,grpc.Call):print(e.details())Which will print'tags' must be non-empty.Regexp validatorFinally, we have the regexp validator that can be used to check whether a string field matches a regular expression.classRouteServiceImpl(RouteServiceServicer):@validate_args(validators={"message.value":RegexpValidator(pattern=r"\d+")})defCreateArea(self,request:Area,context:grpc.ServicerContext):returnEmpty()withgrpc.insecure_channel("127.0.0.1:50051")asc:route_client=RouteServiceStub(channel=c)try:route_client.CreateArea(Area(message=StringValue(value="hello world")))exceptgrpc.RpcErrorase:ifisinstance(e,grpc.Call):print(e.details())Which will print'message.value' must match regexp pattern: \d+.Custom validatorsYou can also write custom validators to flexibily handle your use-case. You need to derive a class fromAbstractArgumentValidatorand implement itscheckmethod. The example below shows how to implement a simple
validator for checking that a path has 5 points. You can provide such custom validators through adictthat
maps a field path to a validator:fromgrpc_argument_validatorimportAbstractArgumentValidatorfromgrpc_argument_validatorimportValidationContextfromgrpc_argument_validatorimportValidationResultfromgoogle.protobuf.descriptorimportFieldDescriptorfromexamples.route_guide_pb2importPathclassPathValidator(AbstractArgumentValidator):defcheck(self,name:str,value:Path,field_descriptor:FieldDescriptor,validation_context:ValidationContext)->ValidationResult:iflen(value.points)>5:returnValidationResult(valid=True)returnValidationResult(False,f"path for '{name}' should be at least five points long")classRouteServiceImpl(RouteServiceServicer):@validate_args(validators={"path":PathValidator()})defCreateRoute(self,request:Route,context:grpc.ServicerContext):returnEmpty()Optional vs. required validatorsFor each of the built-in validators (except for thehasvalidator),validate_argshas not one but two keyword
arguments. One of those is prepended withoptional_. This means that apart fromuuid,non_defaultandnon_emptywe also haveoptional_uuid,optional_non_defaultandoptional_non_empty. The behavior is slightly
different: for any of theoptional_*validators, it is OK if the field is not contained by the incoming request.
Sometimes fields are simply optional, and you only want to validate themifthey are present.Since it is also common that fields arenot optional, we also provide the required validators (withoutoptional_*)
for whichHasFieldmust evaluate toTruefor that field and all preceding fields in the protos hierarchy.The custom validator counterparts arevalidatorsandoptional_validators. Each takes adictwith a mapping of
field paths to validators. These can be used for validators that might be preconfigured such as theRegexpValidatoror for customer validators.Streaming requestsYou can also use the validators for streaming requests. Since streaming requests might not all look the same in a
single stream (e.g. the first request might have metadata describing the remainder of the stream), we provide a
streaming request index in aValidationContextthat is passed to anAbstractArgumentValidator.Here's an example of how that could be used:classStreamingPathValidator(AbstractArgumentValidator):def__init__(self,first_number_of_points:int,second_number_of_points:int):self._first_number_of_points=first_number_of_pointsself._second_number_of_points=second_number_of_pointsdefcheck(self,name:str,value:Any,field_descriptor:FieldDescriptor,validation_context:ValidationContext)->ValidationResult:ifnotvalidation_context.is_streaming:returnValidationResult(False,"request must be a streaming request")ifvalidation_context.streaming_message_index==0:iflen(value.points)!=self._first_number_of_points:returnValidationResult(False,f"first path should have{self._first_number_of_points}points")ifvalidation_context.streaming_message_index==1:iflen(value.points)!=self._second_number_of_points:returnValidationResult(False,f"second path should have{self._second_number_of_points}points")returnValidationResult(True)Enabling rich error detailsTo enablericher error responseswhere each violation is
contained in aBadRequestproto, you can usefromgrpc_argument_validatorimportArgumentValidatorConfigArgumentValidatorConfig.set_rich_grpc_errors(enabled=True)Now, your client-side can parse the error details as follows:defextract_error_details(err):status_proto=status_pb2.Status()formetadatuminerr.trailing_metadata():ifisinstance(metadatum,_Metadatum):ifmetadatum.key=="grpc-status-details-bin":status_proto.MergeFromString(metadatum.value)unpacked=[_unpack_error_detail(det)fordetinstatus_proto.details]returnunpackeddef_unpack_error_detail(grpc_detail):val=error_details_pb2.BadRequest()grpc_detail.Unpack(val)returnvalwithgrpc.insecure_channel("127.0.0.1:50051")asc:route_client=RouteServiceStub(channel=c)try:route_client.CreateArea(Area(message=StringValue(value="hello world")))exceptgrpc.RpcErrorase:error_details=extract_error_details(e)print(error_details)ContributingContributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make aregreatly appreciated.Fork the ProjectCreate your Feature Branch (git checkout -b feature/AmazingFeature)Commit your Changes (git commit -m 'Add some AmazingFeature')Push to the Branch (git push origin feature/AmazingFeature)Open a Pull RequestGenerating HTML DocumentationGenerate the docs by running:pdoc--html-odocssrc/grpc_argument_validatorLicenseDistributed under The BSD 3-Clause License. Copyright (c) 2021, MessageBird