mirror of
https://github.com/status-im/nimbus-eth2.git
synced 2025-01-09 22:06:21 +00:00
f70ff38b53
Some upstream repos still need fixes, but this gets us close enough that style hints can be enabled by default. In general, "canonical" spellings are preferred even if they violate nep-1 - this applies in particular to spec-related stuff like `genesis_validators_root` which appears throughout the codebase.
1134 lines
36 KiB
Nim
1134 lines
36 KiB
Nim
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, 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.pairs():
|
|
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 "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")
|
|
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(jvalue.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.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.pairs():
|
|
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.pairs():
|
|
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.pairs():
|
|
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.pairs():
|
|
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)
|