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

Cookie.pudu

Pudu151 lines5.8 KB

GitHub ↗
1/** @Domain.Cookie.Module — cookies read from responses and matched to requests */2module PuduLangHttpClient.Domain.Cookie34import Std.Json as Json5import Std.Math as Math6import Std.Option as Option7import Std.Text as Text8import PuduLangHttpClient.Domain.Date as Date910/** @Domain.Cookie.Stored — one cookie as a jar keeps it */11export type Stored = {12  name: Str,13  value: Str,14  domain: Str,15  hostOnly: Bool,16  path: Str,17  secure: Bool,18  httpOnly: Bool,19  expires: Option[Int],20  created: Int21} derives Json.Encode, Json.Decode2223/// How much one character of path outweighs creation time when cookies are ordered.24const PATH_WEIGHT: Int = 10000000000000002526/// The cookie a `set-cookie` value sets for a response from `host` to a request for `path`, at27/// `now` milliseconds since the Unix epoch; `None` when it is malformed or names a domain the host28/// does not belong to. `Max-Age` takes precedence over `Expires`.29export fn parse(header: Str, host: Str, path: Str, now: Int) -> Option[Stored] {30  let pieces = header.split(";")31  let pair = pieces[0]32  let equals = pair.indexOf("=")33  if equals < 1 { return None }34  let name = pair.slice(0, equals).trim()35  if name.isEmpty() { return None }36  var cookie = Stored {37    name: name,38    value: unquoted(pair.drop(equals + 1).trim()),39    domain: host.toLower(),40    hostOnly: true,41    path: defaultPath(path),42    secure: false,43    httpOnly: false,44    expires: None,45    created: now46  }47  var maxAge: Option[Int] = None48  var expires: Option[Int] = None49  for piece in pieces.slice(1, pieces.length()) {50    let (named, given) = Text.breakOn(piece, "=")51    let key = named.trim().toLower()52    let value = Text.stripPrefix(given, "=").trim()53    if key == "domain" && !value.isEmpty() {54      let domain = Text.stripPrefix(value, ".").toLower()55      if !domainMatches(host.toLower(), domain) { return None }56      cookie = Stored{..cookie, domain: domain, hostOnly: false}57    } else if key == "path" && value.startsWith("/") {58      cookie = Stored{..cookie, path: value}59    } else if key == "secure" {60      cookie = Stored{..cookie, secure: true}61    } else if key == "httponly" {62      cookie = Stored{..cookie, httpOnly: true}63    } else if key == "max-age" {64      if let Some(seconds) = value.toInt() { maxAge = Some(Math.max(seconds, 0) * 1000) }65    } else if key == "expires" {66      expires = Date.parse(value)67    }68  }69  let ending = match maxAge {70    case Some(millis) => Some(now + millis)71    case None => expires72  }73  Some(Stored{..cookie, expires: ending})74}7576/// Whether a host belongs to a cookie domain: it is the domain, or a name ending in `.` and the77/// domain when the host is not an IP address.78export fn domainMatches(host: Str, domain: Str) -> Bool {79  host == domain || (host.endsWith("." + domain) && !isAddress(host))80}8182/// Whether a request path lies under a cookie path.83export fn pathMatches(requestPath: Str, cookiePath: Str) -> Bool {84  requestPath == cookiePath85    || (requestPath.startsWith(cookiePath) && (cookiePath.endsWith("/") || requestPath.drop(cookiePath.length()).startsWith("/")))86}8788/// Whether a cookie has expired at `now`.89export fn expired(cookie: &Stored, now: Int) -> Bool {90  match cookie.expires {91    case Some(at) => at <= now92    case None => false93  }94}9596/// Whether a cookie is sent with a request to `host` for `path` over a connection that is or is not97/// secured, at `now`.98export fn applies(cookie: &Stored, host: Str, path: Str, secure: Bool, now: Int) -> Bool {99  let lower = host.toLower()100  let hostFits = if cookie.hostOnly { lower == cookie.domain } else { domainMatches(lower, cookie.domain) }101  hostFits && pathMatches(if path.isEmpty() { "/" } else { path }, cookie.path) && (secure || !cookie.secure) && !expired(cookie, now)102}103104/// The jar with a cookie stored: it replaces the one with the same name, domain, and path, keeping105/// that one's creation time, and an expired cookie only removes.106export fn store(jar: &Array[Stored], cookie: &Stored, now: Int) -> Array[Stored] {107  let previous = jar.filter(|held: Stored| same(&held, cookie))108  let others = jar.filter(|held: Stored| !same(&held, cookie) && !expired(&held, now))109  if expired(cookie, now) { return others }110  let created = match previous {111    case [first, ..] => first.created112    case _ => cookie.created113  }114  others.push(Stored{..*cookie, created: created})115}116117/// The `cookie` header value of the cookies, longer paths first and older cookies first among equals.118export fn render(cookies: &Array[Stored]) -> Str {119  let ordered = cookies.sortBy(|left: Stored, right: Stored| rank(&left) < rank(&right))120  ordered.map(|cookie: Stored| cookie.name + "=" + cookie.value).join("; ")121}122123/// The path a cookie defaults to: the request path up to its last segment, or `/`.124export fn defaultPath(path: Str) -> Str {125  if !path.startsWith("/") { return "/" }126  let (front, _) = Text.breakOnEnd(path, "/")127  if front.length() <= 1 { "/" } else { Text.dropEnd(front, 1) }128}129130/// The order a cookie is sent in: longer paths first, then older cookies; a path character outweighs131/// any creation time in milliseconds.132fn rank(cookie: &Stored) -> Int { cookie.created - cookie.path.length() * PATH_WEIGHT }133134/// Whether two cookies have the same name, domain, and path.135fn same(left: &Stored, right: &Stored) -> Bool {136  left.name == right.name && left.domain == right.domain && left.path == right.path137}138139/// Whether a host is an IP address rather than a name.140fn isAddress(host: Str) -> Bool {141  host.contains(":") || Text.allChars(host, |c: Char| c == '.' || (c >= '0' && c <= '9'))142}143144/// A value with one pair of surrounding double quotes removed.145fn unquoted(value: Str) -> Str {146  if value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"") { value.slice(1, value.length() - 1) } else { value }147}148149/// Whether the cookie lasts beyond the session that received it.150export fn isPersistent(cookie: &Stored) -> Bool { Option.isSome(&cookie.expires) }151