
ClientTest.pudu
Pudu135 lines8.8 KB
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 Loopback232425fn 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}333435fn 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}383940fn 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}515253fn 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