Build and Deploy a gRPC-Web App Using Rust Tonic and React

alisdairbr

alisdairbr

Posted on July 19, 2023

Build and Deploy a gRPC-Web App Using Rust Tonic and React

Introduction

gRPC is a modern, high performance remote procedure call (RPC) framework that can be run in any environment. Built on protocol buffers (commonly called protobufs), gRPC is extensible and, efficient, and has wide support in many languages and runtimes. You can take a look at our what is gRPC post to learn more.

In this tutorial, we will go over how to deploy a React application backed by a Rust-based gRPC API to Koyeb. The demo application is a movie database website that feature showcases a selection of movies and their associated metadata. You can find the source for the two application components here:

Prerequisites

In order to follow along with this tutorial, you need the following:

  • A Koyeb account to deploy the Rust and React services to. Koyeb's free tier allows you to run two services every month for free.
  • The protobuf compiler installed on your local computer. We will use this to generate the language-specific stubs from our data format.
  • Rust and cargo installed on your local computer to create the gRPC API service.
  • Node.js and npm installed on your local computer to create the React-based frontend.

Once you have satisfied the requirements, continue on to get started.

Create the Rust API

We will start by creating the Rust API for the backend and the protobuf file that defines the data format both of our services will use to communicate.

Create a new Rust project and install the dependencies

Start by defining a new Rust project.

Use the cargo command to generate a new project directory initialized with the expected package files:

cargo new movies-back
Enter fullscreen mode Exit fullscreen mode

Next, move into the new project directory and install the project's dependencies:

cd movies-back
cargo add tonic@0.9.2 tonic-web@0.9.2
cargo add prost@0.11 prost-types@0.11
cargo add --build tonic-build@0.8
cargo add tonic-health@0.9.2
cargo add tower-http@0.2.3
cargo add --features tokio@1.0/macros,tokio@1.0/rt-multi-thread tokio@1.0
Enter fullscreen mode Exit fullscreen mode

By default, web browsers do not support gRPC, but we will use gRPC-web to make it possible.

Define the data format

Next, create the data format by creating a protobuf definition file.

Create a new directory called proto:

mkdir proto
Enter fullscreen mode Exit fullscreen mode

Inside, create a new file named proto/movie.proto with the following contents:

syntax = "proto3";
package movie;

message MovieItem {
  int32 id = 1;
  string title = 2;
  int32 year = 3;
  string genre = 4;
  string rating = 5;
  string starRating = 6;
  string runtime = 7;
  string cast = 8;
  string image = 9;
}

message MovieRequest {
}

message MovieResponse {
  repeated MovieItem movies = 1;
}

service Movie {
  rpc GetMovies (MovieRequest) returns (MovieResponse);
}
Enter fullscreen mode Exit fullscreen mode

The proto/movie.proto file defines our data format using the protobuf format. It specifies a data structure to hold all of the data about a movie and outlines what a request and response for that data will look like. This definition will be used to define the API between our services.

Create the backend service

Now that we have our data format, we can create our backend service.

Start by configuring the Rust build process to compile the protobuf file. Create a file called build.rs with the following content:

fn main() -> Result<(), Box<dyn std::error::Error>> {
  tonic_build::compile_protos("./proto/movie.proto")?;
  Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Now we can implement the actual API server with the GetMovies endpoint. Open the src/main.rs file and replace the contents with the following:

use std::env;
use tonic::{transport::Server, Request, Response, Status};
pub mod grpc_movie {
    tonic::include_proto!("movie");
}
use grpc_movie::movie_server::{Movie, MovieServer};
use grpc_movie::{MovieRequest, MovieResponse};

#[derive(Debug, Default)]
pub struct MovieService {}

#[tonic::async_trait]
impl Movie for MovieService {
    async fn get_movies(
        &self,
        request: Request<MovieRequest>,
    ) -> Result<Response<MovieResponse>, Status> {
        println!("Got a request: {:?}", request);

        let mut movies = Vec::new();
        movies.push(grpc_movie::MovieItem {
            id: 1,
            title: "Matrix".to_string(),
            year: 1999,
            genre: "Sci-Fi".to_string(),
            rating: "8.7".to_string(),
            star_rating: "4.8".to_string(),
            runtime: "136".to_string(),
            cast: "Keanu Reeves, Laurence Fishburne".to_string(),
            image: "http://image.tmdb.org/t/p/w500//aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg".to_string(),
        });
        movies.push(grpc_movie::MovieItem {
            id: 2,
            title: "Spider-Man: Across the Spider-Verse".to_string(),
            year: 2023,
            genre: "Animation".to_string(),
            rating: "9.7".to_string(),
            star_rating: "4.9".to_string(),
            runtime: "136".to_string(),
            cast: "Donald Glover".to_string(),
            image: "http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg".to_string(),
        });
        movies.push(grpc_movie::MovieItem {
            id: 3,
            title: "Her".to_string(),
            year: 2013,
            genre: "Drama".to_string(),
            rating: "8.7".to_string(),
            star_rating: "4.1".to_string(),
            runtime: "136".to_string(),
            cast: "Joaquin Phoenix".to_string(),
            image: "http://image.tmdb.org/t/p/w500//eCOtqtfvn7mxGl6nfmq4b1exJRc.jpg".to_string(),
        });

        let reply = grpc_movie::MovieResponse { movies: movies };

        Ok(Response::new(reply))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let port = env::var("PORT").unwrap_or("50051".to_string());
    let addr = format!("0.0.0.0:{}", port).parse()?;
    let movie = MovieService::default();
    let movie = MovieServer::new(movie);
    let movie = tonic_web::enable(movie);

    let (mut health_reporter, health_service) = tonic_health::server::health_reporter();
    health_reporter
        .set_serving::<MovieServer<MovieService>>()
        .await;

    println!("Running on port {}...", port);
    Server::builder()
        .accept_http1(true)
        .add_service(health_service)
        .add_service(movie)
        .serve(addr)
        .await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The application uses the tonic gRPC implementation to build and serve the API for our backend based on the structures and interfaces defined by the protobuf file. In a real world scenario, this would typically be backed by a database containing the movie data, but to simplify the implementation, we just include data for a few movies inline.

The service will run on port 50051 by default (modifiable with the PORT environment variable) and will respond for requests for movies using with response objects as defined by the protobuf file.

Test the API backend

The API backend is now complete, so we can start the server locally to test its functionality by typing:

cargo run
Enter fullscreen mode Exit fullscreen mode

The API server will be built and start running on port 50051. You can test the functionality using a gRPC client of your choice like grpcurl or Postman.

For example, you can request the list of movies using grpcurl by typing the following in your project's root directory with the Rust service running:

grpcurl -plaintext -import-path proto -proto movie.proto 127.0.0.1:50051 movie.Movie/GetMovies
Enter fullscreen mode Exit fullscreen mode

The service should return the list of movies as expected:

{
  "movies": [
    {
      "id": 1,
      "title": "Matrix",
      "year": 1999,
      "genre": "Sci-Fi",
      "rating": "8.7",
      "starRating": "4.8",
      "runtime": "136",
      "cast": "Keanu Reeves, Laurence Fishburne",
      "image": "http://image.tmdb.org/t/p/w500//aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg"
    },
    {
      "id": 2,
      "title": "Spider-Man: Across the Spider-Verse",
      "year": 2023,
      "genre": "Animation",
      "rating": "9.7",
      "starRating": "4.9",
      "runtime": "136",
      "cast": "Donald Glover",
      "image": "http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg"
    },
    {
      "id": 3,
      "title": "Her",
      "year": 2013,
      "genre": "Drama",
      "rating": "8.7",
      "starRating": "4.1",
      "runtime": "136",
      "cast": "Joaquin Phoenix",
      "image": "http://image.tmdb.org/t/p/w500//eCOtqtfvn7mxGl6nfmq4b1exJRc.jpg"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Create a Dockerfile

When we deploy the backend to Koyeb, the application will be run in a container. We can define a Dockerfile for our project to describe how the project's code should be packaged and run.

In the project's root directory, create a new file called Dockerfile with the following content:

FROM rust:1.64.0-buster as builder

# install protobuf
RUN apt-get update && apt-get install -y protobuf-compiler libprotobuf-dev

COPY Cargo.toml build.rs /usr/src/app/
COPY src /usr/src/app/src/
COPY proto /usr/src/app/proto/
WORKDIR /usr/src/app
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --target x86_64-unknown-linux-musl --release --bin movies-back

FROM gcr.io/distroless/static-debian11 as runner

# get binary
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/movies-back /

# set run env
EXPOSE 50051

# run it
CMD ["/movies-back"]
Enter fullscreen mode Exit fullscreen mode

When you are finished, add all of the files for the API to a new GitHub repository so that we can deploy it to production later.

We are ready now to create the react application that will consume the API.

Create the React application

Now that the backend is complete, we can begin working on the React frontend service for our application.

Generate a new React project

The fastest way to create a basic react application is with the create-react-app. Check to make sure you are not in the Rust service's directory and then type:

npx create-react-app movies-front
Enter fullscreen mode Exit fullscreen mode

This will a new project directory for your frontend and generate some basic files to help you get started.

Move into the new directory and start the service to validate that everything installed correctly:

cd movies-front
npm run start
Enter fullscreen mode Exit fullscreen mode

A development server will open on port 3000 and React will attempt to open a new browser window to view it. You can visit localhost:3000 if you are not automatically directed there. The default React development page should appear:

The default landing page for a new React project

Press Ctrl-c when you are finished to stop the development server.

Configure the Tailwind CSS framework

Our react application will show a list of movies on a page. To speed up the process of styling it, we are going to use Tailwind CSS. Install the necessary packages and initialize Tailwind by typing:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Now that Tailwind is installed, we need to configure the React application to support it.

First, open the tailwind.config.js file. Modify the content property to pick up all of our expected CSS content:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

We will be using Sass instead of CSS directly, so we need to install the sass package next:

npm install sass
Enter fullscreen mode Exit fullscreen mode

Remove the src/App.css file and replace it with an src/App.scss. Inside, we import all of the Tailwind code that we require:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

We can test Tailwind by changing the main React application file. Change the contents of the src/App.js file like this:

import './App.scss';
import Movie from './Movie';

function App() {
  // for now we will
  let movies = [
    {
      id: 1,
      title: 'The spiderman across the spider verse',
      year: 2023,
      genre: 'animation',
      rating: 'L',
      starRating: '4.9',
      runtime: '2h 22min',
      cast: 'Donald Glover',
      image: 'http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg'
    }
  ]

  return (
    <div className="App">
      {movies.map((movie) => (
          <Movie key={movie.id} details={movie} />
      ))}

    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Here, we create an application that will serve our movie list when the page is requested. At this point, we just mock up a single movie so that we can test our CSS styling.

Create a src/Movie.js file to define how the movie should be styled and displayed:

export default function Movie(props) {
  let movie = props.details

  return (
    <article className="flex items-start space-x-6 p-6">
      <img src={movie.image} alt="" width="60" height="88" className="flex-none rounded-md bg-slate-100" />
      <div className="min-w-0 relative flex-auto">
        <h2 className="font-semibold text-slate-900 truncate pr-20">{movie.title}</h2>
        <dl className="mt-2 flex flex-wrap text-sm leading-6 font-medium">
          <div className="absolute top-0 right-0 flex items-center space-x-1">
            <dt className="text-sky-500">
              <span className="sr-only">Star rating</span>
              <svg width="16" height="20" fill="currentColor">
                <path d="M7.05 3.691c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.372 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.539 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.783.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.363-1.118L.98 9.483c-.784-.57-.381-1.81.587-1.81H5.03a1 1 0 00.95-.69L7.05 3.69z" />
              </svg>
            </dt>
            <dd>{movie.starRating}</dd>
          </div>
          <div>
            <dt className="sr-only">Rating</dt>
            <dd className="px-1.5 ring-1 ring-slate-200 rounded">{movie.rating}</dd>
          </div>
          <div className="ml-2">
            <dt className="sr-only">Year</dt>
            <dd>{movie.year}</dd>
          </div>
          <div>
            <dt className="sr-only">Genre</dt>
            <dd className="flex items-center">
              <svg width="2" height="2" fill="currentColor" className="mx-2 text-slate-300" aria-hidden="true">
                <circle cx="1" cy="1" r="1" />
              </svg>
              {movie.genre}
            </dd>
          </div>
          <div>
            <dt className="sr-only">Runtime</dt>
            <dd className="flex items-center">
              <svg width="2" height="2" fill="currentColor" className="mx-2 text-slate-300" aria-hidden="true">
                <circle cx="1" cy="1" r="1" />
              </svg>
              {movie.runtime}
            </dd>
          </div>
          <div className="flex-none w-full mt-2 font-normal">
            <dt className="sr-only">Cast</dt>
            <dd className="text-slate-400">{movie.cast}</dd>
          </div>
        </dl>
      </div>
    </article>
  )
}
Enter fullscreen mode Exit fullscreen mode

If you start up the development server again, you should be able to see the movie mocked out:

npm run start
Enter fullscreen mode Exit fullscreen mode

Single movie mock display

Fetch movie lists from the API

Next, instead of displaying a hardcoded movie, we will modify the application to fetch data from the gRPC API.

First, install protobuf and gRPC web support for React:

npm install google-protobuf@~3.21.2 grpc-web@~1.4.1
Enter fullscreen mode Exit fullscreen mode

Next, we need to generate the movie entity in JavaScript based on the same protobuf file we defined for the backend Rust application. To do this, we will use the protoc command included in the protobuf installation from the prerequisites.

Assuming the movies-back and movies-front are located next to each other, you can generate the appropriate JavaScript files with the protobuf definition by running the following command in the movies-front directory:

# change the proto_path to the correct place where movie.proto is.
protoc --proto_path=../movies-back/proto/ movie.proto --grpc-web_out=import_style=commonjs,mode=grpcweb:src --js_out=import_style=commonjs,binary:src
Enter fullscreen mode Exit fullscreen mode

This will generate the appropriate JavaScript stubs from your protobuf file so that the React application understands how to communicate with the API.

Now we can change the src/App.js file to use gRPC instead of serving a hardcoded movie:

import './App.scss';
import Movie from './Movie';
import { useState, useEffect} from 'react';

const proto = {};
proto.movie = require('./movie_grpc_web_pb.js');

function App() {
  let url  = process.env.REACT_APP_BACKEND_URL;
  if(url == null){
    url = "http://localhost:50051"
  }

  const client = new proto.movie.MovieClient(url, null, null);
  let [movies, setMovies] = useState([])
  useEffect(() => {
    const req = new proto.movie.MovieRequest();
    client.getMovies(req, {}, (err, response) => {
      if(response == null){
        return
      }

      if (response.getMoviesList().length === 0) {
        return
      }

      let m = []
      response.getMoviesList().forEach((movie) => {
        console.log(movie.toObject())
        m.push(movie.toObject())
      })
      setMovies(m)
    })
  }, [])

  return (
    <div className="App">
      {movies.map((movie) => (
          <Movie key={movie.id} details={movie} />
      ))}
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Start the development server again to test the new changes:

npm run start
Enter fullscreen mode Exit fullscreen mode

This time, you should see the full movie list as served from the API backend:

Full movie display from backend API

After confirming that the application works as expected, add the React app's files to a new GitHub repository and push them. We will deploy the application to production in the next stage.

Deploy the application to Koyeb

Now that our frontend and backend are working as expected, we can deploy both services to Koyeb.

Deploy the API service

First, we will deploy the Rust application to Koyeb.

In the Koyeb control panel, click the Create App button to get started. Select GitHub as your deployment method and then choose the repository for the backend API from your repository list. Alternatively, you can use the example repo for this service which contains the same code we've discussed by putting https://github.com/filhodanuvem/movies-rust-grpc in the Public GitHub repository field.

On the next page, choose Dockerfile as the builder:

Koyeb API deployment configuration

Click Advanced to expand additional settings. Change the value of the PORT environment variable to 50051. In the Exposing your service section, modify the port to 50051 as well. Select HTTP/2 from the protocol drop down list and set the path to /api.

Koyeb API port and health check configuration

When you are finished, click the Deploy button to deploy the API backend.

On the API's service page, copy the value of the Public URL. We will need this value when configuring our React application.

Deploy the React application

Now that the API is running we can deploy the React application. Unlike the API, we will use the buildpack builder for this project rather than building from a Dockerfile.

In the Koyeb control panel, click on the application that includes the API service you just deployed. From the service's index page, click the Create Service button to deploy an additional service within the context of the same Koyeb App.

On the following screen, select GitHub as the deployment method and click the repository for the frontend React application from your repository list. Alternatively, you can use the example repo for this service which contains the same code we've discussed by putting https://github.com/filhodanuvem/movies-react in the Public GitHub repository field.

On the next page, select Buildpack as the builder:

Koyeb frontend deployment configuration

Click Advanced to expand additional settings. Click the Add Variable button and create a new variable called REACT_APP_BACKEND_URL. Use the API service's public URL that you copied as the value. It should look something like the following:

REACT_APP_BACKEND_URL=https://<YOUR_APP_NAME>-<KOYEB_ORG_NAME>.koyeb.app/api
Enter fullscreen mode Exit fullscreen mode

Koyeb frontend environment variable configuration

When you are finished, click the Deploy button to begin building and deploying your application. When the deployment is complete, use click the React application's public URL to access the movie database site.

Conclusion

In this guide, we created and deployed an end-to-end application composed of a Rust backend and a React frontend. The two services communicate using gRPC. We deployed both services to Koyeb to take advantage of its native gRPC support. The services are able to communicate securely in order to fulfill user requests for the movie database site.

If you have any questions or suggestions, feel free to reach out on our community Slack.

💖 💪 🙅 🚩
alisdairbr
alisdairbr

Posted on July 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related