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

Server.pudu

Pudu164 lines7.9 KB

GitHub ↗
1/** @Test.Support.Placeholder.Server.Module — the placeholder REST service on the loopback interface */2module Support.Placeholder.Server34import Std.Concurrent as Concurrent5import Std.Http as Http6import Std.Http.Server.Reply as Reply7import Std.Http.Server.Route as Route8import Std.Http.Server as Server9import Std.Json as Json10import Std.Option as Option11import Std.Result as Result12import Std.Sync as Sync13import Support.Placeholder.Data as Data14import Support.Placeholder.Store as Store1516/** @Test.Support.Placeholder.Server.Placeholder — the running service, its records, and its faults */17export type Placeholder = { running: Server.Running, base: Str, store: Store.Store, faults: Sync.Cell[Int], lock: Sync.Mutex, handled: Sync.Counter }1819/// The query names that steer a listing rather than filter it.20const CONTROLS: Set[Str] = #{"_start", "_limit", "_sort", "_order", "_delay"}2122/// Starts the service with the seed records.23export fn start() -> Placeholder {24  let running = match Server.start("127.0.0.1", 0) {25    case Ok(found) => found26    case Err(problem) => panic("the placeholder service did not start: " + show(problem))27  }28  let made = Placeholder{running: running, base: "http://127.0.0.1:" + show(Server.portOf(&running)), store: Store.seeded(), faults: Sync.cell(0), lock: Sync.mutex(), handled: Sync.counter(0)}29  let server = Server.Server{..Server.server(&Route.routing(&routes(&made))), workers: 32, queueCapacity: 256}30  let _serving = Concurrent.start(fn() -> () { let _ran = Server.run(&server, &running, 0) })31  made32}3334/// Stops the service.35export fn stop(service: &Placeholder) -> () { let _stopped = Server.stop(&service.running) }3637/// Makes the next `count` requests answer 503 with `retry-after: 0`.38export fn failNext(service: &Placeholder, count: Int) -> () { let _set = Sync.set(&service.faults, count) }3940/// How many requests the service has handled.41export fn handled(service: &Placeholder) -> Int { Result.unwrapOr(Sync.count(&service.handled), 0) }4243/// Every route: each verb on each resource, and each nested listing.44fn routes(service: &Placeholder) -> Array[Route.Route] {45  var made: Array[Route.Route] = []46  for resource in Data.RESOURCES {47    let collection = "/" + resource48    let member = collection + "/:id"49    made = made50      .push(Route.get(collection, guarded(service, |request: Route.Request| listing(service, resource, &request, []))))51      .push(Route.get(member, guarded(service, |request: Route.Request| reading(service, resource, &request))))52      .push(Route.post(collection, guarded(service, |request: Route.Request| creating(service, resource, &request))))53      .push(Route.put(member, guarded(service, |request: Route.Request| writing(service, resource, &request, false))))54      .push(Route.patch(member, guarded(service, |request: Route.Request| writing(service, resource, &request, true))))55      .push(Route.delete(member, guarded(service, |request: Route.Request| deleting(service, resource, &request))))56  }57  for relation in Data.RELATIONS {58    made = made.push(Route.get("/" + relation.parent + "/:id/" + relation.child, guarded(service, fn(request: Route.Request) -> Http.Response {59            match idParam(&request) {60              case Some(id) => if Store.find(&service.store, relation.parent, id) == None { notFound() } else { listing(service, relation.child, &request, [(relation.key, show(id))]) }61              case None => notFound()62            }63          })))64  }65  made66}6768/// A handler that counts the request, answers an injected fault while any remain, and waits the69/// `_delay` the query asks for before handling it.70fn guarded(service: &Placeholder, handle: Route.Handler) -> Route.Handler {71  let held = *service72  fn(request: Route.Request) -> Http.Response {73    let _counted = Sync.increment(&held.handled, 1)74    let faulted = Result.unwrapOr(Sync.withLock(&held.lock, fn() -> Bool {75          let left = Result.unwrapOr(Sync.get(&held.faults), 0)76          if left > 0 { let _taken = Sync.set(&held.faults, left - 1) }77          left > 078        }), false)79    if faulted { return Http.Response{..json(503, &Json.object(&[("error", Json.Text("unavailable"))])), headers: [("content-type", "application/json; charset=utf-8"), ("retry-after", "0")]} }80    if let Some(millis) = Option.andThen(request.query.get("_delay"), |text: Str| text.toInt()) { let _waited = Concurrent.sleep(millis) }81    handle(request)82  }83}8485/// The records of a resource the query and the extra filters select, with their total count.86fn listing(service: &Placeholder, resource: Str, request: &Route.Request, extra: Array[(Str, Str)]) -> Http.Response {87  let filters = request.query.entries().filter(|pair: (Str, Str)| !(pair[0] in CONTROLS)).concat(extra)88  let query = Store.Query {89    filters: filters,90    start: Option.unwrapOr(Option.andThen(request.query.get("_start"), |text: Str| text.toInt()), 0),91    limit: Option.andThen(request.query.get("_limit"), |text: Str| text.toInt()),92    sort: request.query.get("_sort"),93    descending: request.query.get("_order") == Some("desc")94  }95  let (page, total) = Store.list(&service.store, resource, &query)96  Http.Response{..json(200, &Json.list(&page)), headers: [("content-type", "application/json; charset=utf-8"), ("x-total-count", show(total))]}97}9899/// One record, or 404.100fn reading(service: &Placeholder, resource: Str, request: &Route.Request) -> Http.Response {101  match Option.andThen(idParam(request), |id: Int| Store.find(&service.store, resource, id)) {102    case Some(record) => json(200, &record)103    case None => notFound()104  }105}106107/// A new record under the next id, answered 201 with its location; a body that is not a JSON object108/// is 400.109fn creating(service: &Placeholder, resource: Str, request: &Route.Request) -> Http.Response {110  match objectBody(request) {111    case Ok(fields) => {112      let made = Store.create(&service.store, resource, &fields)113      Http.Response{..json(201, &made), headers: [("content-type", "application/json; charset=utf-8"), ("location", service.base + "/" + resource + "/" + show(Store.idOf(&made)))]}114    }115    case Err(response) => response116  }117}118119/// A record replaced or patched, or 404 when it is absent, or 400 for a body that is not a JSON object.120fn writing(service: &Placeholder, resource: Str, request: &Route.Request, merging: Bool) -> Http.Response {121  let id = match idParam(request) {122    case Some(found) => found123    case None => { return notFound() }124  }125  match objectBody(request) {126    case Ok(fields) => {127      let written = if merging { Store.patch(&service.store, resource, id, &fields) } else { Store.replace(&service.store, resource, id, &fields) }128      match written {129        case Some(record) => json(200, &record)130        case None => notFound()131      }132    }133    case Err(response) => response134  }135}136137/// An empty object once the record is removed, or 404 when it is absent.138fn deleting(service: &Placeholder, resource: Str, request: &Route.Request) -> Http.Response {139  match idParam(request) {140    case Some(id) => if Store.remove(&service.store, resource, id) { json(200, &Json.object(&[])) } else { notFound() }141    case None => notFound()142  }143}144145/// The request's body as a JSON object, or the 400 response refusing it.146fn objectBody(request: &Route.Request) -> Result[Json.Json, Http.Response] {147  match Json.decode(Route.body(request)) {148    case Ok(value) => if Json.asObject(&value) == None { Err(badRequest("the body must be a JSON object")) } else { Ok(value) }149    case Err(problem) => Err(badRequest(Json.explain(&problem)))150  }151}152153/// The `id` route parameter as a number.154fn idParam(request: &Route.Request) -> Option[Int] { Option.andThen(Route.param(request, "id"), |text: Str| text.toInt()) }155156/// A JSON response.157fn json(code: Int, value: &Json.Json) -> Http.Response { Reply.json(code, Json.encode(value)) }158159/// The empty object the service answers for a missing record.160fn notFound() -> Http.Response { json(404, &Json.object(&[])) }161162/// A refusal naming what was wrong with the request.163fn badRequest(reason: Str) -> Http.Response { json(400, &Json.object(&[("error", Json.Text(reason))])) }164