Skip to content

Commit

Permalink
feat: grpc codegen (#11)
Browse files Browse the repository at this point in the history
* client codegen

* client and server codegen
  • Loading branch information
menduz authored Jan 9, 2022
1 parent e3ff7a4 commit c8a9f05
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 1 deletion.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ temp
tsdoc-metadata.json
coverage
protoc3
src/protocol/*_pb.*
src/protocol/*_pb.*
test/codegen/*_pb.*
test/codegen/*_pb.*
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ install: install_compiler
npm i -S @types/google-protobuf@latest

test:
${PROTOC} "--js_out=binary,import_style=commonjs_strict:$(PWD)/test/codegen" \
--ts_out="$(PWD)/test/codegen" \
-I="$(PWD)/test/codegen" \
"$(PWD)/test/codegen/client.proto"
node_modules/.bin/jest --detectOpenHandles --colors --runInBand $(TESTARGS) --coverage

test-watch:
Expand Down
89 changes: 89 additions & 0 deletions src/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Message } from "google-protobuf"
import { RpcClientPort } from "."

export type Constructor<C> = { new (): C; deserializeBinary(data: Uint8Array): C }

export function clientProcedureUnary<Ctor1 extends Message, Ctor2 extends Message>(
port: unknown | Promise<unknown>,
name: string,
requestType: Constructor<Ctor1>,
requestResponseConstructor: Constructor<Ctor2>
) {
const fn: (arg: Ctor1) => Promise<Ctor2> = async (arg: any): Promise<any> => {
const remoteModule: Record<typeof name, (arg: Uint8Array) => Promise<any>> = (await port) as any

if (!(arg instanceof requestType)) throw new Error("Argument passed to RPC Method " + name + " type mismatch.")
if (!(name in remoteModule)) throw new Error("Method " + name + " not implemented in server port")

const result = await remoteModule[name](arg.serializeBinary())
if (!result) {
throw new Error("Server sent an empty or null response to method call " + name)
}
return requestResponseConstructor.deserializeBinary(result)
}

return fn
}

export function clientProcedureStream<Ctor1 extends Message, Ctor2 extends Message>(
port: unknown | Promise<unknown>,
name: string,
requestType: Constructor<Ctor1>,
requestResponseConstructor: Constructor<Ctor2>
) {
const fn: (arg: Ctor1) => AsyncGenerator<Ctor2> = async function* (arg: any) {
const remoteModule: Record<typeof name, (arg: Uint8Array) => Promise<any>> = (await port) as any

if (!(arg instanceof requestType)) throw new Error("Argument passed to RPC Method " + name + " type mismatch.")
if (!(name in remoteModule)) throw new Error("Method " + name + " not implemented in server port")

const result = await remoteModule[name](arg.serializeBinary())
if (!result) {
throw new Error("Server sent an empty or null response to method call " + name)
}
for await (const bytes of await result) {
yield requestResponseConstructor.deserializeBinary(bytes)
}
}

return fn
}

export function serverProcedureUnary<Ctor1 extends Message, Ctor2 extends Message>(
fn: (arg: Ctor1) => Promise<Ctor2>,
name: string,
ctor1: Constructor<Ctor1>,
ctor2: Constructor<Ctor2>
): (arg: Uint8Array) => Promise<Uint8Array> {
return async function (argBinary) {
const arg = ctor1.deserializeBinary(argBinary)
const result = await fn(arg)

if (!result) throw new Error("Empty or null responses are not allowed. Procedure: " + name)
if (!(result instanceof ctor2))
throw new Error("Result of procedure " + name + " did not match the expected constructor")

return result.serializeBinary()
}
}

export function serverProcedureStream<Ctor1 extends Message, Ctor2 extends Message>(
fn: (arg: Ctor1) => Promise<AsyncGenerator<Ctor2>> | AsyncGenerator<Ctor2>,
name: string,
ctor1: Constructor<Ctor1>,
ctor2: Constructor<Ctor2>
): (arg: Uint8Array) => AsyncGenerator<Uint8Array> {
return async function* (argBinary) {
const arg = ctor1.deserializeBinary(argBinary)
const result = await fn(arg)

if (!result) throw new Error("Empty or null responses are not allowed. Procedure: " + name)

for await (const elem of result) {
if (!(elem instanceof ctor2))
throw new Error("Yielded result of procedure " + name + " did not match the expected constructor")

yield elem.serializeBinary()
}
}
}
120 changes: 120 additions & 0 deletions test/codegen-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Message } from "google-protobuf"
import { RpcClient, RpcClientPort } from "../src"
import { clientProcedureStream, clientProcedureUnary } from "../src/codegen"
import { Book, GetBookRequest, QueryBooksRequest } from "./codegen/client_pb"
import { createSimpleTestEnvironment, takeAsync } from "./helpers"
import { log } from "./logger"

/// service BookService {
export type BookService = {
/// rpc GetBook(GetBookRequest) returns (Book) {}
GetBook(arg: GetBookRequest): Promise<Book>
/// rpc QueryBooks(QueryBooksRequest) returns (stream Book) {}
QueryBooks(arg: QueryBooksRequest): AsyncGenerator<Book>
}
/// }

const FAIL_WITH_EXCEPTION_ISBN = 1

export function loadBookService(port: RpcClientPort): BookService {
const mod = port.loadModule('BookService')
return {
GetBook: clientProcedureUnary(mod, "GetBook", GetBookRequest, Book),
QueryBooks: clientProcedureStream(mod, "QueryBooks", QueryBooksRequest, Book),
}
}

describe("codegen client", () => {
const testEnv = createSimpleTestEnvironment({
async initializePort(port) {
port.registerModule("BookService", async (port) => ({
async GetBook(arg: Uint8Array) {
const req = GetBookRequest.deserializeBinary(arg)

if (req.getIsbn() == FAIL_WITH_EXCEPTION_ISBN) throw new Error("ErrorMessage")

const book = new Book()
book.setAuthor("menduz")
book.setIsbn(req.getIsbn())
book.setTitle("Rpc onion layers")
return book.serializeBinary()
},
async *QueryBooks(arg: Uint8Array) {
const req = QueryBooksRequest.deserializeBinary(arg)

if (req.getAuthorPrefix() == "fail_before_yield") throw new Error("fail_before_yield")

const books = [
{ author: "mr menduz", isbn: 1234, title: "1001 reasons to write your own OS" },
{ author: "mr cazala", isbn: 1111, title: "Advanced CSS" },
{ author: "mr mannakia", isbn: 7666, title: "Advanced binary packing" },
{ author: "mr kuruk", isbn: 7668, title: "Advanced bots AI" },
]

for (const book of books) {
if (book.author.includes(req.getAuthorPrefix())) {
const protoBook = new Book()
protoBook.setAuthor(book.author)
protoBook.setIsbn(book.isbn)
protoBook.setTitle(book.title)
yield protoBook.serializeBinary()
}
}

if (req.getAuthorPrefix() == "fail_before_end") throw new Error("fail_before_end")
},
}))
},
})

let service: BookService

it("basic service wraper creation", async () => {
const { rpcClient } = testEnv

const clientPort = await rpcClient.createPort("test1")
service = loadBookService(clientPort)
})

it("calls an unary method", async () => {
const req = new GetBookRequest()
req.setIsbn(1234)
const ret = await service.GetBook(req)
expect(ret).toBeInstanceOf(Book)
expect(ret.getIsbn()).toEqual(1234)
expect(ret.getAuthor()).toEqual("menduz")
})

it("calls a streaming method", async () => {
const req = new QueryBooksRequest()
req.setAuthorPrefix("mr")

const results: Book[] = []

for await (const book of service.QueryBooks(req)) {
expect(book).toBeInstanceOf(Book)
expect(book.getAuthor()).toMatch(/^mr\s.+/)
results.push(book)
}

expect(results).toHaveLength(4)
})

it("calls to unary fails throws error in client", async () => {
const req = new GetBookRequest()
req.setIsbn(FAIL_WITH_EXCEPTION_ISBN)
await expect(service.GetBook(req)).rejects.toThrowError("RemoteError: ErrorMessage")
})

it("calls to streaming fails throws error in client, fail_before_yield", async () => {
const req = new QueryBooksRequest()
req.setAuthorPrefix("fail_before_yield")
await expect(service.QueryBooks(req).next()).rejects.toThrowError("RemoteError: fail_before_yield")
})

it("calls to streaming fails throws error in client, fail_before_end", async () => {
const req = new QueryBooksRequest()
req.setAuthorPrefix("fail_before_end")
await expect(() => takeAsync(service.QueryBooks(req))).rejects.toThrowError("RemoteError: fail_before_end")
})
})
119 changes: 119 additions & 0 deletions test/codegen-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { RpcServerPort } from "../src"
import { Book, GetBookRequest, QueryBooksRequest } from "./codegen/client_pb"
import { createSimpleTestEnvironment, takeAsync } from "./helpers"
import * as codegen from "../src/codegen"
import { loadBookService } from "./codegen-client.spec"

/// service BookService {
export type BookService = {
/// rpc GetBook(GetBookRequest) returns (Book) {}
GetBook(arg: GetBookRequest): Promise<Book>
/// rpc QueryBooks(QueryBooksRequest) returns (stream Book) {}
QueryBooks(arg: QueryBooksRequest): AsyncGenerator<Book>
}
/// }

const FAIL_WITH_EXCEPTION_ISBN = 1

export type BookServiceModuleInitializator = (port: RpcServerPort) => Promise<BookService>

export function registerBookService(port: RpcServerPort, moduleInitializator: BookServiceModuleInitializator): void {
port.registerModule("BookService", async (port) => {
const mod = await moduleInitializator(port)
return {
GetBook: codegen.serverProcedureUnary(mod["GetBook"].bind(mod), "GetBook", GetBookRequest, Book),
QueryBooks: codegen.serverProcedureStream(mod["QueryBooks"].bind(mod), "QueryBooks", QueryBooksRequest, Book),
}
})
}

describe("codegen client & server", () => {
const testEnv = createSimpleTestEnvironment({
async initializePort(port) {
registerBookService(port, async () => ({
async GetBook(req: GetBookRequest) {
if (req.getIsbn() == FAIL_WITH_EXCEPTION_ISBN) throw new Error("ErrorMessage")

const book = new Book()
book.setAuthor("menduz")
book.setIsbn(req.getIsbn())
book.setTitle("Rpc onion layers")
return book
},
async *QueryBooks(req: QueryBooksRequest) {
if (req.getAuthorPrefix() == "fail_before_yield") throw new Error("fail_before_yield")

const books = [
{ author: "mr menduz", isbn: 1234, title: "1001 reasons to write your own OS" },
{ author: "mr cazala", isbn: 1111, title: "Advanced CSS" },
{ author: "mr mannakia", isbn: 7666, title: "Advanced binary packing" },
{ author: "mr kuruk", isbn: 7668, title: "Advanced bots AI" },
]

for (const book of books) {
if (book.author.includes(req.getAuthorPrefix())) {
const protoBook = new Book()
protoBook.setAuthor(book.author)
protoBook.setIsbn(book.isbn)
protoBook.setTitle(book.title)
yield protoBook
}
}

if (req.getAuthorPrefix() == "fail_before_end") throw new Error("fail_before_end")
},
}))
},
})

let service: BookService

it("basic service wraper creation", async () => {
const { rpcClient } = testEnv

const clientPort = await rpcClient.createPort("test1")
service = loadBookService(clientPort)
})

it("calls an unary method", async () => {
const req = new GetBookRequest()
req.setIsbn(1234)
const ret = await service.GetBook(req)
expect(ret).toBeInstanceOf(Book)
expect(ret.getIsbn()).toEqual(1234)
expect(ret.getAuthor()).toEqual("menduz")
})

it("calls a streaming method", async () => {
const req = new QueryBooksRequest()
req.setAuthorPrefix("mr")

const results: Book[] = []

for await (const book of service.QueryBooks(req)) {
expect(book).toBeInstanceOf(Book)
expect(book.getAuthor()).toMatch(/^mr\s.+/)
results.push(book)
}

expect(results).toHaveLength(4)
})

it("calls to unary fails throws error in client", async () => {
const req = new GetBookRequest()
req.setIsbn(FAIL_WITH_EXCEPTION_ISBN)
await expect(service.GetBook(req)).rejects.toThrowError("RemoteError: ErrorMessage")
})

it("calls to streaming fails throws error in client, fail_before_yield", async () => {
const req = new QueryBooksRequest()
req.setAuthorPrefix("fail_before_yield")
await expect(service.QueryBooks(req).next()).rejects.toThrowError("RemoteError: fail_before_yield")
})

it("calls to streaming fails throws error in client, fail_before_end", async () => {
const req = new QueryBooksRequest()
req.setAuthorPrefix("fail_before_end")
await expect(() => takeAsync(service.QueryBooks(req))).rejects.toThrowError("RemoteError: fail_before_end")
})
})
15 changes: 15 additions & 0 deletions test/codegen/client.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
syntax = "proto3";

message Book {
int64 isbn = 1;
string title = 2;
string author = 3;
}

message GetBookRequest {
int64 isbn = 1;
}

message QueryBooksRequest {
string author_prefix = 1;
}

0 comments on commit c8a9f05

Please sign in to comment.