From 9a7e711e22257f13208b280e4c7a083bde6fc570 Mon Sep 17 00:00:00 2001 From: Eugene Kabanov Date: Tue, 7 Sep 2021 13:28:56 +0300 Subject: [PATCH] Add Accept handling to REST server and client. (#12) * Add Accept handling to REST server and client. Add proper tests to server and client's test suites. Fix some warnings. Bump some nimble dependencies. * Remove debugging echo. --- presto.nimble | 4 +-- presto/client.nim | 25 ++++++++++++-- presto/route.nim | 4 ++- tests/testclient.nim | 82 ++++++++++++++++++++++++++++++++++++++++++++ tests/testserver.nim | 81 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 190 insertions(+), 6 deletions(-) diff --git a/presto.nimble b/presto.nimble index 11d1bd3..a9f6f00 100644 --- a/presto.nimble +++ b/presto.nimble @@ -1,14 +1,14 @@ mode = ScriptMode.Verbose packageName = "presto" -version = "0.0.3" +version = "0.0.4" author = "Status Research & Development GmbH" description = "REST API implementation" license = "MIT" skipDirs = @["tests", "examples"] requires "nim >= 1.2.0", - "chronos >= 3.0.3", + "chronos >= 3.0.6", "chronicles", "stew" diff --git a/presto/client.nim b/presto/client.nim index 85ec872..8115620 100644 --- a/presto/client.nim +++ b/presto/client.nim @@ -15,6 +15,7 @@ export httpclient, httptable, httpcommon, options, agent, httputils template endpoint*(v: string) {.pragma.} template meth*(v: HttpMethod) {.pragma.} +template accept*(v: string) {.pragma.} type RestClient* = object of RootObj @@ -49,6 +50,7 @@ type Status, PlainResponse, GenericResponse, Value const + DefaultAcceptContentType = "application/json" RestContentTypeArg = "restContentType" RestAcceptTypeArg = "restAcceptType" RestClientArg = "restClient" @@ -158,6 +160,15 @@ proc getMethodOrDefault(prc: NimNode, return node[1] return default +proc getAcceptOrDefault(prc: NimNode, + default: string): string {.compileTime.} = + let pragmaNode = prc.pragma() + for node in pragmaNode.items(): + if node.kind == nnkExprColonExpr: + if node[0].kind == nnkIdent and node[0].strVal == "accept": + return node[1].strVal() + return default + proc getAsyncPragma(prc: NimNode): NimNode {.compileTime.} = let pragmaNode = prc.pragma() for node in pragmaNode.items(): @@ -255,6 +266,7 @@ proc isPostMethod(node: NimNode): bool {.compileTime.} = proc transformProcDefinition(prc: NimNode, clientIdent: NimNode, contentIdent: NimNode, acceptIdent: NimNode, + acceptValue: NimNode, stmtList: NimNode): NimNode {.compileTime.} = var procdef = copyNimTree(prc) var parameters = copyNimTree(prc.findChild(it.kind == nnkFormalParams)) @@ -267,7 +279,7 @@ proc transformProcDefinition(prc: NimNode, clientIdent: NimNode, newStrLitNode("application/json")) let acceptTypeArg = newTree(nnkIdentDefs, acceptIdent, newIdentNode("string"), - newStrLitNode("application/json")) + acceptValue) let asyncPragmaArg = newIdentNode("async") @@ -607,6 +619,14 @@ proc restSingleProc(prc: NimNode): NimNode {.compileTime.} = prc.pragma()) res + let accept = + block: + let res = prc.getAcceptOrDefault(DefaultAcceptContentType) + if len(res) == 0: + error("REST procedure should have non-empty {.accept.} pragma", + prc.pragma()) + res + let meth = prc.getMethodOrDefault(newDotExpr(ident("HttpMethod"), ident("MethodGet"))) let spath = SegmentedPath.init(HttpMethod.MethodGet, endpoint, nil) @@ -863,7 +883,8 @@ proc restSingleProc(prc: NimNode): NimNode {.compileTime.} = return `responseResultIdent` let res = transformProcDefinition(prc, clientIdent, contentTypeIdent, - acceptTypeIdent, statements) + acceptTypeIdent, newStrLitNode(accept), + statements) res macro rest*(prc: untyped): untyped = diff --git a/presto/route.nim b/presto/route.nim index 26f46e5..8b69938 100644 --- a/presto/route.nim +++ b/presto/route.nim @@ -327,7 +327,9 @@ macro api*(router: RestRouter, meth: static[HttpMethod], proc `doMain`(`requestParam`: HttpRequestRef, `pathParams`: HttpTable, `queryParams`: HttpTable, `bodyParam`: Option[ContentBody] ): Future[RestApiResponse] {.raises: [Defect], async.} = - + template preferredContentType( + t: varargs[string]): Result[string, cstring] {.used.} = + `requestParam`.preferredContentType(t) `pathDecoder` `optDecoder` `respDecoder` diff --git a/tests/testclient.nim b/tests/testclient.nim index 2e67bfe..3908e2f 100644 --- a/tests/testclient.nim +++ b/tests/testclient.nim @@ -1051,6 +1051,88 @@ suite "REST API client test suite": await server.stop() await server.closeWait() + asyncTest "Accept test": + var router = RestRouter.init(testValidate) + router.api(MethodPost, "/test/accept") do ( + contentBody: Option[ContentBody]) -> RestApiResponse: + let obody = + if contentBody.isSome(): + let b = contentBody.get() + b.contentType & "," & bytesToString(b.data) + else: + "nobody" + let preferred = preferredContentType("app/type1", "app/type2") + return + if preferred.isOk(): + case preferred.get() + of "app/type1": + RestApiResponse.response("type1[" & obody & "]") + of "app/type2": + RestApiResponse.response("type2[" & obody & "]") + else: + # This MUST not be happened. + RestApiResponse.error(Http407, "") + else: + RestApiResponse.error(Http406, "") + + var sres = RestServerRef.new(router, serverAddress) + let server = sres.get() + server.start() + + proc testAccept1(body: string): RestPlainResponse {. + rest, endpoint: "/test/accept", meth: MethodPost, + accept: "*/*".} + proc testAccept2(body: string): RestPlainResponse {. + rest, endpoint: "/test/accept", meth: MethodPost, + accept: "app/type1,app/type2".} + proc testAccept3(body: string): RestPlainResponse {. + rest, endpoint: "/test/accept", meth: MethodPost, + accept: "app/type2".} + proc testAccept4(body: string): RestPlainResponse {. + rest, endpoint: "/test/accept", meth: MethodPost, + accept: "app/type2;q=0.5,app/type1;q=0.7".} + proc testAccept5(body: string): RestPlainResponse {. + rest, endpoint: "/test/accept", meth: MethodPost, + accept: "app/type2".} + proc testAccept6(body: string): RestPlainResponse {. + rest, endpoint: "/test/accept", meth: MethodPost.} + + var client = RestClientRef.new(serverAddress, HttpClientScheme.NonSecure) + + let res1 = await client.testAccept1("accept1") + let res2 = await client.testAccept2("accept2") + let res3 = await client.testAccept3("accept3") + let res4 = await client.testAccept4("accept4") + let res5 = await client.testAccept5("accept5") + # This procedure is missing `accept` pragma in definition, so default + # accept will be used `application/json`. + let res6 = await client.testAccept6("accept6") + let res7 = await client.testAccept6("accept7", + restAcceptType = "app/type1;q=1.0,app/type2;q=0.1") + let res8 = await client.testAccept6("accept8", + restAcceptType = "") + + check: + res1.status == 200 + res2.status == 200 + res3.status == 200 + res4.status == 200 + res5.status == 200 + res6.status == 406 + res7.status == 200 + res8.status == 200 + res1.data.bytesToString() == "type1[application/json,accept1]" + res2.data.bytesToString() == "type1[application/json,accept2]" + res3.data.bytesToString() == "type2[application/json,accept3]" + res4.data.bytesToString() == "type1[application/json,accept4]" + res5.data.bytesToString() == "type2[application/json,accept5]" + res7.data.bytesToString() == "type1[application/json,accept7]" + res8.data.bytesToString() == "type1[application/json,accept8]" + + await client.closeWait() + await server.stop() + await server.closeWait() + test "Leaks test": proc getTrackerLeaks(tracker: string): bool = let tracker = getTracker(tracker) diff --git a/tests/testserver.nim b/tests/testserver.nim index c8e038f..fc19f94 100644 --- a/tests/testserver.nim +++ b/tests/testserver.nim @@ -12,12 +12,15 @@ type data*: string proc httpClient(server: TransportAddress, meth: HttpMethod, url: string, - body: string, ctype = ""): Future[ClientResponse] {.async.} = + body: string, ctype = "", + accept = ""): Future[ClientResponse] {.async.} = var request = $meth & " " & $parseUri(url) & " HTTP/1.1\r\n" request.add("Host: " & $server & "\r\n") request.add("Content-Length: " & $len(body) & "\r\n") if len(ctype) > 0: request.add("Content-Type: " & ctype & "\r\n") + if len(accept) > 0: + request.add("Accept: " & accept & "\r\n") request.add("\r\n") if len(body) > 0: @@ -528,6 +531,82 @@ suite "REST API server test suite": finally: await server.closeWait() + asyncTest "preferredContentType() test": + const PostVectors = [ + ( + ("/test/post", "somebody0908", "text/html", + "app/type1;q=0.9,app/type2;q=0.8"), + ClientResponse(status: 200, data: "type1[text/html,somebody0908]") + ), + ( + ("/test/post", "somebody0908", "text/html", + "app/type2;q=0.8,app/type1;q=0.9"), + ClientResponse(status: 200, data: "type1[text/html,somebody0908]") + ), + ( + ("/test/post", "somebody09", "text/html", + "app/type2,app/type1;q=0.9"), + ClientResponse(status: 200, data: "type2[text/html,somebody09]") + ), + ( + ("/test/post", "somebody09", "text/html", "app/type1;q=0.9,app/type2"), + ClientResponse(status: 200, data: "type2[text/html,somebody09]") + ), + ( + ("/test/post", "somebody", "text/html", "*/*"), + ClientResponse(status: 200, data: "type1[text/html,somebody]") + ), + ( + ("/test/post", "somebody", "text/html", ""), + ClientResponse(status: 200, data: "type1[text/html,somebody]") + ), + ( + ("/test/post", "somebody", "text/html", "app/type2"), + ClientResponse(status: 200, data: "type2[text/html,somebody]") + ), + ( + ("/test/post", "somebody", "text/html", "app/type3"), + ClientResponse(status: 406, data: "") + ) + ] + var router = RestRouter.init(testValidate) + router.api(MethodPost, "/test/post") do ( + body: Option[ContentBody], resp: HttpResponseRef) -> RestApiResponse: + let obody = + if body.isSome(): + let b = body.get() + b.contentType & "," & bytesToString(b.data) + else: + "nobody" + + let preferred = preferredContentType("app/type1", "app/type2") + return + if preferred.isOk(): + case preferred.get() + of "app/type1": + RestApiResponse.response("type1[" & obody & "]") + of "app/type2": + RestApiResponse.response("type2[" & obody & "]") + else: + # This MUST not be happened. + RestApiResponse.error(Http407, "") + else: + RestApiResponse.error(Http406, "") + + var sres = RestServerRef.new(router, serverAddress) + let server = sres.get() + server.start() + try: + for item in PostVectors: + let res = await httpClient(serverAddress, MethodPost, item[0][0], + item[0][1], item[0][2], item[0][3]) + check: + res.status == item[1].status + res.data == item[1].data + finally: + await server.closeWait() + + test "Leaks test": check: getTracker("async.stream.reader").isLeaked() == false