The Cybtans Command Line Interface aka Cybtans CLI is a cross-platform Protocol Buffers compiler for the development of RESTful APIs with AspNet Core driven by definitions in the Protobuf language. Using methodology improves productivity by generating a lot of boilerplate code, thus allowing the developer to focus on the application logic. Which results in a clean architecture with auto-generated code for AspNet Core Controllers and client libraries. The integration between backend and frontend teams is also improved due to code documentation for client libraries and services are generated directly from the protobuf definitions. In addition, the Cybtans CLI allows the developer to create a project structure ready for microservice development.
Generating RESTful Apis using proto files
As mentioned before the Cybtans protobuf compiler uses proto files to create Rest services instead of gRPC. Rest services provide a wide range of integration options for web apps. while gRPC is suitable for internal service communications.
To generate REST Apis using proto files, download the cybtans cli for your platform. Extract it and add the location to your PATH.
Windows
Mac
Portable
Next, create a file named cybtans.json in the project’s directory. This file defines configuration settings and is organized into several steps.
Each step represents a code generation process. In the example below there are two steps defined. The first one is for generating a proto file with messages from classes in a .net assembly file. The second step compiles the specified proto file and generates C# code for service interfaces, API controllers, DTOs, and client libraries.
{
"Steps": [
{
"Type": "messages",
"Output": ".",
"ProtoFile": "./protos/data.proto",
"AssemblyFile": "../WeatherService.Data/bin/Debug/net5.0/WeatherService.Data.dll"
},
{
"Type": "proto",
"ProtoFile": "./protos/wheater.proto",
"Models":
{
"Output": "./Generated/Models",
"Namespace": "WeatherService.Models"
},
"Services": {
"Output": "./Generated/Services",
"Namespace": "WeatherService.Services"
},
"Controllers": {
"Output": "./Generated/Controllers",
"Namespace": "WeatherService.Controllers"
},"
Clients": [
{
"Output": "../react-app/src/services/weather",
"Framework": "react"
},
{"Output": "../angular-app/src/app/services/weather","Framework": "angular"
}
]
}
]
In order to generate messages in protobuf from POCO classes, you need to add the following package. This allows you to mark the classes you want to include in the proto file when the reverse-engineering to the .NET assembly is applied.
dotnet add package Cybtans.Entities.Proto
Then decorate the POCO classes you want to generate message from with the GenerateMessage attribute as shown below. In addition, you can use the MessageExcluded attribute for excluding a property into its corresponding protobuf message.
using Cybtans.Entities;
using System;
using System.ComponentModel;
namespace WeatherService.Data
{
[Description("Contains Forecast information")]
[GenerateMessage]
public class WeatherForecast
{
[Description("Forecast date in UTC")]
public DateTime Date { get; set; }
[Description("Temperature in Celsius")]
public int TemperatureC { get; set; }
[Description("Temparature in Fahrenheit")]
public int TemperatureF=>32+ (int)(TemperatureC/0.5556);
[Description("Forecast summary")]
public string Summary { get; set; }
}
}
Now, in order to run the steps in the cybtans.json file, you need to open a terminal and execute the cybtans-cli command-line tool.
> cybtans-cli [route to your project's directory]
The cybtans-cli generates the following data.proto file. This file contains message definitions from the WeatherService.Data project. This is especially useful when you generate entity-type classes from a database, using Entity Framework Reverse Engineering.
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
syntax = "proto3";
message WeatherForecastDto {
option(description) = "Contains Forecast information";
datetime date = 1 [(description) = "Forecast date in UTC"];
int32 temperatureC = 2 [(description) = "Temperature in Celsius"];
int32 temperatureF = 3 [(description) = "Temparature in Fahrenheit"];
string summary = 4 [(description) = "Forecast summary"];
}
Now you can define a service in the weather.proto file like the one shown below. The weather.proto is the entry point for the cybtans compiler. You can import other files as well like the data.proto and use those messages. In addition, you can define the namespace in the proto file, but the cybtans.json configuration takes precedence and overrides the service namespace
syntax = "proto3";
option csharp_namespace = "WeatherService";
import "protos/data.proto";
service WeatherForecastService {
option (prefix) = "weather";
option (description) = "Weather forecast service";
rpc GetForecast (GetForecastRequest) returns (WeatherForecastReply)
{
option (method) = "GET";
option (template) = "{metric}";
option (description) = "Returns a resume of weather forecast";
}
}
message GetForecastRequest{
string metric = 1;
}
message WeatherForecastReply {
repeated WeatherForecastDto items = 1;
}
The cybtans compiler leverages some options to generate AspNetCore controllers and documentations. The extended options can be found in the cybtans.proto file. So after the code is generated, you can provide the service implementation like in the following code snippet.
using AutoMapper;
using System.Linq;
using System.Threading.Tasks;
using WeatherService.Data;
using WeatherService.Models;
namespace WeatherService.Services
{
public class WeatherForecastService : IWeatherForecastService
{
private readonly IWeatherForecastRepository _repository;
private readonly IMapper _mapper;
public WeatherForecastService(IWeatherForecastRepository
repository, IMapper mapper)
{
_repository=repository;
_mapper=mapper;
}
public async Task <WeatherForecastReply> GetForecast
(GetForecastRequest request)
{
returnnewWeatherForecastReply
{
Items= (await_repository.Get()).Select(x=>_mapper.Map
<WeatherForecastDto>(x)).ToList()
};
}
}
}
The cybtans protobuf compiler can generate as well code for typescript for angular and react as specified in the cybtans.json. The typescript models are generated as interfaces as shown in the following code.
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
/** Contains Forecast information */
export interface WeatherForecastDto {
/** Forecast date in UTC */
date: string|Date;
/** Temperature in Celsius */
temperatureC: number;
/** Temparature in Fahrenheit */
temperatureF: number;
/** Forecast summary */
summary?: string|null;
}
export interface GetForecastRequest {
metric?: string|null;
}
export interface WeatherForecastReply {
items?: WeatherForecastDto[]|null;
}
On the other hand, the cybtans compiler can generate the client’s code for a specific framework or library. For example the clients for React leverage a fetch-like function as shown below. Be aware that in this case the typescript clients' classes are generic enough and do not contains react-specific code.
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
import{GetForecastRequest,WeatherForecastReply,}from'./models';
export type Fetch=(input: RequestInfo, init?:
RequestInit)=>Promise<Response>;
export type ErrorInfo=
{
status:number,
statusText:string,
text: string
};
export interface WheaterOptions{
baseUrl:string;
}
class BaseWheaterService{
protected _options:WheaterOptions;
protected _fetch:Fetch;
constructor(fetch:Fetch,options:WheaterOptions){
this._fetch=fetch;
this._options=options;
}
protected getQueryString(data:any): string|undefined{
if (!data)
return'';
let args=[];
for(let key in data){
if(data.hasOwnProperty(key)){
let element=data[key];
if(element!==undefined&&element!==null&&element!==''){
if (element instanceof Array){
element.forEach(e => args.push
(key+'='+encodeURIComponent(e instanceof
Date ? e.toJSON(): e)));
}else if(element instanceof Date){
args.push(key+'='+encodeURIComponent(element.toJSON()));
}else{
args.push(key+'='+encodeURIComponent(element));
}
}
}
}
return args.length>0 ? '?'+args.join('&') : '';
}
protected getFormData(data:any): FormData{
let form = new FormData();
if(!data)
return form;
for(let key in data){
if(data.hasOwnProperty(key)){
let value=data[key];
if(value!==undefined&&value!==null&&value!==''){
if(value instanceof Date){
form.append(key,value.toJSON());
}else if(typeof value ==='number'|| typeof value
==='bigint'||typeof value==='boolean'){
form.append(key,value.toString());
}else if(value instanceof File){
form.append(key,value,value.name);
}else if(value instanceof Blob){
form.append(key,value,'blob');
}else if(typeof value==='string'){
form.append(key,value);
}else{throw new Error(`value of ${key} is not supported
for multipart/form-data upload`);
}
}
}
}
return form;}
protected getObject<T>(response:Response): Promise<T>{
let status = response.status;
if(status>=200&&status<300){
return response.json();
}
return response.text().then((text)=>Promise.reject<T>(
{
status,
statusText:response.statusText, text }));
}
protected getBlob(response:Response): Promise<Response>{
let status=response.status;
if(status>=200&&status<300){
return Promise.resolve(response);
}
return response.text().then((text)=>Promise.reject<Response>(
{
status,
statusText:response.statusText, text }));
}
protected ensureSuccess(response:Response): Promise<ErrorInfo|void>
{
let status = response.status;
if(status<200||status>=300){
return response.text().then((text)=>Promise.reject<ErrorInfo>(
{
status,
statusText:response.statusText, text }));
}
return Promise.resolve();
}
}
/** Weather forecast service */
export class WeatherForecastService extends BaseWheaterService
{
constructor(fetch:Fetch, options:WheaterOptions){
super(fetch ,options);
}
/** Returns a resume of weather forecast */
getForecast(request:GetForecastRequest) :
Promise<WeatherForecastReply>
{
let options:RequestInit=
{
method: 'GET',
headers:
{
Accept: 'application/json'}
};
let endpoint=this.
_options.baseUrl+`/weather/${request.metric}`;
return this._fetch(endpoint, options)
.then((response:Response)=>this.getObject(response));
}
}
In contrast, the clients for Angular uses Angular dependencies like the HttpClient abstraction, and observables as shown below.
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
import{Injectable} from '@angular/core';
import{Observable,of} from 'rxjs';
import{
HttpClient,
HttpHeaders,
HttpEvent,
HttpResponse
}
from '@angular/common/http';
import{
GetForecastRequest,
WeatherForecastReply,
}
from'./models';
function getQueryString(data:any): string|undefined{
if (!data) return'';
let args=[];
for (let key in data){
if (data.hasOwnProperty(key)){
let element = data[key];
if(element !== undefined && element!==null && element!=='')
{
if(element instanceof Array){
element.forEach(e=>args.push(key
+'='+encodeURIComponent
(e instanceof Date ? e.toJSON(): e)));
}else if(element instanceof Date){
args.push(key+'='+encodeURIComponent(element.toJSON()));
}else{
args.push(key+'='+encodeURIComponent(element));}}}
}
return args.length>0 ? '?'+args.join('&') : '';
}
function getFormData(data:any): FormData{
let form = new FormData();
if(!data)
return form;
for(let key in data){
if(data.hasOwnProperty(key)){
let value=data[key];
if(value!==undefined && value!==null && value!==''){
if(value instanceof Date){
form.append(key,value.toJSON());
}elseif(typeof value === 'number'||typeof value ===
'bigint'||typeof value === 'boolean'){
form.append(key,value.toString());
}else if(value instanceof File){
form.append(key,value,value.name);
}else if(value instanceof Blob){
form.append(key,value,'blob');
}else if(typeof value==='string'){
form.append(key,value);
}else{throw new Error(`value of ${key} is not supported
for multipart/form-data upload`);
}
}
}
}
return form;
}
/** Weather forecast service */
@Injectable({
providedIn: 'root',
})
export class WeatherForecastService{
constructor(private http: HttpClient){}
/** Returns a resume of weather forecast */
getForecast(request: GetForecastRequest):
Observable<WeatherForecastReply>{
return this.http.get<WeatherForecastReply>
(`/weather/${request.metric}`,{
headers: new HttpHeaders({Accept: 'application/json'}),
});
}
}
Generating Restful proxies for gRPC services
The cybtans compiler can generate Rest Apis which communicates with upstream gRPC services. These services are commonly known as API Gateways or Backend for Frontends. Generally, the API Gateway service takes care of authentication and authorization for the upstream gRPC calls, this approach improves the gRPC reusability. Even more, you can provide a GraphQL endpoint for aggregating data from multiple gRPCs. The cybans compiler can help you with this approach by generating all the schema and resolvers, I will cover this topic with more details further in this article.
In order to expose a gRPC through a Rest API, you need to set up a gRPC project first by using for example the Visual Studio template or the DotNet CLI, for example:
dotnet new grpc -o GrpcGreeter
Then copy the cybtans.proto to the Protos folder and import the file as shown below
syntax = "proto3";
option csharp_namespace = "GrpcGreeter";
package greet;
import "Protos/cybtans.proto";
import "Protos/data.proto";
// The greeting service definition.
service Greeter
{option (prefix) = "greeter";
option (grpc_proxy) = true;
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply){
option (template) = "hello";
option (method) = "GET";
}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings.
message HelloReply {
string message = 1;
}
In addition, ensure the cybtans.proto is not compiled by the gRPC toolchain by adding this to the .csproj file.
<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Both" />
<Protobuf Include="Protos\cybtans.proto" GrpcServices="None" /></ItemGroup>
Now create an AspNetCore with Visual Studio or the CLI
dotnet new webapi -o ApiGateway
Once the Web API project is created go ahead and add the following cybtans.json to the gRPC project.
{
"Steps": [
{
"Type": "proto",
"ProtoFile": "./Protos/greet.proto",
"Models":
{
"Output": "../ApiGateway/Generated/Models",
"Namespace": "ApiGateway.Models"
},
"Services":
{
"Output": "../ApiGateway/Generated/Services",
"Namespace": "ApiGateway.Services","Grpc":
{
"Output": "../ApiGateway/Generated/Services",
"Namespace": "ApiGateway.Services"
}
},
"Controllers":
{
"Output": "../ApiGateway/Generated/Controllers",
"Namespace": "ApiGateway.Controllers",
"UseActionInterceptor": true
}
}
]
}
Then after running the cybtans-cli, you will notice the following code is generated, including the service interface, DTO models, controllers, and the service implementation that calls the gRPC.
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using System.Threading.Tasks;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using mds = global::Cybtans.Tests.Gateway.Models;
namespace ApiGateway.Services
{
public class Greeter : IGreeter
{
private readonly global::GrpcGreeter.Greeter.GreeterClient
_client;
private readonly ILogger<Greeter> _logger;
public Greeter(global::GrpcGreeter.Greeter.GreeterClient
client, ILogger<Greeter> logger)
{
_client=client;
_logger=logger;
}
public async Task<mds::HelloReply> SayHello(mds::HelloRequest
request)
{
try
{
var response = await
_client.SayHelloAsync(request.ToProtobufModel());
return response.ToPocoModel();
}
catch(RpcException ex)
{
_logger.LogError(ex, "Failed grpc call
GrpcGreeter.Greeter.GreeterClient.SayHello");
throw;
}
}
}
}
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using ApiGateway.Services;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using mds = global::ApiGateway.Models;
namespace ApiGateway.Controllers
{
[Route("greeter")]
[ApiController]
public partial class GreeterController : ControllerBase
{
private readonly IGreeter _service;
private readonly ILogger<GreeterController> _logger;
public GreeterController(IGreeter service,
ILogger<GreeterController> logger)
{
_service=service;
_logger=logger;
}
[HttpGet("hello")]
public async Task<mds::HelloReply>
SayHello([FromQuery]mds::HelloRequest request)
{
_logger.LogInformation("Executing {Action} {Message}",
nameof(SayHello), request);
return await _service.SayHello(request).ConfigureAwait
(false);
}
}
}
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using System;
namespace ApiGateway.Models
{
public class HelloReply
{
public string Message {get; set;}
public static implicit operator HelloReply(string message)
{
return new HelloReply { Message = message };
}
}
}
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using System;
namespace ApiGateway.Models
{
public class HelloRequest
{
public string Name {get; set;}
public static implicit operator HelloRequest(string name)
{
return new HelloRequest { Name=name };
}
}
}
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using System.Threading.Tasks;
using mds = global::ApiGateway.Models;
namespace ApiGateway.Services
{
public interface IGreeter
{
Task<mds::HelloReply> SayHello(mds::HelloRequest request);
}
}
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using System;
using System.Linq;
using mds = global::ApiGateway.Models;
namespace ApiGateway.Services
{
public static class ProtobufMappingExtensions
{
public static global::GrpcGreeter.HelloRequest
ToProtobufModel(this mds::HelloRequest model)
{
if (model == null) returnnull;
global::GrpcGreeter.HelloRequestresult = new
global::GrpcGreeter.HelloRequest();
result.Name=model.Name?? string.Empty;
return result;
}
public static mds::HelloReply ToPocoModel(this
global::GrpcGreeter.HelloReply model)
{
if(model == null) return null;
mds::HelloReply result = new mds::HelloReply();
result.Message=model.Message;
return result;
}
}
}
The next step to finish this example is to register the service class and the gRPC client with DI in the ConfigureServices method of the Startup class.
Generating GraphQL endpoints for Grpc Services
The cybtans protobuf compiler can generate GraphQL type schemas for graphql-dotnet along with asynchronous resolver functions to pull data from multiple gRPCs. To enable this functionality you can add the following option to the RPC as shown below: option (graphql).query = “hello”;
syntax = "proto3";
option csharp_namespace = "GrpcGreeter";
import "protos/cybtans.proto";;
// The greeting service definition.
service Greeter {
option (prefix) = "greeter";
option (grpc_proxy) = true;
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply){
option (template) = "hello";
option (method) = "GET";
option (graphql).query = "hello";
}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings.
message HelloReply {
string message = 1;
}
To complete the setup for GraphQL generation you need to add the GraphQL configuration section to the cybtans.json as shown below.
{
"Steps": [
{
"Type": "proto",
"ProtoFile": "./Protos/greet.proto",
"Models": {
"Output": "../ApiGateway/Generated/Models",
"Namespace": "ApiGateway.Models"
},
"Services": {
"Output": "../ApiGateway/Generated/Services",
"Namespace": "ApiGateway.Services",
"Grpc": {
"Output": "../ApiGateway/Generated/Services",
"Namespace": "ApiGateway.Services"
},
"GraphQL": {
"Generate": true,
"Output": "../ApiGateway/Generated/Services",
"Namespace": "ApiGateway.GraphQL",
"QueryName": "GraphQLQueryDefinitions"
}
},
"Controllers": {
"Output": "../ApiGateway/Generated/Controllers",
"Namespace": "ApiGateway.Controllers",
"UseActionInterceptor": true
}
}
]}
Note the GraphQL section goes inside the Services section. Then when running the cybtans-cli you can notice the following code is generated.
//*****************************************************
// <auto-generated>
// Generated by the cybtans protocol buffer compiler. DO NOT EDIT!
// Powered By Cybtans
// </auto-generated>
//******************************************************
using System;
using GraphQL;
using GraphQL.Types;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using ApiGateway.Models;
namespace ApiGateway.GraphQL
{
public class HelloReplyGraphType : ObjectGraphType<HelloReply>
{
public HelloReplyGraphType()
{
Field(x=>x.Message, nullable:true);
}
}
public partial class GraphQLQueryDefinitions : ObjectGraphType
{
public void AddGreetDefinitions()
{
#region Greeter
FieldAsync<HelloReplyGraphType>("hello",
arguments: newQueryArguments()
{
new QueryArgument<StringGraphType>(){ Name="Name"
},
},
resolve: async context=>
{
var request = new HelloRequest();
request.Name=context.GetArgument<string>("name",
default(string));
var service = context.RequestServices
.GetRequiredService<global::ApiGateway.Services.IGreeter>();
var result = await service.SayHello(request)
.ConfigureAwait(false);
return result;
}
);
#endregion Greeter
}
}
In the previous code snipped you can see the Query Schema was not generated, neither the constructor for the GraphQLQueryDefinitions class. This is intentional so you can add customize your query and add additional fields from other protos as shown below.
using GraphQL.Types;
using System;
namespace ApiGateway.GraphQL
{
partial class GraphQLQueryDefinitions
{
public GraphQLQueryDefinitions()
{
AddGreetDefinitions();
AddOtherGrpcServiceDefinitions();
}
}
public class ApiGatewayDefinitionsSchema : Schema
{
public ApiGatewayDefinitionsSchema(IServiceProvider provider)
: base(provider)
{
Query=newGraphQLQueryDefinitions();
}
}
}
Now to finish the GraphQl setup you need to register the Schema with DI and add the GraphQL middleware to the AspnetCore pipeline.
services.AddSingleton<ISchema, ApiGatewayDefinitionsSchema>();
services.AddGraphQL(options =>{
options.EnableMetrics = true;
})
.AddErrorInfoProvider(opt => opt.ExposeExceptionStackTrace = true)
.AddSystemTextJson()
.AddGraphTypes(typeof(Startup), ServiceLifetime.Singleton);
You can add the GraphQl middleware in the Configure method of the Startup class as shown below.
// add http for Schema at default url /graphql
app.UseGraphQL<ISchema>();
// use graphql-playground at default url /ui/playground
app.UseGraphQLPlayground("/ui/playground");
Conclusions
The cybtans protobuf compiler is a powerful tool that can save you a lot of time when developing microservice applications. It can generate Restful Apis, API Gateways, and GraphQL endpoints from proto files. This making your proto files the source of truth for your backend services.
The cybtans compiler supports several options for authorization like roles and policy-based authorization. In especial, the policy-based authorization can be used as well for authorization based on the result data.
The advantage the cybtans compilers provides to front-end developers is that they don’t have to spend time writing integration code for backend services liked DTOs and clients components in typescript.
Moreover, front-end developers can leverage the autogenerated GraphQL endpoints to fetch data from multiple microservices using a single request, thus improving the app responsiveness and user experience.
Source: Medium - Ansel Castro
The Tech Platform
Comentários