Search code examples
javascriptpythonprotocol-buffers

How do I deserialize a protobuf string in js sent from a python backend?


I have a Flask server that sends a serialized protocol buffer message. I've de-serialized this message in a few environments successfully, including a C++ client. However, when I attempt to de-serialize my message in js, nothing works. I am using protobuf.js. According to the documentation, I think I am doing it correctly.

Here is the protobuff file vtkMessage.proto:

syntax = "proto3";

package vtkMessage;

message hash_element {
  string name = 1;
  repeated float v = 2;
}

message hash {
  repeated hash_element elem = 1;
}

message vtkMsg {
  repeated int32 tris=1;
  repeated float verts=2;
  hash vals=3;
}

Here is my Flask server test.py:

from flask import Flask
from flask import render_template, request
import vtkMessage_pb2

app = Flask(__name__, static_folder='')

@app.route('/test',methods = ['POST'])
def test():
  data = request.get_json()
  ret = vtkMessage_pb2.vtkMsg()
  ret.tris.extend([1,2,3])
  ret.verts.extend([0.45,0.35,0.11, 0.66,0.78,0.23, 0.11,0.01,0.14])
  a = ret.vals.elem.add()
  a.name = 'test1'
  a.v.extend([0.1, 0.2])
  a = ret.vals.elem.add()
  a.name = 'test2'
  a.v.extend([0.3, 0.5])
  print(ret)
  return ret.SerializeToString() # this decodes successfully when I use C++

@app.route('/protoTest')
def protoTest():
  return render_template('protoTest.html')

Here is templates/protoTest.html:

<html>
  <meta charset="UTF-8">
  <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
  <script src="//cdn.jsdelivr.net/npm/protobufjs@7.2.5/dist/protobuf.js"></script>

  <script type="text/javascript">
    window.onload = main;

    function main() {
      var dat = JSON.stringify({
        step: 0,
        frames: 1,
        fname: "openfoam.vtk"
      });
      protobuf.load('vtkMessage.proto', function(err,root) {
        window.vtkMsg = root.lookupType("vtkMessage.vtkMsg");
        var proto_obj = {};

        $.ajax({
          url:'/test',
          data: dat,
          type: "POST",
          contentType: "application/json;charset=utf-8",
          async: false,
          success: function(res) {
            console.log(res);
            var buffer = new TextEncoder("utf-8").encode(res);
            proto_obj = vtkMsg.decode(buffer); // WHY IS THIS FAILING???
            console.log(proto_obj);
          },
          error: function(res) {
            console.error(res);  // sometimes this also happens
          }
        });

        console.log(proto_obj);

      });
    }
  </script>

  <body>
     <h1>Protobuf test, view in console</h1>
  </body>
</html>

To get the vtkMessage_pb2.py you will need to run: protoc -I=. --python_out=. vtkMessage.proto

I suspect that var buffer = new TextEncoder("utf-8").encode(res); is incorrect, but I don't see any documentation on how this should be done. Also, my fear is that flask and/or jquery does something funky in handling the POST request.


Solution

  • It is reasonable to want to ship Protobuf serialized messages using RESTful mechanisms as you're trying to do but, it's unclear to me why you wouldn't just use pure JSON and REST in this case.

    That said, your code attempts to mix incorrectly JSON (you POST JSON to the test method (which is then discarded) and you attempt to return binary (invalid JSON) from the server.

    I suspect (but don't show here) that it would be possible to post application/grpc (binary) content to a server.

    Instead, I show an approach that combines JSON w/ Protobuf binary messages. In order to ship a Protobuf binary message, you must base64-encode it.

    {
      data: "{base64-encoded serialized Protobuf message}"
    }
    

    I'm not very familiar with Flask nor with JavaScript so the following code would benefit from improvement but it works:

    from flask import Flask
    from flask import jsonify, render_template, request
    
    import base64
    import vtkMessage_pb2
    
    app = Flask(__name__, static_folder='')
    
    @app.route('/test',methods = ['POST'])
    def test():
      # Discard !?
      data = request.get_json()
    
      msg = vtkMessage_pb2.vtkMsg()
      msg.tris.extend([1,2,3])
      msg.verts.extend([0.45,0.35,0.11, 0.66,0.78,0.23, 0.11,0.01,0.14])
      a = msg.vals.elem.add()
      a.name = 'test1'
      a.v.extend([0.1, 0.2])
      a = msg.vals.elem.add()
      a.name = 'test2'
      a.v.extend([0.3, 0.5])
      print(msg)
    
      # Convert Protobuf message to binary string
      b = msg.SerializeToString()
    
      # Base64 encode it to bundle as JSON
      data = base64.b64encode(b).decode("ascii")
    
      # Return JSON message
      return jsonify({"data": data})
    
    @app.route('/protoTest')
    def protoTest():
      return render_template('protoTest.html')
    

    And:

    <html>
      <meta charset="UTF-8">
      <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
      <script src="//cdn.jsdelivr.net/npm/protobufjs@7.2.5/dist/protobuf.js"></script>
    
      <script type="text/javascript">
        window.onload = main;
    
        // Base64 decode and return Uint8Array
        // See: https://stackoverflow.com/a/21797381/609290
        function base64ToBytes(base64) {
          var binaryString = atob(base64);
          var bytes = new Uint8Array(binaryString.length);
          for (var i = 0; i < binaryString.length; i++) {
            bytes[i] = binaryString.charCodeAt(i);
          }
          return bytes;
        }
    
        function main() {
          // POSTed but ignored by the server
          var data = JSON.stringify({
            step: 0,
            frames: 1,
            fname: "openfoam.vtk"
          });
          protobuf.load('vtkMessage.proto', function(err,root) {
            window.vtkMsg = root.lookupType("vtkMessage.vtkMsg");
            var proto_obj = {};
    
            $.ajax({
              url:'/test',
              data: data,
              type: "POST",
              contentType: "application/json;charset=utf-8",
              async: false,
              success: function(res) {
                // JSON response {"data":...}
                console.log(res);
                // Extract the Protobuf binary data
                b = base64ToBytes(res.data);
                // Deserialize the message
                msg = vtkMsg.decode(b);
                console.log(msg);
              },
              error: function(res) {
                console.error(res);
              }
            });
    
            console.log(msg);
    
          });
        }
      </script>
    
      <body>
         <h1>Protobuf test, view in console</h1>
      </body>
    </html>
    

    The console output includes the base64-encoded message:

    {
        "data": "CgMBAgMSJGZm5j4zM7M+rkfhPcP1KD8Urkc/H4VrPq5H4T0K1yM8KVwPPhomChEKBXRlc3QxEgjNzMw9zcxMPgoRCgV0ZXN0MhIImpmZPgAAAD8="
    }
    

    You can use e.g. Bash to extract the hex content:

    printf "CgMBAgMSJGZm5j4zM7M+rkfhPcP1KD8Urkc/H4VrPq5H4T0K1yM8KVwPPhomChEKBXRlc3QxEgjN
    zMw9zcxMPgoRCgV0ZXN0MhIImpmZPgAAAD8=" \
    | base64 --decode \
    | xxd -c 128 -g 128
    
    0a0301020312246666e63e3333b33eae47e13dc3f5283f14ae473f1f856b3eae47e13d0ad7233c295c0f3e1a260a110a0574657374311208cdcccc3dcdcc4c3e0a110a05746573743212089a99993e0000003f
    

    You can paste this into e.g. Protobuf Decoder to confirm that it is correct.

    And if you browse localhost:5000/protoTest, you should see the vtkMsg correctly recreated by the JavaScript client.