diff options
| author | Oskari Timperi <oskari.timperi@iki.fi> | 2018-04-14 13:28:56 +0300 |
|---|---|---|
| committer | Oskari Timperi <oskari.timperi@iki.fi> | 2018-04-14 13:28:56 +0300 |
| commit | 0e423d5f62a36190e7b46b69646bea7db5587001 (patch) | |
| tree | 5d915dd5243522643a33db031cc641b0ded3aa58 | |
| download | nimtwirp-0e423d5f62a36190e7b46b69646bea7db5587001.tar.gz nimtwirp-0e423d5f62a36190e7b46b69646bea7db5587001.zip | |
Initial commit
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 15 | ||||
| -rw-r--r-- | example/Makefile | 10 | ||||
| -rw-r--r-- | example/README.md | 3 | ||||
| -rw-r--r-- | example/haberdasherclient.nim | 25 | ||||
| -rw-r--r-- | example/haberdasherserver.nim | 26 | ||||
| -rw-r--r-- | example/nim.cfg | 1 | ||||
| -rw-r--r-- | example/service.proto | 22 | ||||
| -rw-r--r-- | nimtwirp.nimble | 15 | ||||
| -rw-r--r-- | nimtwirp/errors.nim | 85 | ||||
| -rw-r--r-- | nimtwirp/generator.nim | 123 | ||||
| -rw-r--r-- | nimtwirp/nimtwirp_build.nim | 45 |
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 @@ -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()) |
