
Uri.pudu
Pudu219 lines8.3 KB
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 }151617const SCHEMES: Map[Str, Bool] = mapOf([("http", false), ("https", true)])181920const HEX: Str = "0123456789ABCDEF"212223export 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}444546export 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}555657export fn isAbsolute(text: Str) -> Bool { Option.isSome(&split(text).scheme) }585960export 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}777879export 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}110111112export 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}125126127export fn origin(endpoint: &Endpoint) -> Str { endpoint.scheme + "://" + authority(endpoint) }128129130export 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}135136137export 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}145146147export fn withoutFragment(text: Str) -> Str { join(&Parts{..split(text), fragment: None}) }148149150export 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}168169170fn 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}179180181fn 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}190191192fn merge(base: &Parts, path: Str) -> Str {193 if Option.isSome(&base.authority) && base.path.isEmpty() { return "/" + path }194 Text.breakOnEnd(base.path, "/")[0] + path195}196197198fn withoutLast(segments: &Array[Str]) -> Array[Str] {199 if segments.isEmpty() { *segments } else { segments.slice(0, segments.length() - 1) }200}201202203fn 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}213214215fn portOf(text: Str) -> Option[Int] {216 let number = Text.countOf(text) ?217 if number < 1 || number > 65535 { None } else { Some(number) }218}219