aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOskari Timperi <oskari.timperi@iki.fi>2018-04-14 13:28:56 +0300
committerOskari Timperi <oskari.timperi@iki.fi>2018-04-14 13:28:56 +0300
commit0e423d5f62a36190e7b46b69646bea7db5587001 (patch)
tree5d915dd5243522643a33db031cc641b0ded3aa58
downloadnimtwirp-0e423d5f62a36190e7b46b69646bea7db5587001.tar.gz
nimtwirp-0e423d5f62a36190e7b46b69646bea7db5587001.zip
Initial commit
-rw-r--r--.gitignore1
-rw-r--r--LICENSE21
-rw-r--r--README.md15
-rw-r--r--example/Makefile10
-rw-r--r--example/README.md3
-rw-r--r--example/haberdasherclient.nim25
-rw-r--r--example/haberdasherserver.nim26
-rw-r--r--example/nim.cfg1
-rw-r--r--example/service.proto22
-rw-r--r--nimtwirp.nimble15
-rw-r--r--nimtwirp/errors.nim85
-rw-r--r--nimtwirp/generator.nim123
-rw-r--r--nimtwirp/nimtwirp_build.nim45
13 files changed, 392 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..739a54a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+nimcache
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f593051
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Oskari Timperi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8a46450
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+# nimtwirp
+
+A simple RPC framework. See [twirp](https://twitchtv.github.io/twirp/docs/intro.html)
+for more information.
+
+# Example
+
+See the `example` directory for an example.
+
+# Features
+
+[x] server generation from protobuf Service definition
+[x] client generation from protobuf Service definition
+[x] protobuf input/output
+[ ] json input/output
diff --git a/example/Makefile b/example/Makefile
new file mode 100644
index 0000000..63da56f
--- /dev/null
+++ b/example/Makefile
@@ -0,0 +1,10 @@
+all: haberdasherserver haberdasherclient
+
+haberdasherserver: haberdasherserver.nim service_pb.nim service_twirp.nim
+ nim c haberdasherserver.nim
+
+haberdasherclient: haberdasherclient.nim service_pb.nim service_twirp.nim
+ nim c haberdasherclient.nim
+
+%_pb.nim %_twirp.nim: %.proto
+ ../nimtwirp/nimtwirp_build -I. --out=. $^
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 0000000..acdee60
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,3 @@
+# Nimtwirp usage example
+
+This example is basically stolen from [twirp](https://twitchtv.github.io/twirp/docs/example.html).
diff --git a/example/haberdasherclient.nim b/example/haberdasherclient.nim
new file mode 100644
index 0000000..d402356
--- /dev/null
+++ b/example/haberdasherclient.nim
@@ -0,0 +1,25 @@
+import os
+import strformat
+import strutils
+
+import service_pb
+import service_twirp
+
+if paramCount() != 1:
+ echo("usage: " & getAppFilename() & " <size>")
+ quit(QuitFailure)
+
+var size = newtwirp_example_haberdasher_Size()
+try:
+ size.inches = parseInt(paramStr(1)).int32
+except:
+ echo("invalid size")
+ quit(QuitFailure)
+
+let client = newHaberdasherClient("http://localhost:8080")
+
+try:
+ let hat = MakeHat(client, size)
+ echo(&"I have a nice new hat: {hat.inches} inch {hat.color} {hat.name}")
+except Exception as exc:
+ echo(&"oh no: {exc.msg}")
diff --git a/example/haberdasherserver.nim b/example/haberdasherserver.nim
new file mode 100644
index 0000000..a41c3b8
--- /dev/null
+++ b/example/haberdasherserver.nim
@@ -0,0 +1,26 @@
+import asynchttpserver
+import asyncdispatch
+import random
+
+import nimtwirp/errors
+
+import service_pb
+import service_twirp
+
+type
+ HaberdasherService = object
+
+proc MakeHat(x: HaberdasherService, size: twirp_example_haberdasher_Size): twirp_example_haberdasher_Hat =
+ if size.inches <= 0:
+ raise newTwirpError(TwirpInvalidArgument, "I can't make a hat that small!")
+
+ result = newtwirp_example_haberdasher_Hat()
+ result.inches = size.inches
+ result.color = rand(["white", "black", "brown", "red", "blue"])
+ result.name = rand(["bowler", "baseball cap", "top hat", "derby"])
+
+var
+ server = newAsyncHttpServer()
+ service: HaberdasherService
+
+waitFor server.serve(Port(8080), HaberdasherServer(service, "/"))
diff --git a/example/nim.cfg b/example/nim.cfg
new file mode 100644
index 0000000..8ea4922
--- /dev/null
+++ b/example/nim.cfg
@@ -0,0 +1 @@
+--path=".."
diff --git a/example/service.proto b/example/service.proto
new file mode 100644
index 0000000..1945a72
--- /dev/null
+++ b/example/service.proto
@@ -0,0 +1,22 @@
+syntax = "proto3";
+
+package twirp.example.haberdasher;
+option go_package = "haberdasher";
+
+// Haberdasher service makes hats for clients.
+service Haberdasher {
+ // MakeHat produces a hat of mysterious, randomly-selected color!
+ rpc MakeHat(Size) returns (Hat);
+}
+
+// Size of a Hat, in inches.
+message Size {
+ int32 inches = 1; // must be > 0
+}
+
+// A Hat is a piece of headwear made by a Haberdasher.
+message Hat {
+ int32 inches = 1;
+ string color = 2; // anything but "invisible"
+ string name = 3; // i.e. "bowler"
+}
diff --git a/nimtwirp.nimble b/nimtwirp.nimble
new file mode 100644
index 0000000..737f6ec
--- /dev/null
+++ b/nimtwirp.nimble
@@ -0,0 +1,15 @@
+# Package
+
+version = "0.1.0"
+author = "Oskari Timperi"
+description = "A new awesome nimble package"
+license = "MIT"
+
+skipDirs = @["tests", "example"]
+
+bin = @["nimtwirp/nimtwirp_build"]
+
+# Dependencies
+
+requires "nim >= 0.18.0"
+requires "nimpb"
diff --git a/nimtwirp/errors.nim b/nimtwirp/errors.nim
new file mode 100644
index 0000000..dba2bd9
--- /dev/null
+++ b/nimtwirp/errors.nim
@@ -0,0 +1,85 @@
+import json
+import httpcore
+
+type
+ TwirpError* = object of Exception
+ code*: string
+ httpStatus*: HttpCode
+
+ TwirpCanceled* = object of TwirpError
+ TwirpUnknown* = object of TwirpError
+ TwirpInvalidArgument* = object of TwirpError
+ TwirpDeadlineExceeded* = object of TwirpError
+ TwirpNotFound* = object of TwirpError
+ TwirpBadRoute* = object of TwirpError
+ TwirpAlreadyExists* = object of TwirpError
+ TwirpPermissionDenied* = object of TwirpError
+ TwirpUnauthenticated* = object of TwirpError
+ TwirpResourceExhausted* = object of TwirpError
+ TwirpFailedPrecondition* = object of TwirpError
+ TwirpAborted* = object of TwirpError
+ TwirpOutOfRange* = object of TwirpError
+ TwirpUnimplemented* = object of TwirpError
+ TwirpInternal* = object of TwirpError
+ TwirpUnavailable* = object of TwirpError
+ TwirpDataloss* = object of TwirpError
+
+template setErrorInfo(error: ref TwirpError, cod: string, httpStatu: HttpCode) =
+ error.code = cod
+ error.httpStatus = httpStatu
+
+template newTwirpError*(T: typedesc, msg: string): untyped =
+ var err = newException(T, msg)
+ when T is TwirpCanceled: setErrorInfo(err, "canceled", Http408)
+ elif T is TwirpUnknown: setErrorInfo(err, "unknown", Http500)
+ elif T is TwirpInvalidArgument: setErrorInfo(err, "invalid_argument", Http400)
+ elif T is TwirpDeadlineExceeded: setErrorInfo(err, "deadline_exceeded", Http408)
+ elif T is TwirpNotFound: setErrorInfo(err, "not_found", Http404)
+ elif T is TwirpBadRoute: setErrorInfo(err, "bad_route", Http404)
+ elif T is TwirpAlreadyExists: setErrorInfo(err, "already_exists", Http409)
+ elif T is TwirpPermissionDenied: setErrorInfo(err, "permission_denied", Http403)
+ elif T is TwirpUnauthenticated: setErrorInfo(err, "unauthenticated", Http401)
+ elif T is TwirpResourceExhausted: setErrorInfo(err, "resource_exhausted", Http403)
+ elif T is TwirpFailedPrecondition: setErrorInfo(err, "failed_precondition", Http412)
+ elif T is TwirpAborted: setErrorInfo(err, "aborted", Http409)
+ elif T is TwirpOutOfRange: setErrorInfo(err, "out_of_range", Http400)
+ elif T is TwirpUnimplemented: setErrorInfo(err, "unimplemented", Http501)
+ elif T is TwirpInternal: setErrorInfo(err, "internal", Http500)
+ elif T is TwirpUnavailable: setErrorInfo(err, "unavailable", Http503)
+ elif T is TwirpDataloss: setErrorInfo(err, "dataloss", Http500)
+ else:
+ {.fatal:"unknown twirp error".}
+ err
+
+proc twirpErrorToJson*[T](error: T): JsonNode =
+ %*{
+ "code": error.code,
+ "msg": error.msg
+ }
+
+proc twirpErrorFromJson*(node: JsonNode): ref TwirpError =
+ if node.kind != JObject:
+ raise newException(ValueError, "object expected")
+
+ let code = node["code"].str
+ let msg = node["msg"].str
+
+ case code
+ of "canceled": result = newTwirpError(TwirpCanceled, msg)
+ of "unknown": result = newTwirpError(TwirpUnknown, msg)
+ of "invalid_argument": result = newTwirpError(TwirpInvalidArgument, msg)
+ of "deadline_exceeded": result = newTwirpError(TwirpDeadlineExceeded, msg)
+ of "not_found": result = newTwirpError(TwirpNotFound, msg)
+ of "bad_route": result = newTwirpError(TwirpBadRoute, msg)
+ of "already_exists": result = newTwirpError(TwirpAlreadyExists, msg)
+ of "permission_denied": result = newTwirpError(TwirpPermissionDenied, msg)
+ of "unauthenticated": result = newTwirpError(TwirpUnauthenticated, msg)
+ of "resource_exhausted": result = newTwirpError(TwirpResourceExhausted, msg)
+ of "failed_precondition": result = newTwirpError(TwirpFailedPrecondition, msg)
+ of "aborted": result = newTwirpError(TwirpAborted, msg)
+ of "out_of_range": result = newTwirpError(TwirpOutOfRange, msg)
+ of "unimplemented": result = newTwirpError(TwirpUnimplemented, msg)
+ of "internal": result = newTwirpError(TwirpInternal, msg)
+ of "unavailable": result = newTwirpError(TwirpUnavailable, msg)
+ of "dataloss": result = newTwirpError(TwirpDataloss, msg)
+ else: raise newException(ValueError, "Invalid twirp error code in response")
diff --git a/nimtwirp/generator.nim b/nimtwirp/generator.nim
new file mode 100644
index 0000000..c0560b1
--- /dev/null
+++ b/nimtwirp/generator.nim
@@ -0,0 +1,123 @@
+import strformat
+import strformat
+
+import nimpb/compiler/compiler
+
+proc fullName(service: Service): string =
+ result = service.package
+ if len(result) > 0:
+ add(result, ".")
+ add(result, service.name)
+
+proc genImports(gen: ServiceGenerator): string =
+ result = &"""
+import asyncdispatch
+import asynchttpserver
+import httpclient
+import json
+import strutils
+
+import {gen.fileName}
+
+import nimtwirp/errors
+
+"""
+
+proc genServer(service: Service): string =
+ result = &"""
+type
+ {service.name}* = concept x
+"""
+
+ for meth in service.methods:
+ result &= &"""
+ x.{meth.name}({meth.inputType}) is {meth.outputType}
+"""
+
+ result &= &"""
+
+proc {service.name}Server*(service: {service.name}, prefix: string): auto =
+ let headers = newHttpHeaders({{"Content-Type": "application/protobuf"}})
+ proc cb(req: Request): Future[void] =
+ try:
+ let servicePrefix = prefix & "{service.fullName}/"
+ if startsWith(req.url.path, servicePrefix):
+ var methodName = req.url.path[len(servicePrefix)..^1]
+"""
+
+ for index, meth in service.methods:
+ var ifel = "if"
+ if index > 0:
+ ifel = "elif"
+ result &= &"""
+ {ifel} methodName == "{meth.name}":
+ let inputMsg = new{meth.inputType}(req.body)
+ let outputMsg = service.{meth.name}(inputMsg)
+ let body = serialize(outputMsg)
+ result = respond(req, Http200, body, headers)
+"""
+
+ result &= &"""
+ else:
+ raise newTwirpError(TwirpNotFound, "method not found")
+ else:
+ raise newTwirpError(TwirpNotFound, "service not found")
+ except TwirpError as exc:
+ let headers = newHttpHeaders({{"Content-Type": "application/json"}})
+ result = req.respond(exc.httpStatus, $twirpErrorToJson(exc), headers)
+ except Exception as exc:
+ let headers = newHttpHeaders({{"Content-Type": "application/json"}})
+ var err = newTwirpError(TwirpInternal, exc.msg)
+ result = req.respond(err.httpStatus, $twirpErrorToJson(err), headers)
+ result = cb
+
+"""
+
+proc genClient(service: Service): string =
+ result = &"""
+
+
+type
+ {service.name}Client* = ref object
+ client*: HttpClient
+ address*: string
+
+proc new{service.name}Client*(address: string): {service.name}Client =
+ new(result)
+ result.client = newHttpClient()
+ result.client.headers = newHttpHeaders({{"Content-Type": "application/protobuf"}})
+ result.address = address
+
+"""
+
+ for meth in service.methods:
+ result &= &"""
+proc {meth.name}*(client: {service.name}Client, req: {meth.inputType}): {meth.outputType} =
+ let body = serialize(req)
+ let resp = client.client.request(client.address & "/{service.fullName}/{meth.name}", httpMethod=HttpPost, body=body)
+ let httpStatus = code(resp)
+ if httpStatus != Http200:
+ if contentType(resp) != "application/json":
+ raise newTwirpError(TwirpInternal, "Invalid Content-Type in response")
+ let errorInfo = parseJson(resp.body)
+ raise twirpErrorFromJson(errorInfo)
+ else:
+ result = new{meth.outputType}(resp.body)
+
+"""
+
+proc genService(service: Service): string =
+ result = genServer(service)
+ result &= genClient(service)
+
+proc newTwirpServiceGenerator*(): ServiceGenerator =
+ new(result)
+
+ let gen = result
+
+ proc myGenImports(): string =
+ result = genImports(gen)
+
+ result.genImports = myGenImports
+ result.genService = genService
+ result.fileSuffix = "twirp"
diff --git a/nimtwirp/nimtwirp_build.nim b/nimtwirp/nimtwirp_build.nim
new file mode 100644
index 0000000..f269d9a
--- /dev/null
+++ b/nimtwirp/nimtwirp_build.nim
@@ -0,0 +1,45 @@
+import os
+import osproc
+import strformat
+import strutils
+
+import nimpb/compiler/compiler
+import generator
+
+proc usage() {.noreturn.} =
+ echo(&"""
+{getAppFilename()} --out=OUTDIR [-IPATH [-IPATH]...] PROTOFILE...
+
+ --out The output directory for the generated files
+ -I Add a path to the set of include paths
+""")
+ quit(QuitFailure)
+
+var includes: seq[string] = @[]
+var protos: seq[string] = @[]
+var outdir: string
+
+if paramCount() == 0:
+ usage()
+
+for idx in 1..paramCount():
+ let param = paramStr(idx)
+
+ if param.startsWith("-I"):
+ add(includes, param[2..^1])
+ elif param.startsWith("--out="):
+ outdir = param[6..^1]
+ elif param == "--help":
+ usage()
+ else:
+ add(protos, param)
+
+if outdir == nil:
+ echo("error: --out is required")
+ quit(QuitFailure)
+
+if len(protos) == 0:
+ echo("error: no input files")
+ quit(QuitFailure)
+
+compileProtos(protos, includes, outdir, newTwirpServiceGenerator())