From 2ee2dd039767cc760a6a196edb6fc75bc420bf53 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Fri, 22 Sep 2023 04:19:40 +0000 Subject: [PATCH 1/6] feat(stdlib): URI Parsing module --- compiler/test/stdlib/uri.test.gr | 422 +++++++++++ stdlib/uri.gr | 1189 ++++++++++++++++++++++++++++++ stdlib/uri.md | 644 ++++++++++++++++ 3 files changed, 2255 insertions(+) create mode 100644 compiler/test/stdlib/uri.test.gr create mode 100644 stdlib/uri.gr create mode 100644 stdlib/uri.md diff --git a/compiler/test/stdlib/uri.test.gr b/compiler/test/stdlib/uri.test.gr new file mode 100644 index 0000000000..20c1b54d1e --- /dev/null +++ b/compiler/test/stdlib/uri.test.gr @@ -0,0 +1,422 @@ +module UriTest + +include "uri" +include "result" + +record Uri { + scheme: Option, + userinfo: Option, + host: Option, + port: Option, + path: String, + query: Option, + fragment: Option, + string: String, +} + +let default = { + scheme: None, + userinfo: None, + host: None, + port: None, + path: "", + query: None, + fragment: None, + string: "", +} + +let testValid = (uriString, expected) => { + let uri = Result.unwrap(Uri.parse(uriString)) + assert Uri.scheme(uri) == expected.scheme + assert Uri.userinfo(uri) == expected.userinfo + assert Uri.host(uri) == expected.host + assert Uri.port(uri) == expected.port + assert Uri.path(uri) == expected.path + assert Uri.query(uri) == expected.query + assert Uri.fragment(uri) == expected.fragment + assert Uri.toString(uri) == + (if (expected.string == "") uriString else expected.string) +} + +testValid( + "https://grain-lang.org", + { ...default, scheme: Some("https"), host: Some("grain-lang.org") } +) +testValid( + "http://user:password@www.domain.com:80/path/inner?q1=v1&q2=v2#frag", + { + ...default, + scheme: Some("http"), + userinfo: Some("user:password"), + host: Some("www.domain.com"), + port: Some(80), + path: "/path/inner", + query: Some("q1=v1&q2=v2"), + fragment: Some("frag"), + } +) +testValid( + "http://www.domain.com:80/path?q1=v1/?q2=v2#frag/?", + { + ...default, + scheme: Some("http"), + host: Some("www.domain.com"), + port: Some(80), + path: "/path", + query: Some("q1=v1/?q2=v2"), + fragment: Some("frag/?"), + } +) +testValid( + "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255://?1%1f@:/?#/?a", + { + ...default, + scheme: Some("a12+3-4.5"), + userinfo: Some( + "1a-._~%1f%Fa!$&'()*+,;=:" + ), // Do not turn %1f into %1F in userinfo + host: Some("0.99.100.255"), + path: "//", + query: Some("1%1F@:/?"), + fragment: Some("/?a"), + string: "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255//?1%1F@:/?#/?a", + } +) +testValid( + "mailto:me@email.com", + { ...default, scheme: Some("mailto"), path: "me@email.com" } +) +testValid( + "urn:hello:world", + { ...default, scheme: Some("urn"), path: "hello:world" } +) +testValid( + "tel:+1-888-888-8888", + { ...default, scheme: Some("tel"), path: "+1-888-888-8888" } +) +testValid("scheme:/", { ...default, scheme: Some("scheme"), path: "/" }) +testValid("scheme://", { ...default, scheme: Some("scheme"), host: Some("") }) +testValid( + "scheme:///", + { ...default, scheme: Some("scheme"), host: Some(""), path: "/" } +) +testValid( + "ScHeMe://HoSt%2a.COM/Path", + { + ...default, + scheme: Some("scheme"), + host: Some("host%2A.com"), + path: "/Path", + string: "scheme://host%2A.com/Path", + } +) +testValid( + "scheme://%41:%61@%48%65%6c%6C%6f/%25%7e%68%69", + { + ...default, + scheme: Some("scheme"), + userinfo: Some("%41:%61"), + host: Some("hello"), + path: "/%25~hi", + string: "scheme://%41:%61@hello/%25~hi", + } +) +testValid("scheme:", { ...default, scheme: Some("scheme") }) +testValid( + "http://:80", + { ...default, scheme: Some("http"), host: Some(""), port: Some(80) } +) +testValid("google.com", { ...default, path: "google.com" }) +testValid("//google.com/", { ...default, host: Some("google.com"), path: "/" }) +testValid("", default) +testValid( + "google.com:80", + { ...default, scheme: Some("google.com"), path: "80" } +) +testValid(".././..", { ...default, path: ".././.." }) +testValid( + "http://?#", + { + ...default, + scheme: Some("http"), + host: Some(""), + path: "", + query: Some(""), + fragment: Some(""), + } +) + +assert Uri.parse("example://a/b/c/%7Bfoo%7D") == + Uri.parse("eXAMPLE://a/./b/../b/%63/%7bfoo%7d") + +let testInvalid = uriString => { + assert Uri.parse(uriString) == Err(Uri.ParseError) +} + +testInvalid("scheme://a.com:a") +testInvalid("1http://site.com") +testInvalid(":") +testInvalid("http://%2") + +let testHostValid = (host, parsed="") => { + let parsed = if (parsed == "") host else parsed + testValid( + "scheme://" ++ host, + { + ...default, + scheme: Some("scheme"), + host: Some(parsed), + string: "scheme://" ++ parsed, + } + ) +} + +// host tests +testHostValid("0.99.100.255") +testHostValid("0.0.0.205") +testHostValid("0.0") +testHostValid("[0000:0:0:ffff:FFFF:0:0:0]", parsed="[0000:0:0:ffff:ffff:0:0:0]") +testHostValid( + "[::0:0:ffff:FFFF:0:0.0.0.0]", + parsed="[::0:0:ffff:ffff:0:0.0.0.0]" +) +testHostValid("[0:0::0:0.0.0.0]") +testHostValid("[::0]") +testHostValid("[::]") +testHostValid("[v1a.a:!]") + +let testHostInvalid = host => { + testInvalid("scheme://" ++ host) +} + +testHostInvalid("::0") +testHostInvalid("[::0") +testHostInvalid("[v.a]") +testHostInvalid("[v1.]") +testHostInvalid("[0.0.0.0]") +testHostInvalid("[0:0:0:0:0:0:0]") +testHostInvalid("[0:0:0:0:0:0:0:0:0]") +testHostInvalid("[::00000]") +testHostInvalid("0.0.0.0.0") + +let testPath = (path, expected) => { + testValid( + "scheme://domain" ++ path, + { + ...default, + scheme: Some("scheme"), + host: Some("domain"), + path: expected, + string: "scheme://domain" ++ expected, + } + ) +} + +testPath("", "") +testPath("/.", "/") +testPath("/./", "/") +testPath("/..", "/") +testPath("/../", "/") +testPath("/../a", "/a") +testPath("/a/", "/a/") +testPath("/../..", "/") +testPath("/../../", "/") +testPath("/../../path", "/path") +testPath("/p1/../p2/../path", "/path") +testPath("/p1/././p2/..", "/p1/") +testPath("/p1/././p2/.", "/p1/p2/") +// %2e = . +testPath("/p1/%2e/%2e/p2/%2e%2e", "/p1/") +testPath("//", "//") + +// resolveReference + +let testResolve = (base, ref, expected) => { + let base = Result.unwrap(Uri.parse(base)) + let ref = Result.unwrap(Uri.parse(ref)) + let uri = Uri.resolveReference(base, ref) + let expected = Result.unwrap(Uri.parse(expected)) + assert uri == Ok(expected) +} + +testResolve("a://a.com?a#a", "b://b.com?b#b", "b://b.com?b#b") +testResolve("a://a.com?a#a", "//b.com", "a://b.com") +testResolve("a://a.com/a?a#a", "?b#b", "a://a.com/a?b#b") +testResolve("a://a.com/a?a#a", "#b", "a://a.com/a?a#b") +testResolve("a://a.com/a?a#a", "/b", "a://a.com/b") +testResolve("a://a.com/a?a#a", "/b/", "a://a.com/b/") +testResolve("a://a.com?a#a", "b", "a://a.com/b") +testResolve("a://a.com?a#a", "b/", "a://a.com/b/") +testResolve("a://a.com/a/a?a#a", "b", "a://a.com/a/b") +testResolve("a://a.com/a/a?a#a", "./b", "a://a.com/a/b") +testResolve("a://a/b/c/d", "..", "a://a/b/") +testResolve("a://a/b/c/d", ".", "a://a/b/c/") +testResolve("a://a/b/c/d/", ".", "a://a/b/c/d/") +testResolve("a://a/b/c/d", "./", "a://a/b/c/") +testResolve("a://a/b/c", "../../../d", "a://a/d") +testResolve("s://d.com", "#frag", "s://d.com#frag") +testResolve("a://a/b", ".a", "a://a/.a") +testResolve("a://a/b", "a.", "a://a/a.") +testResolve("a://a/b", "..a", "a://a/..a") +testResolve("a://a/b", "a..", "a://a/a..") +testResolve("a://a.com?a#a", "", "a://a.com?a") + +// make + +assert Uri.make(scheme=Some("+"), percentEncodeComponents=false) == + Err(Uri.InvalidSchemeError) +assert Uri.make( + userinfo=Some("%"), + host=Some("a"), + percentEncodeComponents=false +) == + Err(Uri.InvalidUserinfoError) +assert Uri.make(host=Some("#"), percentEncodeComponents=false) == + Err(Uri.InvalidHostError) +assert Uri.make(port=Some(-1), host=Some("a"), percentEncodeComponents=false) == + Err(Uri.InvalidPortError) +assert Uri.make(path="%2", percentEncodeComponents=false) == + Err(Uri.InvalidPathError) +assert Uri.make(query=Some("#"), percentEncodeComponents=false) == + Err(Uri.InvalidQueryError) +assert Uri.make(fragment=Some("%"), percentEncodeComponents=false) == + Err(Uri.InvalidFragmentError) +assert Uri.make(userinfo=Some("me")) == Err(Uri.UserinfoWithNoHost) +assert Uri.make(port=Some(80)) == Err(Uri.PortWithNoHost) +assert Result.map( + Uri.toString, + Uri.make( + scheme=Some("https"), + userinfo=Some("me:pw"), + host=Some("grain-lang.org"), + port=Some(80), + path="/docs", + query=Some("abc=def"), + fragment=Some("Uri-make") + ) +) == + Ok("https://me:pw@grain-lang.org:80/docs?abc=def#Uri-make") +assert Result.map( + Uri.toString, + Uri.make( + scheme=Some("HT+1-TP"), + userinfo=Some("me@pw"), + host=Some("G+r/a*in:80"), + port=Some(80), + path="/%20d:o'c#s!", + query=Some("/a?b#c=d:ef"), + fragment=Some("Ur#i-m/ake"), + percentEncodeComponents=true + ) +) == + Ok( + "ht+1-tp://me%40pw@g+r%2Fa*in%3A80:80/%2520d:o'c%23s!?/a?b%23c=d:ef#Ur%23i-m/ake" + ) +assert Result.map( + Uri.toString, + Uri.make( + scheme=Some("http"), + host=Some("[1::1]"), + percentEncodeComponents=true + ) +) == + Ok("http://[1::1]") + +// update + +let orig = Result.unwrap(Uri.make()) +assert Uri.update( + orig, + scheme=Some(Some("+")), + percentEncodeComponents=false +) == + Err(Uri.InvalidSchemeError) +assert Uri.update( + orig, + userinfo=Some(Some("%")), + host=Some(Some("a")), + percentEncodeComponents=false +) == + Err(Uri.InvalidUserinfoError) +assert Uri.update(orig, host=Some(Some("#")), percentEncodeComponents=false) == + Err(Uri.InvalidHostError) +assert Uri.update( + orig, + port=Some(Some(1.1)), + host=Some(Some("a")), + percentEncodeComponents=false +) == + Err(Uri.InvalidPortError) +assert Uri.update(orig, path=Some("%2"), percentEncodeComponents=false) == + Err(Uri.InvalidPathError) +assert Uri.update(orig, query=Some(Some("#")), percentEncodeComponents=false) == + Err(Uri.InvalidQueryError) +assert Uri.update( + orig, + fragment=Some(Some("%")), + percentEncodeComponents=false +) == + Err(Uri.InvalidFragmentError) +assert Uri.update(orig, port=Some(Some(80))) == Err(Uri.PortWithNoHost) + +let orig = Result.unwrap( + Uri.parse("https://me:pw@grain-lang.org:80/docs?k=v#frag") +) +assert Uri.update(orig) == Ok(orig) +assert Result.map( + Uri.toString, + Uri.update( + orig, + scheme=Some(None), + userinfo=Some(None), + host=Some(None), + port=Some(None), + path=Some(""), + query=Some(None), + fragment=Some(None) + ) +) == + Ok("") +assert Result.map( + Uri.toString, + Uri.update( + orig, + scheme=Some(Some("HT+1-TP")), + userinfo=Some(Some("me@pw")), + host=Some(Some("G+r/a*in:80")), + port=Some(Some(80)), + path=Some("/%20d:o'c#s!"), + query=Some(Some("/a?b#c=d:ef")), + fragment=Some(Some("Ur#i-m/ake")), + percentEncodeComponents=true + ) +) == + Ok( + "ht+1-tp://me%40pw@g+r%2Fa*in%3A80:80/%2520d:o'c%23s!?/a?b%23c=d:ef#Ur%23i-m/ake" + ) +assert Result.map( + Uri.toString, + Uri.update(orig, host=Some(Some("[1::1]")), percentEncodeComponents=true) +) == + Ok("https://me:pw@[1::1]:80/docs?k=v#frag") + +let orig = Result.unwrap(Uri.parse("ftp:path")) +assert Uri.update(orig, host=Some(Some("domain"))) == Err(Uri.InvalidPathError) + +// percentEncode/percentDecode + +let encoded = "%F0%9F%8C%BE" +let decoded = "🌾" +assert Uri.percentDecode(encoded) == Ok(decoded) +assert Uri.percentEncode(decoded) == encoded + +assert Uri.percentDecode("%2") == Err(Uri.InvalidPercentEncoding) + +// encodeQuery/decodeQuery + +let encoded = "val=%F0%9F%8C%BE&val%F0%9F%A7%B12=x%3Dy%26a%3Db" +let decoded = [("val", "🌾"), ("val🧱2", "x=y&a=b")] +assert Uri.encodeQuery(decoded) == encoded +assert Uri.decodeQuery(encoded) == Ok(decoded) + +assert Uri.decodeQuery("%2") == Err(Uri.InvalidPercentEncoding) diff --git a/stdlib/uri.gr b/stdlib/uri.gr new file mode 100644 index 0000000000..9cf30b7d02 --- /dev/null +++ b/stdlib/uri.gr @@ -0,0 +1,1189 @@ +/** + * Utilities for working with URIs. + * + * @example include "uri" + * + * @since v0.6.0 + */ + +module Uri + +include "string" +include "char" +include "uint8" +include "number" +include "bytes" +include "buffer" +include "list" +include "array" +include "map" +include "option" +include "result" + +/** + * Represents a parsed RFC 3986 URI. + */ +abstract record Uri { + scheme: Option, + userinfo: Option, + host: Option, + port: Option, + path: String, + query: Option, + fragment: Option, +} + +/** + * Represents an error encountered while parsing a URI. + */ +provide enum ParseError { + ParseError, +} + +/** + * Represents an error encountered while constructing a URI with `make` or `update`. + */ +provide enum ConstructUriError { + UserinfoWithNoHost, + PortWithNoHost, + InvalidSchemeError, + InvalidUserinfoError, + InvalidHostError, + InvalidPortError, + InvalidPathError, + InvalidQueryError, + InvalidFragmentError, +} + +/** + * Represents an error encountered while attempting to resolve a URI reference to a target URI. + */ +provide enum ResolveReferenceError { + BaseNotAbsolute, +} + +/** + * Represents an error encountered while attempting to percent-decode a string. + */ +provide enum PercentDecodingError { + InvalidPercentEncoding, +} + +/** + * Used to specify which characters to percent-encode from a string. + */ +provide enum PercentEncodeSet { + EncodeNonUnreserved, + EncodeUserinfo, + EncodeRegisteredHost, + EncodePath, + EncodePathSegment, + EncodeQueryOrFragment, + EncodeCustom(Char => Bool), +} + +let isDigit = char => { + let code = Char.code(char) + code >= 0x30 && code <= 0x39 +} + +let isAlpha = char => { + let code = Char.code(char) + code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a +} + +let isHexDigit = char => { + let code = Char.code(char) + isDigit(char) || code >= 0x41 && code <= 0x46 || code >= 0x61 && code <= 0x66 +} + +let isUnreservedChar = char => { + isDigit(char) || + isAlpha(char) || + char == '-' || + char == '.' || + char == '_' || + char == '~' +} + +let isSubDelim = char => { + let subDelims = ['!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '='] + List.contains(char, subDelims) +} + +let isPchar = char => { + isUnreservedChar(char) || isSubDelim(char) || char == ':' || char == '@' +} + +let makePercentEncoder = (encodeSet: PercentEncodeSet) => { + let shouldEncodeForNonUnreserved = char => !isUnreservedChar(char) + + let shouldEncodeForUserinfo = char => { + !(isUnreservedChar(char) || isSubDelim(char) || char == ':') + } + + let shouldEncodeForRegisteredHost = char => { + !(isUnreservedChar(char) || isSubDelim(char)) + } + + let shouldEncodeForPath = char => { + !(isPchar(char) || char == '/') + } + + let shouldEncodeForPathSegment = char => { + !isPchar(char) + } + + let shouldEncodeForQueryOrFragment = char => { + !(isPchar(char) || char == '/' || char == '?') + } + + match (encodeSet) { + EncodeNonUnreserved => shouldEncodeForNonUnreserved, + EncodeUserinfo => shouldEncodeForUserinfo, + EncodeRegisteredHost => shouldEncodeForRegisteredHost, + EncodePath => shouldEncodeForPath, + EncodePathSegment => shouldEncodeForPathSegment, + EncodeQueryOrFragment => shouldEncodeForQueryOrFragment, + EncodeCustom(shouldEncodeCharFn) => shouldEncodeCharFn, + } +} + +let charToLower = char => { + let code = Char.code(char) + let newCode = if (code >= 0x41 && code <= 0x5a) code + 0x20 else code + Char.fromCode(newCode) +} + +let charToUpper = char => { + let code = Char.code(char) + let newCode = if (code >= 0x61 && code <= 0x7a) code - 0x20 else code + Char.fromCode(newCode) +} + +let toLower = str => { + let chars = String.explode(str) + let newChars = Array.map(charToLower, chars) + String.implode(newChars) +} + +let charToHexValue = char => { + if (isDigit(char)) { + Char.code(char) - 0x30 + } else { + let char = charToLower(char) + Char.code(char) - 0x60 + 9 + } +} + +let hexValueToChar = val => { + if (val < 10) { + Char.fromCode(val + 0x30) + } else { + Char.fromCode(val + 0x40 - 9) + } +} + +let percentDecodeValid = (str, onlyUnreserved=false) => { + let bytes = String.encode(str, String.UTF8) + let len = Bytes.length(bytes) + let out = Buffer.make(len) + let cAt = i => Char.fromCode(Uint8.toNumber(Bytes.getUint8(i, bytes))) + for (let mut i = 0; i < len; i += 1) { + if (i >= len - 2 || cAt(i) != '%') { + let byte = Bytes.getUint8(i, bytes) + Buffer.addUint8(byte, out) + } else { + let next = cAt(i + 1) + let nextNext = cAt(i + 2) + let pctDecodedVal = charToHexValue(next) * 16 + charToHexValue(nextNext) + if (onlyUnreserved && !isUnreservedChar(Char.fromCode(pctDecodedVal))) { + Buffer.addChar('%', out) + Buffer.addChar(charToUpper(next), out) + Buffer.addChar(charToUpper(nextNext), out) + } else { + Buffer.addUint8(Uint8.fromNumber(pctDecodedVal), out) + } + i += 2 + } + } + Buffer.toString(out) +} + +let isValidPercentEncoding = str => { + let chars = String.explode(str) + let len = Array.length(chars) + for (let mut i = 0; i < len; i += 1) { + if ( + chars[i] == '%' && + (i >= len - 2 || !isHexDigit(chars[i + 1]) || !isHexDigit(chars[i + 2])) + ) { + return false + } + } + return true +} + +// Lowercase all non-percent-encoded alphabetical characters +let normalizeHost = str => { + let str = percentDecodeValid(str, onlyUnreserved=true) + + let chars = String.explode(str) + let rec getChars = (i, acc) => { + if (i < 0) { + acc + } else if (i >= 2 && chars[i - 2] == '%') { + getChars(i - 3, ['%', chars[i - 1], chars[i], ...acc]) + } else { + getChars(i - 1, [charToLower(chars[i]), ...acc]) + } + } + let chars = getChars(String.length(str) - 1, []) + String.implode(Array.fromList(chars)) +} + +// Algorithm following RFC 3986 section 5.2.4 to remove . and .. path segments +let removeDotSegments = path => { + let tail = list => { + match (list) { + [_, ...rest] => rest, + _ => list, + } + } + + let rec traverse = (in, out) => { + if (in == "" || in == "." || in == "..") { + out + } else if (String.startsWith("../", in)) { + traverse(String.slice(3, in), out) + } else if (String.startsWith("./", in)) { + traverse(String.slice(2, in), out) + } else if (String.startsWith("/./", in)) { + traverse(String.slice(2, in), out) + } else if (in == "/.") { + traverse("/", out) + } else if (String.startsWith("/../", in)) { + traverse(String.slice(3, in), tail(out)) + } else if (in == "/..") { + traverse("/", tail(out)) + } else { + let (in, prefix) = if (String.charAt(0, in) == '/') { + (String.slice(1, in), "/") + } else { + (in, "") + } + let (segment, rest) = match (String.indexOf("/", in)) { + Some(i) => (String.slice(0, end=i, in), String.slice(i, in)), + None => (in, ""), + } + traverse(rest, [prefix ++ segment, ...out]) + } + } + let out = traverse(path, []) + List.join("", List.reverse(out)) +} + +/** + * Percent-encodes characters in a string based on the specified `PercentEncodeSet`. + * + * @param str: The string to encode + * @param encodeSet: An indication for which characters to percent-encode. `EncodeNonUnreserved` by default + * @returns A percent-encoding of the given string + * + * @example Uri.percentEncode("h3ll0_.w ?o+rld", encodeSet=Uri.EncodeNonUnreserved) // "h3ll0_.w%20%3Fo%2Brld" + * @example Uri.percentEncode("d+om@i:n.com", encodeSet=Uri.EncodeRegisteredHost) // "d+om%40i%3An.com" + * @example Uri.percentEncode("word", encodeSet=Uri.EncodeCustom(c => c == 'o')) // "w%6Frd" + * + * @since v0.6.0 + */ +provide let percentEncode = (str, encodeSet=EncodeNonUnreserved) => { + let shouldEncode = makePercentEncoder(encodeSet) + let chars = String.explode(str) + let rec getChars = (i, acc) => { + if (i < 0) { + acc + } else { + let c = chars[i] + let acc = if (!shouldEncode(c)) { + [c, ...acc] + } else { + let charStr = Char.toString(c) + let bytes = String.encode(charStr, String.UTF8) + let pctEncodings = List.init(Bytes.length(bytes), i => { + let byte = Uint8.toNumber(Bytes.getUint8(i, bytes)) + let firstHalf = byte >> 4 + let secondHalf = byte & 0x000f + ['%', hexValueToChar(firstHalf), hexValueToChar(secondHalf)] + }) + List.append(List.flatten(pctEncodings), acc) + } + getChars(i - 1, acc) + } + } + let chars = getChars(String.length(str) - 1, []) + String.implode(Array.fromList(chars)) +} + +/** + * Decodes any percent-encoded characters in a string. + * + * @param str: The string to decode + * @returns `Ok(decoded)` containing a the decoded string or `Err(err)` if the decoding failed + * + * @since v0.6.0 + */ +provide let percentDecode = str => { + if (!isValidPercentEncoding(str)) { + Err(InvalidPercentEncoding) + } else { + Ok(percentDecodeValid(str)) + } +} + +/** + * Encodes a list of key-value pairs into an query string. + * + * @param urlVals: A list of key-value pairs + * @returns A query string + * + * @since v0.6.0 + */ +provide let encodeQuery = (urlVals, encodeSet=EncodeNonUnreserved) => { + let parts = List.map(((key, val)) => { + percentEncode(key, encodeSet=encodeSet) ++ + "=" ++ + percentEncode(val, encodeSet=encodeSet) + }, urlVals) + + List.join("&", parts) +} + +/** + * Decodes a query string into a list of pairs. + * + * @param str: A query string + * @returns A list of key-value pairs containing the values of the encoded string + * + * @since v0.6.0 + */ +provide let decodeQuery = str => { + if (!isValidPercentEncoding(str)) { + Err(InvalidPercentEncoding) + } else { + let parts = Array.toList(String.split("&", str)) + Ok( + List.map(part => { + match (String.indexOf("=", part)) { + // Some parts may only have a key, set value to empty string in this case + None => (part, ""), + Some(i) => { + let name = String.slice(0, end=i, part) + let val = String.slice(i + 1, part) + (percentDecodeValid(name), percentDecodeValid(val)) + }, + } + }, parts) + ) + } +} + +module Matchers { + // The functions in this module take the string being parsed and current + // index of the string being examined; if they are able to match with a + // portion of the string starting from that index they return Some(endI) with + // the index they scanned past, or None if they do not match successfully. + + // Helpers + + let charTest = test => (i, str) => { + if (i >= String.length(str) || !test(String.charAt(i, str))) { + None + } else { + Some(i + 1) + } + } + + provide let char = target => charTest(c => c == target) + + provide let chars = targets => charTest(c => List.contains(c, targets)) + + // Akin to regex ? + provide let opt = scan => (i, str) => { + match (scan(i, str)) { + None => Some(i), + Some(i) => Some(i), + } + } + + provide let empty = (i, _) => Some(i) + + // Akin to regex * + provide let star = scan => { + let rec scanStar = (i, str) => { + match (scan(i, str)) { + None => Some(i), + Some(i) => scanStar(i, str), + } + } + scanStar + } + + // Akin to regex + + provide let plus = scan => (i, str) => { + match (scan(i, str)) { + None => None, + Some(i) => star(scan)(i, str), + } + } + + // At most n matches of a pattern (ABNF equivalent: n*pattern) + let rec limit = (n, scan) => (i, str) => { + if (n == 0) { + Some(i) + } else { + match (scan(i, str)) { + None => Some(i), + Some(i) => limit(n - 1, scan)(i, str), + } + } + } + + provide let digit = charTest(isDigit) + + provide let digitInRange = (low, high) => + charTest(char => { + let code = Char.code(char) + let zero = 0x30 + code >= zero + low && code <= zero + high + }) + + provide let alpha = charTest(isAlpha) + + provide let hexDigit = charTest(isHexDigit) + + // Akin to regex | + provide let any = fns => (i, str) => { + List.reduce((acc, fn) => match (acc) { + None => fn(i, str), + x => x, + }, None, fns) + } + + provide let seq = fns => (i, str) => { + List.reduce((acc, fn) => match (acc) { + None => None, + Some(nextI) => fn(nextI, str), + }, Some(i), fns) + } + + provide let string = str => + seq(List.map(char, Array.toList(String.explode(str)))) + + // Exactly N repetitions of a pattern + let nTimes = (n, scan) => seq(List.init(n, (_) => scan)) + + // Grammar rules from Appendix A of RFC 3986 + + provide let pctEncoded = seq([char('%'), hexDigit, hexDigit]) + + provide let subDelims = charTest(isSubDelim) + + provide let unreserved = charTest(isUnreservedChar) + + provide let pchar = any( + [unreserved, pctEncoded, subDelims, chars([':', '@'])] + ) + + provide let scheme = seq( + [alpha, star(any([alpha, digit, chars(['+', '-', '.'])]))] + ) + + provide let userinfo = star( + any([unreserved, pctEncoded, subDelims, char(':')]) + ) + + let decOctet = any( + [ + seq([char('2'), char('5'), digitInRange(0, 5)]), + seq([char('2'), digitInRange(0, 4), digit]), + seq([char('1'), digit, digit]), + seq([digitInRange(1, 9), digit]), + digit, + ] + ) + + let ipv4Address = seq( + [decOctet, char('.'), decOctet, char('.'), decOctet, char('.'), decOctet] + ) + + let h16 = (i, str) => { + match (hexDigit(i, str)) { + None => None, + Some(i) => limit(3, hexDigit)(i, str), + } + } + + let ls32 = any([seq([h16, char(':'), h16]), ipv4Address]) + + let ipv6Address = { + let h16Colon = seq([h16, char(':')]) + let colonH16 = seq([char(':'), h16]) + any( + [ + seq([nTimes(6, h16Colon), ls32]), + seq([string("::"), nTimes(5, h16Colon), ls32]), + seq([opt(h16), string("::"), nTimes(4, h16Colon), ls32]), + seq( + [ + opt(seq([h16, limit(1, colonH16)])), + string("::"), + nTimes(3, h16Colon), + ls32, + ] + ), + seq( + [ + opt(seq([h16, limit(2, colonH16)])), + string("::"), + nTimes(2, h16Colon), + ls32, + ] + ), + seq( + [opt(seq([h16, limit(3, colonH16)])), string("::"), h16Colon, ls32] + ), + seq([opt(seq([h16, limit(4, colonH16)])), string("::"), ls32]), + seq([opt(seq([h16, limit(5, colonH16)])), string("::"), h16]), + seq([opt(seq([h16, limit(6, colonH16)])), string("::")]), + ] + ) + } + + let ipvFuture = seq( + [ + char('v'), + plus(hexDigit), + char('.'), + plus(any([unreserved, subDelims, char(':')])), + ] + ) + + let ipLiteral = seq([char('['), any([ipv6Address, ipvFuture]), char(']')]) + + provide let ipAddress = any([ipLiteral, ipv4Address]) + + let regName = star(any([unreserved, pctEncoded, subDelims])) + + provide let host = any([ipAddress, regName]) + + provide let port = star(digit) + + provide let pathAbempty = star(seq([char('/'), star(pchar)])) + + provide let pathAbsolute = seq( + [char('/'), opt(seq([plus(pchar), pathAbempty]))] + ) + + provide let pathNoScheme = seq( + [plus(any([unreserved, pctEncoded, subDelims, char('@')])), pathAbempty] + ) + + provide let pathRootless = seq([plus(pchar), pathAbempty]) + + provide let query = star(any([pchar, char('/'), char('?')])) + + provide let fragment = query +} +from Matchers use * + +let parseScheme = (str, withDelim=false) => { + let matcher = if (withDelim) seq([scheme, char(':')]) else scheme + match (matcher(0, str)) { + None => (0, None), + Some(i) => + ( + i, + Some(toLower(String.slice(0, end=i - (if (withDelim) 1 else 0), str))), + ), + } +} + +let parseIpAddress = (i, str) => { + match (ipAddress(i, str)) { + None => Err(ParseError), + Some(endI) => Ok((endI, normalizeHost(String.slice(i, end=endI, str)))), + } +} + +let parseHost = (i, str) => { + match (host(i, str)) { + None => Err(ParseError), + Some(endI) => Ok((endI, normalizeHost(String.slice(i, end=endI, str)))), + } +} + +let parseUserinfo = (i, str, withDelim=false) => { + let matcher = if (withDelim) seq([userinfo, char('@')]) else userinfo + match (matcher(i, str)) { + None => (i, None), + Some(endI) => + (endI, Some(String.slice(i, end=endI - (if (withDelim) 1 else 0), str))), + } +} + +let parsePortWithDelim = (i, str) => { + match (seq([char(':'), port])(i, str)) { + None => (i, None), + Some(endI) => { + let port = if (endI == i + 1) { + None + } else { + let portStr = String.slice(i + 1, end=endI, str) + Some(Result.unwrap(Number.parseInt(portStr, 10))) + } + (endI, port) + }, + } +} + +let parsePath = (i, str, isAbsolute, hasAuthority) => { + let processPath = if (isAbsolute) removeDotSegments else identity + if (hasAuthority) { + let endI = Option.unwrap(pathAbempty(i, str)) + let path = processPath( + percentDecodeValid(String.slice(i, end=endI, str), onlyUnreserved=true) + ) + (endI, path) + } else { + let extraOption = if (isAbsolute) pathRootless else pathNoScheme + let endI = Option.unwrap(any([pathAbsolute, extraOption, empty])(i, str)) + let path = processPath( + percentDecodeValid(String.slice(i, end=endI, str), onlyUnreserved=true) + ) + (endI, path) + } +} + +let parseAfterScheme = (i, str, isAbsolute) => { + match (string("//")(i, str)) { + Some(i) => { + let (i, userinfo) = parseUserinfo(i, str, withDelim=true) + let (i, host) = match (parseHost(i, str)) { + Ok(x) => x, + Err(err) => return Err(err), + } + let (i, port) = parsePortWithDelim(i, str) + let (i, path) = parsePath(i, str, isAbsolute, true) + return Ok((i, userinfo, Some(host), port, path)) + }, + None => { + let (i, path) = parsePath(i, str, isAbsolute, false) + return Ok((i, None, None, None, path)) + }, + } +} + +let parseQuery = (i, str, withDelim=false) => { + let matcher = if (withDelim) seq([char('?'), query]) else query + match (matcher(i, str)) { + None => (i, None), + Some(endI) => + ( + endI, + Some( + percentDecodeValid( + String.slice(i + (if (withDelim) 1 else 0), end=endI, str), + onlyUnreserved=true + ) + ), + ), + } +} + +let parseFragment = (i, str, withDelim=false) => { + let matcher = if (withDelim) seq([char('#'), fragment]) else fragment + match (matcher(i, str)) { + None => (i, None), + Some(endI) => + ( + endI, + Some( + percentDecodeValid( + String.slice(i + (if (withDelim) 1 else 0), end=endI, str), + onlyUnreserved=true + ) + ), + ), + } +} + +/** + * Parses a string into a `Uri` according to RFC 3986. If the URI string has a + * path it will be automatically normalized, removing unnecessary `.` and `..` + * segments. + * + * @param str: The RFC 3986 URI string to parse + * @returns `Ok(uri)` containing a `Uri` if the given string is a valid URI, `Err(ParseError)` otherwise + * + * @example Uri.parse("https://grain-lang.org") == Ok(...) + * @example Uri.parse("http://@*^%") == Err(Uri.ParseError) + * + * @since v0.6.0 + */ +provide let parse = str => { + let (i, scheme) = parseScheme(str, withDelim=true) + let isAbsolute = Option.isSome(scheme) + let (i, userinfo, host, port, path) = match (parseAfterScheme( + i, + str, + isAbsolute + )) { + Ok(x) => x, + Err(err) => return Err(err), + } + let (i, query) = parseQuery(i, str, withDelim=true) + let (i, fragment) = parseFragment(i, str, withDelim=true) + if (i != String.length(str)) { + return Err(ParseError) + } else { + return Ok({ scheme, userinfo, host, port, path, query, fragment }) + } +} + +/** + * Transforms a base URI and a URI reference into a target URI + * + * @param base: The base URI to resolve a URI reference on + * @param rel: The URI reference to apply onto the base + * @returns `Ok(uri)` containing the target `Uri`, or `Err(err)` if input is malformed + * + * @example resolveReference(unwrap(parse("https://grain-lang.org/docs/stdlib/uri")), unwrap(parse("../intro"))) // https://grain-lang.org/docs/intro + * @example resolveReference(unwrap(parse("https://grain-lang.org/docs")), unwrap(parse("?key=val"))) // https://grain-lang.org/docs?key=val + * @example resolveReference(unwrap(parse("https://grain-lang.org/docs")), unwrap(parse("google.com/search"))) // https://google.com/search + * + * @since v0.6.0 + */ +provide let resolveReference = (base, ref) => { + let mergePath = (base, ref) => { + if (base.host != None && base.path == "") { + "/" ++ ref.path + } else { + let basePath = match (String.lastIndexOf("/", base.path)) { + Some(i) => String.slice(0, end=i + 1, base.path), + None => base.path, + } + basePath ++ ref.path + } + } + + if (base.scheme == None) { + Err(BaseNotAbsolute) + } else { + let uri = if (ref.scheme != None) { + ref + } else { + if (ref.host != None) { + { ...ref, scheme: base.scheme } + } else { + if (ref.path == "") { + from Option use { (||) } + { ...base, query: ref.query || base.query, fragment: ref.fragment } + } else { + let path = if (String.startsWith("/", ref.path)) { + ref.path + } else { + removeDotSegments(mergePath(base, ref)) + } + { ...base, path, query: ref.query, fragment: ref.fragment } + } + } + } + Ok(uri) + } +} + +/** + * Constructs a new `Uri` from components. + * + * @param scheme: `Some(scheme)` containing the desired scheme component or `None` for a scheme-less URI + * @param userinfo: `Some(userinfo)` containing the desired userinfo component or `None` for a userinfo-less URI + * @param host: `Some(host)` containing the desired host component or `None` for a host-less URI + * @param port: `Some(port)` containing the desired port component or `None` for a port-less URI + * @param path: The desired path for the URI. `""` by default + * @param query: `Some(query)` containing the desired query string component or `None` for a query-less URI + * @param fragment: `Some(fragment)` containing the desired fragment component or `None` for a fragment-less URI + * @param percentEncodeComponents: Whether or not to apply percent encoding for each component to remove unsafe characters for each component + * + * @example Uri.make(scheme=Some("https"), host=Some("grain-lang.org")) // https://grain-lang.org + * @example Uri.make(host=Some("g/r@in"), percentEncodeComponents=false) // Err(Uri.InvalidHostError) + * @example Uri.make(scheme=Some("abc"), host=Some("g/r@in"), query=Some("k/ey=v^@l"), percentEncodeComponents=true) // abc://g%2Fr%40in?k/ey=v%5E@l + * @example Uri.make(port=Some(80)) // Err(Uri.PortWithNoHost) + * + * @since v0.6.0 + */ +provide let make = + ( + scheme=None, + userinfo=None, + host=None, + port=None, + path="", + query=None, + fragment=None, + percentEncodeComponents=false, + ) => { + match ((host, userinfo, port)) { + (None, Some(_), None) => return Err(UserinfoWithNoHost), + (None, None, Some(_)) => return Err(PortWithNoHost), + _ => void, + } + + let parseInfallible = (fn, val) => { + match (val) { + None => Ok(None), + Some(str) => { + let (i, parsed) = fn(0, str) + if (i != String.length(str)) Err(ParseError) else Ok(parsed) + }, + } + } + + let parseFallible = (fn, val) => { + match (val) { + None => Ok(None), + Some(str) => { + match (fn(0, str)) { + Ok((i, parsed)) => { + if (i != String.length(str)) Err(ParseError) else Ok(Some(parsed)) + }, + Err(err) => Err(err), + } + }, + } + } + + let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { + let encode = (val, encodeSet) => + Option.map(val => percentEncode(val, encodeSet=encodeSet), val) + + let isIpAddressHost = Result.isOk(parseFallible(parseIpAddress, host)) + + ( + encode(userinfo, EncodeUserinfo), + if (!isIpAddressHost) encode(host, EncodeRegisteredHost) else host, + percentEncode(path, encodeSet=EncodePath), + encode(query, EncodeQueryOrFragment), + encode(fragment, EncodeQueryOrFragment), + ) + } else { + (userinfo, host, path, query, fragment) + } + + let parseScheme = (_, x) => parseScheme(x) + let scheme = match (parseInfallible(parseScheme, scheme)) { + Ok(x) => x, + Err(_) => return Err(InvalidSchemeError), + } + + let parseUserinfo = (i, x) => parseUserinfo(i, x) + let userinfo = match (parseInfallible(parseUserinfo, userinfo)) { + Ok(x) => x, + Err(_) => return Err(InvalidUserinfoError), + } + + let host = match (parseFallible(parseHost, host)) { + Ok(x) => x, + Err(_) => return Err(InvalidHostError), + } + + match (port) { + Some(port) when port < 0 || !Number.isInteger(port) => + return Err(InvalidPortError), + _ => void, + } + + let isAbsolute = Option.isSome(scheme) + let hasAuthority = Option.isSome(host) + let (i, _) = parsePath(0, path, isAbsolute, hasAuthority) + if (i != String.length(path)) { + return Err(InvalidPathError) + } + + let parseQuery = (i, x) => parseQuery(i, x) + let query = match (parseInfallible(parseQuery, query)) { + Ok(x) => x, + Err(_) => return Err(InvalidQueryError), + } + + let parseFragment = (i, x) => parseFragment(i, x) + let fragment = match (parseInfallible(parseFragment, fragment)) { + Ok(x) => x, + Err(_) => return Err(InvalidFragmentError), + } + + return Ok({ scheme, userinfo, host, port, path, query, fragment }) +} + +enum UpdateAction { + KeepOriginal, + UpdateTo(a), + UpdateParseError, +} + +/** + * Constructs a new `Uri` from a base `Uri` and components to update. The + * pattern used to update each component is that `None` means the base URI's + * component should be used and `Some(val)` means that a new value should be + * used for that component. + * + * @param uri: The base `Uri` to apply updates on top of + * @param scheme: `Some(scheme)` containing the desired updated scheme component or `None` to maintain the base URI's scheme + * @param userinfo: `Some(userinfo)` containing the desired updated userinfo component or `None` to maintain the base URI's userinfo + * @param host: `Some(host)` containing the desired updated host component or `None` to maintain the base URI's host + * @param port: `Some(port)` containing the desired updated port component or `None` to maintain the base URI's port + * @param path: `Some(path)` containing the desired updated path component or `None` to maintain the base URI's path + * @param query: `Some(query)` containing the desired updated query string component or `None` to maintain the base URI's query + * @param fragment: `Some(fragment)` containing the desired updated fragment component or `None` to maintain the base URI's fragment + * @param percentEncodeComponents: Whether or not to apply percent encoding for each updated component to remove unsafe characters + * + * @example let uri = Result.unwrap(Uri.parse("https://grain-lang.org/docs?k=v")) // Base URI for following examples + * @example Uri.update(uri, scheme=Some(Some("ftp"))) // ftp://grain-lang.org/docs?k=v + * @example Uri.update(uri, query=Some(None)) // https://grain-lang.org/docs + * @example Uri.update(uri, host=Some(Some("g/r@in")), percentEncodeComponents=true) // https://g%2Fr%40in/docs?k=v + * @example Uri.update(uri, host=Some(None), port=Some(Some(80))) // Err(Uri.PortWithNoHost) + * + * @since v0.6.0 + */ +provide let update = + ( + uri, + scheme=None, + userinfo=None, + host=None, + port=None, + path=None, + query=None, + fragment=None, + percentEncodeComponents=false, + ) => { + let (??) = (new, old) => Option.unwrapWithDefault(old, new) + match ((host ?? uri.host, userinfo ?? uri.userinfo, port ?? uri.port)) { + (None, Some(_), None) => return Err(UserinfoWithNoHost), + (None, None, Some(_)) => return Err(PortWithNoHost), + _ => void, + } + + let parseInfallible = (fn, val) => { + match (val) { + None => KeepOriginal, + Some(None) => UpdateTo(None), + Some(Some(str)) => { + let (i, parsed) = fn(0, str) + if (i != String.length(str)) UpdateParseError else UpdateTo(parsed) + }, + } + } + + let parseFallible = (fn, val) => { + match (val) { + None => KeepOriginal, + Some(None) => UpdateTo(None), + Some(Some(str)) => { + match (fn(0, str)) { + Ok((i, parsed)) => { + if (i != String.length(str)) UpdateParseError + else UpdateTo(Some(parsed)) + }, + Err(err) => UpdateParseError, + } + }, + } + } + + let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { + let encode = (val, encodeSet) => + match (val) { + Some(Some(val)) => Some(Some(percentEncode(val, encodeSet=encodeSet))), + val => val, + } + + let isIpAddressHost = match (parseFallible(parseIpAddress, host)) { + UpdateParseError => false, + _ => true, + } + + ( + encode(userinfo, EncodeUserinfo), + if (!isIpAddressHost) encode(host, EncodeRegisteredHost) else host, + Option.map(path => percentEncode(path, encodeSet=EncodePath), path), + encode(query, EncodeQueryOrFragment), + encode(fragment, EncodeQueryOrFragment), + ) + } else { + (userinfo, host, path, query, fragment) + } + + let parseScheme = (_, x) => parseScheme(x) + let scheme = match (parseInfallible(parseScheme, scheme)) { + KeepOriginal => uri.scheme, + UpdateTo(x) => x, + UpdateParseError => return Err(InvalidSchemeError), + } + + let parseUserinfo = (i, x) => parseUserinfo(i, x) + let userinfo = match (parseInfallible(parseUserinfo, userinfo)) { + KeepOriginal => uri.userinfo, + UpdateTo(x) => x, + UpdateParseError => return Err(InvalidUserinfoError), + } + + let host = match (parseFallible(parseHost, host)) { + KeepOriginal => uri.host, + UpdateTo(x) => x, + UpdateParseError => return Err(InvalidHostError), + } + + let port = match (port) { + None => uri.port, + Some(Some(port)) when port < 0 || !Number.isInteger(port) => + return Err(InvalidPortError), + Some(port) => port, + } + + let hasAuthority = Option.isSome(host) + let isAbsolute = Option.isSome(scheme) + let path = path ?? uri.path + // We also want to catch situations where the path isn't directly updated but + // the host or scheme is, resulting in the path no longer being valid + let (i, _) = parsePath(0, path, isAbsolute, hasAuthority) + if (i != String.length(path)) { + return Err(InvalidPathError) + } + + let parseQuery = (i, x) => parseQuery(i, x) + let query = match (parseInfallible(parseQuery, query)) { + KeepOriginal => uri.query, + UpdateTo(x) => x, + UpdateParseError => return Err(InvalidQueryError), + } + + let parseFragment = (i, x) => parseFragment(i, x) + let fragment = match (parseInfallible(parseFragment, fragment)) { + KeepOriginal => uri.fragment, + UpdateTo(x) => x, + UpdateParseError => return Err(InvalidFragmentError), + } + + return Ok({ scheme, userinfo, host, port, path, query, fragment }) +} + +/** + * Retrieves the scheme component of a `Uri`, if it has one + * + * @param uri: The `Uri` to get the scheme of + * @returns `Some(scheme)` containing the scheme of the `Uri`, or `None` if the `Uri` is a relative reference + * + * @since v0.6.0 + */ +provide let scheme = uri => uri.scheme + +/** + * Retrieves the userinfo component of a `Uri`, if it has one + * + * @param uri: The `Uri` to get the userinfo of + * @returns `Some(userinfo)` containing the userinfo of the `Uri`, or `None` if the `Uri` does not have one + * + * @since v0.6.0 + */ +provide let userinfo = uri => uri.userinfo + +/** + * Retrieves the host component of a `Uri` + * + * @param uri: The `Uri` to get the host of + * @returns `Some(host)` containing the host of the `Uri`, or `None` if the `Uri` does not have one + * + * @since v0.6.0 + */ +provide let host = uri => uri.host + +/** + * Retrieves the port component of a `Uri`, if it has one + * + * @param uri: The `Uri` to get the port of + * @returns `Some(port)` containing the port of the `Uri`, or `None` if the `Uri` is a relative reference + * + * @since v0.6.0 + */ +provide let port = uri => uri.port + +/** + * Retrieves the path component of a `Uri` + * + * @param uri: The `Uri` to get the path of + * @returns The path of the given `Uri` + * + * @since v0.6.0 + */ +provide let path = uri => uri.path + +/** + * Retrieves the query string component of a `Uri`, if it has one + * + * @param uri: The `Uri` to get the query string of + * @returns `Some(query)` containing the query string of the `Uri`, or `None` if the `Uri` does not have one + * + * @since v0.6.0 + */ +provide let query = uri => uri.query + +/** + * Retrieves the fragment component of a `Uri`, if it has one + * + * @param uri: The `Uri` to get the fragment of + * @returns `Some(fragment)` containing the fragment of the `Uri`, or `None` if the `Uri` does not have one + * + * @since v0.6.0 + */ +provide let fragment = uri => uri.fragment + +/** + * Determines whether or not a `Uri` has an authority (i.e. has a host component) + * + * @param uri: The `Uri` to consider + * @returns `true` if the `Uri` has an authority component, `false` otherwise + * + * @since v0.6.0 + */ +provide let hasAuthority = uri => uri.host != None + +/** + * Determines whether or not a `Uri` is an absolute URI (has a scheme component) + * + * @param uri: The `Uri` to consider + * @returns `true` if the `Uri` is absolute (has a scheme component), `false` otherwise + * + * @since v0.6.0 + */ +provide let isAbsolute = uri => uri.scheme != None + +/** + * Converts the given `Uri` into a string. + * + * @param uri: The `Uri` to convert to a string + * @returns A string representation of the `Uri` + * + * @since v0.6.0 + */ +provide let toString = uri => { + let optStr = (opt, display) => Option.mapWithDefault(display, "", opt) + + optStr(uri.scheme, s => s ++ ":") ++ + optStr(uri.host, (_) => "//") ++ + optStr(uri.userinfo, u => u ++ "@") ++ + optStr(uri.host, identity) ++ + optStr(uri.port, p => ":" ++ toString(p)) ++ + uri.path ++ + optStr(uri.query, q => "?" ++ q) ++ + optStr(uri.fragment, f => "#" ++ f) +} diff --git a/stdlib/uri.md b/stdlib/uri.md new file mode 100644 index 0000000000..53086bc553 --- /dev/null +++ b/stdlib/uri.md @@ -0,0 +1,644 @@ +--- +title: Uri +--- + +Utilities for working with URIs. + +
+Added in next +No other changes yet. +
+ +```grain +include "uri" +``` + +## Types + +Type declarations included in the Uri module. + +### Uri.**Uri** + +```grain +type Uri +``` + +Represents a parsed RFC 3986 URI. + +### Uri.**ParseError** + +```grain +enum ParseError { + ParseError, +} +``` + +Represents an error encountered while parsing a URI. + +### Uri.**ConstructUriError** + +```grain +enum ConstructUriError { + UserinfoWithNoHost, + PortWithNoHost, + InvalidSchemeError, + InvalidUserinfoError, + InvalidHostError, + InvalidPortError, + InvalidPathError, + InvalidQueryError, + InvalidFragmentError, +} +``` + +Represents an error encountered while constructing a URI with `make` or `update`. + +### Uri.**ResolveReferenceError** + +```grain +enum ResolveReferenceError { + BaseNotAbsolute, +} +``` + +Represents an error encountered while attempting to resolve a URI reference to a target URI. + +### Uri.**PercentDecodingError** + +```grain +enum PercentDecodingError { + InvalidPercentEncoding, +} +``` + +Represents an error encountered while attempting to percent-decode a string. + +### Uri.**PercentEncodeSet** + +```grain +enum PercentEncodeSet { + EncodeNonUnreserved, + EncodeUserinfo, + EncodeRegisteredHost, + EncodePath, + EncodePathSegment, + EncodeQueryOrFragment, + EncodeCustom((Char => Bool)), +} +``` + +Used to specify which characters to percent-encode from a string. + +## Values + +Functions and constants included in the Uri module. + +### Uri.**percentEncode** + +
+Added in next +No other changes yet. +
+ +```grain +percentEncode : (str: String, ?encodeSet: PercentEncodeSet) => String +``` + +Percent-encodes characters in a string based on the specified `PercentEncodeSet`. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`str`|`String`|The string to encode| +|`encodeSet`|`Option`|An indication for which characters to percent-encode. `EncodeNonUnreserved` by default| + +Returns: + +|type|description| +|----|-----------| +|`String`|A percent-encoding of the given string| + +Examples: + +```grain +Uri.percentEncode("h3ll0_.w ?o+rld", encodeSet=Uri.EncodeNonUnreserved) // "h3ll0_.w%20%3Fo%2Brld" +``` + +```grain +Uri.percentEncode("d+om@i:n.com", encodeSet=Uri.EncodeRegisteredHost) // "d+om%40i%3An.com" +``` + +```grain +Uri.percentEncode("word", encodeSet=Uri.EncodeCustom(c => c == 'o')) // "w%6Frd" +``` + +### Uri.**percentDecode** + +
+Added in next +No other changes yet. +
+ +```grain +percentDecode : (str: String) => Result +``` + +Decodes any percent-encoded characters in a string. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`str`|`String`|The string to decode| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(decoded)` containing a the decoded string or `Err(err)` if the decoding failed| + +### Uri.**encodeQuery** + +
+Added in next +No other changes yet. +
+ +```grain +encodeQuery : (urlVals: List<(String, String)>) => String +``` + +Encodes a list of key-value pairs into an query string. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`urlVals`|`List<(String, String)>`|A list of key-value pairs| + +Returns: + +|type|description| +|----|-----------| +|`String`|A query string| + +### Uri.**decodeQuery** + +
+Added in next +No other changes yet. +
+ +```grain +decodeQuery : + (str: String) => Result, PercentDecodingError> +``` + +Decodes a query string into a list of pairs. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`str`|`String`|A query string| + +Returns: + +|type|description| +|----|-----------| +|`Result, PercentDecodingError>`|A list of key-value pairs containing the values of the encoded string| + +### Uri.**parse** + +
+Added in next +No other changes yet. +
+ +```grain +parse : (str: String) => Result +``` + +Parses a string into a `Uri` according to RFC 3986. If the URI string has a +path it will be automatically normalized, removing unnecessary `.` and `..` +segments. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`str`|`String`|The RFC 3986 URI string to parse| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(uri)` containing a `Uri` if the given string is a valid URI, `Err(ParseError)` otherwise| + +Examples: + +```grain +Uri.parse("https://grain-lang.org") == Ok(...) +``` + +```grain +Uri.parse("http://@*^%") == Err(Uri.ParseError) +``` + +### Uri.**resolveReference** + +
+Added in next +No other changes yet. +
+ +```grain +resolveReference : + (base: Uri, ref: Uri) => Result +``` + +Transforms a base URI and a URI reference into a target URI + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`base`|`Uri`|The base URI to resolve a URI reference on| +|`rel`|`Uri`|The URI reference to apply onto the base| + +Returns: + +|type|description| +|----|-----------| +|`Result`|`Ok(uri)` containing the target `Uri`, or `Err(err)` if input is malformed| + +Examples: + +```grain +resolveReference(unwrap(parse("https://grain-lang.org/docs/stdlib/uri")), unwrap(parse("../intro"))) // https://grain-lang.org/docs/intro +``` + +```grain +resolveReference(unwrap(parse("https://grain-lang.org/docs")), unwrap(parse("?key=val"))) // https://grain-lang.org/docs?key=val +``` + +```grain +resolveReference(unwrap(parse("https://grain-lang.org/docs")), unwrap(parse("google.com/search"))) // https://google.com/search +``` + +### Uri.**make** + +
+Added in next +No other changes yet. +
+ +```grain +make : + (?scheme: Option, ?userinfo: Option, ?host: Option, + ?port: Option, ?path: String, ?query: Option, + ?fragment: Option, ?percentEncodeComponents: Bool) => + Result +``` + +Constructs a new `Uri` from components. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`scheme`|`Option>`|`Some(scheme)` containing the desired scheme component or `None` for a scheme-less URI| +|`userinfo`|`Option>`|`Some(userinfo)` containing the desired userinfo component or `None` for a userinfo-less URI| +|`host`|`Option>`|`Some(host)` containing the desired host component or `None` for a host-less URI| +|`port`|`Option>`|`Some(port)` containing the desired port component or `None` for a port-less URI| +|`path`|`Option`|The desired path for the URI. `""` by default| +|`query`|`Option>`|`Some(query)` containing the desired query string component or `None` for a query-less URI| +|`fragment`|`Option>`|`Some(fragment)` containing the desired fragment component or `None` for a fragment-less URI| +|`percentEncodeComponents`|`Option`|Whether or not to apply percent encoding for each component to remove unsafe characters for each component| + +Examples: + +```grain +Uri.make(scheme=Some("https"), host=Some("grain-lang.org")) // https://grain-lang.org +``` + +```grain +Uri.make(host=Some("g/r@in"), percentEncodeComponents=false) // Err(Uri.InvalidHostError) +``` + +```grain +Uri.make(scheme=Some("abc"), host=Some("g/r@in"), query=Some("k/ey=v^@l"), percentEncodeComponents=true) // abc://g%2Fr%40in?k/ey=v%5E@l +``` + +```grain +Uri.make(port=Some(80)) // Err(Uri.PortWithNoHost) +``` + +### Uri.**update** + +
+Added in next +No other changes yet. +
+ +```grain +update : + (uri: Uri, ?scheme: Option>, + ?userinfo: Option>, ?host: Option>, + ?port: Option>, ?path: Option, + ?query: Option>, ?fragment: Option>, + ?percentEncodeComponents: Bool) => Result +``` + +Constructs a new `Uri` from a base `Uri` and components to update. The +pattern used to update each component is that `None` means the base URI's +component should be used and `Some(val)` means that a new value should be +used for that component. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The base `Uri` to apply updates on top of| +|`scheme`|`Option>>`|`Some(scheme)` containing the desired updated scheme component or `None` to maintain the base URI's scheme| +|`userinfo`|`Option>>`|`Some(userinfo)` containing the desired updated userinfo component or `None` to maintain the base URI's userinfo| +|`host`|`Option>>`|`Some(host)` containing the desired updated host component or `None` to maintain the base URI's host| +|`port`|`Option>>`|`Some(port)` containing the desired updated port component or `None` to maintain the base URI's port| +|`path`|`Option>`|`Some(path)` containing the desired updated path component or `None` to maintain the base URI's path| +|`query`|`Option>>`|`Some(query)` containing the desired updated query string component or `None` to maintain the base URI's query| +|`fragment`|`Option>>`|`Some(fragment)` containing the desired updated fragment component or `None` to maintain the base URI's fragment| +|`percentEncodeComponents`|`Option`|Whether or not to apply percent encoding for each updated component to remove unsafe characters| + +Examples: + +```grain +let uri = Result.unwrap(Uri.parse("https://grain-lang.org/docs?k=v")) // Base URI for following examples +``` + +```grain +Uri.update(uri, scheme=Some(Some("ftp"))) // ftp://grain-lang.org/docs?k=v +``` + +```grain +Uri.update(uri, query=Some(None)) // https://grain-lang.org/docs +``` + +```grain +Uri.update(uri, host=Some(Some("g/r@in")), percentEncodeComponents=true) // https://g%2Fr%40in/docs?k=v +``` + +```grain +Uri.update(uri, host=Some(None), port=Some(Some(80))) // Err(Uri.PortWithNoHost) +``` + +### Uri.**scheme** + +
+Added in next +No other changes yet. +
+ +```grain +scheme : (uri: Uri) => Option +``` + +Retrieves the scheme component of a `Uri`, if it has one + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the scheme of| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(scheme)` containing the scheme of the `Uri`, or `None` if the `Uri` is a relative reference| + +### Uri.**userinfo** + +
+Added in next +No other changes yet. +
+ +```grain +userinfo : (uri: Uri) => Option +``` + +Retrieves the userinfo component of a `Uri`, if it has one + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the userinfo of| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(userinfo)` containing the userinfo of the `Uri`, or `None` if the `Uri` does not have one| + +### Uri.**host** + +
+Added in next +No other changes yet. +
+ +```grain +host : (uri: Uri) => Option +``` + +Retrieves the host component of a `Uri` + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the host of| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(host)` containing the host of the `Uri`, or `None` if the `Uri` does not have one| + +### Uri.**port** + +
+Added in next +No other changes yet. +
+ +```grain +port : (uri: Uri) => Option +``` + +Retrieves the port component of a `Uri`, if it has one + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the port of| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(port)` containing the port of the `Uri`, or `None` if the `Uri` is a relative reference| + +### Uri.**path** + +
+Added in next +No other changes yet. +
+ +```grain +path : (uri: Uri) => String +``` + +Retrieves the path component of a `Uri` + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the path of| + +Returns: + +|type|description| +|----|-----------| +|`String`|The path of the given `Uri`| + +### Uri.**query** + +
+Added in next +No other changes yet. +
+ +```grain +query : (uri: Uri) => Option +``` + +Retrieves the query string component of a `Uri`, if it has one + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the query string of| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(query)` containing the query string of the `Uri`, or `None` if the `Uri` does not have one| + +### Uri.**fragment** + +
+Added in next +No other changes yet. +
+ +```grain +fragment : (uri: Uri) => Option +``` + +Retrieves the fragment component of a `Uri`, if it has one + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to get the fragment of| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(fragment)` containing the fragment of the `Uri`, or `None` if the `Uri` does not have one| + +### Uri.**hasAuthority** + +
+Added in next +No other changes yet. +
+ +```grain +hasAuthority : (uri: Uri) => Bool +``` + +Determines whether or not a `Uri` has an authority (i.e. has a host component) + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to consider| + +Returns: + +|type|description| +|----|-----------| +|`Bool`|`true` if the `Uri` has an authority component, `false` otherwise| + +### Uri.**isAbsolute** + +
+Added in next +No other changes yet. +
+ +```grain +isAbsolute : (uri: Uri) => Bool +``` + +Determines whether or not a `Uri` is an absolute URI (has a scheme component) + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to consider| + +Returns: + +|type|description| +|----|-----------| +|`Bool`|`true` if the `Uri` is absolute (has a scheme component), `false` otherwise| + +### Uri.**toString** + +
+Added in next +No other changes yet. +
+ +```grain +toString : (uri: Uri) => String +``` + +Converts the given `Uri` into a string. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`uri`|`Uri`|The `Uri` to convert to a string| + +Returns: + +|type|description| +|----|-----------| +|`String`|A string representation of the `Uri`| + From b2406dbbfeef09a0b1b775e91fa943012b9e7cce Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sun, 28 Jan 2024 06:25:28 +0000 Subject: [PATCH 2/6] Apply review feedback --- stdlib/uri.gr | 64 ++++++++++++++++++--------------------------------- 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/stdlib/uri.gr b/stdlib/uri.gr index 9cf30b7d02..9cf1d92c69 100644 --- a/stdlib/uri.gr +++ b/stdlib/uri.gr @@ -82,24 +82,16 @@ provide enum PercentEncodeSet { EncodeCustom(Char => Bool), } -let isDigit = char => { - let code = Char.code(char) - code >= 0x30 && code <= 0x39 -} - -let isAlpha = char => { - let code = Char.code(char) - code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a -} - let isHexDigit = char => { - let code = Char.code(char) - isDigit(char) || code >= 0x41 && code <= 0x46 || code >= 0x61 && code <= 0x66 + from Char use { (<=), (>=) } + Char.isAsciiDigit(char) || + char >= 'A' && char <= 'F' || + char >= 'a' && char <= 'f' } let isUnreservedChar = char => { - isDigit(char) || - isAlpha(char) || + Char.isAsciiDigit(char) || + Char.isAsciiAlpha(char) || char == '-' || char == '.' || char == '_' || @@ -107,8 +99,10 @@ let isUnreservedChar = char => { } let isSubDelim = char => { - let subDelims = ['!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '='] - List.contains(char, subDelims) + match (char) { + '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' => true, + _ => false, + } } let isPchar = char => { @@ -149,29 +143,11 @@ let makePercentEncoder = (encodeSet: PercentEncodeSet) => { } } -let charToLower = char => { - let code = Char.code(char) - let newCode = if (code >= 0x41 && code <= 0x5a) code + 0x20 else code - Char.fromCode(newCode) -} - -let charToUpper = char => { - let code = Char.code(char) - let newCode = if (code >= 0x61 && code <= 0x7a) code - 0x20 else code - Char.fromCode(newCode) -} - -let toLower = str => { - let chars = String.explode(str) - let newChars = Array.map(charToLower, chars) - String.implode(newChars) -} - let charToHexValue = char => { - if (isDigit(char)) { + if (Char.isAsciiDigit(char)) { Char.code(char) - 0x30 } else { - let char = charToLower(char) + let char = Char.toAsciiLowercase(char) Char.code(char) - 0x60 + 9 } } @@ -199,8 +175,8 @@ let percentDecodeValid = (str, onlyUnreserved=false) => { let pctDecodedVal = charToHexValue(next) * 16 + charToHexValue(nextNext) if (onlyUnreserved && !isUnreservedChar(Char.fromCode(pctDecodedVal))) { Buffer.addChar('%', out) - Buffer.addChar(charToUpper(next), out) - Buffer.addChar(charToUpper(nextNext), out) + Buffer.addChar(Char.toAsciiUppercase(next), out) + Buffer.addChar(Char.toAsciiUppercase(nextNext), out) } else { Buffer.addUint8(Uint8.fromNumber(pctDecodedVal), out) } @@ -235,7 +211,7 @@ let normalizeHost = str => { } else if (i >= 2 && chars[i - 2] == '%') { getChars(i - 3, ['%', chars[i - 1], chars[i], ...acc]) } else { - getChars(i - 1, [charToLower(chars[i]), ...acc]) + getChars(i - 1, [Char.toAsciiLowercase(chars[i]), ...acc]) } } let chars = getChars(String.length(str) - 1, []) @@ -448,7 +424,7 @@ module Matchers { } } - provide let digit = charTest(isDigit) + provide let digit = charTest(Char.isAsciiDigit) provide let digitInRange = (low, high) => charTest(char => { @@ -457,7 +433,7 @@ module Matchers { code >= zero + low && code <= zero + high }) - provide let alpha = charTest(isAlpha) + provide let alpha = charTest(Char.isAsciiAlpha) provide let hexDigit = charTest(isHexDigit) @@ -603,7 +579,11 @@ let parseScheme = (str, withDelim=false) => { Some(i) => ( i, - Some(toLower(String.slice(0, end=i - (if (withDelim) 1 else 0), str))), + Some( + String.toAsciiLowercase( + String.slice(0, end=i - (if (withDelim) 1 else 0), str) + ) + ), ), } } From 606ed1700fbfda3e5e0ef74a957e05789ef4341a Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Mon, 29 Jan 2024 01:20:27 +0000 Subject: [PATCH 3/6] Provide uri record, remove accessor functions --- compiler/test/stdlib/uri.test.gr | 181 ++++++++++++++----------- stdlib/uri.gr | 74 +--------- stdlib/uri.md | 224 ++++--------------------------- 3 files changed, 133 insertions(+), 346 deletions(-) diff --git a/compiler/test/stdlib/uri.test.gr b/compiler/test/stdlib/uri.test.gr index 20c1b54d1e..90943fc2f1 100644 --- a/compiler/test/stdlib/uri.test.gr +++ b/compiler/test/stdlib/uri.test.gr @@ -3,146 +3,169 @@ module UriTest include "uri" include "result" -record Uri { - scheme: Option, - userinfo: Option, - host: Option, - port: Option, - path: String, - query: Option, - fragment: Option, - string: String, +record ExpectedUri { + expectedScheme: Option, + expectedUserinfo: Option, + expectedHost: Option, + expectedPort: Option, + expectedPath: String, + expectedQuery: Option, + expectedFragment: Option, + expectedString: String, } let default = { - scheme: None, - userinfo: None, - host: None, - port: None, - path: "", - query: None, - fragment: None, - string: "", + expectedScheme: None, + expectedUserinfo: None, + expectedHost: None, + expectedPort: None, + expectedPath: "", + expectedQuery: None, + expectedFragment: None, + expectedString: "", } let testValid = (uriString, expected) => { let uri = Result.unwrap(Uri.parse(uriString)) - assert Uri.scheme(uri) == expected.scheme - assert Uri.userinfo(uri) == expected.userinfo - assert Uri.host(uri) == expected.host - assert Uri.port(uri) == expected.port - assert Uri.path(uri) == expected.path - assert Uri.query(uri) == expected.query - assert Uri.fragment(uri) == expected.fragment + assert uri.scheme == expected.expectedScheme + assert uri.userinfo == expected.expectedUserinfo + assert uri.host == expected.expectedHost + assert uri.port == expected.expectedPort + assert uri.path == expected.expectedPath + assert uri.query == expected.expectedQuery + assert uri.fragment == expected.expectedFragment assert Uri.toString(uri) == - (if (expected.string == "") uriString else expected.string) + (if (expected.expectedString == "") uriString else expected.expectedString) } testValid( "https://grain-lang.org", - { ...default, scheme: Some("https"), host: Some("grain-lang.org") } + { + ...default, + expectedScheme: Some("https"), + expectedHost: Some("grain-lang.org"), + } ) testValid( "http://user:password@www.domain.com:80/path/inner?q1=v1&q2=v2#frag", { ...default, - scheme: Some("http"), - userinfo: Some("user:password"), - host: Some("www.domain.com"), - port: Some(80), - path: "/path/inner", - query: Some("q1=v1&q2=v2"), - fragment: Some("frag"), + expectedScheme: Some("http"), + expectedUserinfo: Some("user:password"), + expectedHost: Some("www.domain.com"), + expectedPort: Some(80), + expectedPath: "/path/inner", + expectedQuery: Some("q1=v1&q2=v2"), + expectedFragment: Some("frag"), } ) testValid( "http://www.domain.com:80/path?q1=v1/?q2=v2#frag/?", { ...default, - scheme: Some("http"), - host: Some("www.domain.com"), - port: Some(80), - path: "/path", - query: Some("q1=v1/?q2=v2"), - fragment: Some("frag/?"), + expectedScheme: Some("http"), + expectedHost: Some("www.domain.com"), + expectedPort: Some(80), + expectedPath: "/path", + expectedQuery: Some("q1=v1/?q2=v2"), + expectedFragment: Some("frag/?"), } ) testValid( "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255://?1%1f@:/?#/?a", { ...default, - scheme: Some("a12+3-4.5"), - userinfo: Some( + expectedScheme: Some("a12+3-4.5"), + expectedUserinfo: Some( "1a-._~%1f%Fa!$&'()*+,;=:" ), // Do not turn %1f into %1F in userinfo - host: Some("0.99.100.255"), - path: "//", - query: Some("1%1F@:/?"), - fragment: Some("/?a"), - string: "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255//?1%1F@:/?#/?a", + expectedHost: Some("0.99.100.255"), + expectedPath: "//", + expectedQuery: Some("1%1F@:/?"), + expectedFragment: Some("/?a"), + expectedString: "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255//?1%1F@:/?#/?a", } ) testValid( "mailto:me@email.com", - { ...default, scheme: Some("mailto"), path: "me@email.com" } + { ...default, expectedScheme: Some("mailto"), expectedPath: "me@email.com" } ) testValid( "urn:hello:world", - { ...default, scheme: Some("urn"), path: "hello:world" } + { ...default, expectedScheme: Some("urn"), expectedPath: "hello:world" } ) testValid( "tel:+1-888-888-8888", - { ...default, scheme: Some("tel"), path: "+1-888-888-8888" } + { ...default, expectedScheme: Some("tel"), expectedPath: "+1-888-888-8888" } +) +testValid( + "scheme:/", + { ...default, expectedScheme: Some("scheme"), expectedPath: "/" } +) +testValid( + "scheme://", + { ...default, expectedScheme: Some("scheme"), expectedHost: Some("") } ) -testValid("scheme:/", { ...default, scheme: Some("scheme"), path: "/" }) -testValid("scheme://", { ...default, scheme: Some("scheme"), host: Some("") }) testValid( "scheme:///", - { ...default, scheme: Some("scheme"), host: Some(""), path: "/" } + { + ...default, + expectedScheme: Some("scheme"), + expectedHost: Some(""), + expectedPath: "/", + } ) testValid( "ScHeMe://HoSt%2a.COM/Path", { ...default, - scheme: Some("scheme"), - host: Some("host%2A.com"), - path: "/Path", - string: "scheme://host%2A.com/Path", + expectedScheme: Some("scheme"), + expectedHost: Some("host%2A.com"), + expectedPath: "/Path", + expectedString: "scheme://host%2A.com/Path", } ) testValid( "scheme://%41:%61@%48%65%6c%6C%6f/%25%7e%68%69", { ...default, - scheme: Some("scheme"), - userinfo: Some("%41:%61"), - host: Some("hello"), - path: "/%25~hi", - string: "scheme://%41:%61@hello/%25~hi", + expectedScheme: Some("scheme"), + expectedUserinfo: Some("%41:%61"), + expectedHost: Some("hello"), + expectedPath: "/%25~hi", + expectedString: "scheme://%41:%61@hello/%25~hi", } ) -testValid("scheme:", { ...default, scheme: Some("scheme") }) +testValid("scheme:", { ...default, expectedScheme: Some("scheme") }) testValid( "http://:80", - { ...default, scheme: Some("http"), host: Some(""), port: Some(80) } + { + ...default, + expectedScheme: Some("http"), + expectedHost: Some(""), + expectedPort: Some(80), + } +) +testValid("google.com", { ...default, expectedPath: "google.com" }) +testValid( + "//google.com/", + { ...default, expectedHost: Some("google.com"), expectedPath: "/" } ) -testValid("google.com", { ...default, path: "google.com" }) -testValid("//google.com/", { ...default, host: Some("google.com"), path: "/" }) testValid("", default) testValid( "google.com:80", - { ...default, scheme: Some("google.com"), path: "80" } + { ...default, expectedScheme: Some("google.com"), expectedPath: "80" } ) -testValid(".././..", { ...default, path: ".././.." }) +testValid(".././..", { ...default, expectedPath: ".././.." }) testValid( "http://?#", { ...default, - scheme: Some("http"), - host: Some(""), - path: "", - query: Some(""), - fragment: Some(""), + expectedScheme: Some("http"), + expectedHost: Some(""), + expectedPath: "", + expectedQuery: Some(""), + expectedFragment: Some(""), } ) @@ -164,9 +187,9 @@ let testHostValid = (host, parsed="") => { "scheme://" ++ host, { ...default, - scheme: Some("scheme"), - host: Some(parsed), - string: "scheme://" ++ parsed, + expectedScheme: Some("scheme"), + expectedHost: Some(parsed), + expectedString: "scheme://" ++ parsed, } ) } @@ -204,10 +227,10 @@ let testPath = (path, expected) => { "scheme://domain" ++ path, { ...default, - scheme: Some("scheme"), - host: Some("domain"), - path: expected, - string: "scheme://domain" ++ expected, + expectedScheme: Some("scheme"), + expectedHost: Some("domain"), + expectedPath: expected, + expectedString: "scheme://domain" ++ expected, } ) } diff --git a/stdlib/uri.gr b/stdlib/uri.gr index 9cf1d92c69..b8f8d3a1a7 100644 --- a/stdlib/uri.gr +++ b/stdlib/uri.gr @@ -23,7 +23,7 @@ include "result" /** * Represents a parsed RFC 3986 URI. */ -abstract record Uri { +provide record Uri { scheme: Option, userinfo: Option, host: Option, @@ -734,7 +734,7 @@ provide let parse = str => { * Transforms a base URI and a URI reference into a target URI * * @param base: The base URI to resolve a URI reference on - * @param rel: The URI reference to apply onto the base + * @param ref: The URI reference to apply onto the base * @returns `Ok(uri)` containing the target `Uri`, or `Err(err)` if input is malformed * * @example resolveReference(unwrap(parse("https://grain-lang.org/docs/stdlib/uri")), unwrap(parse("../intro"))) // https://grain-lang.org/docs/intro @@ -1057,76 +1057,6 @@ provide let update = return Ok({ scheme, userinfo, host, port, path, query, fragment }) } -/** - * Retrieves the scheme component of a `Uri`, if it has one - * - * @param uri: The `Uri` to get the scheme of - * @returns `Some(scheme)` containing the scheme of the `Uri`, or `None` if the `Uri` is a relative reference - * - * @since v0.6.0 - */ -provide let scheme = uri => uri.scheme - -/** - * Retrieves the userinfo component of a `Uri`, if it has one - * - * @param uri: The `Uri` to get the userinfo of - * @returns `Some(userinfo)` containing the userinfo of the `Uri`, or `None` if the `Uri` does not have one - * - * @since v0.6.0 - */ -provide let userinfo = uri => uri.userinfo - -/** - * Retrieves the host component of a `Uri` - * - * @param uri: The `Uri` to get the host of - * @returns `Some(host)` containing the host of the `Uri`, or `None` if the `Uri` does not have one - * - * @since v0.6.0 - */ -provide let host = uri => uri.host - -/** - * Retrieves the port component of a `Uri`, if it has one - * - * @param uri: The `Uri` to get the port of - * @returns `Some(port)` containing the port of the `Uri`, or `None` if the `Uri` is a relative reference - * - * @since v0.6.0 - */ -provide let port = uri => uri.port - -/** - * Retrieves the path component of a `Uri` - * - * @param uri: The `Uri` to get the path of - * @returns The path of the given `Uri` - * - * @since v0.6.0 - */ -provide let path = uri => uri.path - -/** - * Retrieves the query string component of a `Uri`, if it has one - * - * @param uri: The `Uri` to get the query string of - * @returns `Some(query)` containing the query string of the `Uri`, or `None` if the `Uri` does not have one - * - * @since v0.6.0 - */ -provide let query = uri => uri.query - -/** - * Retrieves the fragment component of a `Uri`, if it has one - * - * @param uri: The `Uri` to get the fragment of - * @returns `Some(fragment)` containing the fragment of the `Uri`, or `None` if the `Uri` does not have one - * - * @since v0.6.0 - */ -provide let fragment = uri => uri.fragment - /** * Determines whether or not a `Uri` has an authority (i.e. has a host component) * diff --git a/stdlib/uri.md b/stdlib/uri.md index 53086bc553..6e1102b348 100644 --- a/stdlib/uri.md +++ b/stdlib/uri.md @@ -20,7 +20,15 @@ Type declarations included in the Uri module. ### Uri.**Uri** ```grain -type Uri +record Uri { + scheme: Option, + userinfo: Option, + host: Option, + port: Option, + path: String, + query: Option, + fragment: Option, +} ``` Represents a parsed RFC 3986 URI. @@ -111,7 +119,7 @@ Parameters: |param|type|description| |-----|----|-----------| |`str`|`String`|The string to encode| -|`encodeSet`|`Option`|An indication for which characters to percent-encode. `EncodeNonUnreserved` by default| +|`?encodeSet`|`PercentEncodeSet`|An indication for which characters to percent-encode. `EncodeNonUnreserved` by default| Returns: @@ -166,7 +174,8 @@ No other changes yet. ```grain -encodeQuery : (urlVals: List<(String, String)>) => String +encodeQuery : + (urlVals: List<(String, String)>, ?encodeSet: PercentEncodeSet) => String ``` Encodes a list of key-value pairs into an query string. @@ -265,7 +274,7 @@ Parameters: |param|type|description| |-----|----|-----------| |`base`|`Uri`|The base URI to resolve a URI reference on| -|`rel`|`Uri`|The URI reference to apply onto the base| +|`ref`|`Uri`|The URI reference to apply onto the base| Returns: @@ -308,14 +317,14 @@ Parameters: |param|type|description| |-----|----|-----------| -|`scheme`|`Option>`|`Some(scheme)` containing the desired scheme component or `None` for a scheme-less URI| -|`userinfo`|`Option>`|`Some(userinfo)` containing the desired userinfo component or `None` for a userinfo-less URI| -|`host`|`Option>`|`Some(host)` containing the desired host component or `None` for a host-less URI| -|`port`|`Option>`|`Some(port)` containing the desired port component or `None` for a port-less URI| -|`path`|`Option`|The desired path for the URI. `""` by default| -|`query`|`Option>`|`Some(query)` containing the desired query string component or `None` for a query-less URI| -|`fragment`|`Option>`|`Some(fragment)` containing the desired fragment component or `None` for a fragment-less URI| -|`percentEncodeComponents`|`Option`|Whether or not to apply percent encoding for each component to remove unsafe characters for each component| +|`?scheme`|`Option`|`Some(scheme)` containing the desired scheme component or `None` for a scheme-less URI| +|`?userinfo`|`Option`|`Some(userinfo)` containing the desired userinfo component or `None` for a userinfo-less URI| +|`?host`|`Option`|`Some(host)` containing the desired host component or `None` for a host-less URI| +|`?port`|`Option`|`Some(port)` containing the desired port component or `None` for a port-less URI| +|`?path`|`String`|The desired path for the URI. `""` by default| +|`?query`|`Option`|`Some(query)` containing the desired query string component or `None` for a query-less URI| +|`?fragment`|`Option`|`Some(fragment)` containing the desired fragment component or `None` for a fragment-less URI| +|`?percentEncodeComponents`|`Bool`|Whether or not to apply percent encoding for each component to remove unsafe characters for each component| Examples: @@ -361,14 +370,14 @@ Parameters: |param|type|description| |-----|----|-----------| |`uri`|`Uri`|The base `Uri` to apply updates on top of| -|`scheme`|`Option>>`|`Some(scheme)` containing the desired updated scheme component or `None` to maintain the base URI's scheme| -|`userinfo`|`Option>>`|`Some(userinfo)` containing the desired updated userinfo component or `None` to maintain the base URI's userinfo| -|`host`|`Option>>`|`Some(host)` containing the desired updated host component or `None` to maintain the base URI's host| -|`port`|`Option>>`|`Some(port)` containing the desired updated port component or `None` to maintain the base URI's port| -|`path`|`Option>`|`Some(path)` containing the desired updated path component or `None` to maintain the base URI's path| -|`query`|`Option>>`|`Some(query)` containing the desired updated query string component or `None` to maintain the base URI's query| -|`fragment`|`Option>>`|`Some(fragment)` containing the desired updated fragment component or `None` to maintain the base URI's fragment| -|`percentEncodeComponents`|`Option`|Whether or not to apply percent encoding for each updated component to remove unsafe characters| +|`?scheme`|`Option>`|`Some(scheme)` containing the desired updated scheme component or `None` to maintain the base URI's scheme| +|`?userinfo`|`Option>`|`Some(userinfo)` containing the desired updated userinfo component or `None` to maintain the base URI's userinfo| +|`?host`|`Option>`|`Some(host)` containing the desired updated host component or `None` to maintain the base URI's host| +|`?port`|`Option>`|`Some(port)` containing the desired updated port component or `None` to maintain the base URI's port| +|`?path`|`Option`|`Some(path)` containing the desired updated path component or `None` to maintain the base URI's path| +|`?query`|`Option>`|`Some(query)` containing the desired updated query string component or `None` to maintain the base URI's query| +|`?fragment`|`Option>`|`Some(fragment)` containing the desired updated fragment component or `None` to maintain the base URI's fragment| +|`?percentEncodeComponents`|`Bool`|Whether or not to apply percent encoding for each updated component to remove unsafe characters| Examples: @@ -392,181 +401,6 @@ Uri.update(uri, host=Some(Some("g/r@in")), percentEncodeComponents=true) // http Uri.update(uri, host=Some(None), port=Some(Some(80))) // Err(Uri.PortWithNoHost) ``` -### Uri.**scheme** - -
-Added in next -No other changes yet. -
- -```grain -scheme : (uri: Uri) => Option -``` - -Retrieves the scheme component of a `Uri`, if it has one - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the scheme of| - -Returns: - -|type|description| -|----|-----------| -|`Option`|`Some(scheme)` containing the scheme of the `Uri`, or `None` if the `Uri` is a relative reference| - -### Uri.**userinfo** - -
-Added in next -No other changes yet. -
- -```grain -userinfo : (uri: Uri) => Option -``` - -Retrieves the userinfo component of a `Uri`, if it has one - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the userinfo of| - -Returns: - -|type|description| -|----|-----------| -|`Option`|`Some(userinfo)` containing the userinfo of the `Uri`, or `None` if the `Uri` does not have one| - -### Uri.**host** - -
-Added in next -No other changes yet. -
- -```grain -host : (uri: Uri) => Option -``` - -Retrieves the host component of a `Uri` - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the host of| - -Returns: - -|type|description| -|----|-----------| -|`Option`|`Some(host)` containing the host of the `Uri`, or `None` if the `Uri` does not have one| - -### Uri.**port** - -
-Added in next -No other changes yet. -
- -```grain -port : (uri: Uri) => Option -``` - -Retrieves the port component of a `Uri`, if it has one - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the port of| - -Returns: - -|type|description| -|----|-----------| -|`Option`|`Some(port)` containing the port of the `Uri`, or `None` if the `Uri` is a relative reference| - -### Uri.**path** - -
-Added in next -No other changes yet. -
- -```grain -path : (uri: Uri) => String -``` - -Retrieves the path component of a `Uri` - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the path of| - -Returns: - -|type|description| -|----|-----------| -|`String`|The path of the given `Uri`| - -### Uri.**query** - -
-Added in next -No other changes yet. -
- -```grain -query : (uri: Uri) => Option -``` - -Retrieves the query string component of a `Uri`, if it has one - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the query string of| - -Returns: - -|type|description| -|----|-----------| -|`Option`|`Some(query)` containing the query string of the `Uri`, or `None` if the `Uri` does not have one| - -### Uri.**fragment** - -
-Added in next -No other changes yet. -
- -```grain -fragment : (uri: Uri) => Option -``` - -Retrieves the fragment component of a `Uri`, if it has one - -Parameters: - -|param|type|description| -|-----|----|-----------| -|`uri`|`Uri`|The `Uri` to get the fragment of| - -Returns: - -|type|description| -|----|-----------| -|`Option`|`Some(fragment)` containing the fragment of the `Uri`, or `None` if the `Uri` does not have one| - ### Uri.**hasAuthority**
From 79bafe312e279bb886aefd65d970c2af30bfeb78 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 24 Feb 2024 19:22:05 +0000 Subject: [PATCH 4/6] Update with new include syntax --- compiler/test/stdlib/uri.test.gr | 4 +- stdlib/uri.gr | 143 +++++++++++++++---------------- stdlib/uri.md | 2 +- 3 files changed, 70 insertions(+), 79 deletions(-) diff --git a/compiler/test/stdlib/uri.test.gr b/compiler/test/stdlib/uri.test.gr index 90943fc2f1..408454a2d4 100644 --- a/compiler/test/stdlib/uri.test.gr +++ b/compiler/test/stdlib/uri.test.gr @@ -1,7 +1,7 @@ module UriTest -include "uri" -include "result" +from "uri" include Uri +from "result" include Result record ExpectedUri { expectedScheme: Option, diff --git a/stdlib/uri.gr b/stdlib/uri.gr index b8f8d3a1a7..f924674ff6 100644 --- a/stdlib/uri.gr +++ b/stdlib/uri.gr @@ -1,24 +1,23 @@ /** * Utilities for working with URIs. * - * @example include "uri" + * @example from "uri" include Uri * * @since v0.6.0 */ - module Uri -include "string" -include "char" -include "uint8" -include "number" -include "bytes" -include "buffer" -include "list" -include "array" -include "map" -include "option" -include "result" +from "string" include String +from "char" include Char +from "uint8" include Uint8 +from "number" include Number +from "bytes" include Bytes +from "buffer" include Buffer +from "list" include List +from "array" include Array +from "map" include Map +from "option" include Option +from "result" include Result /** * Represents a parsed RFC 3986 URI. @@ -83,7 +82,7 @@ provide enum PercentEncodeSet { } let isHexDigit = char => { - from Char use { (<=), (>=) } + use Char.{ (<=), (>=) } Char.isAsciiDigit(char) || char >= 'A' && char <= 'F' || char >= 'a' && char <= 'f' @@ -347,19 +346,17 @@ provide let decodeQuery = str => { Err(InvalidPercentEncoding) } else { let parts = Array.toList(String.split("&", str)) - Ok( - List.map(part => { - match (String.indexOf("=", part)) { - // Some parts may only have a key, set value to empty string in this case - None => (part, ""), - Some(i) => { - let name = String.slice(0, end=i, part) - let val = String.slice(i + 1, part) - (percentDecodeValid(name), percentDecodeValid(val)) - }, - } - }, parts) - ) + Ok(List.map(part => { + match (String.indexOf("=", part)) { + // Some parts may only have a key, set value to empty string in this case + None => (part, ""), + Some(i) => { + let name = String.slice(0, end=i, part) + let val = String.slice(i + 1, part) + (percentDecodeValid(name), percentDecodeValid(val)) + }, + } + }, parts)) } } @@ -426,12 +423,11 @@ module Matchers { provide let digit = charTest(Char.isAsciiDigit) - provide let digitInRange = (low, high) => - charTest(char => { - let code = Char.code(char) - let zero = 0x30 - code >= zero + low && code <= zero + high - }) + provide let digitInRange = (low, high) => charTest(char => { + let code = Char.code(char) + let zero = 0x30 + code >= zero + low && code <= zero + high + }) provide let alpha = charTest(Char.isAsciiAlpha) @@ -525,9 +521,7 @@ module Matchers { ls32, ] ), - seq( - [opt(seq([h16, limit(3, colonH16)])), string("::"), h16Colon, ls32] - ), + seq([opt(seq([h16, limit(3, colonH16)])), string("::"), h16Colon, ls32]), seq([opt(seq([h16, limit(4, colonH16)])), string("::"), ls32]), seq([opt(seq([h16, limit(5, colonH16)])), string("::"), h16]), seq([opt(seq([h16, limit(6, colonH16)])), string("::")]), @@ -570,7 +564,7 @@ module Matchers { provide let fragment = query } -from Matchers use * +use Matchers.* let parseScheme = (str, withDelim=false) => { let matcher = if (withDelim) seq([scheme, char(':')]) else scheme @@ -582,7 +576,7 @@ let parseScheme = (str, withDelim=false) => { Some( String.toAsciiLowercase( String.slice(0, end=i - (if (withDelim) 1 else 0), str) - ) + ), ), ), } @@ -674,7 +668,7 @@ let parseQuery = (i, str, withDelim=false) => { percentDecodeValid( String.slice(i + (if (withDelim) 1 else 0), end=endI, str), onlyUnreserved=true - ) + ), ), ), } @@ -691,7 +685,7 @@ let parseFragment = (i, str, withDelim=false) => { percentDecodeValid( String.slice(i + (if (withDelim) 1 else 0), end=endI, str), onlyUnreserved=true - ) + ), ), ), } @@ -713,11 +707,9 @@ let parseFragment = (i, str, withDelim=false) => { provide let parse = str => { let (i, scheme) = parseScheme(str, withDelim=true) let isAbsolute = Option.isSome(scheme) - let (i, userinfo, host, port, path) = match (parseAfterScheme( - i, - str, - isAbsolute - )) { + let (i, userinfo, host, port, path) = match ( + parseAfterScheme(i, str, isAbsolute) + ) { Ok(x) => x, Err(err) => return Err(err), } @@ -766,7 +758,7 @@ provide let resolveReference = (base, ref) => { { ...ref, scheme: base.scheme } } else { if (ref.path == "") { - from Option use { (||) } + use Option.{ (||) } { ...base, query: ref.query || base.query, fragment: ref.fragment } } else { let path = if (String.startsWith("/", ref.path)) { @@ -801,17 +793,16 @@ provide let resolveReference = (base, ref) => { * * @since v0.6.0 */ -provide let make = - ( - scheme=None, - userinfo=None, - host=None, - port=None, - path="", - query=None, - fragment=None, - percentEncodeComponents=false, - ) => { +provide let make = ( + scheme=None, + userinfo=None, + host=None, + port=None, + path="", + query=None, + fragment=None, + percentEncodeComponents=false, +) => { match ((host, userinfo, port)) { (None, Some(_), None) => return Err(UserinfoWithNoHost), (None, None, Some(_)) => return Err(PortWithNoHost), @@ -934,18 +925,17 @@ enum UpdateAction { * * @since v0.6.0 */ -provide let update = - ( - uri, - scheme=None, - userinfo=None, - host=None, - port=None, - path=None, - query=None, - fragment=None, - percentEncodeComponents=false, - ) => { +provide let update = ( + uri, + scheme=None, + userinfo=None, + host=None, + port=None, + path=None, + query=None, + fragment=None, + percentEncodeComponents=false, +) => { let (??) = (new, old) => Option.unwrapWithDefault(old, new) match ((host ?? uri.host, userinfo ?? uri.userinfo, port ?? uri.port)) { (None, Some(_), None) => return Err(UserinfoWithNoHost), @@ -971,8 +961,10 @@ provide let update = Some(Some(str)) => { match (fn(0, str)) { Ok((i, parsed)) => { - if (i != String.length(str)) UpdateParseError - else UpdateTo(Some(parsed)) + if (i != String.length(str)) + UpdateParseError + else + UpdateTo(Some(parsed)) }, Err(err) => UpdateParseError, } @@ -981,11 +973,10 @@ provide let update = } let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { - let encode = (val, encodeSet) => - match (val) { - Some(Some(val)) => Some(Some(percentEncode(val, encodeSet=encodeSet))), - val => val, - } + let encode = (val, encodeSet) => match (val) { + Some(Some(val)) => Some(Some(percentEncode(val, encodeSet=encodeSet))), + val => val, + } let isIpAddressHost = match (parseFallible(parseIpAddress, host)) { UpdateParseError => false, diff --git a/stdlib/uri.md b/stdlib/uri.md index 6e1102b348..5da45df0a0 100644 --- a/stdlib/uri.md +++ b/stdlib/uri.md @@ -10,7 +10,7 @@ No other changes yet.
```grain -include "uri" +from "uri" include Uri ``` ## Types From e731f87c495b4cc028a8b322f32358b924ea946c Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 24 Feb 2024 19:26:29 +0000 Subject: [PATCH 5/6] Changed wording from `percentEncode` to `encode` --- compiler/test/stdlib/uri.test.gr | 49 ++++++++++++-------------------- stdlib/uri.gr | 42 +++++++++++++-------------- stdlib/uri.md | 14 ++++----- 3 files changed, 46 insertions(+), 59 deletions(-) diff --git a/compiler/test/stdlib/uri.test.gr b/compiler/test/stdlib/uri.test.gr index 408454a2d4..64c8abc8de 100644 --- a/compiler/test/stdlib/uri.test.gr +++ b/compiler/test/stdlib/uri.test.gr @@ -71,21 +71,16 @@ testValid( expectedFragment: Some("frag/?"), } ) -testValid( - "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255://?1%1f@:/?#/?a", - { - ...default, - expectedScheme: Some("a12+3-4.5"), - expectedUserinfo: Some( - "1a-._~%1f%Fa!$&'()*+,;=:" - ), // Do not turn %1f into %1F in userinfo - expectedHost: Some("0.99.100.255"), - expectedPath: "//", - expectedQuery: Some("1%1F@:/?"), - expectedFragment: Some("/?a"), - expectedString: "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255//?1%1F@:/?#/?a", - } -) +testValid("a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255://?1%1f@:/?#/?a", { + ...default, + expectedScheme: Some("a12+3-4.5"), + expectedUserinfo: Some("1a-._~%1f%Fa!$&'()*+,;=:"), // Do not turn %1f into %1F in userinfo + expectedHost: Some("0.99.100.255"), + expectedPath: "//", + expectedQuery: Some("1%1F@:/?"), + expectedFragment: Some("/?a"), + expectedString: "a12+3-4.5://1a-._~%1f%Fa!$&'()*+,;=:@0.99.100.255//?1%1F@:/?#/?a", +}) testValid( "mailto:me@email.com", { ...default, expectedScheme: Some("mailto"), expectedPath: "me@email.com" } @@ -333,7 +328,7 @@ assert Result.map( ) ) == Ok( - "ht+1-tp://me%40pw@g+r%2Fa*in%3A80:80/%2520d:o'c%23s!?/a?b%23c=d:ef#Ur%23i-m/ake" + "ht+1-tp://me%40pw@g+r%2Fa*in%3A80:80/%2520d:o'c%23s!?/a?b%23c=d:ef#Ur%23i-m/ake", ) assert Result.map( Uri.toString, @@ -348,11 +343,7 @@ assert Result.map( // update let orig = Result.unwrap(Uri.make()) -assert Uri.update( - orig, - scheme=Some(Some("+")), - percentEncodeComponents=false -) == +assert Uri.update(orig, scheme=Some(Some("+")), percentEncodeComponents=false) == Err(Uri.InvalidSchemeError) assert Uri.update( orig, @@ -374,11 +365,7 @@ assert Uri.update(orig, path=Some("%2"), percentEncodeComponents=false) == Err(Uri.InvalidPathError) assert Uri.update(orig, query=Some(Some("#")), percentEncodeComponents=false) == Err(Uri.InvalidQueryError) -assert Uri.update( - orig, - fragment=Some(Some("%")), - percentEncodeComponents=false -) == +assert Uri.update(orig, fragment=Some(Some("%")), percentEncodeComponents=false) == Err(Uri.InvalidFragmentError) assert Uri.update(orig, port=Some(Some(80))) == Err(Uri.PortWithNoHost) @@ -415,7 +402,7 @@ assert Result.map( ) ) == Ok( - "ht+1-tp://me%40pw@g+r%2Fa*in%3A80:80/%2520d:o'c%23s!?/a?b%23c=d:ef#Ur%23i-m/ake" + "ht+1-tp://me%40pw@g+r%2Fa*in%3A80:80/%2520d:o'c%23s!?/a?b%23c=d:ef#Ur%23i-m/ake", ) assert Result.map( Uri.toString, @@ -426,14 +413,14 @@ assert Result.map( let orig = Result.unwrap(Uri.parse("ftp:path")) assert Uri.update(orig, host=Some(Some("domain"))) == Err(Uri.InvalidPathError) -// percentEncode/percentDecode +// encode/decode let encoded = "%F0%9F%8C%BE" let decoded = "🌾" -assert Uri.percentDecode(encoded) == Ok(decoded) -assert Uri.percentEncode(decoded) == encoded +assert Uri.decode(encoded) == Ok(decoded) +assert Uri.encode(decoded) == encoded -assert Uri.percentDecode("%2") == Err(Uri.InvalidPercentEncoding) +assert Uri.decode("%2") == Err(Uri.InvalidPercentEncoding) // encodeQuery/decodeQuery diff --git a/stdlib/uri.gr b/stdlib/uri.gr index f924674ff6..5c8713bfb5 100644 --- a/stdlib/uri.gr +++ b/stdlib/uri.gr @@ -265,13 +265,13 @@ let removeDotSegments = path => { * @param encodeSet: An indication for which characters to percent-encode. `EncodeNonUnreserved` by default * @returns A percent-encoding of the given string * - * @example Uri.percentEncode("h3ll0_.w ?o+rld", encodeSet=Uri.EncodeNonUnreserved) // "h3ll0_.w%20%3Fo%2Brld" - * @example Uri.percentEncode("d+om@i:n.com", encodeSet=Uri.EncodeRegisteredHost) // "d+om%40i%3An.com" - * @example Uri.percentEncode("word", encodeSet=Uri.EncodeCustom(c => c == 'o')) // "w%6Frd" + * @example Uri.encode("h3ll0_.w ?o+rld", encodeSet=Uri.EncodeNonUnreserved) // "h3ll0_.w%20%3Fo%2Brld" + * @example Uri.encode("d+om@i:n.com", encodeSet=Uri.EncodeRegisteredHost) // "d+om%40i%3An.com" + * @example Uri.encode("word", encodeSet=Uri.EncodeCustom(c => c == 'o')) // "w%6Frd" * * @since v0.6.0 */ -provide let percentEncode = (str, encodeSet=EncodeNonUnreserved) => { +provide let encode = (str, encodeSet=EncodeNonUnreserved) => { let shouldEncode = makePercentEncoder(encodeSet) let chars = String.explode(str) let rec getChars = (i, acc) => { @@ -307,7 +307,7 @@ provide let percentEncode = (str, encodeSet=EncodeNonUnreserved) => { * * @since v0.6.0 */ -provide let percentDecode = str => { +provide let decode = str => { if (!isValidPercentEncoding(str)) { Err(InvalidPercentEncoding) } else { @@ -325,9 +325,9 @@ provide let percentDecode = str => { */ provide let encodeQuery = (urlVals, encodeSet=EncodeNonUnreserved) => { let parts = List.map(((key, val)) => { - percentEncode(key, encodeSet=encodeSet) ++ + encode(key, encodeSet=encodeSet) ++ "=" ++ - percentEncode(val, encodeSet=encodeSet) + encode(val, encodeSet=encodeSet) }, urlVals) List.join("&", parts) @@ -834,17 +834,17 @@ provide let make = ( } let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { - let encode = (val, encodeSet) => - Option.map(val => percentEncode(val, encodeSet=encodeSet), val) + let encodeOption = (val, encodeSet) => + Option.map(val => encode(val, encodeSet=encodeSet), val) let isIpAddressHost = Result.isOk(parseFallible(parseIpAddress, host)) ( - encode(userinfo, EncodeUserinfo), - if (!isIpAddressHost) encode(host, EncodeRegisteredHost) else host, - percentEncode(path, encodeSet=EncodePath), - encode(query, EncodeQueryOrFragment), - encode(fragment, EncodeQueryOrFragment), + encodeOption(userinfo, EncodeUserinfo), + if (!isIpAddressHost) encodeOption(host, EncodeRegisteredHost) else host, + encode(path, encodeSet=EncodePath), + encodeOption(query, EncodeQueryOrFragment), + encodeOption(fragment, EncodeQueryOrFragment), ) } else { (userinfo, host, path, query, fragment) @@ -973,8 +973,8 @@ provide let update = ( } let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { - let encode = (val, encodeSet) => match (val) { - Some(Some(val)) => Some(Some(percentEncode(val, encodeSet=encodeSet))), + let encodeOption = (val, encodeSet) => match (val) { + Some(Some(val)) => Some(Some(encode(val, encodeSet=encodeSet))), val => val, } @@ -984,11 +984,11 @@ provide let update = ( } ( - encode(userinfo, EncodeUserinfo), - if (!isIpAddressHost) encode(host, EncodeRegisteredHost) else host, - Option.map(path => percentEncode(path, encodeSet=EncodePath), path), - encode(query, EncodeQueryOrFragment), - encode(fragment, EncodeQueryOrFragment), + encodeOption(userinfo, EncodeUserinfo), + if (!isIpAddressHost) encodeOption(host, EncodeRegisteredHost) else host, + Option.map(path => encode(path, encodeSet=EncodePath), path), + encodeOption(query, EncodeQueryOrFragment), + encodeOption(fragment, EncodeQueryOrFragment), ) } else { (userinfo, host, path, query, fragment) diff --git a/stdlib/uri.md b/stdlib/uri.md index 5da45df0a0..cc7e815c6e 100644 --- a/stdlib/uri.md +++ b/stdlib/uri.md @@ -101,7 +101,7 @@ Used to specify which characters to percent-encode from a string. Functions and constants included in the Uri module. -### Uri.**percentEncode** +### Uri.**encode**
Added in next @@ -109,7 +109,7 @@ No other changes yet.
```grain -percentEncode : (str: String, ?encodeSet: PercentEncodeSet) => String +encode : (str: String, ?encodeSet: PercentEncodeSet) => String ``` Percent-encodes characters in a string based on the specified `PercentEncodeSet`. @@ -130,18 +130,18 @@ Returns: Examples: ```grain -Uri.percentEncode("h3ll0_.w ?o+rld", encodeSet=Uri.EncodeNonUnreserved) // "h3ll0_.w%20%3Fo%2Brld" +Uri.encode("h3ll0_.w ?o+rld", encodeSet=Uri.EncodeNonUnreserved) // "h3ll0_.w%20%3Fo%2Brld" ``` ```grain -Uri.percentEncode("d+om@i:n.com", encodeSet=Uri.EncodeRegisteredHost) // "d+om%40i%3An.com" +Uri.encode("d+om@i:n.com", encodeSet=Uri.EncodeRegisteredHost) // "d+om%40i%3An.com" ``` ```grain -Uri.percentEncode("word", encodeSet=Uri.EncodeCustom(c => c == 'o')) // "w%6Frd" +Uri.encode("word", encodeSet=Uri.EncodeCustom(c => c == 'o')) // "w%6Frd" ``` -### Uri.**percentDecode** +### Uri.**decode**
Added in next @@ -149,7 +149,7 @@ No other changes yet.
```grain -percentDecode : (str: String) => Result +decode : (str: String) => Result ``` Decodes any percent-encoded characters in a string. From 8a672a7496919734ce0e2c8965a51e38ee4ee209 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 2 Mar 2024 21:37:18 +0000 Subject: [PATCH 6/6] Address review feedback --- compiler/test/stdlib/uri.test.gr | 49 ++++++------- stdlib/uri.gr | 119 ++++++++++++++++--------------- stdlib/uri.md | 57 ++++++++------- 3 files changed, 109 insertions(+), 116 deletions(-) diff --git a/compiler/test/stdlib/uri.test.gr b/compiler/test/stdlib/uri.test.gr index 64c8abc8de..a5535974ad 100644 --- a/compiler/test/stdlib/uri.test.gr +++ b/compiler/test/stdlib/uri.test.gr @@ -281,23 +281,18 @@ testResolve("a://a.com?a#a", "", "a://a.com?a") // make -assert Uri.make(scheme=Some("+"), percentEncodeComponents=false) == +assert Uri.make(scheme=Some("+"), encodeComponents=false) == Err(Uri.InvalidSchemeError) -assert Uri.make( - userinfo=Some("%"), - host=Some("a"), - percentEncodeComponents=false -) == +assert Uri.make(userinfo=Some("%"), host=Some("a"), encodeComponents=false) == Err(Uri.InvalidUserinfoError) -assert Uri.make(host=Some("#"), percentEncodeComponents=false) == +assert Uri.make(host=Some("#"), encodeComponents=false) == Err(Uri.InvalidHostError) -assert Uri.make(port=Some(-1), host=Some("a"), percentEncodeComponents=false) == +assert Uri.make(port=Some(-1), host=Some("a"), encodeComponents=false) == Err(Uri.InvalidPortError) -assert Uri.make(path="%2", percentEncodeComponents=false) == - Err(Uri.InvalidPathError) -assert Uri.make(query=Some("#"), percentEncodeComponents=false) == +assert Uri.make(path="%2", encodeComponents=false) == Err(Uri.InvalidPathError) +assert Uri.make(query=Some("#"), encodeComponents=false) == Err(Uri.InvalidQueryError) -assert Uri.make(fragment=Some("%"), percentEncodeComponents=false) == +assert Uri.make(fragment=Some("%"), encodeComponents=false) == Err(Uri.InvalidFragmentError) assert Uri.make(userinfo=Some("me")) == Err(Uri.UserinfoWithNoHost) assert Uri.make(port=Some(80)) == Err(Uri.PortWithNoHost) @@ -324,7 +319,7 @@ assert Result.map( path="/%20d:o'c#s!", query=Some("/a?b#c=d:ef"), fragment=Some("Ur#i-m/ake"), - percentEncodeComponents=true + encodeComponents=true ) ) == Ok( @@ -332,40 +327,36 @@ assert Result.map( ) assert Result.map( Uri.toString, - Uri.make( - scheme=Some("http"), - host=Some("[1::1]"), - percentEncodeComponents=true - ) + Uri.make(scheme=Some("http"), host=Some("[1::1]"), encodeComponents=true) ) == Ok("http://[1::1]") // update let orig = Result.unwrap(Uri.make()) -assert Uri.update(orig, scheme=Some(Some("+")), percentEncodeComponents=false) == +assert Uri.update(orig, scheme=Some(Some("+")), encodeComponents=false) == Err(Uri.InvalidSchemeError) assert Uri.update( orig, userinfo=Some(Some("%")), host=Some(Some("a")), - percentEncodeComponents=false + encodeComponents=false ) == Err(Uri.InvalidUserinfoError) -assert Uri.update(orig, host=Some(Some("#")), percentEncodeComponents=false) == +assert Uri.update(orig, host=Some(Some("#")), encodeComponents=false) == Err(Uri.InvalidHostError) assert Uri.update( orig, port=Some(Some(1.1)), host=Some(Some("a")), - percentEncodeComponents=false + encodeComponents=false ) == Err(Uri.InvalidPortError) -assert Uri.update(orig, path=Some("%2"), percentEncodeComponents=false) == +assert Uri.update(orig, path=Some("%2"), encodeComponents=false) == Err(Uri.InvalidPathError) -assert Uri.update(orig, query=Some(Some("#")), percentEncodeComponents=false) == +assert Uri.update(orig, query=Some(Some("#")), encodeComponents=false) == Err(Uri.InvalidQueryError) -assert Uri.update(orig, fragment=Some(Some("%")), percentEncodeComponents=false) == +assert Uri.update(orig, fragment=Some(Some("%")), encodeComponents=false) == Err(Uri.InvalidFragmentError) assert Uri.update(orig, port=Some(Some(80))) == Err(Uri.PortWithNoHost) @@ -398,7 +389,7 @@ assert Result.map( path=Some("/%20d:o'c#s!"), query=Some(Some("/a?b#c=d:ef")), fragment=Some(Some("Ur#i-m/ake")), - percentEncodeComponents=true + encodeComponents=true ) ) == Ok( @@ -406,7 +397,7 @@ assert Result.map( ) assert Result.map( Uri.toString, - Uri.update(orig, host=Some(Some("[1::1]")), percentEncodeComponents=true) + Uri.update(orig, host=Some(Some("[1::1]")), encodeComponents=true) ) == Ok("https://me:pw@[1::1]:80/docs?k=v#frag") @@ -420,7 +411,7 @@ let decoded = "🌾" assert Uri.decode(encoded) == Ok(decoded) assert Uri.encode(decoded) == encoded -assert Uri.decode("%2") == Err(Uri.InvalidPercentEncoding) +assert Uri.decode("%2") == Err(Uri.InvalidEncoding) // encodeQuery/decodeQuery @@ -429,4 +420,4 @@ let decoded = [("val", "🌾"), ("val🧱2", "x=y&a=b")] assert Uri.encodeQuery(decoded) == encoded assert Uri.decodeQuery(encoded) == Ok(decoded) -assert Uri.decodeQuery("%2") == Err(Uri.InvalidPercentEncoding) +assert Uri.decodeQuery("%2") == Err(Uri.InvalidEncoding) diff --git a/stdlib/uri.gr b/stdlib/uri.gr index 5c8713bfb5..5f198e9979 100644 --- a/stdlib/uri.gr +++ b/stdlib/uri.gr @@ -7,17 +7,16 @@ */ module Uri -from "string" include String -from "char" include Char -from "uint8" include Uint8 -from "number" include Number -from "bytes" include Bytes +from "array" include Array from "buffer" include Buffer +from "bytes" include Bytes +from "char" include Char from "list" include List -from "array" include Array -from "map" include Map +from "number" include Number from "option" include Option from "result" include Result +from "string" include String +from "uint8" include Uint8 /** * Represents a parsed RFC 3986 URI. @@ -64,14 +63,14 @@ provide enum ResolveReferenceError { /** * Represents an error encountered while attempting to percent-decode a string. */ -provide enum PercentDecodingError { - InvalidPercentEncoding, +provide enum DecodingError { + InvalidEncoding, } /** * Used to specify which characters to percent-encode from a string. */ -provide enum PercentEncodeSet { +provide enum EncodeSet { EncodeNonUnreserved, EncodeUserinfo, EncodeRegisteredHost, @@ -108,7 +107,7 @@ let isPchar = char => { isUnreservedChar(char) || isSubDelim(char) || char == ':' || char == '@' } -let makePercentEncoder = (encodeSet: PercentEncodeSet) => { +let makeEncoder = (encodeSet: EncodeSet) => { let shouldEncodeForNonUnreserved = char => !isUnreservedChar(char) let shouldEncodeForUserinfo = char => { @@ -159,18 +158,18 @@ let hexValueToChar = val => { } } -let percentDecodeValid = (str, onlyUnreserved=false) => { +let decodeValid = (str, onlyUnreserved=false) => { let bytes = String.encode(str, String.UTF8) let len = Bytes.length(bytes) let out = Buffer.make(len) - let cAt = i => Char.fromCode(Uint8.toNumber(Bytes.getUint8(i, bytes))) + let charAt = i => Char.fromCode(Uint8.toNumber(Bytes.getUint8(i, bytes))) for (let mut i = 0; i < len; i += 1) { - if (i >= len - 2 || cAt(i) != '%') { + if (i >= len - 2 || charAt(i) != '%') { let byte = Bytes.getUint8(i, bytes) Buffer.addUint8(byte, out) } else { - let next = cAt(i + 1) - let nextNext = cAt(i + 2) + let next = charAt(i + 1) + let nextNext = charAt(i + 2) let pctDecodedVal = charToHexValue(next) * 16 + charToHexValue(nextNext) if (onlyUnreserved && !isUnreservedChar(Char.fromCode(pctDecodedVal))) { Buffer.addChar('%', out) @@ -185,7 +184,7 @@ let percentDecodeValid = (str, onlyUnreserved=false) => { Buffer.toString(out) } -let isValidPercentEncoding = str => { +let isValidEncoding = str => { let chars = String.explode(str) let len = Array.length(chars) for (let mut i = 0; i < len; i += 1) { @@ -201,7 +200,7 @@ let isValidPercentEncoding = str => { // Lowercase all non-percent-encoded alphabetical characters let normalizeHost = str => { - let str = percentDecodeValid(str, onlyUnreserved=true) + let str = decodeValid(str, onlyUnreserved=true) let chars = String.explode(str) let rec getChars = (i, acc) => { @@ -259,7 +258,7 @@ let removeDotSegments = path => { } /** - * Percent-encodes characters in a string based on the specified `PercentEncodeSet`. + * Percent-encodes characters in a string based on the specified `EncodeSet`. * * @param str: The string to encode * @param encodeSet: An indication for which characters to percent-encode. `EncodeNonUnreserved` by default @@ -272,7 +271,8 @@ let removeDotSegments = path => { * @since v0.6.0 */ provide let encode = (str, encodeSet=EncodeNonUnreserved) => { - let shouldEncode = makePercentEncoder(encodeSet) + let shouldEncode = makeEncoder(encodeSet) + // TODO(#2053): use String.map when implemented let chars = String.explode(str) let rec getChars = (i, acc) => { if (i < 0) { @@ -303,15 +303,15 @@ provide let encode = (str, encodeSet=EncodeNonUnreserved) => { * Decodes any percent-encoded characters in a string. * * @param str: The string to decode - * @returns `Ok(decoded)` containing a the decoded string or `Err(err)` if the decoding failed + * @returns `Ok(decoded)` containing the decoded string or `Err(err)` if the decoding failed * * @since v0.6.0 */ provide let decode = str => { - if (!isValidPercentEncoding(str)) { - Err(InvalidPercentEncoding) + if (isValidEncoding(str)) { + Ok(decodeValid(str)) } else { - Ok(percentDecodeValid(str)) + Err(InvalidEncoding) } } @@ -325,9 +325,7 @@ provide let decode = str => { */ provide let encodeQuery = (urlVals, encodeSet=EncodeNonUnreserved) => { let parts = List.map(((key, val)) => { - encode(key, encodeSet=encodeSet) ++ - "=" ++ - encode(val, encodeSet=encodeSet) + encode(key, encodeSet=encodeSet) ++ "=" ++ encode(val, encodeSet=encodeSet) }, urlVals) List.join("&", parts) @@ -337,14 +335,12 @@ provide let encodeQuery = (urlVals, encodeSet=EncodeNonUnreserved) => { * Decodes a query string into a list of pairs. * * @param str: A query string - * @returns A list of key-value pairs containing the values of the encoded string + * @returns `Ok(decoded)` containing a list of key-value pairs from the decoded string or `Err(err)` if the decoding failed * * @since v0.6.0 */ provide let decodeQuery = str => { - if (!isValidPercentEncoding(str)) { - Err(InvalidPercentEncoding) - } else { + if (isValidEncoding(str)) { let parts = Array.toList(String.split("&", str)) Ok(List.map(part => { match (String.indexOf("=", part)) { @@ -353,10 +349,12 @@ provide let decodeQuery = str => { Some(i) => { let name = String.slice(0, end=i, part) let val = String.slice(i + 1, part) - (percentDecodeValid(name), percentDecodeValid(val)) + (decodeValid(name), decodeValid(val)) }, } }, parts)) + } else { + Err(InvalidEncoding) } } @@ -564,9 +562,9 @@ module Matchers { provide let fragment = query } -use Matchers.* let parseScheme = (str, withDelim=false) => { + use Matchers.{ seq, char, scheme } let matcher = if (withDelim) seq([scheme, char(':')]) else scheme match (matcher(0, str)) { None => (0, None), @@ -583,20 +581,21 @@ let parseScheme = (str, withDelim=false) => { } let parseIpAddress = (i, str) => { - match (ipAddress(i, str)) { + match (Matchers.ipAddress(i, str)) { None => Err(ParseError), Some(endI) => Ok((endI, normalizeHost(String.slice(i, end=endI, str)))), } } let parseHost = (i, str) => { - match (host(i, str)) { + match (Matchers.host(i, str)) { None => Err(ParseError), Some(endI) => Ok((endI, normalizeHost(String.slice(i, end=endI, str)))), } } let parseUserinfo = (i, str, withDelim=false) => { + use Matchers.{ seq, char, userinfo } let matcher = if (withDelim) seq([userinfo, char('@')]) else userinfo match (matcher(i, str)) { None => (i, None), @@ -606,6 +605,7 @@ let parseUserinfo = (i, str, withDelim=false) => { } let parsePortWithDelim = (i, str) => { + use Matchers.{ seq, char, port } match (seq([char(':'), port])(i, str)) { None => (i, None), Some(endI) => { @@ -623,23 +623,24 @@ let parsePortWithDelim = (i, str) => { let parsePath = (i, str, isAbsolute, hasAuthority) => { let processPath = if (isAbsolute) removeDotSegments else identity if (hasAuthority) { - let endI = Option.unwrap(pathAbempty(i, str)) + let endI = Option.unwrap(Matchers.pathAbempty(i, str)) let path = processPath( - percentDecodeValid(String.slice(i, end=endI, str), onlyUnreserved=true) + decodeValid(String.slice(i, end=endI, str), onlyUnreserved=true) ) (endI, path) } else { + use Matchers.{ pathRootless, pathNoScheme, pathAbsolute, empty, any } let extraOption = if (isAbsolute) pathRootless else pathNoScheme let endI = Option.unwrap(any([pathAbsolute, extraOption, empty])(i, str)) let path = processPath( - percentDecodeValid(String.slice(i, end=endI, str), onlyUnreserved=true) + decodeValid(String.slice(i, end=endI, str), onlyUnreserved=true) ) (endI, path) } } let parseAfterScheme = (i, str, isAbsolute) => { - match (string("//")(i, str)) { + match (Matchers.string("//")(i, str)) { Some(i) => { let (i, userinfo) = parseUserinfo(i, str, withDelim=true) let (i, host) = match (parseHost(i, str)) { @@ -658,6 +659,7 @@ let parseAfterScheme = (i, str, isAbsolute) => { } let parseQuery = (i, str, withDelim=false) => { + use Matchers.{ seq, char, query } let matcher = if (withDelim) seq([char('?'), query]) else query match (matcher(i, str)) { None => (i, None), @@ -665,7 +667,7 @@ let parseQuery = (i, str, withDelim=false) => { ( endI, Some( - percentDecodeValid( + decodeValid( String.slice(i + (if (withDelim) 1 else 0), end=endI, str), onlyUnreserved=true ), @@ -675,6 +677,7 @@ let parseQuery = (i, str, withDelim=false) => { } let parseFragment = (i, str, withDelim=false) => { + use Matchers.{ seq, char, fragment } let matcher = if (withDelim) seq([char('#'), fragment]) else fragment match (matcher(i, str)) { None => (i, None), @@ -682,7 +685,7 @@ let parseFragment = (i, str, withDelim=false) => { ( endI, Some( - percentDecodeValid( + decodeValid( String.slice(i + (if (withDelim) 1 else 0), end=endI, str), onlyUnreserved=true ), @@ -697,7 +700,7 @@ let parseFragment = (i, str, withDelim=false) => { * segments. * * @param str: The RFC 3986 URI string to parse - * @returns `Ok(uri)` containing a `Uri` if the given string is a valid URI, `Err(ParseError)` otherwise + * @returns `Ok(uri)` containing a `Uri` if the given string is a valid URI or `Err(ParseError)` otherwise * * @example Uri.parse("https://grain-lang.org") == Ok(...) * @example Uri.parse("http://@*^%") == Err(Uri.ParseError) @@ -727,7 +730,7 @@ provide let parse = str => { * * @param base: The base URI to resolve a URI reference on * @param ref: The URI reference to apply onto the base - * @returns `Ok(uri)` containing the target `Uri`, or `Err(err)` if input is malformed + * @returns `Ok(uri)` containing the target `Uri` or `Err(err)` if the input is malformed * * @example resolveReference(unwrap(parse("https://grain-lang.org/docs/stdlib/uri")), unwrap(parse("../intro"))) // https://grain-lang.org/docs/intro * @example resolveReference(unwrap(parse("https://grain-lang.org/docs")), unwrap(parse("?key=val"))) // https://grain-lang.org/docs?key=val @@ -784,11 +787,11 @@ provide let resolveReference = (base, ref) => { * @param path: The desired path for the URI. `""` by default * @param query: `Some(query)` containing the desired query string component or `None` for a query-less URI * @param fragment: `Some(fragment)` containing the desired fragment component or `None` for a fragment-less URI - * @param percentEncodeComponents: Whether or not to apply percent encoding for each component to remove unsafe characters for each component + * @param encodeComponents: Whether or not to apply percent encoding for each component to remove unsafe characters for each component * * @example Uri.make(scheme=Some("https"), host=Some("grain-lang.org")) // https://grain-lang.org - * @example Uri.make(host=Some("g/r@in"), percentEncodeComponents=false) // Err(Uri.InvalidHostError) - * @example Uri.make(scheme=Some("abc"), host=Some("g/r@in"), query=Some("k/ey=v^@l"), percentEncodeComponents=true) // abc://g%2Fr%40in?k/ey=v%5E@l + * @example Uri.make(host=Some("g/r@in"), encodeComponents=false) // Err(Uri.InvalidHostError) + * @example Uri.make(scheme=Some("abc"), host=Some("g/r@in"), query=Some("k/ey=v^@l"), encodeComponents=true) // abc://g%2Fr%40in?k/ey=v%5E@l * @example Uri.make(port=Some(80)) // Err(Uri.PortWithNoHost) * * @since v0.6.0 @@ -801,7 +804,7 @@ provide let make = ( path="", query=None, fragment=None, - percentEncodeComponents=false, + encodeComponents=false, ) => { match ((host, userinfo, port)) { (None, Some(_), None) => return Err(UserinfoWithNoHost), @@ -833,7 +836,7 @@ provide let make = ( } } - let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { + let (userinfo, host, path, query, fragment) = if (encodeComponents) { let encodeOption = (val, encodeSet) => Option.map(val => encode(val, encodeSet=encodeSet), val) @@ -907,7 +910,7 @@ enum UpdateAction
{ * component should be used and `Some(val)` means that a new value should be * used for that component. * - * @param uri: The base `Uri` to apply updates on top of + * @param uri: The `Uri` to update * @param scheme: `Some(scheme)` containing the desired updated scheme component or `None` to maintain the base URI's scheme * @param userinfo: `Some(userinfo)` containing the desired updated userinfo component or `None` to maintain the base URI's userinfo * @param host: `Some(host)` containing the desired updated host component or `None` to maintain the base URI's host @@ -915,12 +918,12 @@ enum UpdateAction { * @param path: `Some(path)` containing the desired updated path component or `None` to maintain the base URI's path * @param query: `Some(query)` containing the desired updated query string component or `None` to maintain the base URI's query * @param fragment: `Some(fragment)` containing the desired updated fragment component or `None` to maintain the base URI's fragment - * @param percentEncodeComponents: Whether or not to apply percent encoding for each updated component to remove unsafe characters + * @param encodeComponents: Whether or not to apply percent encoding for each updated component to remove unsafe characters * * @example let uri = Result.unwrap(Uri.parse("https://grain-lang.org/docs?k=v")) // Base URI for following examples * @example Uri.update(uri, scheme=Some(Some("ftp"))) // ftp://grain-lang.org/docs?k=v * @example Uri.update(uri, query=Some(None)) // https://grain-lang.org/docs - * @example Uri.update(uri, host=Some(Some("g/r@in")), percentEncodeComponents=true) // https://g%2Fr%40in/docs?k=v + * @example Uri.update(uri, host=Some(Some("g/r@in")), encodeComponents=true) // https://g%2Fr%40in/docs?k=v * @example Uri.update(uri, host=Some(None), port=Some(Some(80))) // Err(Uri.PortWithNoHost) * * @since v0.6.0 @@ -934,7 +937,7 @@ provide let update = ( path=None, query=None, fragment=None, - percentEncodeComponents=false, + encodeComponents=false, ) => { let (??) = (new, old) => Option.unwrapWithDefault(old, new) match ((host ?? uri.host, userinfo ?? uri.userinfo, port ?? uri.port)) { @@ -972,7 +975,7 @@ provide let update = ( } } - let (userinfo, host, path, query, fragment) = if (percentEncodeComponents) { + let (userinfo, host, path, query, fragment) = if (encodeComponents) { let encodeOption = (val, encodeSet) => match (val) { Some(Some(val)) => Some(Some(encode(val, encodeSet=encodeSet))), val => val, @@ -1049,20 +1052,20 @@ provide let update = ( } /** - * Determines whether or not a `Uri` has an authority (i.e. has a host component) + * Determines whether a `Uri` has an authority (i.e. has a host component) * * @param uri: The `Uri` to consider - * @returns `true` if the `Uri` has an authority component, `false` otherwise + * @returns `true` if the `Uri` has an authority component or `false` otherwise * * @since v0.6.0 */ provide let hasAuthority = uri => uri.host != None /** - * Determines whether or not a `Uri` is an absolute URI (has a scheme component) + * Determines whether a `Uri` is an absolute URI (has a scheme component) * * @param uri: The `Uri` to consider - * @returns `true` if the `Uri` is absolute (has a scheme component), `false` otherwise + * @returns `true` if the `Uri` is absolute (has a scheme component) or `false` otherwise * * @since v0.6.0 */ @@ -1071,7 +1074,7 @@ provide let isAbsolute = uri => uri.scheme != None /** * Converts the given `Uri` into a string. * - * @param uri: The `Uri` to convert to a string + * @param uri: The `Uri` to convert * @returns A string representation of the `Uri` * * @since v0.6.0 diff --git a/stdlib/uri.md b/stdlib/uri.md index cc7e815c6e..5c69ace9d9 100644 --- a/stdlib/uri.md +++ b/stdlib/uri.md @@ -71,20 +71,20 @@ enum ResolveReferenceError { Represents an error encountered while attempting to resolve a URI reference to a target URI. -### Uri.**PercentDecodingError** +### Uri.**DecodingError** ```grain -enum PercentDecodingError { - InvalidPercentEncoding, +enum DecodingError { + InvalidEncoding, } ``` Represents an error encountered while attempting to percent-decode a string. -### Uri.**PercentEncodeSet** +### Uri.**EncodeSet** ```grain -enum PercentEncodeSet { +enum EncodeSet { EncodeNonUnreserved, EncodeUserinfo, EncodeRegisteredHost, @@ -109,17 +109,17 @@ No other changes yet. ```grain -encode : (str: String, ?encodeSet: PercentEncodeSet) => String +encode : (str: String, ?encodeSet: EncodeSet) => String ``` -Percent-encodes characters in a string based on the specified `PercentEncodeSet`. +Percent-encodes characters in a string based on the specified `EncodeSet`. Parameters: |param|type|description| |-----|----|-----------| |`str`|`String`|The string to encode| -|`?encodeSet`|`PercentEncodeSet`|An indication for which characters to percent-encode. `EncodeNonUnreserved` by default| +|`?encodeSet`|`EncodeSet`|An indication for which characters to percent-encode. `EncodeNonUnreserved` by default| Returns: @@ -149,7 +149,7 @@ No other changes yet. ```grain -decode : (str: String) => Result +decode : (str: String) => Result ``` Decodes any percent-encoded characters in a string. @@ -164,7 +164,7 @@ Returns: |type|description| |----|-----------| -|`Result`|`Ok(decoded)` containing a the decoded string or `Err(err)` if the decoding failed| +|`Result`|`Ok(decoded)` containing the decoded string or `Err(err)` if the decoding failed| ### Uri.**encodeQuery** @@ -175,7 +175,7 @@ No other changes yet. ```grain encodeQuery : - (urlVals: List<(String, String)>, ?encodeSet: PercentEncodeSet) => String + (urlVals: List<(String, String)>, ?encodeSet: EncodeSet) => String ``` Encodes a list of key-value pairs into an query string. @@ -200,8 +200,7 @@ No other changes yet. ```grain -decodeQuery : - (str: String) => Result, PercentDecodingError> +decodeQuery : (str: String) => Result, DecodingError> ``` Decodes a query string into a list of pairs. @@ -216,7 +215,7 @@ Returns: |type|description| |----|-----------| -|`Result, PercentDecodingError>`|A list of key-value pairs containing the values of the encoded string| +|`Result, DecodingError>`|`Ok(decoded)` containing a list of key-value pairs from the decoded string or `Err(err)` if the decoding failed| ### Uri.**parse** @@ -243,7 +242,7 @@ Returns: |type|description| |----|-----------| -|`Result`|`Ok(uri)` containing a `Uri` if the given string is a valid URI, `Err(ParseError)` otherwise| +|`Result`|`Ok(uri)` containing a `Uri` if the given string is a valid URI or `Err(ParseError)` otherwise| Examples: @@ -280,7 +279,7 @@ Returns: |type|description| |----|-----------| -|`Result`|`Ok(uri)` containing the target `Uri`, or `Err(err)` if input is malformed| +|`Result`|`Ok(uri)` containing the target `Uri` or `Err(err)` if the input is malformed| Examples: @@ -307,7 +306,7 @@ No other changes yet. make : (?scheme: Option, ?userinfo: Option, ?host: Option, ?port: Option, ?path: String, ?query: Option, - ?fragment: Option, ?percentEncodeComponents: Bool) => + ?fragment: Option, ?encodeComponents: Bool) => Result ``` @@ -324,7 +323,7 @@ Parameters: |`?path`|`String`|The desired path for the URI. `""` by default| |`?query`|`Option`|`Some(query)` containing the desired query string component or `None` for a query-less URI| |`?fragment`|`Option`|`Some(fragment)` containing the desired fragment component or `None` for a fragment-less URI| -|`?percentEncodeComponents`|`Bool`|Whether or not to apply percent encoding for each component to remove unsafe characters for each component| +|`?encodeComponents`|`Bool`|Whether or not to apply percent encoding for each component to remove unsafe characters for each component| Examples: @@ -333,11 +332,11 @@ Uri.make(scheme=Some("https"), host=Some("grain-lang.org")) // https://grain-lan ``` ```grain -Uri.make(host=Some("g/r@in"), percentEncodeComponents=false) // Err(Uri.InvalidHostError) +Uri.make(host=Some("g/r@in"), encodeComponents=false) // Err(Uri.InvalidHostError) ``` ```grain -Uri.make(scheme=Some("abc"), host=Some("g/r@in"), query=Some("k/ey=v^@l"), percentEncodeComponents=true) // abc://g%2Fr%40in?k/ey=v%5E@l +Uri.make(scheme=Some("abc"), host=Some("g/r@in"), query=Some("k/ey=v^@l"), encodeComponents=true) // abc://g%2Fr%40in?k/ey=v%5E@l ``` ```grain @@ -357,7 +356,7 @@ update : ?userinfo: Option>, ?host: Option>, ?port: Option>, ?path: Option, ?query: Option>, ?fragment: Option>, - ?percentEncodeComponents: Bool) => Result + ?encodeComponents: Bool) => Result ``` Constructs a new `Uri` from a base `Uri` and components to update. The @@ -369,7 +368,7 @@ Parameters: |param|type|description| |-----|----|-----------| -|`uri`|`Uri`|The base `Uri` to apply updates on top of| +|`uri`|`Uri`|The `Uri` to update| |`?scheme`|`Option>`|`Some(scheme)` containing the desired updated scheme component or `None` to maintain the base URI's scheme| |`?userinfo`|`Option>`|`Some(userinfo)` containing the desired updated userinfo component or `None` to maintain the base URI's userinfo| |`?host`|`Option>`|`Some(host)` containing the desired updated host component or `None` to maintain the base URI's host| @@ -377,7 +376,7 @@ Parameters: |`?path`|`Option`|`Some(path)` containing the desired updated path component or `None` to maintain the base URI's path| |`?query`|`Option>`|`Some(query)` containing the desired updated query string component or `None` to maintain the base URI's query| |`?fragment`|`Option>`|`Some(fragment)` containing the desired updated fragment component or `None` to maintain the base URI's fragment| -|`?percentEncodeComponents`|`Bool`|Whether or not to apply percent encoding for each updated component to remove unsafe characters| +|`?encodeComponents`|`Bool`|Whether or not to apply percent encoding for each updated component to remove unsafe characters| Examples: @@ -394,7 +393,7 @@ Uri.update(uri, query=Some(None)) // https://grain-lang.org/docs ``` ```grain -Uri.update(uri, host=Some(Some("g/r@in")), percentEncodeComponents=true) // https://g%2Fr%40in/docs?k=v +Uri.update(uri, host=Some(Some("g/r@in")), encodeComponents=true) // https://g%2Fr%40in/docs?k=v ``` ```grain @@ -412,7 +411,7 @@ No other changes yet. hasAuthority : (uri: Uri) => Bool ``` -Determines whether or not a `Uri` has an authority (i.e. has a host component) +Determines whether a `Uri` has an authority (i.e. has a host component) Parameters: @@ -424,7 +423,7 @@ Returns: |type|description| |----|-----------| -|`Bool`|`true` if the `Uri` has an authority component, `false` otherwise| +|`Bool`|`true` if the `Uri` has an authority component or `false` otherwise| ### Uri.**isAbsolute** @@ -437,7 +436,7 @@ No other changes yet. isAbsolute : (uri: Uri) => Bool ``` -Determines whether or not a `Uri` is an absolute URI (has a scheme component) +Determines whether a `Uri` is an absolute URI (has a scheme component) Parameters: @@ -449,7 +448,7 @@ Returns: |type|description| |----|-----------| -|`Bool`|`true` if the `Uri` is absolute (has a scheme component), `false` otherwise| +|`Bool`|`true` if the `Uri` is absolute (has a scheme component) or `false` otherwise| ### Uri.**toString** @@ -468,7 +467,7 @@ Parameters: |param|type|description| |-----|----|-----------| -|`uri`|`Uri`|The `Uri` to convert to a string| +|`uri`|`Uri`|The `Uri` to convert| Returns: