
TransportTest.pudu
Pudu179 lines11.5 KB
1/** @Test.Transport.Suite — the pooled primary handler against a loopback server */2module PuduLangHttpClient.TransportTest34import Std.Compress.Gzip as Gzip5import Std.Concurrent.Cancel as Cancel6import Std.Concurrent as Concurrent7import Std.Http as Http8import Std.Http.Server.Reply as Reply9import Std.Http.Server.Route as Route10import Std.Io as Io11import Std.Result as Result12import Std.Test as Test13import PuduLangHttpClient.Content as Content14import PuduLangHttpClient.Cookies as Cookies15import PuduLangHttpClient.Domain.Version as Version16import PuduLangHttpClient as HttpClient17import PuduLangHttpClient.Request as Request18import PuduLangHttpClient.Response as Response19import PuduLangHttpClient.Transport.Pool as Pool20import PuduLangHttpClient.Transport as Transport21import Support.Loopback as Loopback222324fn fetch(transport: &Transport.Transport, request: Request.Request) -> HttpClient.Outcome[Response.Response] {25 Transport.send(transport)(request, Cancel.expiring(5000))26}272829fn summary(outcome: &HttpClient.Outcome[Response.Response]) -> Result[(Int, Str), HttpClient.Failure] {30 Result.map(*outcome, |response: Response.Response| (response.status.code, Result.unwrapOr(Response.text(&response), "")))31}323334fn bodyOf(summarized: &Result[(Int, Str), HttpClient.Failure]) -> Str {35 match summarized {36 case Ok((_, body)) => body37 case Err(_) => ""38 }39}404142fn bare(code: Int, headers: Array[(Str, Str)]) -> Http.Response { Http.Response{status: Http.status(code), headers: headers, body: "", binaryBody: None} }434445fn routes() -> Array[Route.Route] {46 [47 Route.get("/hello", |_request: Route.Request| Reply.text(200, "hello")),48 Route.post("/echo", |request: Route.Request| Reply.text(200, Route.body(&request))),49 Route.get("/headers", |request: Route.Request| Reply.text(200, request.message.headers.map(|pair: (Str, Str)| pair[0].toLower() + "=" + pair[1]).join("|"))),50 Route.get("/found", |_request: Route.Request| bare(302, [("location", "/hello")])),51 Route.post("/see-other", |_request: Route.Request| bare(303, [("location", "/hello")])),52 Route.post("/temporary", |_request: Route.Request| bare(307, [("location", "/echo")])),53 Route.get("/loop", |_request: Route.Request| bare(302, [("location", "/loop")])),54 Route.get("/set", |_request: Route.Request| bare(200, [("set-cookie", "session=abc; Path=/"), ("set-cookie", "theme=dark; Max-Age=3600")])),55 Route.get("/gzip", |_request: Route.Request| Http.Response{..Reply.bytes(200, "text/plain", &Gzip.compressText("compressed hello", &Gzip.config())), headers: [("content-type", "text/plain"), ("content-encoding", "gzip")]}),56 Route.get("/big", |_request: Route.Request| Reply.text(200, "0123456789")),57 Route.get("/slow", |_request: Route.Request| {58 let _waited = Concurrent.sleep(150)59 Reply.text(200, "slow")60 })61 ]62}636465fn main() -> Int {66 let server = Loopback.serve(&routes())67 let base = server.base68 let pooled = Transport.create(Transport.defaults())69 let first = summary(&fetch(&pooled, Request.get(base + "/hello")))70 let second = summary(&fetch(&pooled, Request.get(base + "/hello")))71 let reuse = Transport.statistics(&pooled)72 let echoed = summary(&fetch(&pooled, Request.post(base + "/echo", Content.plain("ping"))))73 let found = fetch(&pooled, Request.get(base + "/found"))74 let seeOther = fetch(&pooled, Request.post(base + "/see-other", Content.plain("gone")))75 let temporary = summary(&fetch(&pooled, Request.post(base + "/temporary", Content.plain("kept"))))76 let looping = fetch(&Transport.create(Transport.defaults().withMaxRedirects(3)), Request.get(base + "/loop"))77 let unfollowed = summary(&fetch(&Transport.create(Transport.defaults().withoutRedirects()), Request.get(base + "/found")))7879 let jar = Cookies.jar()80 let cookied = Transport.create(Transport.defaults().withCookies(jar))81 let _set = fetch(&cookied, Request.get(base + "/set"))82 let sentCookies = summary(&fetch(&cookied, Request.get(base + "/headers")))83 let ownJar = Transport.cookies(&Transport.create(Transport.defaults().usingCookies()))8485 let decompressing = Transport.create(Transport.defaults().withDecompression())86 let unzipped = fetch(&decompressing, Request.get(base + "/gzip"))87 let asked = summary(&fetch(&decompressing, Request.get(base + "/headers")))88 let zipped = fetch(&pooled, Request.get(base + "/gzip"))8990 let publicOnly = fetch(&Transport.create(Transport.defaults().withAddressPolicy(Transport.PublicOnly([]))), Request.get(base + "/hello"))91 let permitted = summary(&fetch(&Transport.create(Transport.defaults().withAddressPolicy(Transport.PublicOnly(["127.0.0.1"]))), Request.get(base + "/hello")))92 let limited = fetch(&pooled, Request.get(base + "/big").withOption(&Transport.bufferLimit(), 4))93 let headLimited = fetch(&Transport.create(Transport.defaults().withMaxResponseHeadersLength(8)), Request.get(base + "/hello"))94 let timedOut = Transport.send(&pooled)(Request.get(base + "/slow"), Cancel.expiring(40))95 let cancelledToken = Cancel.token()96 let _cancelled = Cancel.cancel(&cancelledToken, "stop")97 let cancelled = Transport.send(&pooled)(Request.get(base + "/hello"), cancelledToken)9899 let shortLived = Transport.create(Transport.defaults().withPooledConnectionLifetime(0))100 let _a = fetch(&shortLived, Request.get(base + "/hello"))101 let _b = fetch(&shortLived, Request.get(base + "/hello"))102 let idle = Transport.create(Transport.defaults().withPooledConnectionIdleTimeout(30))103 let _c = fetch(&idle, Request.get(base + "/hello"))104 let _rested = Concurrent.sleep(60)105 let _d = fetch(&idle, Request.get(base + "/hello"))106107 let single = Transport.create(Transport.defaults().withMaxConnectionsPerServer(1))108 let workers = [1, 2, 3, 4].map(|_n: Int| Result.unwrapOr(Concurrent.start(fn() -> () { let _e = fetch(&single, Request.get(base + "/slow")) }), Concurrent.Task{token: 0}))109 let _joined = Concurrent.joinAll(&workers)110111 let closing = Transport.create(Transport.defaults())112 let _f = fetch(&closing, Request.get(base + "/hello"))113 Transport.close(&closing)114 let afterClose = summary(&fetch(&closing, Request.get(base + "/hello")))115116 let exact2 = fetch(&pooled, Request.get(base + "/hello").withVersion(Http.Http2, Version.Exact))117 let lower2 = summary(&fetch(&pooled, Request.get(base + "/hello").withVersion(Http.Http2, Version.OrLower)))118 let badHeader = fetch(&pooled, Request.get(base + "/hello").withHeader("x-bad", "a\r\nb: c"))119 let notHttp = fetch(&pooled, Request.get("ftp://example.test/"))120 let refused = fetch(&pooled, Request.get("http://127.0.0.1:1/"))121 Loopback.stop(&server)122123 let checks = Test.suite("Transport", &[124 Test.equals("a get answers its status and body", &first, &Ok((200, "hello"))),125 Test.equals("a second request answers the same", &second, &Ok((200, "hello"))),126 Test.equals("a kept-alive connection serves the second request", &(reuse.opened, reuse.reused, reuse.idle), &(1, 1, 1)),127 Test.equals("a post carries its body", &echoed, &Ok((200, "ping"))),128 Test.equals("a found redirect is followed to the final address", &Result.map(found, |r: Response.Response| (r.status.code, r.request.uri)), &Ok((200, base + "/hello"))),129 Test.equals("a see-other redirect turns a post into a get without a body", &Result.map(seeOther, |r: Response.Response| (r.request.method, r.request.content == None)), &Ok((Http.Get, true))),130 Test.equals("a temporary redirect keeps the method and the body", &temporary, &Ok((200, "kept"))),131 Test.equals("a redirect loop stops at the limit", &Result.map(looping, |r: Response.Response| r.status.code), &Err(HttpClient.TooManyRedirects(3))),132 Test.equals("a transport without redirects answers the redirect", &unfollowed, &Ok((302, ""))),133 Test.that("a jar sends back the cookies a response set", bodyOf(&sentCookies).contains("cookie=session=abc; theme=dark")),134 Test.equals("the jar holds both cookies", &Cookies.cookies(&jar).length(), &2),135 Test.that("a transport using cookies without a jar makes its own", ownJar != None),136 Test.equals("a gzip body is decompressed and its encoding removed", &Result.map(unzipped, |r: Response.Response| (Result.unwrapOr(Response.text(&r), ""), Response.header(&r, "content-encoding"))), &Ok(("compressed hello", None))),137 Test.that("a decompressing transport asks for gzip", bodyOf(&asked).contains("accept-encoding=gzip")),138 Test.equals("a transport that does not decompress keeps the encoding", &Result.map(zipped, |r: Response.Response| Response.header(&r, "content-encoding")), &Ok(Some("gzip"))),139 Test.that("a public-only policy refuses the loopback address", match publicOnly {140 case Err(HttpClient.NotPermitted(_)) => true141 case _ => false142 }),143 Test.equals("a permitted host is reached", &permitted, &Ok((200, "hello"))),144 Test.equals("a body over the request's limit is refused", &Result.map(limited, |r: Response.Response| r.status.code), &Err(HttpClient.ContentTooLarge(4))),145 Test.equals("a head over the limit is refused", &Result.map(headLimited, |r: Response.Response| r.status.code), &Err(HttpClient.HeadersTooLarge(8))),146 Test.that("a request past its deadline times out", match timedOut {147 case Err(HttpClient.TimedOut(_)) => true148 case _ => false149 }),150 Test.equals("a cancelled token stops the request before it is sent", &Result.map(cancelled, |r: Response.Response| r.status.code), &Err(HttpClient.Cancelled("stop"))),151 Test.equals("a connection lifetime of zero opens a connection per request", &Transport.statistics(&shortLived).opened, &2),152 Test.equals("an idle connection past its timeout is replaced", &(Transport.statistics(&idle).opened, Transport.statistics(&idle).closed), &(2, 1)),153 Test.equals("a per-server limit of one serves every request over one connection", &(Transport.statistics(&single).opened, Transport.statistics(&single).active), &(1, 0)),154 Test.equals("a closed transport still answers", &afterClose, &Ok((200, "hello"))),155 Test.equals("a closed transport keeps no connection", &(Transport.statistics(&closing).idle, Transport.isClosed(&closing)), &(0, true)),156 Test.equals("an exact HTTP/2 request is refused", &Result.map(exact2, |r: Response.Response| r.status.code), &Err(HttpClient.VersionUnsupported("HTTP/2"))),157 Test.equals("an HTTP/2 request that may go lower is sent as HTTP/1.1", &lower2, &Ok((200, "hello"))),158 Test.that("a header with a line break is refused", match badHeader {159 case Err(HttpClient.InvalidRequest(_)) => true160 case _ => false161 }),162 Test.that("an address that is not http is refused", match notHttp {163 case Err(HttpClient.InvalidRequest(_)) => true164 case _ => false165 }),166 Test.that("a refused connection is a connection failure", match refused {167 case Err(HttpClient.Connection(_)) => true168 case _ => false169 }),170 Test.equals("the defaults are valid", &Transport.validate(&Transport.defaults()), &[]),171 Test.equals("every invalid option is reported", &Transport.validate(&Transport.defaults().withMaxConnectionsPerServer(0).withMaxRedirects(0).withConnectTimeout(0).withPooledConnectionLifetime(-5).withPooledConnectionIdleTimeout(-2).withMaxResponseHeadersLength(0).withMaxResponseContentLength(-1)).length(), &7)172 ])173 let ran = Test.run(&checks)174 for failure in Test.failuresOf(&ran) {175 let _reported = Io.writeErrorLine(failure)176 }177 Test.report(&ran)178}179