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

ClientTest.pudu

Pudu135 lines8.8 KB

GitHub ↗
1/** @Test.Client.Suite — a configured client sending through its pipeline */2module PuduLangHttpClient.ClientTest34import Std.Concurrent.Cancel as Cancel5import Std.Concurrent as Concurrent6import Std.Http as Http7import Std.Http.Server.Reply as Reply8import Std.Http.Server.Route as Route9import Std.Io as Io10import Std.Result as Result11import Std.Sync as Sync12import Std.Test as Test13import PuduLangHttpClient.Client as Client14import PuduLangHttpClient.Constants.Defaults as Defaults15import PuduLangHttpClient.Content as Content16import PuduLangHttpClient.Domain.Version as Version17import PuduLangHttpClient as HttpClient18import PuduLangHttpClient.Request as Request19import PuduLangHttpClient.Response as Response20import PuduLangHttpClient.Stub as Stub21import PuduLangHttpClient.Transport as Transport22import Support.Loopback as Loopback2324/// A primary handler that waits for its token, as the transport does, and answers how it stopped.25fn waiting(request: Request.Request, token: Cancel.Token) -> HttpClient.Outcome[Response.Response] {26  let began = clock()27  match Cancel.pause(&token, 5000) {28    case Ok(_) => Ok(Response.answer(&request, 200))29    case Err(Cancel.Requested(reason)) => Err(HttpClient.Cancelled(reason))30    case Err(Cancel.DeadlineExceeded) => Err(HttpClient.TimedOut(clock() - began))31  }32}3334/// The status and text of an outcome.35fn shape(outcome: &HttpClient.Outcome[Response.Response]) -> Result[(Int, Str), HttpClient.Failure] {36  Result.map(*outcome, |response: Response.Response| (response.status.code, Result.unwrapOr(Response.text(&response), "")))37}3839/// The connections opened and reused by two copies of one pooled client fetching from the server.40fn sharedPool(server: &Loopback.Routed) -> (Int, Int) {41  let options = Transport.defaults().withPooledConnectionLifetime(60000)42  let transport = Transport.create(options)43  let shared = Client.over(&transport).withBaseAddress(server.base + "/")44  let elsewhere = shared45  let _first = Client.getText(&shared, "hello")46  let _second = Client.getText(&elsewhere, "hello")47  let _built = Client.pooled(options)48  let stats = Transport.statistics(&transport)49  (stats.opened, stats.reused)50}5152/// Runs the suite.53fn main() -> Int {54  let server = Loopback.serve(&[Route.get("/hello", |_request: Route.Request| Reply.text(200, "hi"))])55  let stub = Stub.create()56  Stub.on(&stub, Http.Get, "/v1/echo", |request: Request.Request| Ok(Response.answer(&request, 200).withText(request.uri + " " + request.headers.map(|pair: (Str, Str)| pair[0] + "=" + pair[1]).join(","))))57  Stub.respond(&stub, Http.Get, "/v1/missing", 404, "no")58  Stub.on(&stub, Http.Post, "/v1/echo", |request: Request.Request| Ok(Response.answer(&request, 201).withText(Result.unwrapOr(Content.readText(&match request.content { case Some(found) => found case None => Content.empty() }), ""))))59  Stub.respond(&stub, Http.Put, "/v1/echo", 204, "")60  Stub.respond(&stub, Http.Patch, "/v1/echo", 200, "patched")61  Stub.respond(&stub, Http.Delete, "/v1/echo", 202, "")62  Stub.respond(&stub, Http.Head, "/v1/echo", 200, "")63  let client = Client.create(Stub.send(&stub)).withBaseAddress("https://api.test/v1/").withDefaultHeader("user-agent", "shop/1").withDefaultHeader("accept", "*/*").withDefaultVersion(Http.Http10, Version.OrHigher).named("shop")64  let relative = shape(&Client.get(&client, "echo?x=1"))65  let overridden = shape(&Client.send(&client, Request.get("echo").withHeader("Accept", "text/plain")))66  let absolute = shape(&Client.get(&client, "https://api.test/v1/echo"))67  let escaped = shape(&Client.get(&client, "echo?q=a b"))68  let text = Client.getText(&client, "echo")69  let bytes = Result.map(Client.getBytes(&client, "missing"), |found: Bytes| found.length())70  let posted = shape(&Client.post(&client, "echo", Content.plain("body")))71  let others = [Client.put(&client, "echo", Content.empty()), Client.patch(&client, "echo", Content.plain("p")), Client.delete(&client, "echo"), Client.head(&client, "echo")].map(|outcome: HttpClient.Outcome[Response.Response]| Result.map(outcome, |r: Response.Response| r.status.code))72  let pieces = Sync.cell("")73  let streamed = Client.stream(&client, "echo", fn(piece: Bytes) -> Bool {74      let _kept = Sync.set(&pieces, Result.unwrapOr(Sync.get(&pieces), "") + match piece.toText() { case Some(found) => found case None => "" })75      true76    })77  let built = Client.build(&client, Http.Get, "x")78  let noBase = Client.get(&Client.create(Stub.send(&stub)), "echo")79  let limited = Stub.received(&stub)80  let bufferLimit = Request.option(&limited[0], &Transport.bufferLimit())8182  let slow = Client.create(waiting).withTimeout(30)83  let timedOut = shape(&Client.get(&slow, "http://a/"))84  let callerToken = Cancel.token()85  let _cancelled = Cancel.cancel(&callerToken, "caller")86  let callerCancelled = shape(&Client.sendWith(&slow, Request.get("http://a/"), callerToken))87  let callerDeadline = shape(&Client.sendWith(&Client.create(waiting).withTimeout(Defaults.INFINITE), Request.get("http://a/"), Cancel.expiring(20)))88  let pending = Client.create(waiting)89  let outcome = Sync.cell(Ok((0, "")))90  let started = match Concurrent.start(fn() -> () { let _set = Sync.set(&outcome, shape(&Client.get(&pending, "http://a/"))) }) {91    case Ok(found) => found92    case Err(problem) => panic(show(problem))93  }94  let _paused = Concurrent.sleep(30)95  Client.cancelPending(&pending)96  let _joined = Concurrent.join(&started)97  let afterCancel = Client.sendWith(&pending, Request.get("http://a/"), Cancel.expiring(20))9899  let checks = Test.suite("Client", &[100      Test.equals("a relative address resolves against the base address and gets the default headers", &relative, &Ok((200, "https://api.test/v1/echo?x=1 user-agent=shop/1,accept=*/*"))),101      Test.equals("a request's own header wins over a default", &overridden, &Ok((200, "https://api.test/v1/echo user-agent=shop/1,Accept=text/plain"))),102      Test.equals("an absolute address is sent as given", &absolute, &Ok((200, "https://api.test/v1/echo user-agent=shop/1,accept=*/*"))),103      Test.equals("characters an address cannot carry are escaped", &escaped, &Ok((200, "https://api.test/v1/echo?q=a%20b user-agent=shop/1,accept=*/*"))),104      Test.equals("get text answers the body of a success", &text, &Ok("https://api.test/v1/echo user-agent=shop/1,accept=*/*")),105      Test.equals("get bytes refuses a failed status", &bytes, &Err(HttpClient.Unsuccessful(404, "Not Found"))),106      Test.equals("a post carries its body", &posted, &Ok((201, "body"))),107      Test.equals("put, patch, delete, and head use their methods", &others, &[Ok(204), Ok(200), Ok(202), Ok(200)]),108      Test.equals("a stream hands the body to the receiver", &(shape(&streamed), Result.unwrapOr(Sync.get(&pieces), "")), &(Ok((200, "")), "https://api.test/v1/echo user-agent=shop/1,accept=*/*")),109      Test.equals("helpers send with the client's default version", &(built.version, built.versionPolicy), &(Http.Http10, Version.OrHigher)),110      Test.equals("a relative address without a base address is refused", &Result.map(noBase, |r: Response.Response| r.status.code), &Err(HttpClient.InvalidRequest("'echo' is relative and the client has no base address"))),111      Test.equals("every request carries the client's buffer limit", &bufferLimit, &Some(Defaults.MAX_RESPONSE_BUFFER)),112      Test.equals("the client's timeout is reported as its own", &timedOut, &Err(HttpClient.TimedOut(30))),113      Test.equals("a caller's cancellation is reported as one", &callerCancelled, &Err(HttpClient.Cancelled("caller"))),114      Test.that("a caller's deadline is reported as the time spent", match callerDeadline {115          case Err(HttpClient.TimedOut(millis)) => millis >= 15 && millis < 1000116          case _ => false117        }),118      Test.equals("cancelling pending requests stops the one in flight", &Result.unwrapOr(Sync.get(&outcome), Ok((0, ""))), &Err(HttpClient.Cancelled("the client cancelled its pending requests"))),119      Test.that("a request after cancelling pending ones is not cancelled", match afterCancel {120          case Err(HttpClient.TimedOut(_)) => true121          case _ => false122        }),123      Test.equals("the defaults are valid", &Client.validate(&client), &[]),124      Test.equals("every invalid setting is reported", &Client.validate(&Client.create(waiting).withBaseAddress("api.test").withTimeout(0).withMaxResponseContentBufferSize(-1).withDefaultHeader("x", "a\nb")), &["baseAddress must be an absolute http or https address: api.test", "timeout must be positive or INFINITE", "maxResponseContentBufferSize must be at least 0", "the header 'x' contains a line break or an invalid name"]),125      Test.equals("a pooled client's copies share one pool", &sharedPool(&server), &(1, 1)),126      Test.equals("a client over a transport has the default name", &Client.over(&Transport.create(Transport.defaults())).name, &"")127    ])128  Loopback.stop(&server)129  let ran = Test.run(&checks)130  for failure in Test.failuresOf(&ran) {131    let _reported = Io.writeErrorLine(failure)132  }133  Test.report(&ran)134}135