Dart gRPC How to handle multi-data type property

eye-catch Dart and Flutter

In Protocol Buffer, we can define a property that can have multiple data types. A different data type needs to be assigned depending on the situation. Null might need to be assigned in some cases. How can we define the property in Protocol Buffer and implement it in Dart? Let’s dive with me.

Clone my GitHub repository if you want to try it yourself.

Sponsored links

Protocol Buffer and the service class

The proto definition looks like this for the examples in this post.

syntax = "proto3";

option go_package = "pkg/api-test/grpc/apitest";

service TypesDef {
  rpc WithInt64(WithInt64RequestResponse) returns (WithInt64RequestResponse) {}
  rpc WithOneof(WithOneofRequest) returns (WithOneofResponse) {}
  rpc WithPrimitive(WithPrimitiveRequest) returns (WithPrimitiveResponse) {}
  rpc WithOptional(WithOptionalRequest) returns (WithOptionalResponse) {}
}

Each message definition will be written in each section.

You can check which files are imported here.

// Server
import "package:dart_grpc/proto/types_def.pb.dart";
import "package:dart_grpc/proto/types_def.pbgrpc.dart" as rpc;
import "package:grpc/grpc.dart";
import 'package:fixnum/fixnum.dart' as $fixnum;

class TypesDefService extends rpc.TypesDefServiceBase {
    // implementation here
}

// Client
import 'dart:async';

import 'package:dart_grpc/proto/types_def.pbgrpc.dart' as rpc;
import 'package:fixnum/fixnum.dart' as $fixnum;

class TypesDefServiceHandler {
  final rpc.TypesDefClient client;

  TypesDefServiceHandler(this.client);

  // implementation here
}
Sponsored links

Int64 values

We should know the simplest case first. It’s int64.

message WithInt64RequestResponse {
  int64 value = 1;
}

How to convert int value to Int64

Int64 is not a built-in type in Dart. Therefore, int value must be converted to by using fixnum package. However, the comaprison can be done without converting the value.

// Server
@override
Future<rpc.WithInt64RequestResponse> withInt64(
  ServiceCall ctx,
  rpc.WithInt64RequestResponse request,
) async {
  print("--- withInt64 ---");
  print("value: ${request.value.toString()}");

  if (request.value == 0) {
    return rpc.WithInt64RequestResponse();
  }

  return rpc.WithInt64RequestResponse()..value = $fixnum.Int64(11);
}

// Client
Future<void> withInt64(int value) async {
  print("--- withInt64 ---");
  final request = rpc.WithInt64RequestResponse();

  if (value != 0) {
    request.value = $fixnum.Int64(123);
  }

  final result = await client.withInt64(request);
  print("(has: ${result.hasValue()}, value: ${result.value})");
}

hasValue can be used for a mandatory parameter

I’m wondering why hasValue() exists even though it’s a mandatory parameter. Let’s see the result anyway first.

We call the function with the following parameters.

await typeHandler.withInt64(0);
await typeHandler.withInt64(123);
$ make runClient       | $ make runServer 
--- withInt64 ---      | --- withInt64 ---
(has: false, value: 0) | value: 0
--- withInt64 ---      | --- withInt64 ---
(has: true, value: 11) | value: 123

The default value is assigned to the non-specified value. If we want the default value to send a request, we don’t have to specify the value but it’s better to set it to make it clear because the default value might not be what we expect for some data types.

Oneof parameter holds multiple data type

oneof keyword can be used if a property could be a different type depending on the situation. In the following case, WithOneofRequest message has oneOfValue property. It has either number property for int64, text property for string, or the property can be null. It’s impossible to have both values at the same time. If a value is assigned to both, only the last value set is valid. The default value is set to the other properties in this case in Dart. It might be different behavior in other languages though.

message WithOneofRequest {
  oneof one_of_value {
    int64 number = 1;
    string text = 2;
  }
  string type = 3;
}
message WithOneofResponse {
  oneof one_of_value {
    int64 number = 1;
    string text = 2;
  }
}

Use whichXxxx to know which value is set

We need to know which parameter has a value. whichOneOfValue() needs to be used to know it. It returns the property name defined in enum class but it can also return notSet if it’s not set. Note that both properties can be read even though the property is not set. The default value can be read in this case.

The name depends on the property. If the name is Val, the name of the function will be whichVal() .

// Server
@override
Future<rpc.WithOneofResponse> withOneof(
  ServiceCall ctx,
  rpc.WithOneofRequest request,
) async {
  print("--- withOneof ---");
  print("which: ${request.whichOneOfValue()}");
  print("text: (${request.hasText()}, ${request.text})");
  print("number: (${request.hasNumber()}, ${request.number})");
  print("type: ${request.type}");

  if (request.whichOneOfValue() == rpc.WithOneofRequest_OneOfValue.notSet) {
    return rpc.WithOneofResponse();
  }

  if (request.hasNumber()) {
    return rpc.WithOneofResponse()..number = $fixnum.Int64(11);
  }

  return rpc.WithOneofResponse()..text = "dummy text";
}

// Client
Future<void> withOneof({String type = ""}) async {
  print("--- withOneof ---");
  final request = rpc.WithOneofRequest();

  if (type == "number") {
    request
      ..number = $fixnum.Int64(111)
      ..type = type;
  } else if (type == "string") {
    request
      ..text = "dummy text"
      ..type = type;
  } else if (type.isNotEmpty) {
    request
      ..number = $fixnum.Int64(111)
      ..text = "dummy text"
      ..type = type;
  }

  final result = await client.withOneof(request);
  print("which: ${result.whichOneOfValue()}");
  print("(has: ${result.hasNumber()}, number: ${result.number})");
  print("(has: ${result.hasText()}, text: ${result.text})");
}

Let’s call the function with the following parameters.

await typeHandler.withOneof();
await typeHandler.withOneof(type: "both");
await typeHandler.withOneof(type: "number");
await typeHandler.withOneof(type: "string");

The non-set property has the default value. We have to be aware of the case where a value is set to both properties as mentioned above. See the second result on the server side. The number is set to 0 even though 111 is assigned because a value is set to another property. Only the property that is set at last is valid.

$ make runClient                           | $ make runServer
--- withOneof ---                          | --- withOneof ---
which: WithOneofResponse_OneOfValue.notSet | which: WithOneofRequest_OneOfValue.notSet
(has: false, number: 0)                    | text: (false, )
(has: false, text: )                       | number: (false, 0)
--- withOneof ---                          | type: 
which: WithOneofResponse_OneOfValue.text   | --- withOneof ---
(has: false, number: 0)                    | which: WithOneofRequest_OneOfValue.text
(has: true, text: dummy text)              | text: (true, dummy text)
--- withOneof ---                          | number: (false, 0)
which: WithOneofResponse_OneOfValue.number | type: both
(has: true, number: 11)                    | --- withOneof ---
(has: false, text: )                       | which: WithOneofRequest_OneOfValue.number
--- withOneof ---                          | text: (false, )
which: WithOneofResponse_OneOfValue.text   | number: (true, 111)
(has: false, number: 0)                    | type: number
(has: true, text: dummy text)              | --- withOneof ---
                                           | which: WithOneofRequest_OneOfValue.text
                                           | text: (true, dummy text)
                                           | number: (false, 0)
                                           | type: string

Create primitive type by using oneof keyword

Let’s try to use oneof keyword for a different way. Let’s define a message that defines another message in it. Then, use the message to a property. PrimitiveType message has a value that can be string, double, int64, uint64, bool, or bytes.

message WithPrimitiveRequest {
  PrimitiveType primitive = 1;
}

message WithPrimitiveResponse {
  PrimitiveType primitive = 1;
}

message PrimitiveType {
  message Value {
    oneof element {
      string text = 1;
      double double_value = 2;
      int64 int64_value = 3;
      uint64 uint64_value = 4;
      bool boolean = 5;
      bytes raw_bytes = 6;
    }
  }
  Value value = 1;
}

Attempted to change a read-only message

Let’s see a server implementation first. Since the primitive type has several data types, we need to use switch-case or if-else to assign the desired value. We might create a response instance first and then assign a value.

// Server NG sample
@override
Future<rpc.WithPrimitiveResponse> withPrimitive(
  ServiceCall ctx,
  rpc.WithPrimitiveRequest request,
) async {
  print("--- withPrimitive ---");
  print("hasPrimitive: ${request.hasPrimitive()}");
  print("value: ${request.primitive.value}");
  print("whichElement: ${request.primitive.value.whichElement()})");
  print("(has: ${request.hasType()}, type: ${request.type})");

  final response = rpc.WithPrimitiveResponse();
  // Assume that we have switch-case here. Then, assign a double value.
  response.primitive.value = PrimitiveType_Value()..doubleValue = 1.2;
  return response;
}

But this doesn’t work. It seems that the property with a custom data type is frozen when it’s created. It means that the property value can’t be updated. When assigning a new value, the following error is thrown.

Unhandled exception:
Unsupported operation: Attempted to change a read-only message (PrimitiveType.Value)
#0      _throwFrozenMessageModificationError (package:protobuf/src/protobuf/field_set.dart:13:3)
#1      _FieldSet._ensureWritable (package:protobuf/src/protobuf/field_set.dart:163:7)
#2      _FieldSet._$set (package:protobuf/src/protobuf/field_set.dart:485:5)
#3      GeneratedMessage.$_setInt64 (package:protobuf/src/protobuf/generated_message.dart:509:56)
#4      PrimitiveType_Value.int64Value= (package:dart_grpc/proto/types_def.pb.dart:722:37)
#5      TypesDefServiceHandler.withPrimitive (package:dart_grpc/client/types_def.dart:97:33)
#6      main (file:///workspaces/api-test/languages/dart/bin/client.dart:53:21)
<asynchronous suspension>
make: *** [Makefile:21: runClient] Error 255

How to assign a value dynamically to a nested property

I tried multiple ways.

// OK
final response = rpc.WithPrimitiveResponse()..primitive = rpc.PrimitiveType();
response.primitive.value = PrimitiveType_Value()..doubleValue = 1.2;

// NG
final response = rpc.WithPrimitiveResponse()..primitive = rpc.PrimitiveType();
response.primitive.value.doubleValue = 1.2;

// OK
final primitive = rpc.PrimitiveType()..value = rpc.PrimitiveType_Value();
final response = rpc.WithPrimitiveResponse()..primitive = primitive;
response.primitive.value.doubleValue = 1.2;

I don’t know exactly because I don’t read the code from gRPC but it seems that 2 level deeper properties are frozen.

How to set a value to the same data type

Let’s assign the same type as the requested value in this example. Let’s implement it in this way.

// Server
@override
Future<rpc.WithPrimitiveResponse> withPrimitive(
  ServiceCall ctx,
  rpc.WithPrimitiveRequest request,
) async {
  print("--- withPrimitive ---");
  print("hasPrimitive: ${request.hasPrimitive()}");
  print("value: ${request.primitive.value}");
  print("whichElement: ${request.primitive.value.whichElement()})");
  print("(has: ${request.hasType()}, type: ${request.type})");

  final primitive = rpc.PrimitiveType()..value = rpc.PrimitiveType_Value();
  final response = rpc.WithPrimitiveResponse()..primitive = primitive;
  final element = request.primitive.value.whichElement();
  final tagNumber = request.primitive.value.getTagNumber(element.name) ?? 0;
  print("element: $element, tagNumber: $tagNumber)");

  if (element != PrimitiveType_Value_Element.notSet) {
    response.primitive.value.setField(
      tagNumber,
      request.primitive.value.getField(tagNumber),
    );
  }

  return response;
}

We get the name of the requested property first. Then, get the target number used for the property. If a value is not set, the tag number is always 0. Then, we can set the value there by setField().

We can implement it in the same way on the client side too.

// Client
Future<void> withPrimitive({String type = ""}) async {
  print("--- withPrimitive ---");
  final primitive = rpc.PrimitiveType()..value = rpc.PrimitiveType_Value();
  final request = rpc.WithPrimitiveRequest()..primitive = primitive;

  switch (type) {
    case "text":
      request.primitive.value.text = "dummy text";
      break;
    case "double":
      request.primitive.value.doubleValue = 2.22;
      break;
    case "int64":
      request.primitive.value.int64Value = $fixnum.Int64(123);
      break;
    case "uint64":
      request.primitive.value.uint64Value = $fixnum.Int64(567);
      break;
    case "bool":
      request.primitive.value.boolean = true;
      break;
    case "bytes":
      request.primitive.value.rawBytes = [48, 49, 50];
      break;
  }

  final result = await client.withPrimitive(request);
  print("(has: ${result.hasPrimitive()}"
      ", value: ${result.primitive.value})");
}

Let’s call the function with the following parameters.

await typeHandler.withPrimitive();
await typeHandler.withPrimitive(type: "int64");
await typeHandler.withPrimitive(type: "text");
await typeHandler.withPrimitive(type: "bytes");

I recognized that type property is not used but it’s okay. The value is correctly assigned depending on the request data type.

$ make runClient                |  $ make runServer
--- withPrimitive ---           | Server listening on port 8080...
(has: true, value: )            | --- withPrimitive ---
--- withPrimitive ---           | hasPrimitive: true
(has: true,                     | value: 
 value: int64Value: 123)        | whichElement: PrimitiveType_Value_Element.notSet)
--- withPrimitive ---           | (has: false, type: )
(has: true,                     | element: PrimitiveType_Value_Element.notSet, tagNumber: 0)
 value: text: dummy text)       | --- withPrimitive ---
--- withPrimitive ---           | hasPrimitive: true
(has: true,                     | value: int64Value: 123
 value: rawBytes: [48, 49, 50]) | 
                                | whichElement: PrimitiveType_Value_Element.int64Value)
                                | (has: false, type: )
                                | element: PrimitiveType_Value_Element.int64Value, tagNumber: 3)
                                | --- withPrimitive ---
                                | hasPrimitive: true
                                | value: text: dummy text
                                | 
                                | whichElement: PrimitiveType_Value_Element.text)
                                | (has: false, type: )
                                | element: PrimitiveType_Value_Element.text, tagNumber: 1)
                                | --- withPrimitive ---
                                | hasPrimitive: true
                                | value: rawBytes: [48, 49, 50]
                                | 
                                | whichElement: PrimitiveType_Value_Element.rawBytes)
                                | (has: false, type: )
                                | element: PrimitiveType_Value_Element.rawBytes, tagNumber: 6)

Optional keyword to make a property nullable

oneof keyword can have multiple data types. Null is also possible to use but we should use optional keyword if the property has only a single data type.

Add optional keyword to the property that needs to be null.

message WithOptionalRequest {
  optional int64 option_value = 1;
  string type = 2;
}
message WithOptionalResponse {
  optional int64 option_value = 1;
}

The implementation is very simple. Since it can be null, we must check whether or not it has a value by hasXxxx() method before reading the value.

// Server
@override
Future<rpc.WithOptionalResponse> withOptional(
  ServiceCall ctx,
  rpc.WithOptionalRequest request,
) async {
  print("--- withOptional ---");
  print("optionValue: (${request.hasOptionValue()}, ${request.optionValue})"
      ", type: (${request.hasType()}, ${request.type})");

  if (request.hasOptionValue()) {
    return rpc.WithOptionalResponse()..optionValue = $fixnum.Int64(11);
  }

  return rpc.WithOptionalResponse();
}

We try to send a request with/without a value.

// Client
Future<void> withOptional({String type = ""}) async {
  print("--- withOptional ---");
  final request = rpc.WithOptionalRequest();

  if (type == "number") {
    request
      ..optionValue = $fixnum.Int64(111)
      ..type = type;
  } else if (type == "empty") {
    request.type = type;
  }

  final result = await client.withOptional(request);
  print("(has: ${result.hasOptionValue()}, value: ${result.optionValue})");
}

We’ll try to call the function with the following parameters.

await typeHandler.withOptional();
await typeHandler.withOptional(type: "number");
await typeHandler.withOptional(type: "empty");

When a value is not set, the default value is set to the optional property.

$ make runClient       | $ make runServer
--- withOptional ---   | Server listening on port 8080...
(has: false, value: 0) | --- withOptional ---
--- withOptional ---   | optionValue: (false, 0), type: (false, )
(has: true, value: 11) | --- withOptional ---
--- withOptional ---   | optionValue: (true, 111), type: (true, number)
(has: false, value: 0) | --- withOptional ---
                       | optionValue: (false, 0), type: (true, empty)

You might think that it’s the same as standard property. A property has hasXxxx() method in Dart even if it’s not optional. However, the proto definition can be used in other languages too. Use optional keyword if the property is nullable so that the data can be properly processed in any language.

Related article

Comments

Copied title and URL