1147 lines
36 KiB
Nim
1147 lines
36 KiB
Nim
# beacon_chain
|
|
# Copyright (c) 2021-2022 Status Research & Development GmbH
|
|
# Licensed and distributed under either of
|
|
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
|
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
|
|
|
import std/[strutils, os, options, uri, json, tables]
|
|
import stew/[results, io2, base10]
|
|
import confutils, chronicles, httputils,
|
|
chronos, chronos/streams/[asyncstream, tlsstream]
|
|
|
|
const
|
|
RestTesterName* = "Ethereum2 REST API Tester"
|
|
RestTesterMajor*: int = 0
|
|
RestTesterMinor*: int = 0
|
|
RestTesterPatch*: int = 1
|
|
RestTesterVersion* = $RestTesterMajor & "." & $RestTesterMinor & "." &
|
|
$RestTesterPatch
|
|
RestTesterIdent* = "RestTester/$1 ($2/$3)" % [RestTesterVersion,
|
|
hostCPU, hostOS]
|
|
RestTesterCopyright* = "Copyright(C) 2021" &
|
|
" Status Research & Development GmbH"
|
|
RestTesterHeader* = RestTesterName & ", Version " & RestTesterVersion &
|
|
" [" & hostOS & ": " & hostCPU & "]\r\n" &
|
|
RestTesterCopyright & "\r\n"
|
|
HeadersMark = @[0x0D'u8, 0x0A'u8, 0x0D'u8, 0x0A'u8]
|
|
|
|
type
|
|
StatusOperatorKind* {.pure.} = enum
|
|
Equals, OneOf, Inside, InsideOrEq
|
|
|
|
HeaderOperatorKind {.pure.} = enum
|
|
Exists, NotExists, Equals, OneOf, Substr
|
|
|
|
BodyOperatorKind {.pure.} = enum
|
|
Exists, JsonStructCmpS, JsonStructCmpNS
|
|
|
|
StatusExpect = object
|
|
kind: StatusOperatorKind
|
|
value: seq[int]
|
|
|
|
HeaderExpect = object
|
|
kind: HeaderOperatorKind
|
|
key: string
|
|
value: seq[string]
|
|
|
|
HeadersExpect = object
|
|
headers: seq[HeaderExpect]
|
|
|
|
BodyItemExpect = object
|
|
kind: BodyOperatorKind
|
|
startPath: seq[string]
|
|
value: JsonNode
|
|
|
|
BodyExpect = object
|
|
items: seq[BodyItemExpect]
|
|
|
|
TestResultKind* {.pure.} = enum
|
|
RuleError,
|
|
NoSupportError,
|
|
WriteRequestError,
|
|
ReadResponseHeadersError,
|
|
ReadResponseBodyError,
|
|
RequestError,
|
|
ResponseError,
|
|
ValidationError,
|
|
ValidationSuccess
|
|
|
|
TestResultFlag* {.pure.} = enum
|
|
ResetConnection, StatusValidationFailure,
|
|
HeadersValidationFailure, BodyValidationFailure
|
|
|
|
TestResult* = object
|
|
kind: TestResultKind
|
|
message: string
|
|
flags: set[TestResultFlag]
|
|
times: array[4, Duration]
|
|
|
|
TestCase* = object
|
|
index: int
|
|
rule: JsonNode
|
|
|
|
TestCaseResult* = object
|
|
index: int
|
|
data: TestResult
|
|
|
|
HttpConnectionType* {.pure.} = enum
|
|
Nonsecure, Secure
|
|
|
|
HttpConnectionRef* = ref object
|
|
case kind*: HttpConnectionType
|
|
of HttpConnectionType.Nonsecure:
|
|
discard
|
|
of HttpConnectionType.Secure:
|
|
stream: TLSAsyncStream
|
|
treader: AsyncStreamReader
|
|
twriter: AsyncStreamWriter
|
|
transp: StreamTransport
|
|
reader*: AsyncStreamReader
|
|
writer*: AsyncStreamWriter
|
|
|
|
RestTestError* = object of CatchableError
|
|
ConnectionError* = object of RestTestError
|
|
|
|
RestTesterConf* = object
|
|
delayTime* {.
|
|
defaultValue: 0
|
|
desc: "Time (in seconds) to wait before initial connection could be " &
|
|
"established"
|
|
abbr: "d"
|
|
name: "delay" .}: int
|
|
|
|
attemptTimeout* {.
|
|
defaultValue: 60
|
|
desc: "Time (in seconds) during which to continue trying to establish " &
|
|
"connection with remote server"
|
|
name: "timeout" .}: int
|
|
|
|
noVerifyCert* {.
|
|
defaultValue: false
|
|
desc: "Skip remote server SSL/TLS certificate validation"
|
|
name: "no-verify-host" .}: bool
|
|
|
|
noVerifyName* {.
|
|
defaultValue: false
|
|
desc: "Skep remote server name verification"
|
|
name: "no-verify-name" .}: bool
|
|
|
|
rulesFilename* {.
|
|
defaultValue: "resttest-rules.json",
|
|
desc: "JSON formatted tests file"
|
|
name: "rules-file" .}: string
|
|
|
|
topicsFilter* {.
|
|
desc: "Topics which should be included in testing"
|
|
name: "topic",
|
|
abbr: "t" .}: seq[string]
|
|
|
|
skipTopicsFilter* {.
|
|
desc: "Topics which should be skipped in testing"
|
|
name: "skip-topic",
|
|
abbr: "s" .}: seq[string]
|
|
|
|
connectionsCount* {.
|
|
defaultValue: 1
|
|
desc: "Number of concurrent connections to remote server"
|
|
name: "connections"
|
|
abbr: "c" .}: int
|
|
|
|
url* {.
|
|
argument,
|
|
desc: "Address of remote REST server to test"
|
|
.}: string
|
|
|
|
proc getUri(conf: RestTesterConf): Result[Uri, cstring] =
|
|
var res = parseUri(conf.url)
|
|
if res.scheme notin ["http", "https"]:
|
|
return err("URL scheme should be http or https")
|
|
if len(res.hostname) == 0:
|
|
return err("URL missing hostname")
|
|
if len(res.query) != 0:
|
|
return err("URL should not contain query parameters")
|
|
if len(res.anchor) != 0:
|
|
return err("URL should not contain anchor parameter")
|
|
# TODO: Disable this check when at least BASIC AUTH will be implemented.
|
|
if len(res.username) != 0 or len(res.password) != 0:
|
|
return err("URL should not contain username:password")
|
|
if len(res.port) == 0:
|
|
if res.scheme == "http":
|
|
res.port = "80"
|
|
else:
|
|
res.port = "443"
|
|
ok(res)
|
|
|
|
proc getAddress(uri: Uri): Result[TransportAddress, cstring] =
|
|
let txtaddr = uri.hostname & ":" & uri.port
|
|
let numeric =
|
|
try:
|
|
initTAddress(txtaddr)
|
|
except TransportAddressError:
|
|
# We ignore errors here because `hostname` address could be non-numeric.
|
|
TransportAddress()
|
|
if numeric.family in {AddressFamily.IPv4, AddressFamily.IPv6}:
|
|
ok(numeric)
|
|
else:
|
|
var default: seq[TransportAddress]
|
|
let v4addresses =
|
|
try:
|
|
resolveTAddress(txtaddr, AddressFamily.IPv4)
|
|
except TransportAddressError:
|
|
# We ignore errors here because `hostname` could be resolved to IPv6.
|
|
default
|
|
if len(v4addresses) > 0:
|
|
ok(v4addresses[0])
|
|
else:
|
|
let v6addresses =
|
|
try:
|
|
resolveTAddress(txtaddr, AddressFamily.IPv6)
|
|
except TransportAddressError:
|
|
return err("Unable to resolve hostname")
|
|
if len(v6addresses) == 0:
|
|
return err("Unable to resolve hostname")
|
|
ok(v6addresses[0])
|
|
|
|
proc getTLSFlags(conf: RestTesterConf): set[TLSFlags] =
|
|
var res: set[TLSFlags]
|
|
if conf.noVerifyName:
|
|
res.incl(TLSFlags.NoVerifyServerName)
|
|
if conf.noVerifyCert:
|
|
res.incl(TLSFlags.NoVerifyHost)
|
|
res
|
|
|
|
proc checkTopic(conf: RestTesterConf, rule: JsonNode): bool =
|
|
var default: seq[string]
|
|
if (len(conf.topicsFilter) == 0) and (len(conf.skipTopicsFilter) == 0):
|
|
true
|
|
else:
|
|
let topics =
|
|
block:
|
|
let jtopics = rule.getOrDefault("topics")
|
|
if isNil(jtopics):
|
|
default
|
|
else:
|
|
case jtopics.kind
|
|
of JString:
|
|
@[jtopics.str]
|
|
of JArray:
|
|
if len(jtopics.elems) == 0:
|
|
default
|
|
else:
|
|
var res: seq[string]
|
|
for jitem in jtopics.elems:
|
|
case jitem.kind:
|
|
of JString:
|
|
res.add(jitem.str)
|
|
else:
|
|
continue
|
|
res
|
|
else:
|
|
default
|
|
if len(conf.topicsFilter) == 0:
|
|
if len(topics) == 0:
|
|
true
|
|
else:
|
|
for item in topics:
|
|
if item in conf.skipTopicsFilter:
|
|
return false
|
|
true
|
|
else:
|
|
for item in topics:
|
|
if item in conf.skipTopicsFilter:
|
|
return false
|
|
for item in topics:
|
|
if item in conf.topicsFilter:
|
|
return true
|
|
false
|
|
|
|
proc getTestRules(conf: RestTesterConf): Result[seq[JsonNode], cstring] =
|
|
let data =
|
|
block:
|
|
let res = io2.readAllChars(conf.rulesFilename)
|
|
if res.isErr():
|
|
fatal "Could not read rules file", error_msg = ioErrorMsg(res.error()),
|
|
error_os_code = $res.error(), filename = conf.rulesFilename
|
|
return err("Unable to read rules file")
|
|
res.get()
|
|
let node =
|
|
try:
|
|
parseJson(data)
|
|
except CatchableError as exc:
|
|
fatal "JSON processing error while reading rules file",
|
|
error_msg = exc.msg, filename = conf.rulesFilename
|
|
return err("Unable to parse json")
|
|
except Exception as exc:
|
|
raiseAssert exc.msg
|
|
|
|
let elems = node.getElems()
|
|
if len(elems) == 0:
|
|
fatal "There empty array of rules found in file",
|
|
filename = conf.rulesFilename
|
|
return err("Incorrect json")
|
|
|
|
var res: seq[JsonNode]
|
|
for item in elems:
|
|
if conf.checkTopic(item):
|
|
res.add(item)
|
|
|
|
notice "Rules file loaded", total_rules_count = len(elems),
|
|
rules_count = len(res)
|
|
ok(res)
|
|
|
|
proc openConnection*(address: TransportAddress, uri: Uri,
|
|
flags: set[TLSFlags]): Future[HttpConnectionRef] {.async.} =
|
|
let transp =
|
|
try:
|
|
await connect(address)
|
|
except TransportOsError as exc:
|
|
raise newException(ConnectionError, "Unable to establish connection")
|
|
|
|
let treader = newAsyncStreamReader(transp)
|
|
let twriter = newAsyncStreamWriter(transp)
|
|
if uri.scheme == "http":
|
|
return HttpConnectionRef(
|
|
kind: HttpConnectionType.Nonsecure,
|
|
transp: transp, reader: treader, writer: twriter
|
|
)
|
|
else:
|
|
let tlsstream = newTLSClientAsyncStream(treader, twriter, uri.hostname,
|
|
flags = flags)
|
|
return HttpConnectionRef(
|
|
kind: HttpConnectionType.Secure,
|
|
transp: transp, reader: tlsstream.reader, writer: tlsstream.writer,
|
|
treader: treader, twriter: twriter
|
|
)
|
|
|
|
proc closeWait*(conn: HttpConnectionRef): Future[void] {.async.} =
|
|
case conn.kind
|
|
of HttpConnectionType.Nonsecure:
|
|
await allFutures(conn.reader.closeWait(), conn.writer.closeWait())
|
|
await conn.transp.closeWait()
|
|
of HttpConnectionType.Secure:
|
|
await allFutures(conn.reader.closeWait(), conn.writer.closeWait())
|
|
await allFutures(conn.treader.closeWait(), conn.twriter.closeWait())
|
|
await conn.transp.closeWait()
|
|
|
|
proc checkConnection*(conf: RestTesterConf, uri: Uri): Future[void] {.async.} =
|
|
let timeFut = sleepAsync(conf.attemptTimeout.seconds)
|
|
var sleepTime = 1000.milliseconds
|
|
let hostname = uri.hostname & ":" & uri.port
|
|
while true:
|
|
if timeFut.finished():
|
|
fatal "Connection with remote host could not be established in time",
|
|
uri = uri.hostname, time = $conf.attemptTimeout.seconds
|
|
raise newException(ConnectionError, "Unable to establish connection")
|
|
|
|
let address =
|
|
block:
|
|
let res = uri.getAddress()
|
|
if res.isErr():
|
|
fatal "Unable to resolve remote host address", host = hostname
|
|
raise newException(ConnectionError, "Unable to resolve address")
|
|
else:
|
|
res.get()
|
|
|
|
let conn =
|
|
try:
|
|
await openConnection(address, uri, conf.getTLSFlags())
|
|
except ConnectionError:
|
|
notice "Unable to establish connection with remote host",
|
|
host = hostname,
|
|
sleep_until_next_attempt = $(((sleepTime * 3) div 2).seconds)
|
|
nil
|
|
|
|
if not(isNil(conn)):
|
|
notice "Connection with remote host established", host = hostname
|
|
await closeWait(conn)
|
|
return
|
|
|
|
if timeFut.finished():
|
|
fatal "Connection with remote host could not be established in time",
|
|
uri = hostname, time = $conf.attemptTimeout.seconds
|
|
raise newException(ConnectionError, "Unable to establish connection")
|
|
|
|
await sleepAsync(sleepTime)
|
|
# Increasing sleep time by 50%.
|
|
sleepTime = (sleepTime * 3) div 2
|
|
|
|
proc compact(v: string, size: int): string =
|
|
let delim = "..."
|
|
doAssert(size >= (len(delim) + 2))
|
|
if len(v) <= size:
|
|
v
|
|
else:
|
|
var length1 = (size - len(delim)) div 2
|
|
var length2 = size - length1 - len(delim)
|
|
if length1 < length2:
|
|
swap(length1, length2)
|
|
v[0 .. (length1 - 1)] & delim & v[len(v) - length2 .. ^1]
|
|
|
|
proc getTestName(rule: JsonNode): string =
|
|
let request = rule.getOrDefault("request")
|
|
if isNil(request):
|
|
"[incorrect]"
|
|
else:
|
|
let juri = request.getOrDefault("url")
|
|
if isNil(juri):
|
|
"[incorrect]"
|
|
else:
|
|
compact(juri.str, 40)
|
|
|
|
proc prepareRequest(uri: Uri,
|
|
rule: JsonNode): Result[tuple[url: string, request: string],
|
|
cstring] =
|
|
let request = rule.getOrDefault("request")
|
|
if isNil(request):
|
|
return err("Missing `request` field")
|
|
|
|
let meth =
|
|
block:
|
|
let jmethod = request.getOrDefault("method")
|
|
if isNil(jmethod):
|
|
"GET"
|
|
else:
|
|
if jmethod.kind != JString:
|
|
return err("Field `method` should be string")
|
|
jmethod.str
|
|
|
|
let requestUri =
|
|
block:
|
|
let juri = request.getOrDefault("url")
|
|
if isNil(juri):
|
|
return err("Missing requests' `url`")
|
|
else:
|
|
if juri.kind != JString:
|
|
return err("Field `url` should be string")
|
|
juri.str
|
|
|
|
let requestHeaders =
|
|
block:
|
|
var default: seq[tuple[key: string, value: string]]
|
|
let jheaders = request.getOrDefault("headers")
|
|
if isNil(jheaders):
|
|
default
|
|
else:
|
|
var res: seq[tuple[key: string, value: string]]
|
|
if jheaders.kind != JObject:
|
|
return err("Field `headers` should be an object")
|
|
for key, value in jheaders.fields:
|
|
if value.kind != JString:
|
|
return err("Field `headers` element should be only strings")
|
|
res.add((key, value.str))
|
|
res
|
|
|
|
let (requestBodyType, requestBodyData) =
|
|
block:
|
|
let jbody = request.getOrDefault("body")
|
|
if isNil(jbody):
|
|
("", "")
|
|
else:
|
|
if jbody.kind != JObject:
|
|
return err("Field `body` should be object")
|
|
let btype = jbody.getOrDefault("content-type")
|
|
if isNil(btype):
|
|
return err("Field `body.content-type` must be present")
|
|
if btype.kind != JString:
|
|
return err("Field `body.content-type` should be string")
|
|
let bdata = jbody.getOrDefault("data")
|
|
if isNil(bdata):
|
|
return err("Field `body.data` must be present")
|
|
if bdata.kind != JString:
|
|
return err("Field `body.data` should be string")
|
|
(btype.str, bdata.str)
|
|
|
|
var res = meth & " " & uri.path & requestUri & " HTTP/1.1\r\n"
|
|
res.add("Content-Length: " & Base10.toString(uint64(len(requestBodyData))) &
|
|
"\r\n")
|
|
|
|
if len(requestBodyType) > 0:
|
|
res.add("Content-Type: " & requestBodyType & "\r\n")
|
|
|
|
for item in requestHeaders:
|
|
res.add(item.key & ": " & item.value & "\r\n")
|
|
|
|
let (hostPresent, datePresent) =
|
|
block:
|
|
var flag1 = false
|
|
var flag2 = false
|
|
for item in requestHeaders:
|
|
if cmpIgnoreCase(item.key, "host") == 0:
|
|
flag1 = true
|
|
elif cmpIgnoreCase(item.key, "date") == 0:
|
|
flag2 = true
|
|
(flag1, flag2)
|
|
|
|
if not(hostPresent):
|
|
res.add("Host: " & $uri.hostname & "\r\n")
|
|
if not(datePresent):
|
|
res.add("Date: " & httpDate() & "\r\n")
|
|
res.add("\r\n")
|
|
if len(requestBodyData) > 0:
|
|
res.add(requestBodyData)
|
|
ok((uri.path & requestUri, res))
|
|
|
|
proc getResponseStatusExpect(rule: JsonNode): Result[StatusExpect, cstring] =
|
|
let response = rule.getOrDefault("response")
|
|
if isNil(response):
|
|
return err("Missing `response` field")
|
|
let jstatus = response.getOrDefault("status")
|
|
if isNil(jstatus):
|
|
return err("Missing `response.status` field")
|
|
|
|
let value =
|
|
block:
|
|
var res: seq[int]
|
|
let jvalue = jstatus.getOrDefault("value")
|
|
if isNil(jvalue):
|
|
return err("Field `status.value` should be present")
|
|
case jvalue.kind
|
|
of JString:
|
|
let nres = Base10.decode(uint16, jvalue.str)
|
|
if nres.isErr():
|
|
return err("Field `status.value` has incorrect value")
|
|
res.add(int(nres.get()))
|
|
of JInt:
|
|
if jvalue.num < 0 or jvalue.num >= 1000:
|
|
return err("Field `status.value` has incorrect value")
|
|
res.add(int(jvalue.num))
|
|
of JArray:
|
|
if len(jvalue.elems) == 0:
|
|
return err("Field `status.value` has an empty array")
|
|
for jitem in jvalue.elems:
|
|
let iitem =
|
|
case jitem.kind
|
|
of JString:
|
|
let nres = Base10.decode(uint16, jitem.str)
|
|
if nres.isErr():
|
|
return err("Field `status.value` element has incorrect value")
|
|
int(nres.get())
|
|
of JInt:
|
|
if jitem.num < 0 or jitem.num >= 1000:
|
|
return err("Field `status.value` element has incorrect value")
|
|
int(jitem.num)
|
|
else:
|
|
return err("Field `status.value` has incorrect elements")
|
|
res.add(iitem)
|
|
else:
|
|
return err("Field `status.value` should be an array, string or integer")
|
|
res
|
|
|
|
let kind =
|
|
block:
|
|
let joperator = jstatus.getOrDefault("operator")
|
|
if isNil(joperator):
|
|
if len(value) > 1:
|
|
StatusOperatorKind.OneOf
|
|
else:
|
|
StatusOperatorKind.Equals
|
|
else:
|
|
if joperator.kind != JString:
|
|
return err("Field `status.operator` should be string")
|
|
case toLowerAscii(joperator.str)
|
|
of "equals":
|
|
StatusOperatorKind.Equals
|
|
of "oneof":
|
|
StatusOperatorKind.OneOf
|
|
of "insideoreq":
|
|
StatusOperatorKind.InsideOrEq
|
|
of "inside":
|
|
StatusOperatorKind.Inside
|
|
else:
|
|
return err("Field `status.operator` has unknown or empty value")
|
|
|
|
ok(StatusExpect(kind: kind, value: value))
|
|
|
|
proc getResponseHeadersExpect(rule: JsonNode): Result[HeadersExpect, cstring] =
|
|
let response = rule.getOrDefault("response")
|
|
if isNil(response):
|
|
return err("Missing `response` field")
|
|
let jheaders = response.getOrDefault("headers")
|
|
if isNil(jheaders):
|
|
return ok(HeadersExpect())
|
|
if jheaders.kind != JArray:
|
|
return err("`response.headers` should be array")
|
|
if len(jheaders.elems) == 0:
|
|
return ok(HeadersExpect())
|
|
var res: seq[HeaderExpect]
|
|
for jitem in jheaders.elems:
|
|
if jitem.kind != JObject:
|
|
return err("`response.headers` elements should be objects")
|
|
let jkey = jitem.getOrDefault("key")
|
|
if isNil(jkey) or jkey.kind != JString:
|
|
continue
|
|
let key = jkey.str
|
|
let operator =
|
|
block:
|
|
let jop = jitem.getOrDefault("operator")
|
|
if isNil(jop) or jop.kind != JString:
|
|
HeaderOperatorKind.Exists
|
|
else:
|
|
case toLowerAscii(jop.str)
|
|
of "exists":
|
|
HeaderOperatorKind.Exists
|
|
of "notexists":
|
|
HeaderOperatorKind.NotExists
|
|
of "equals":
|
|
HeaderOperatorKind.Equals
|
|
of "oneof":
|
|
HeaderOperatorKind.OneOf
|
|
of "substr":
|
|
HeaderOperatorKind.Substr
|
|
else:
|
|
return err("`response.header` element has incorrect operator")
|
|
let value =
|
|
block:
|
|
var vres: seq[string]
|
|
let jvalue = jitem.getOrDefault("value")
|
|
if not isnil(jvalue):
|
|
case jvalue.kind
|
|
of JArray:
|
|
if len(jvalue.elems) == 0:
|
|
return err("`response.header` element has an empty array value")
|
|
for jelem in jvalue.elems:
|
|
case jelem.kind
|
|
of JString:
|
|
vres.add(jelem.str)
|
|
of JInt:
|
|
vres.add(Base10.toString(uint64(jvalue.num)))
|
|
else:
|
|
return err("`response.header` element has incorrect value")
|
|
of JString:
|
|
vres.add(jvalue.str)
|
|
of JInt:
|
|
vres.add(Base10.toString(uint64(jvalue.num)))
|
|
else:
|
|
return err("`response.header` element has incorrect value")
|
|
vres
|
|
res.add(HeaderExpect(key: key, value: value, kind: operator))
|
|
ok(HeadersExpect(headers: res))
|
|
|
|
proc getResponseBodyExpect(rule: JsonNode): Result[BodyExpect, cstring] =
|
|
let response = rule.getOrDefault("response")
|
|
if isNil(response):
|
|
return err("Missing `response` field")
|
|
let jbody = response.getOrDefault("body")
|
|
if isNil(jbody):
|
|
return ok(BodyExpect())
|
|
if jbody.kind != JArray:
|
|
return err("`response.body` should be array")
|
|
if len(jbody.elems) == 0:
|
|
return ok(BodyExpect())
|
|
|
|
var res: seq[BodyItemExpect]
|
|
|
|
for jitem in jbody.elems:
|
|
if jitem.kind != JObject:
|
|
return err("`response.body` elements should be objects")
|
|
|
|
let operator =
|
|
block:
|
|
let jop = jitem.getOrDefault("operator")
|
|
if isNil(jop) or jop.kind != JString:
|
|
BodyOperatorKind.Exists
|
|
else:
|
|
case toLowerAscii(jop.str)
|
|
of "exists":
|
|
BodyOperatorKind.Exists
|
|
of "jstructcmps":
|
|
BodyOperatorKind.JsonStructCmpS
|
|
of "jstructcmpns":
|
|
BodyOperatorKind.JsonStructCmpNS
|
|
else:
|
|
return err("`response.body` element has incorrect operator")
|
|
|
|
case operator
|
|
of BodyOperatorKind.Exists:
|
|
res.add(BodyItemExpect(kind: operator))
|
|
of BodyOperatorKind.JsonStructCmpS, BodyOperatorKind.JsonStructCmpNS:
|
|
let start =
|
|
block:
|
|
var default: seq[string]
|
|
var rstart: seq[string]
|
|
let jstart = jitem.getOrDefault("start")
|
|
if isNil(jstart):
|
|
default
|
|
else:
|
|
case jstart.kind
|
|
of JString:
|
|
rstart.add(jstart.str)
|
|
of JArray:
|
|
if len(jstart.elems) != 0:
|
|
for elem in jstart.elems:
|
|
case elem.kind
|
|
of JString:
|
|
rstart.add(elem.str)
|
|
else:
|
|
return err("`response.body` element has incorrect `start`" &
|
|
" option")
|
|
else:
|
|
return err("`response.body` element has incorrect `start` option")
|
|
rstart
|
|
let body =
|
|
block:
|
|
let jvalue = jitem.getOrDefault("value")
|
|
if jvalue.isNil():
|
|
return err("`response.body` element has incorrect `value` option")
|
|
jvalue
|
|
res.add(BodyItemExpect(kind: operator, startPath: start, value: body))
|
|
ok(BodyExpect(items: res))
|
|
|
|
proc validateStatus(status: int, expect: StatusExpect): bool =
|
|
case expect.kind
|
|
of StatusOperatorKind.Equals:
|
|
expect.value[0] == status
|
|
of StatusOperatorKind.OneOf:
|
|
status in expect.value
|
|
of StatusOperatorKind.InsideOrEq:
|
|
if len(expect.value) < 2:
|
|
status >= expect.value[0]
|
|
else:
|
|
status >= expect.value[0] and status <= expect.value[1]
|
|
of StatusOperatorKind.Inside:
|
|
if len(expect.value) < 2:
|
|
status > expect.value[0]
|
|
else:
|
|
status > expect.value[0] and status < expect.value[1]
|
|
|
|
proc validateHeaders(resp: HttpResponseHeader, expect: HeadersExpect): bool =
|
|
if len(expect.headers) == 0:
|
|
true
|
|
else:
|
|
for item in expect.headers:
|
|
case item.kind
|
|
of HeaderOperatorKind.Exists:
|
|
if item.key notin resp:
|
|
return false
|
|
of HeaderOperatorKind.NotExists:
|
|
if item.key in resp:
|
|
return false
|
|
of HeaderOperatorKind.Equals:
|
|
if item.key notin resp:
|
|
return false
|
|
let v = resp[item.key]
|
|
if cmpIgnoreCase(v, item.value[0]) != 0:
|
|
return false
|
|
of HeaderOperatorKind.OneOf:
|
|
if item.key notin resp:
|
|
return false
|
|
let v = resp[item.key]
|
|
var r = false
|
|
for citem in item.value:
|
|
if cmpIgnoreCase(citem, v) == 0:
|
|
r = true
|
|
break
|
|
if not(r):
|
|
return false
|
|
of HeaderOperatorKind.Substr:
|
|
if item.key notin resp:
|
|
return false
|
|
let v = resp[item.key]
|
|
if strutils.find(v, item.value[0]) < 0:
|
|
return false
|
|
true
|
|
|
|
proc jsonBody(body: openArray[byte]): Result[JsonNode, cstring] =
|
|
var sbody = cast[string](@body)
|
|
let res =
|
|
try:
|
|
parseJson(sbody)
|
|
except CatchableError as exc:
|
|
return err("Unable to parse json")
|
|
except Exception as exc:
|
|
raiseAssert exc.msg
|
|
ok(res)
|
|
|
|
proc getPath(jobj: JsonNode, path: seq[string]): Result[JsonNode, cstring] =
|
|
var jnode = jobj
|
|
for item in path:
|
|
let jitem = jnode.getOrDefault(item)
|
|
if isNil(jitem):
|
|
return err("Path not found")
|
|
jnode = jitem
|
|
ok(jnode)
|
|
|
|
proc structCmp(j1, j2: JsonNode, strict: bool): bool =
|
|
if j1.kind != j2.kind:
|
|
return false
|
|
case j1.kind
|
|
of JArray:
|
|
# In case of array we checking first element of `expect` with all the
|
|
# elements in `result`.
|
|
if len(j1.elems) == 0:
|
|
true
|
|
else:
|
|
if len(j2.elems) == 0:
|
|
false
|
|
else:
|
|
for item in j1.elems:
|
|
if not(structCmp(item, j2.elems[0], strict)):
|
|
return false
|
|
true
|
|
of JObject:
|
|
if strict:
|
|
if len(j1.fields) != len(j2.fields):
|
|
return false
|
|
for key, value in j1.fields:
|
|
let j2node = j2.getOrDefault(key)
|
|
if isNil(j2node):
|
|
return false
|
|
if not(structCmp(value, j2node, strict)):
|
|
return false
|
|
true
|
|
else:
|
|
for key, value in j2.fields:
|
|
let j1node = j1.getOrDefault(key)
|
|
if isNil(j1node):
|
|
return false
|
|
if not(structCmp(j1node, value, strict)):
|
|
return false
|
|
true
|
|
else:
|
|
true
|
|
|
|
proc validateBody(body: openArray[byte], expect: BodyExpect): bool =
|
|
if len(expect.items) == 0:
|
|
true
|
|
else:
|
|
for item in expect.items:
|
|
case item.kind
|
|
of BodyOperatorKind.Exists:
|
|
if len(body) == 0:
|
|
return false
|
|
of BodyOperatorKind.JsonStructCmpS, BodyOperatorKind.JsonStructCmpNS:
|
|
let jbody =
|
|
block:
|
|
let jres = jsonBody(body)
|
|
if jres.isErr():
|
|
return false
|
|
let jnode = jres.get()
|
|
let jpathres = jnode.getPath(item.startPath)
|
|
if jpathres.isErr():
|
|
return false
|
|
jpathres.get()
|
|
let strict =
|
|
if item.kind == BodyOperatorKind.JsonStructCmpS:
|
|
true
|
|
else:
|
|
false
|
|
if not(structCmp(jbody, item.value, strict)):
|
|
return false
|
|
true
|
|
|
|
proc failure(t: typedesc[TestResult], code: TestResultKind,
|
|
message: string = "",
|
|
flags: set[TestResultFlag] = {},
|
|
times: array[4, Duration]): TestResult =
|
|
TestResult(kind: code, message: message, flags: flags, times: times)
|
|
|
|
proc success(t: typedesc[TestResult], times: array[4, Duration]): TestResult =
|
|
TestResult(kind: TestResultKind.ValidationSuccess, times: times)
|
|
|
|
proc runTest(conn: HttpConnectionRef, uri: Uri,
|
|
rule: JsonNode, workerIndex: int,
|
|
testIndex: int): Future[TestResult] {.async.} =
|
|
var times: array[4, Duration]
|
|
let testName = rule.getTestName()
|
|
let testPath = uri.path & rule.getTestName()
|
|
|
|
debug "Running test", name = testName, test_index = testIndex,
|
|
worker_index = workerIndex
|
|
|
|
let (requestUri, request) =
|
|
block:
|
|
let res = prepareRequest(uri, rule)
|
|
if res.isErr():
|
|
return TestResult.failure(TestResultKind.RuleError,
|
|
"Could not read request data: " &
|
|
$res.error(), times = times)
|
|
res.get()
|
|
|
|
let statusExpect =
|
|
block:
|
|
let res = getResponseStatusExpect(rule)
|
|
if res.isErr():
|
|
return TestResult.failure(TestResultKind.RuleError,
|
|
"Could not read response status data: " &
|
|
$res.error(), times = times)
|
|
res.get()
|
|
|
|
let headersExpect =
|
|
block:
|
|
let res = getResponseHeadersExpect(rule)
|
|
if res.isErr():
|
|
return TestResult.failure(TestResultKind.RuleError,
|
|
"Could not read response headers data: " &
|
|
$res.error(), times = times)
|
|
res.get()
|
|
|
|
let bodyExpect =
|
|
block:
|
|
let res = getResponseBodyExpect(rule)
|
|
if res.isErr():
|
|
return TestResult.failure(TestResultKind.RuleError,
|
|
"Could not read response body data: " &
|
|
$res.error(), times = times)
|
|
res.get()
|
|
|
|
let testSm = Moment.now()
|
|
var headersBuf = newSeq[byte](8192)
|
|
var dataBuf = newSeq[byte](8192)
|
|
|
|
try:
|
|
let sm = Moment.now()
|
|
await conn.writer.write(request)
|
|
times[0] = Moment.now() - sm
|
|
debug "Request sent", name = testName,
|
|
elapsed = $times[0], test_index = testIndex,
|
|
worker_index = workerIndex
|
|
except AsyncStreamError:
|
|
return TestResult.failure(TestResultKind.WriteRequestError,
|
|
"Unable to send request", {ResetConnection},
|
|
times)
|
|
let rlen =
|
|
try:
|
|
let sm = Moment.now()
|
|
let res = await conn.reader.readUntil(addr headersBuf[0],
|
|
len(headersBuf), HeadersMark)
|
|
times[1] = Moment.now() - sm
|
|
debug "Response headers received", name = testName,
|
|
length = res, elapsed = $times[1],
|
|
test_index = testIndex,
|
|
worker_index = workerIndex
|
|
res
|
|
except AsyncStreamError:
|
|
return TestResult.failure(TestResultKind.ReadResponseHeadersError,
|
|
"Unable to read response headers",
|
|
{ResetConnection}, times)
|
|
|
|
headersBuf.setLen(rlen)
|
|
|
|
let resp = parseResponse(headersBuf, true)
|
|
if not(resp.success()):
|
|
return TestResult.failure(TestResultKind.ResponseError,
|
|
"Response headers could not be parsed",
|
|
{ResetConnection}, times)
|
|
|
|
if "Content-Length" notin resp:
|
|
return TestResult.failure(TestResultKind.ResponseError,
|
|
"Content-Length header must be present",
|
|
{ResetConnection}, times)
|
|
let contentLength = resp.contentLength()
|
|
if contentLength < 0:
|
|
return TestResult.failure(TestResultKind.ResponseError,
|
|
"Content-Length value is incorrect",
|
|
{ResetConnection}, times)
|
|
else:
|
|
# TODO: We are not checking Content-Length size here
|
|
if contentLength > 0:
|
|
dataBuf.setLen(contentLength)
|
|
try:
|
|
let sm = Moment.now()
|
|
await conn.reader.readExactly(addr dataBuf[0], len(dataBuf))
|
|
times[2] = Moment.now() - sm
|
|
debug "Response body received", length = len(dataBuf),
|
|
name = testName,
|
|
elapsed = $times[2], test_index = testIndex,
|
|
worker_index = workerIndex
|
|
except AsyncStreamError:
|
|
return TestResult.failure(TestResultKind.ReadResponseBodyError,
|
|
"Unable to read response body",
|
|
{ResetConnection}, times)
|
|
else:
|
|
debug "Response body is missing", name = testName, path = testPath
|
|
dataBuf.setLen(0)
|
|
|
|
let res1 = validateStatus(resp.code, statusExpect)
|
|
let res2 = validateHeaders(resp, headersExpect)
|
|
let res3 = validateBody(dataBuf, bodyExpect)
|
|
times[3] = Moment.now() - testSm
|
|
|
|
if res1 and res2 and res3:
|
|
return TestResult.success(times)
|
|
else:
|
|
let flags =
|
|
block:
|
|
var res: set[TestResultFlag]
|
|
if not(res1):
|
|
res.incl(StatusValidationFailure)
|
|
if not(res2):
|
|
res.incl(HeadersValidationFailure)
|
|
if not(res3):
|
|
res.incl(BodyValidationFailure)
|
|
res
|
|
return TestResult.failure(TestResultKind.ValidationError, times = times,
|
|
flags = flags)
|
|
|
|
proc workerLoop(address: TransportAddress, uri: Uri, worker: int,
|
|
conf: RestTesterConf,
|
|
inputQueue: AsyncQueue[TestCase],
|
|
outputQueue: AsyncQueue[TestCaseResult]) {.async.} =
|
|
let hostname = uri.hostname & ":" & uri.port
|
|
var conn: HttpConnectionRef = nil
|
|
var index: int
|
|
debug "Test worker has been started", worker = worker
|
|
while true:
|
|
try:
|
|
let test = await inputQueue.popFirst()
|
|
index = test.index
|
|
if isNil(conn):
|
|
conn = await openConnection(address, uri, conf.getTLSFlags())
|
|
debug "Opened new connection with remote host", address = $address,
|
|
worker = worker
|
|
let testRes = await runTest(conn, uri, test.rule, worker, test.index)
|
|
let caseRes = TestCaseResult(index: test.index, data: testRes)
|
|
await outputQueue.addLast(caseRes)
|
|
if ResetConnection in testRes.flags:
|
|
await conn.closeWait()
|
|
conn = nil
|
|
index = 0
|
|
except CancelledError:
|
|
if not(isNil(conn)):
|
|
await conn.closeWait()
|
|
conn = nil
|
|
notice "Got signal, exiting", worker = worker
|
|
return
|
|
except ConnectionError:
|
|
warn "Unable to establish connection with remote host", host = hostname,
|
|
worker = worker
|
|
return
|
|
except CatchableError as exc:
|
|
warn "Unexpected exception while running test test run", host = hostname,
|
|
error_name = exc.name, error_msg = exc.msg, index = index,
|
|
worker = worker
|
|
return
|
|
|
|
proc startTests(conf: RestTesterConf, uri: Uri,
|
|
rules: seq[JsonNode]): Future[int] {.async.} =
|
|
var workers = newSeq[Future[void]](conf.connectionsCount)
|
|
var inputQueue = newAsyncQueue[TestCase](len(rules))
|
|
var outputQueue = newAsyncQueue[TestCaseResult](conf.connectionsCount)
|
|
var results = newSeq[TestResult](len(rules))
|
|
var restarts = 0
|
|
|
|
let address =
|
|
block:
|
|
let res = uri.getAddress()
|
|
if res.isErr():
|
|
fatal "Unable to resolve remote host address, exiting",
|
|
uri = $uri
|
|
return 1
|
|
res.get()
|
|
|
|
for index, item in rules:
|
|
inputQueue.addLastNoWait(TestCase(index: index, rule: item))
|
|
|
|
for i in 0 ..< len(workers):
|
|
workers[i] = workerLoop(address, uri, i, conf, inputQueue, outputQueue)
|
|
|
|
block:
|
|
var pending = newSeq[FutureBase](len(workers) + 1)
|
|
for i in 0 ..< len(workers):
|
|
pending[i] = workers[i]
|
|
|
|
var fut: Future[TestCaseResult]
|
|
for i in 0 ..< len(rules):
|
|
fut = outputQueue.popFirst()
|
|
pending[^1] = fut
|
|
discard await race(pending)
|
|
for i in 0 ..< len(pending):
|
|
if pending[i].finished():
|
|
if i < len(workers):
|
|
warn "Test worker quits unexpectedly", index = i, restarts = restarts
|
|
return 1
|
|
else:
|
|
if pending[i].failed() or pending[i].cancelled():
|
|
warn "Unexpected result from queue"
|
|
return 1
|
|
|
|
let tcaseRes = fut.read()
|
|
results[tcaseRes.index] = tcaseRes.data
|
|
notice "Got test result", name = rules[tcaseRes.index].getTestName(),
|
|
index = tcaseRes.index,
|
|
value = tcaseRes.data.kind
|
|
pending[i] = nil
|
|
|
|
debug "Stopping workers"
|
|
# Stopping workers
|
|
block:
|
|
var pending = newSeq[Future[void]]()
|
|
for worker in workers:
|
|
if not(worker.finished()):
|
|
pending.add(worker.cancelAndWait())
|
|
await allFutures(pending)
|
|
|
|
var errCode = 0
|
|
let headerLine = "\r\n" &
|
|
'-'.repeat(45 + 20 + 7 + 20 + 20) & "\r\n" &
|
|
alignLeft("TEST", 45) & alignLeft("STATUS", 20) &
|
|
alignLeft("ERROR", 7) & alignLeft("ELAPSED", 20) &
|
|
alignLeft("MESSAGE", 20) & "\r\n" &
|
|
'-'.repeat(45 + 20 + 7 + 20 + 20)
|
|
echo headerLine
|
|
for index, item in rules:
|
|
let errorFlag =
|
|
block:
|
|
var tmp = "---"
|
|
if StatusValidationFailure in results[index].flags:
|
|
tmp[0] = 'S'
|
|
if HeadersValidationFailure in results[index].flags:
|
|
tmp[1] = 'H'
|
|
if BodyValidationFailure in results[index].flags:
|
|
tmp[2] = 'R'
|
|
tmp
|
|
let line =
|
|
alignLeft(item.getTestName() & "#" & $index, 45) &
|
|
alignLeft($results[index].kind, 20) &
|
|
alignLeft(errorFlag, 7) &
|
|
alignLeft($results[index].times[3], 20) &
|
|
alignLeft($results[index].message, 20)
|
|
echo line
|
|
if results[index].kind != ValidationSuccess:
|
|
errCode = 1
|
|
return errCode
|
|
|
|
proc run(conf: RestTesterConf): int =
|
|
let uri =
|
|
block:
|
|
let res = conf.getUri()
|
|
if res.isErr():
|
|
fatal "Incomplete/incorrect URL", url = conf.url,
|
|
error_msg = $res.error()
|
|
return 1
|
|
res.get()
|
|
|
|
let jnodes =
|
|
block:
|
|
let res = conf.getTestRules()
|
|
if res.isErr():
|
|
fatal "Incomplete/incorrect rules file", file = conf.rulesFilename
|
|
return 1
|
|
res.get()
|
|
|
|
notice "Waiting for initial connection attempt", time = conf.delayTime
|
|
try:
|
|
waitFor(sleepAsync(conf.delayTime.seconds))
|
|
except CatchableError as exc:
|
|
fatal "Unexpected test failure", error_name = exc.name, error_msg = exc.msg
|
|
return 1
|
|
|
|
notice "Exploring remote server hostname",
|
|
hostname = uri.hostname & ":" & uri.port
|
|
try:
|
|
waitFor(checkConnection(conf, uri))
|
|
except ConnectionError as exc:
|
|
return 1
|
|
|
|
try:
|
|
return waitFor(startTests(conf, uri, jnodes))
|
|
except CatchableError as exc:
|
|
fatal "Unexpected test failure", error_name = exc.name, error_msg = exc.msg
|
|
return 1
|
|
|
|
when isMainModule:
|
|
echo RestTesterHeader
|
|
var conf = RestTesterConf.load(version = RestTesterVersion)
|
|
quit run(conf)
|