Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-httpclient

Named HTTP clients for Pudu: a client factory, delegating handlers, pooled keep-alive connections, handler lifetimes, logging, and resilience

0.1.1Apache-2.01

InstallClose

TransportTest.pudu

Pudu179 lines11.5 KB

GitHub ↗
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 Loopback2223/// The response a request gets through a transport within five seconds.24fn fetch(transport: &Transport.Transport, request: Request.Request) -> HttpClient.Outcome[Response.Response] {25  Transport.send(transport)(request, Cancel.expiring(5000))26}2728/// The status and text of an outcome.29fn 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}3233/// The body of a summary, or empty text.34fn bodyOf(summarized: &Result[(Int, Str), HttpClient.Failure]) -> Str {35  match summarized {36    case Ok((_, body)) => body37    case Err(_) => ""38  }39}4041/// A response with a status and headers and no body.42fn bare(code: Int, headers: Array[(Str, Str)]) -> Http.Response { Http.Response{status: Http.status(code), headers: headers, body: "", binaryBody: None} }4344/// The routes every check talks to.45fn 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}6364/// Runs the suite.65fn 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