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

FactoryTest.pudu

Pudu132 lines7.4 KB

GitHub ↗
1/** @Test.Factory.Suite — named clients over pipelines the factory owns and renews */2module PuduLangHttpClient.FactoryTest34import 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.Events as Events16import PuduLangHttpClient.Factory.Builder as Builder17import PuduLangHttpClient.Factory as Factory18import PuduLangHttpClient.Handler as Handler19import PuduLangHttpClient as HttpClient20import PuduLangHttpClient.Request as Request21import PuduLangHttpClient.Response as Response22import PuduLangHttpClient.Stub as Stub23import PuduLangHttpClient.Transport as Transport24import Support.Loopback as Loopback2526/** @Test.Factory.Catalog — a typed client wrapping a named one */27type Catalog = { client: Client.Client }2829/// The names of the items a typed client lists.30fn itemsOf(catalog: &Catalog) -> HttpClient.Outcome[Str] { Client.getText(&catalog.client, "items") }3132/// A handler adding its name to the `trace` header.33fn tracing(name: Str) -> Handler.Handler { Handler.before(|request: Request.Request| request.withHeader("trace", name)) }3435/// The text of a successful outcome, or the failure's description.36fn textOf(outcome: &HttpClient.Outcome[Str]) -> Str {37  match outcome {38    case Ok(found) => found39    case Err(failure) => HttpClient.describe(&failure)40  }41}4243/// Runs the suite.44fn main() -> Int {45  let server = Loopback.serve(&[46      Route.get("/items", |_request: Route.Request| Reply.text(200, "tea,cake")),47      Route.get("/trace", |request: Route.Request| Reply.text(200, request.message.headers.filter(|pair: (Str, Str)| pair[0] == "trace" || pair[0] == "user-agent").map(|pair: (Str, Str)| pair[1]).join(">")))48    ])49  let events = Sync.cell([])50  let lock = Sync.mutex()51  let options = Factory.Options{listeners: Events.listeners().onPipelineChanged(fn(event: Events.PipelineChanged) -> () {52        let _recorded = Sync.withLock(&lock, fn() -> () { let _kept = Sync.set(&events, Result.unwrapOr(Sync.get(&events), []).push((event.client, event.generation, event.stage))) })53      }) }54  let stub = Stub.create()55  Stub.respond(&stub, Http.Get, "/ping", 200, "pong")56  let built = Factory.buildWith(&options, [57      Builder.defaults().withDefaultHeader("user-agent", "shop/1").withHandler(tracing("default")),58      Builder.named("catalog").withBaseAddress(server.base + "/").withHandler(tracing("first")),59      Builder.named("catalog").withHandler(tracing("second")).configureHandlers(|handlers: Array[Handler.Handler]| handlers.reverse()),60      Builder.named("rotating").withBaseAddress(server.base + "/").withHandlerLifetime(30),61      Builder.named("stubbed").withPrimary(|| Stub.send(&stub)).withBaseAddress("http://stub.test/"),62      Builder.named("tuned").withTransport(Transport.defaults().withMaxConnectionsPerServer(2)).configureTransport(|options: Transport.Options| options.withPooledConnectionIdleTimeout(10)).withTimeout(Defaults.INFINITE)63    ])64  let factory = match built {65    case Ok(found) => found66    case Err(invalid) => panic(Factory.explain(&invalid))67  }68  let catalog = Factory.typed(&factory, "catalog", |client: Client.Client| Catalog{client: client})69  let items = itemsOf(&catalog)70  let traced = Client.getText(&Factory.createClient(&factory, "catalog"), "trace")71  let sameHandler = Factory.statistics(&factory, "catalog")72  let unnamed = Factory.client(&factory)73  let stubbed = Client.getText(&Factory.createClient(&factory, "stubbed"), "ping")7475  let first = Client.getText(&Factory.createClient(&factory, "rotating"), "items")76  let firstStats = Factory.statistics(&factory, "rotating")77  let _rested = Concurrent.sleep(40)78  let rotatingSender = Factory.rotatingHandler(&factory, "rotating")79  let second = rotatingSender(Request.get(server.base + "/items"), Cancel.expiring(5000))80  let pendingAfterRotation = Factory.pendingExpired(&factory)81  let _graced = Concurrent.sleep(40)82  let _third = Client.getText(&Factory.createClient(&factory, "rotating"), "items")83  let lifecycle = Result.unwrapOr(Sync.get(&events), [])84  Factory.dispose(&factory)85  let afterDispose = Result.unwrapOr(Sync.get(&events), []).length() > lifecycle.length()86  let stillWorks = Client.getText(&catalog.client, "items")87  Loopback.stop(&server)8889  let invalid = Factory.build([90      Builder.defaults().withTimeout(-3),91      Builder.named("bad").withBaseAddress("relative/").withHandlerLifetime(-7).withTransport(Transport.defaults().withMaxConnectionsPerServer(0))92    ])93  let problems = match invalid {94    case Ok(_) => []95    case Err(found) => found.problems96  }97  let checks = Test.suite("Factory", &[98      Test.equals("a typed client wraps a named client", &items, &Ok("tea,cake")),99      Test.equals("defaults apply first, then a name's builders in order, then its rearrangement", &textOf(&traced), &"shop/1>second>first>default"),100      Test.that("a named pipeline keeps its connections between clients", match sameHandler {101          case Some(stats) => stats.reused >= 1 && stats.opened == 1102          case None => false103        }),104      Test.equals("an unconfigured name gets the defaults", &(unnamed.name, unnamed.defaultHeaders, unnamed.baseAddress), &("", [("user-agent", "shop/1")], None)),105      Test.equals("a primary handler of the program's own is used", &stubbed, &Ok("pong")),106      Test.equals("a stub has no connection counters", &Factory.statistics(&factory, "stubbed"), &None),107      Test.equals("the names configured on their own are listed", &Factory.names(&factory), &["catalog", "rotating", "stubbed", "tuned"]),108      Test.equals("a client of a pipeline that expired still answers", &(first, Result.map(second, |r: Response.Response| r.status.code)), &(Ok("tea,cake"), Ok(200))),109      Test.that("a pipeline's first transport opened its connection", match firstStats {110          case Some(stats) => stats.opened == 1111          case None => false112        }),113      Test.equals("an expired pipeline waits out its grace before closing", &pendingAfterRotation, &1),114      Test.that("pipelines were created, expired, and closed in turn", lifecycle == [("catalog", 1, Events.Created), ("", 2, Events.Created), ("stubbed", 3, Events.Created), ("rotating", 4, Events.Created), ("rotating", 5, Events.Created), ("rotating", 4, Events.Expired), ("rotating", 6, Events.Created), ("rotating", 5, Events.Expired), ("rotating", 4, Events.Closed)]),115      Test.that("disposing closes every pipeline and says so", afterDispose),116      Test.equals("a client made before disposing still answers", &stillWorks, &Ok("tea,cake")),117      Test.equals("every problem of every name is reported at once", &problems, &[118          "client 'bad': baseAddress must be an absolute http or https address: relative/",119          "client 'bad': timeout must be positive or INFINITE",120          "client 'bad': handlerLifetime must be at least 0 ms or INFINITE",121          "client 'bad': maxConnectionsPerServer must be at least 1",122          "client '(defaults)': timeout must be positive or INFINITE"123        ]),124      Test.that("the problems are explained in one sentence", Factory.explain(&Factory.Invalid{problems: ["a", "b"]}) == "The client factory is invalid: a; b")125    ])126  let ran = Test.run(&checks)127  for failure in Test.failuresOf(&ran) {128    let _reported = Io.writeErrorLine(failure)129  }130  Test.report(&ran)131}132