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

Uri.pudu

Pudu219 lines8.3 KB

GitHub ↗
1/** @Domain.Uri.Module — addresses split, resolved, and reduced to endpoints */2module PuduLangHttpClient.Domain.Uri34import Std.Char as Char5import Std.Math as Math6import Std.Option as Option7import Std.Text as Text8import PuduLangHttpClient.Constants.Defaults as Defaults910/** @Domain.Uri.Parts — the five components of an address, absent ones kept apart from empty ones */11export type Parts = { scheme: Option[Str], authority: Option[Str], path: Str, query: Option[Str], fragment: Option[Str] }1213/** @Domain.Uri.Endpoint — where an absolute http or https address connects */14export type Endpoint = { scheme: Str, host: Str, port: Int, secure: Bool }1516/// The schemes a request may use, and whether each is secured.17const SCHEMES: Map[Str, Bool] = mapOf([("http", false), ("https", true)])1819/// The hexadecimal digits a percent escape is written with.20const HEX: Str = "0123456789ABCDEF"2122/// The address split into its components without decoding any of them.23export fn split(text: Str) -> Parts {24  let (scheme, afterScheme) = schemeOf(text)25  let (named, afterAuthority) = if afterScheme.startsWith("//") {26    let rest = afterScheme.drop(2)27    let end = firstOf(rest, &["/", "?", "#"])28    (Some(rest.slice(0, end)), rest.drop(end))29  } else { (None, afterScheme) }30  let fragmentAt = Text.find(afterAuthority, "#")31  let beforeFragment = match fragmentAt {32    case Some(at) => afterAuthority.slice(0, at)33    case None => afterAuthority34  }35  let fragment = match fragmentAt {36    case Some(at) => Some(afterAuthority.drop(at + 1))37    case None => None38  }39  match Text.find(beforeFragment, "?") {40    case Some(at) => Parts{scheme: scheme, authority: named, path: beforeFragment.slice(0, at), query: Some(beforeFragment.drop(at + 1)), fragment: fragment}41    case None => Parts{scheme: scheme, authority: named, path: beforeFragment, query: None, fragment: fragment}42  }43}4445/// The address the components write.46export fn join(parts: &Parts) -> Str {47  var text = ""48  if let Some(scheme) = parts.scheme { text = text + scheme + ":" }49  if let Some(named) = parts.authority { text = text + "//" + named }50  text = text + parts.path51  if let Some(query) = parts.query { text = text + "?" + query }52  if let Some(fragment) = parts.fragment { text = text + "#" + fragment }53  text54}5556/// Whether the address names a scheme.57export fn isAbsolute(text: Str) -> Bool { Option.isSome(&split(text).scheme) }5859/// The reference resolved against the base address, with dot segments removed.60export fn resolve(base: Str, reference: Str) -> Str {61  let from = split(base)62  let given = split(reference)63  if Option.isSome(&given.scheme) {64    return join(&Parts{..given, path: removeDotSegments(given.path)})65  }66  let resolved = if Option.isSome(&given.authority) {67    Parts{..given, scheme: from.scheme, path: removeDotSegments(given.path)}68  } else if given.path.isEmpty() {69    Parts{..from, query: if Option.isSome(&given.query) { given.query } else { from.query }, fragment: given.fragment}70  } else if given.path.startsWith("/") {71    Parts{..from, path: removeDotSegments(given.path), query: given.query, fragment: given.fragment}72  } else {73    Parts{..from, path: removeDotSegments(merge(&from, given.path)), query: given.query, fragment: given.fragment}74  }75  join(&resolved)76}7778/// The path with every `.` and `..` segment applied.79export fn removeDotSegments(path: Str) -> Str {80  var input = path81  var output: Array[Str] = []82  while !input.isEmpty() {83    if input.startsWith("../") {84      input = input.drop(3)85    } else if input.startsWith("./") {86      input = input.drop(2)87    } else if input.startsWith("/./") {88      input = input.drop(2)89    } else if input == "/." {90      input = "/"91    } else if input.startsWith("/../") {92      input = input.drop(3)93      output = withoutLast(&output)94    } else if input == "/.." {95      input = "/"96      output = withoutLast(&output)97    } else if input == "." || input == ".." {98      input = ""99    } else {100      let next = match Text.find(input.drop(1), "/") {101        case Some(at) => at + 1102        case None => input.length()103      }104      output = output.push(input.slice(0, next))105      input = input.drop(next)106    }107  }108  output.join("")109}110111/// The endpoint an absolute http or https address connects to, or `None` for any other address.112export fn endpointOf(text: Str) -> Option[Endpoint] {113  let parts = split(text)114  let scheme = parts.scheme ?.toLower()115  let secure = SCHEMES.get(scheme) ?116  let named = parts.authority ?117  let hostAndPort = match Text.find(named, "@") {118    case Some(at) => named.drop(at + 1)119    case None => named120  }121  let fallback = if secure { Defaults.HTTPS_PORT } else { Defaults.HTTP_PORT }122  let (host, port) = hostPort(hostAndPort, fallback) ?123  if host.isEmpty() { None } else { Some(Endpoint{scheme: scheme, host: host.toLower(), port: port, secure: secure}) }124}125126/// The scheme, host, and port of an endpoint, as an origin.127export fn origin(endpoint: &Endpoint) -> Str { endpoint.scheme + "://" + authority(endpoint) }128129/// The host and, when it is not the scheme's own, the port: the value of a `host` header.130export fn authority(endpoint: &Endpoint) -> Str {131  let named = if endpoint.host.contains(":") { "[" + endpoint.host + "]" } else { endpoint.host }132  let fallback = if endpoint.secure { Defaults.HTTPS_PORT } else { Defaults.HTTP_PORT }133  if endpoint.port == fallback { named } else { named + ":" + show(endpoint.port) }134}135136/// The path and query of an address, as an origin-form request target.137export fn target(text: Str) -> Str {138  let parts = split(text)139  let path = if parts.path.isEmpty() { "/" } else { parts.path }140  match parts.query {141    case Some(query) => path + "?" + query142    case None => path143  }144}145146/// The address without its fragment, which is never sent.147export fn withoutFragment(text: Str) -> Str { join(&Parts{..split(text), fragment: None}) }148149/// The address with every character a request line cannot carry percent-encoded as UTF-8.150export fn escape(text: Str) -> Str {151  var out = ""152  for character in text.chars() {153    let code = character.code()154    if code > 32 && code < 127 {155      out = out + character.toText()156    } else {157      for octet in character.toText().toBytes().toArray() {158        let value = match convertInteger[Int](octet) {159          case Some(held) => held160          case None => 0161        }162        out = out + "%" + HEX.slice(value / 16, value / 16 + 1) + HEX.slice(value % 16, value % 16 + 1)163      }164    }165  }166  out167}168169/// The address's scheme, when it names a valid one, and what follows its colon.170fn schemeOf(text: Str) -> (Option[Str], Str) {171  let end = firstOf(text, &[":", "/", "?", "#"])172  if end == 0 || text.slice(end, end + 1) != ":" { return (None, text) }173  let candidate = text.slice(0, end)174  let first = candidate.chars()[0]175  if Char.isLetter(first) && Char.isAscii(first) && Text.allChars(candidate, |c: Char| Char.isAlphanumeric(c) || c == '+' || c == '-' || c == '.') {176    (Some(candidate), text.drop(end + 1))177  } else { (None, text) }178}179180/// The position of the earliest of the markers in the text, or its length when none occurs.181fn firstOf(text: Str, markers: &Array[Str]) -> Int {182  var earliest = text.length()183  for marker in markers {184    if let Some(at) = Text.find(text, marker) {185      earliest = Math.min(earliest, at)186    }187  }188  earliest189}190191/// The base path up to its last segment, followed by the relative path.192fn merge(base: &Parts, path: Str) -> Str {193  if Option.isSome(&base.authority) && base.path.isEmpty() { return "/" + path }194  Text.breakOnEnd(base.path, "/")[0] + path195}196197/// The segments without the last one.198fn withoutLast(segments: &Array[Str]) -> Array[Str] {199  if segments.isEmpty() { *segments } else { segments.slice(0, segments.length() - 1) }200}201202/// The host and port an authority names, with brackets removed from an IPv6 literal.203fn hostPort(text: Str, fallback: Int) -> Option[(Str, Int)] {204  if text.startsWith("[") {205    let close = Text.find(text, "]") ?206    let rest = text.drop(close + 1)207    let port = if rest.isEmpty() { fallback } else { portOf(Text.stripPrefix(rest, ":")) ? }208    return Some((text.slice(1, close), port))209  }210  let (front, back) = Text.breakOnEnd(text, ":")211  if front.isEmpty() { Some((text, fallback)) } else { Some((Text.dropEnd(front, 1), portOf(back) ?)) }212}213214/// A port number from 1 to 65535, or `None`.215fn portOf(text: Str) -> Option[Int] {216  let number = Text.countOf(text) ?217  if number < 1 || number > 65535 { None } else { Some(number) }218}219