Skip to content

Commit

Permalink
Merge pull request #3 from nepeckman/v0.2.0
Browse files Browse the repository at this point in the history
v0.2.0 release
  • Loading branch information
vepeckman authored Jul 25, 2019
2 parents bca5ee1 + c719239 commit b0400fe
Show file tree
Hide file tree
Showing 20 changed files with 624 additions and 318 deletions.
121 changes: 74 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ Nerve is available on Nim's builtin package manager, [nimble](https://github.com
# Hello World
```nim
# api.nim
import nerve, nerve/utils
import nerve, nerve/web
rpc Hello, "/api/hello": # The identifier for the rpc object
# As well as the url the service will use
service HelloService, "/api/hello":
# Normal Nim proc definition
proc helloWorld(): Future[wstring] = # Return type must be a future
Expand All @@ -31,74 +30,102 @@ rpc Hello, "/api/hello": # The identifier for the rpc object
result = fwrap(greeting & " " & name) # Utility function for declaring and completing a future
# server.nim
import asynchttpserver, asyncdispatch, json
import nerve/utils, api
var server = newAsyncHttpServer()
proc cb(req: Request) {.async.} =
case req.url.path
of "/": # Send index html
await req.respond(Http200, """<html><head><meta charset="UTF-8"></head><body>Testing</body><script src="client.js"></script></html>""")
of "/client.js": # Send client file (make sure to compile it first)
await req.respond(Http200, readFile("client.js"))
of Hello.rpcUri: # Const string provided to rpc macro
await req.respond(Http200, $ await Hello.routeRpc(req.body)) # Do the RPC dispatch and return the response
else: # Not found
await req.respond(Http404, "Not found")
waitFor server.serve(Port(8080), cb)
import asynchttpserver, asyncdispatch, nerve, nerve/web
import api
let server = newAsyncHttpServer()
proc generateCb(): proc (req: Request): Future[void] {.gcsafe.} =
# Generate server callback in a function to avoid making the rpc server global
# A threadlocal var could be used instead, or a manual gcsafe annotation
let helloServer = HelloService.newServer()
proc cb(req: Request) {.async, gcsafe.} =
case req.url.path
of HelloService.rpcUri: # Const string provided to service
# Do the rpc dispatch for the service, with the given server
await req.respond(Http200, $ await HelloService.routeRpc(helloServer, req.body))
of "/client.js": # Send client file (make sure to compile it first)
let headers = newHttpHeaders()
headers["Content-Type"] = "application/javascript"
await req.respond(Http200, readFile("client.js"), headers)
of "/": # Send index html
await req.respond(Http200, """<html><head><meta charset="UTF-8"></head><body>Testing</body><script src="client.js"></script></html>""")
else: # Not found
await req.respond(Http404, "Not Found")
result = cb
waitFor server.serve(Port(1234), generateCb())
# client.nim
import asyncjs
import nerve, nerve/promises
import api
# This file can be compiled for native or JS targets
const host = if defined(js): "" else: "http://127.0.0.1:1234"
proc main() {.async.} =
echo await Hello.greet("Hello", "Nerve") # prints "Hello Nerve" to the console
let helloClient = HelloService.newHttpClient(host)
echo await helloClient.greet("Hello", "Nerve") # prints Hello World
discard main()
when defined(js):
discard main()
else:
waitFor main()
```

### `rpc` macro
### `service` macro
```nim
macro rpc(name, uri, body: untyped): untyped
macro service*(name: untyped, uri: untyped = nil, body: untyped = nil): untyped
```
Nerve's `rpc` macro contains most of the functionality of the framework. It takes an identifier, a uri, and a list of normal Nim procedures as its body. It produces an object (accessible via the identifier) that has fields for each of the given procedures. The object's type is generated with it, but it extends the `RpcServer` type provided by Nerve. When compiled for Nim's native target, the macro also generates a dispatch function. When compiled for Nim's JS target, the macro modifies the body of each provided procedure, replacing the server code with the code necessary to create a request, send it, and return the response. The provided procedures must have a return type of `Future[T]`, as the client will always use these functions asynchronusly.
Nerve's `service` macro contains most of the functionality of the framework. It takes an identifier, an optional uri, and a list of normal Nim procedures as its body. It produces a RpcService (accessible via the identifier) that can be instantiated into either a client or a server object with fields for each of the provided procs. The client/server object's type is generated with it, but it extends the `RpcServerInst` type provided by Nerve. The macro generates functions to construct new clients and servers, accessible with the service identified. When compiled for Nim's native target, the macro also generates a dispatch function. The clients (available for both native and JS targets) are provided with a driver to handle constructing and sending the requests. The provided procedures must have a return type of `Future[T]`, as the client will always use these functions asynchronusly.

As the files with the `rpc` macro need to be compiled for both native and JS targets, those files should focus _only_ on the API functionality. Server instantiation and heavier server logic should go elsewhere. Be aware that any types used by the API files also need to be accessible on both targets.
As the files with the `service` macro need to be compiled for both native and JS targets, those files should focus _only_ on the API functionality. Server instantiation and heavier server logic should go elsewhere. Be aware that any types used by the API files also need to be accessible on both targets.

# Utilities
Nerve provides some utility functions, located in `nerve/utils`. These utilities are provided to make
# nerve/drivers
`type NerveDriver* = proc (req: JsObject): Future[JsObject] {.gcsafe.}`
As stated earlier, Nerve uses drivers to power its clients. The driver recieves a completed JSON RPC object, and is responsible for sending that to the server and returning the response. The `drivers` module provides common drivers (such as an http driver), but user defined drivers can be used as well.

# nerve/web
Nerve provides a web module to ease some web compatibility issues. The `web` module provides some function and type aliases to allow the same code to be compiled for JS and native targets. It also provides:

### `wstring`
The implementaion of `wstring` (web string) is dependant on the compile target. On the native target, `wstring` is an alias for native Nim string. On the client, it is an alias for JavaScript strings (`cstring` type in Nim). This target dependant alias is needed for full stack Nim code. On the server, Nim's native string serializes to a JavaScript string when the response is serialized to JSON. As the client is receiving this JavaScript string, it must be told to expect a JavaScript string. `wstring` *must* be used instead of `string` in any type or function exposed to both the server and the client.
The implementaion of `wstring` (web string) is dependant on the compile target. On the native target, `wstring` is an alias for native Nim string. On the client, it is an alias for JavaScript strings (`cstring` type in Nim). This target dependant alias is needed for full stack Nim code. On the server, Nim's native string serializes to a JavaScript string when the response is serialized to JSON. As the client is receiving this JavaScript string, it must be told to expect a JavaScript string. For any server expecting JS clients, `wstring` *must* be used instead of `string` in any type or function exposed to both the server and the client.

### `rpcUri`
```nim
macro rpcUri(rpc: RpcServer): untyped
```
This macro takes an RPC object and returns the constant uri string. This is a macro rather than a normal procedure so the constant string can be used in `case` branches.
# nerve/promises
The `promsies` module is an extension of the asyncdispatch module for native targets, and the asyncjs module for the JS target. It exports both of those modules, providing all of the typical async functionality of both targets. It also provides some helper functions for dealing with futures, including future chaining with `then`.

### `routeRpc`
# Errors
Errors in RPC calls are propogated to the client. The client code will throw an `RpcError` with information from the error thrown on the server. If the server responds with a non-200 error code, the client throws an `InvalidResponseError`. The server throws errors for incorrect requests, per the JSON-RPC spec.

# Server Injection (experimental)
All of the parameters for the RPC procedures must come from the client. However, Nerve provides a method for injecting variables from the server (such as client connection references, or anything that doesn't serialize well). To define variables for injection, place an `inject` statement in the service declaration. In the inject statement, include `var` definitions for the desired variables. These variable can then be used in any of the RPC procs. The actual injection is done in the `newServer` constructor, where the injected variables are provided to the server.
```nim
macro routeRpc*(rpc: RpcServer, req: JsonNode): untyped
service GreetingService, "/api/greeting":
macro routeRpc*(rpc: RpcServer, req: string): untyped
```
This macro takes an RPC object and an RPC request, either a string or JSON. The client sends requests over HTTP, as the body of a post request. The client should always send valid JSON and a valid request. The macro inserts a link to a generated dispatch function from the `rpc` macro.
inject:
var
id = 100
count: int
var uuid = "asdf"
### `fwrap`
```nim
proc fwrap*[T](it: T): Future[T]
```
A simple proc for wrapping a future, added to assist with future returns from RPC procs.
proc greet(greeting = wstring("Hello"), name = wstring("World")): Future[wstring] =
echo uuid
fwrap(greeting & " " & name)
# Errors
Errors in RPC calls are propogated to the client. The client code will throw an `RpcError` with information from the error thrown on the server. If the server responds with a non-200 error code, the client throws an `InvalidResponseError`. The server throws errors for incorrect requests, per the JSON-RPC spec.
let server = GreetingService.newServer(count = 1, uuid = "fdsa")
```

# Gotchas
Nerve trys to be as low friction as possible. However there are a couple edges to watch for.
1) Usages of Nim strings. As stated earlier, Nim strings don't serialize well, and wstrings need to be used for any type compiled under both native and js targets.
2) Procedures under the same RPC server must have different names.
3) Errors for the server injection might reference generated procedures.

# Roadmap
Nerve was written primarily for my use, as a way to speed up the process of writing web APIs. It has the majority of features I set out to include, but I'm open to new features (or pull requests) if anyone else finds this project useful.
1) Configuration macros. Inform Nerve if it should generate a server, client, or both.
2) A `whenServer` statement to allow situational evaluation for servers.
3) Implement servers for the JS client.
6 changes: 3 additions & 3 deletions nerve.nimble
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Package

version = "0.1.1"
version = "0.2.0"
author = "nepeckman"
description = "A new awesome nimble package"
description = "An RPC framework"
license = "MIT"
srcDir = "src"

Expand All @@ -13,4 +13,4 @@ requires "nim >= 0.19.6"

task itests, "Runs intergration tests":
exec "nimble js tests/client.nim"
exec "nimble c -r tests/server.nim"
exec "nimble c -r tests/server.nim"
73 changes: 61 additions & 12 deletions src/nerve.nim
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
import macros
import nerve/service, nerve/types, nerve/common, nerve/web, nerve/drivers

when not defined(js):
import json
import nerve/server, nerve/serverRuntime
macro service*(name: untyped, uri: untyped = nil, body: untyped = nil): untyped =
## Macro to create a RpcService. The name param is the identifier used to reference
## the RpcService. The service can then be used in the other macros, including
## the server and client constructors. The uri is optional.
if body.kind != nnkNilLit:
result = rpcService(name, uri.strVal(), body)
else:
result = rpcService(name, "", uri)

macro rpc*(name, uri, body: untyped): untyped =
result = rpcServer(name, uri.strVal(), body)
macro newServer*(rpc: static[RpcService], injections: varargs[untyped]): untyped =
## Macro to construct a new server for a RpcService. Injections can be provided
## for the server, if defined by an ``inject`` statement in the service.
let rpcName = $rpc
let serverFactoryProc = rpcServerFactoryProc(rpcName)
result = quote do:
`serverFactoryProc`()
for injection in injections:
result.add(injection)

export json, serverRuntime
else:
import jsffi
import nerve/client, nerve/clientRuntime
macro newClient*(rpc: static[RpcService], driver: NerveDriver): untyped =
## Macro for constructing a new client for a RpcService. A driver can be
## found in the ``drivers`` module or user defined.
let clientFactoryProc = rpcClientFactoryProc($rpc)
result = quote do:
`clientFactoryProc`(`driver`)

macro rpc*(name, uri, body: untyped): untyped =
result = rpcClient(name, uri.strVal(), body)
macro newHttpClient*(rpc: static[RpcService], host: static[string] = ""): untyped =
## Macro to create a new client loaded with the http driver. The macro uses
## the provided service uri, prefixed with an optional host.
let clientFactoryProc = rpcClientFactoryProc($rpc)
let serviceName = ident($rpc)
result = quote do:
`clientFactoryProc`(newHttpDriver(`host` & `serviceName`.rpcUri))

export jsffi, clientRuntime
macro rpcUri*(rpc: static[RpcService]): untyped =
## Macro that provides a compile time reference to the
## provided service uri. Useful in ``case`` statements.
let rpcName = $rpc
let uriConst = rpcName.rpcUriConstName
result = quote do:
`uriConst`

macro rpcType*(rpc: static[RpcService]): untyped =
## Macro to provide reference to the generated
## RpcServiceInst subtype. This type describes the objects
## returned by ``newClient`` and ``newServer``
let typeName = rpcServiceName($rpc)
result = quote do:
`typeName`

macro routeRpc*(rpc: static[RpcService], server: RpcServiceInst, req: JsObject): untyped =
## Macro to do the server side dispatch of the RPC request
let rpcName = $rpc
let routerProc = rpcName.rpcRouterProcName
result = quote do:
`routerProc`(`server`, `req`)

macro routeRpc*(rpc: static[RpcService], server: RpcServiceInst, req: string): untyped =
## Macro to do the server side dispatch of the RPC request
let rpcName = $rpc
let routerProc = rpcName.rpcRouterProcName
result = quote do:
`routerProc`(`server`, `req`)

export types, drivers
65 changes: 21 additions & 44 deletions src/nerve/client.nim
Original file line number Diff line number Diff line change
@@ -1,67 +1,44 @@
import macros, tables
import common

proc procDefs(node: NimNode): seq[NimNode] =
# Gets all the proc definitions from the statement list
for child in node:
if child.kind == nnkProcDef:
result.add(child)

proc getParams(formalParams: NimNode): seq[Table[string, NimNode]] =
# Find all the parameters and build a table with needed information
assert(formalParams[0].len > 1, "RPC procs need to return a future")
assert(formalParams[0][0].strVal == "Future", "RPC procs need to return a future")
for param in formalParams:
if param.kind == nnkIdentDefs:
let defaultIdx = param.len - 1
let typeIdx = param.len - 2
for i in 0 ..< typeIdx:
result.add(
{
"name": param[i],
"nameStr": newStrLitNode(param[i].strVal),
}.toTable
)


proc procBody(p: NimNode, uri = "/rpc"): NimNode =
let nameStr = newStrLitNode(p.name.strVal)
proc networkProcBody(p: NimNode, methodName: string): NimNode =
let formalParams = p.findChild(it.kind == nnkFormalParams)
let retType = formalParams[0][1]
let params = formalParams.getParams()
let req = genSym()
let driver = ident("nerveDriver")

var paramJson = nnkStmtList.newTree()
for param in params:
let nameStr = param["nameStr"]
let name = param["name"]
paramJson.add(
quote do:
`req`["body"]["params"][`nameStr`] = `name`.toJs()
`req`["params"][`nameStr`] = toJs `name`
)

result = quote do:
let `req` = newJsObject()
`req`["method"] = cstring"POST"
`req`["body"] = newJsObject()
`req`["body"]["jsonrpc"] = cstring("2.0")
`req`["body"]["id"] = 0
`req`["body"]["method"] = cstring`nameStr`
`req`["body"]["params"] = newJsObject()
`req`["jsonrpc"] = toJs "2.0"
`req`["id"] = toJs 0
`req`["method"] = toJs `methodName`
`req`["params"] = newJsObject()
`paramJson`
`req`["body"] = JSON.stringify(`req`["body"])
result = fetch(cstring(`uri`), `req`)
.then(respToJson)
result = `driver`(`req`)
.then(handleRpcResponse[`retType`])

proc rpcClient*(name: NimNode, uri: string, body: NimNode): NimNode =
proc networkProcs*(procs: seq[NimNode]): NimNode =
result = newStmtList()
let procs = procDefs(body)
for p in procs:
let newBody = procBody(p, uri)
p[p.len - 1] = newBody
result.add(p)
result.add(rpcServiceType(name, procs))
result.add(rpcServiceObject(name, procs, uri))
if defined(nerveRpcDebug):
echo repr result
let networkProc = copy(p)
let methodName = p[0].basename.strVal()
networkProc[0] = networkProcName(methodName)
networkProc[networkProc.len - 1] = networkProcBody(networkProc, methodName)
networkProc.findChild(it.kind == nnkFormalParams).add(
nnkIdentDefs.newTree(
ident("nerveDriver"),
ident("NerveDriver"),
newEmptyNode()
)
)
result.add(networkProc)
36 changes: 21 additions & 15 deletions src/nerve/clientRuntime.nim
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import jsffi
import web

type
InvalidResponseError* = ref object of CatchableError
RpcError* = ref object of CatchableError

proc Boolean(o: JsObject): bool {. importc .}

proc respJson*(data: JsObject): JsObject {. importcpp: "#.json()" .}

proc respToJson*(resp: JsObject): JsObject =
if Boolean(resp.ok):
return respJson(resp)
let msg = "Invalid Response: Server responsed with code " & $to(resp.status, int)
raise InvalidResponseError(msg: msg)

proc handleRpcResponse*[T](rpcResponse: JsObject): T =
let error = rpcResponse["error"]
if Boolean(error):
let msg = $error.message.to(cstring) & ": " & $error.data.msg.to(cstring) & "\n" & $error.data.stackTrace.to(cstring) & "\n"
if hasKey(rpcResponse, "error"):
let error = rpcResponse["error"]
let msg = $error["message"].to(wstring) & ": " & $error["data"]["msg"].to(wstring) & "\n" & $error["data"]["stackTrace"].to(wstring) & "\n"
raise RpcError(msg: msg)
rpcResponse["result"].to(T)

var JSON* {. importc, nodecl .}: JsObject
when not defined(js):
proc respToJson*(resp: string): JsObject =
try:
result = parseJson(resp)
except:
let msg = "Invalid Response: Unable to parse JSON"
raise InvalidResponseError(msg: msg)
else:
proc Boolean(o: JsObject): bool {. importc .}

proc respJson*(data: JsObject): JsObject {. importcpp: "#.json()" .}

proc respToJson*(resp: JsObject): JsObject =
if Boolean(resp.ok):
return respJson(resp)
let msg = "Invalid Response: Server responsed with code " & $to(resp.status, int)
raise InvalidResponseError(msg: msg)
Loading

0 comments on commit b0400fe

Please sign in to comment.