ConnectRPC


Schema first approach is the way to go!

I don’t care about holy wars like schema first vs code first, but I do care about the API contract. There are many ways to define an API contract, but the most common way is to use OpenAPI and that’s because it can be used for the public APIs and it can be used to generate the client code. In theory you can do the same with ConnectRPC, but it’s not as popular as OpenAPI and probably you would like to use it for your internal APIs.


What is ConnectRPC?

As stated in the official documentation: ConnectRPC


Connect is a family of libraries for building browser and gRPC-compatible HTTP APIs: you write a short Protocol Buffer schema and implement your application logic, and Connect generates code to handle marshaling, routing, compression, and content type negotiation. It also generates an idiomatic, type-safe client in any supported language.

Hello Daniel!
Hi, what’s up?
Could you show me some a simple example how to use ConnectRPC? I am tired of asking other developers on slack how their API works
Sure, I will provide you an example so you could understand ConnectRPC!

Have you ever heard about gRPC and how it works? Have you ever heard about Protocol Buffers and how it works? If you haven’t heard about it, I suggest you to read about it before you continue reading this blog post. See these links below:


ConnectRPC example with Go

In order to use ConnectRPC you need to install the required tools so please follow the instructions on the official documentation. docs

Ok, so let’s start with creating proto file for a simple API which will be a CRUD for a users.

syntax = "proto3";

package proto.user.v1;

option go_package = "github.com/kayn1/guidero/gen/proto/user/v1";

message User {
    string id = 1;
    string name = 2;
    string email = 3;
    string created_at = 5;
    string updated_at = 6;
}

message UserResponse {
    User user = 1;
}

message ListResponse {
    repeated User users = 1;
}

message CreateRequest {
    string name = 1;
    string email = 2;
}

message CreateResponse {
    User user = 1;
}

message UpdateRequest {
    string id = 1;
    string name = 2;
    string email = 3;
}

message UpdateResponse {
    User user = 1;
}

message DeleteRequest {
    string id = 1;
}

message DeleteResponse {
    User user = 1;
}

message GetRequest {
    string id = 1;
}

message GetResponse {
    User user = 1;
}

message UsersQuery {
    string name = 1;
    string email = 2;
}

message ListRequest {
    UsersQuery query = 1;
}

service UserService {
    rpc Create(CreateRequest) returns (CreateResponse);
    rpc Update(UpdateRequest) returns (UpdateResponse);
    rpc Delete(DeleteRequest) returns (DeleteResponse);
    rpc Get(GetRequest) returns (GetResponse);
    rpc List(ListRequest) returns (ListResponse);
}

As you can see it is very simple example so we can focus on the ConnectRPC part.

First of all, we are going to use buf instead of protoc to generate the code. Buf is super user friendly and it the confugration is very simple. You can find the configuration file in the root of the project. We just need to have two files: buf.yaml and buf.gen.yaml.


LinkedInbuf.yaml
# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
version: v2
modules:
  - path: .
    name: buf.build/kayn1/guidero
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
LinkedInbuf.gen.yaml
version: v2
managed:
  enabled: true
plugins:
  - local: protoc-gen-go
    out: gen
    opt: paths=source_relative
  - local: protoc-gen-connect-go
    out: gen
    opt: paths=source_relative
  - remote: buf.build/community/pseudomuto-doc:v1.5.1
    out: gen
  - local: protoc-gen-es
    out: web/gen
    opt: target=ts
  - local: protoc-gen-connect-es
    out: web/gen
    opt: target=ts
  - local: protoc-gen-connect-query
    out: web/gen
    opt: target=ts

TLDR: These two files are needed to generate the code and set the registry. How cool is that that you can see the docs for the serice here: docs so this is basically the line we’have set under the modules -> name. Buf allows you to store the generated code in the registry so you can easily access the docs for the service.


The other thing is about the plugins. We are using the local plugins to generate the code for the Go and TypeScript. Local means that these plugins needs to be installed on your machine. You can also use the remote plugins, but you need to have the access to the internet which is not always the case.


Hit the enter and generate the code!


    $ buf generate
    

After you run the command you will see the generated code in the specified directories in the buf.gen.yaml file.


Let’s use the generated code to build the server.


    crpc "connectrpc.com/connect"
	"github.com/kayn1/guidero/internal"
	"github.com/kayn1/guidero/internal/domain"
	"github.com/kayn1/guidero/internal/gen/proto/user/v1/v1connect"
	"github.com/kayn1/guidero/internal/server"
	"github.com/rs/cors"

    type ConnectRpcServer struct {
	app          domain.Application
	logger       *slog.Logger
	listener     *http.Server
	interceptors []crpc.Interceptor
	corsOptions  *cors.Options
}

    func (s *ConnectRpcServer) Start() error {
	mux := http.NewServeMux()
	path, handler := v1connect.NewUserServiceHandler(
		s,
		crpc.WithInterceptors(s.interceptors...),
	)
	mux.Handle(path, handler)

	if s.corsOptions == nil {
		s.logger.Error("CORS options are not set")
	}

	var finalHandler http.Handler = mux
	if s.corsOptions != nil {
		finalHandler = withCORS(mux, s.corsOptions)
	}

	s.listener = &http.Server{
		Addr:    ":8080",
		Handler: finalHandler,
	}

	go func() {
		err := s.listener.ListenAndServe()
		if err != nil && err != http.ErrServerClosed {
			s.logger.Error("Failed to start server", slog.String("error", err.Error()))
		}
	}()

	s.logger.Info("Server has been started", slog.Int("port", 8080))

	sigint := make(chan os.Signal, 1)
	signal.Notify(sigint, os.Interrupt)
	<-sigint

	return s.Stop()
}

As you can we are using the import v1connect which is the generated code from the proto file. The NewUserServiceHandler comes from the generated code and it is the handler for the service. We need to pass a service which implements the service interface genereted by the buf.

LinkedIn…user.connect.go

type UserServiceHandler interface {
    Create(context.Context, *connect.Request[v1.CreateRequest]) (*connect.Response[v1.CreateResponse], error)
    Update(context.Context, *connect.Request[v1.UpdateRequest]) (*connect.Response[v1.UpdateResponse], error)
    Delete(context.Context, *connect.Request[v1.DeleteRequest]) (*connect.Response[v1.DeleteResponse], error)
    Get(context.Context, *connect.Request[v1.GetRequest]) (*connect.Response[v1.GetResponse], error)
    List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error)
}

In this case all these methods were implemented for the ConnectRpcServer struct. Let’s see the implementation of the Create method as an example.


func (s *ConnectRpcServer) Create(ctx context.Context, req *connect.Request[v1.CreateRequest]) (*connect.Response[v1.CreateResponse], error) {
	user, err := s.app.UserService.CreateUser(context.Background(), domain.CreateUserRequest{
		Email: req.Msg.Email,
		Name:  req.Msg.Name,
	})

	if err != nil {
		return nil, err
	}

	res := &connect.Response[v1.CreateResponse]{
		Msg: &v1.CreateResponse{
			User: &v1.User{
				Id:    user.ID.String(),
				Email: user.Email,
				Name:  user.Name,
			},
		},
	}

	return res, nil
}

    

We are just taking the request and passing it to the domain layer but the cool part is that we are using the generated code. There are some missing bits here like validation but you can easily add it with a plugin, interceptor or by wrapping your genereted structs with your own structs. Check the validation plugin for more details.


We have the server, but what about the client?

The client is also generated by the buf and it is very simple to use it.


v1connect.NewUserServiceClient(guideroClient.httpClient, guideroClient.url.String())
    

That’s it! You can use the client to call the server.

Let’s run the server and the client to see if it works.


$ go run cmd/server/main.go
    

Ok, once the server is running we can run the client and send one request to the server.


$ go run cmd/client/main.go
    

👆
This will send a request to the server and log the result

client log

I also build the web client for the ConnectRPC so you can see how it works, as you already saw in the buf.gen.yaml file we are using the plugins to generate the TypeScript code.

E.g: We can use the generated code to build the web client for the ConnectRPC using react query.


import { createConnectTransport } from "@connectrpc/connect-web";
import { TransportProvider } from "@connectrpc/connect-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateUser } from "./components/CreateUser";

const finalTransport = createConnectTransport({
  baseUrl: import.meta.env.API_URL || "http://localhost:8080",
});

const queryClient = new QueryClient();

export const App = () => {
  return (
    <TransportProvider transport={finalTransport}>
      <QueryClientProvider client={queryClient}>
        <CreateUser />
      </QueryClientProvider>
    </TransportProvider>
  );
};

    

And the useage of the CreateUser component.


import React, { useState } from "react";
import { useMutation } from "@connectrpc/connect-query";
import { create } from "../../gen/proto/user/v1/user-UserService_connectquery";

interface CreateUserFormProps {
  onSubmit: (name: string, email: string) => void;
  isLoading: boolean;
  error: string | null;
}

const CreateUserForm: React.FC<CreateUserFormProps> = ({
  onSubmit,
  isLoading,
  error,
}) => {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit(name, email);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
        required
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? "Creating..." : "Create User"}
      </button>
      {error && <p style={{ color: "red" }}>{error}</p>}
    </form>
  );
};

export const CreateUser: React.FC = () => {
  const createUserMutation = useMutation(create);
  const [error, setError] = useState<string | null>(null);

  const handleCreateUser = async (name: string, email: string) => {
    try {
      await createUserMutation.mutateAsync({ name, email });
      setError(null);
    } catch (error) {
      setError("Error creating user");
      console.error("Error creating user:", error);
    }
  };

  return (
    <CreateUserForm
      onSubmit={handleCreateUser}
      isLoading={createUserMutation.status === "pending"}
      error={error}
    />
  );
};

    

and the network’s tab in the browser will look like this for the request to the server.

chrome network tak request image

There are few noticable things but the thing you should spot is the fact the POST request returns 200 status code. See more details about the protocol


I think that’s it what I wanted to show you in this blog post.

Hope you enjoyed it and learned something new
🙇


You can see the full code in the repository