1 /** 2 * A URL handling library. 3 * 4 * URLs are Unique Resource Locators. They consist of a scheme and a host, with some optional 5 * elements like port, path, username, and password. 6 * 7 * This module aims to make it simple to muck about with them. 8 * 9 * Example usage: 10 * --- 11 * auto url = "ssh://me:password@192.168.0.8/".parseURL; 12 * auto files = system("ssh", url.toString, "ls").splitLines; 13 * foreach (file; files) { 14 * system("scp", url ~ file, "."); 15 * } 16 * --- 17 * 18 * License: The MIT license. 19 */ 20 module url; 21 22 import std.conv; 23 import std.string; 24 25 pure: 26 @safe: 27 28 /// An exception thrown when something bad happens with URLs. 29 class URLException : Exception 30 { 31 this(string msg) pure { super(msg); } 32 } 33 34 /** 35 * A mapping from schemes to their default ports. 36 * 37 * This is not exhaustive. Not all schemes use ports. Not all schemes uniquely identify a port to 38 * use even if they use ports. Entries here should be treated as best guesses. 39 */ 40 enum ushort[string] schemeToDefaultPort = [ 41 "aaa": 3868, 42 "aaas": 5658, 43 "acap": 674, 44 "amqp": 5672, 45 "cap": 1026, 46 "coap": 5683, 47 "coaps": 5684, 48 "dav": 443, 49 "dict": 2628, 50 "ftp": 21, 51 "git": 9418, 52 "go": 1096, 53 "gopher": 70, 54 "http": 80, 55 "https": 443, 56 "ws": 80, 57 "wss": 443, 58 "iac": 4569, 59 "icap": 1344, 60 "imap": 143, 61 "ipp": 631, 62 "ipps": 631, // yes, they're both mapped to port 631 63 "irc": 6667, // De facto default port, not the IANA reserved port. 64 "ircs": 6697, 65 "iris": 702, // defaults to iris.beep 66 "iris.beep": 702, 67 "iris.lwz": 715, 68 "iris.xpc": 713, 69 "iris.xpcs": 714, 70 "jabber": 5222, // client-to-server 71 "ldap": 389, 72 "ldaps": 636, 73 "msrp": 2855, 74 "msrps": 2855, 75 "mtqp": 1038, 76 "mupdate": 3905, 77 "news": 119, 78 "nfs": 2049, 79 "pop": 110, 80 "redis": 6379, 81 "reload": 6084, 82 "rsync": 873, 83 "rtmfp": 1935, 84 "rtsp": 554, 85 "shttp": 80, 86 "sieve": 4190, 87 "sip": 5060, 88 "sips": 5061, 89 "smb": 445, 90 "smtp": 25, 91 "snews": 563, 92 "snmp": 161, 93 "soap.beep": 605, 94 "ssh": 22, 95 "stun": 3478, 96 "stuns": 5349, 97 "svn": 3690, 98 "teamspeak": 9987, 99 "telnet": 23, 100 "tftp": 69, 101 "tip": 3372, 102 ]; 103 104 /** 105 * A collection of query parameters. 106 * 107 * This is effectively a multimap of string -> strings. 108 */ 109 struct QueryParams 110 { 111 pure: 112 import std.typecons; 113 alias Tuple!(string, "key", string, "value") Param; 114 Param[] params; 115 116 @property size_t length() const { 117 return params.length; 118 } 119 120 /// Get a range over the query parameter values for the given key. 121 auto opIndex(string key) const 122 { 123 import std.algorithm.searching : find; 124 import std.algorithm.iteration : map; 125 return params.find!(x => x.key == key).map!(x => x.value); 126 } 127 128 /// Add a query parameter with the given key and value. 129 /// If one already exists, there will now be two query parameters with the given name. 130 void add(string key, string value) { 131 params ~= Param(key, value); 132 } 133 134 /// Add a query parameter with the given key and value. 135 /// If there are any existing parameters with the same key, they are removed and overwritten. 136 void overwrite(string key, string value) { 137 for (int i = 0; i < params.length; i++) { 138 if (params[i].key == key) { 139 params[i] = params[$-1]; 140 params.length--; 141 } 142 } 143 params ~= Param(key, value); 144 } 145 146 private struct QueryParamRange 147 { 148 pure: 149 size_t i; 150 const(Param)[] params; 151 bool empty() { return i >= params.length; } 152 void popFront() { i++; } 153 Param front() { return params[i]; } 154 } 155 156 /** 157 * A range over the query parameters. 158 * 159 * Usage: 160 * --- 161 * foreach (key, value; url.queryParams) {} 162 * --- 163 */ 164 auto range() const 165 { 166 return QueryParamRange(0, this.params); 167 } 168 /// ditto 169 alias range this; 170 171 /// Convert this set of query parameters into a query string. 172 string toString() const { 173 import std.array : Appender; 174 Appender!string s; 175 bool first = true; 176 foreach (tuple; this) { 177 if (!first) { 178 s ~= '&'; 179 } 180 first = false; 181 s ~= tuple.key.percentEncode; 182 if (tuple.value.length > 0) { 183 s ~= '='; 184 s ~= tuple.value.percentEncode; 185 } 186 } 187 return s.data; 188 } 189 190 /// Clone this set of query parameters. 191 QueryParams dup() { 192 QueryParams other = this; 193 other.params = params.dup; 194 return other; 195 } 196 } 197 198 /** 199 * A Unique Resource Locator. 200 * 201 * URLs can be parsed (see parseURL) and implicitly convert to strings. 202 */ 203 struct URL 204 { 205 pure: 206 /// The URL scheme. For instance, ssh, ftp, or https. 207 string scheme; 208 209 /// The username in this URL. Usually absent. If present, there will also be a password. 210 string user; 211 212 /// The password in this URL. Usually absent. 213 string pass; 214 215 /// The hostname. 216 string host; 217 218 /** 219 * The port. 220 * 221 * This is inferred from the scheme if it isn't present in the URL itself. 222 * If the scheme is not known and the port is not present, the port will be given as 0. 223 * For some schemes, port will not be sensible -- for instance, file or chrome-extension. 224 * 225 * If you explicitly need to detect whether the user provided a port, check the providedPort 226 * field. 227 */ 228 @property ushort port() const 229 { 230 if (providedPort != 0) { 231 return providedPort; 232 } 233 if (auto p = scheme in schemeToDefaultPort) { 234 return *p; 235 } 236 return 0; 237 } 238 239 /** 240 * Set the port. 241 * 242 * This sets the providedPort field and is provided for convenience. 243 */ 244 @property ushort port(ushort value) 245 { 246 return providedPort = value; 247 } 248 249 /// The port that was explicitly provided in the URL. 250 ushort providedPort; 251 252 /** 253 * The path. 254 * 255 * For instance, in the URL https://cnn.com/news/story/17774?visited=false, the path is 256 * "/news/story/17774". 257 */ 258 string path; 259 260 /** 261 * The query parameters associated with this URL. 262 */ 263 QueryParams queryParams; 264 265 /** 266 * The fragment. In web documents, this typically refers to an anchor element. 267 * For instance, in the URL https://cnn.com/news/story/17774#header2, the fragment is "header2". 268 */ 269 string fragment; 270 271 /** 272 * Convert this URL to a string. 273 * The string is properly formatted and usable for, eg, a web request. 274 */ 275 string toString() const 276 { 277 return toString(false); 278 } 279 280 /** 281 * Convert this URL to a string. 282 * 283 * The string is intended to be human-readable rather than machine-readable. 284 */ 285 string toHumanReadableString() const 286 { 287 return toString(true); 288 } 289 290 /// 291 unittest 292 { 293 auto url = "https://xn--m3h.xn--n3h.org/?hi=bye".parseURL; 294 assert(url.toString == "https://xn--m3h.xn--n3h.org/?hi=bye", url.toString); 295 assert(url.toHumanReadableString == "https://☂.☃.org/?hi=bye", url.toString); 296 } 297 298 unittest 299 { 300 assert("http://example.org/some_path".parseURL.toHumanReadableString == 301 "http://example.org/some_path"); 302 } 303 304 private string toString(bool humanReadable) const 305 { 306 import std.array : Appender; 307 Appender!string s; 308 s ~= scheme; 309 s ~= "://"; 310 if (user) { 311 s ~= humanReadable ? user : user.percentEncode; 312 s ~= ":"; 313 s ~= humanReadable ? pass : pass.percentEncode; 314 s ~= "@"; 315 } 316 s ~= humanReadable ? host : host.toPuny; 317 if (providedPort) { 318 if ((scheme in schemeToDefaultPort) == null || schemeToDefaultPort[scheme] != providedPort) { 319 s ~= ":"; 320 s ~= providedPort.to!string; 321 } 322 } 323 string p = path; 324 if (p.length == 0 || p == "/") { 325 s ~= '/'; 326 } else { 327 if (humanReadable) { 328 s ~= p; 329 } else { 330 if (p[0] == '/') { 331 p = p[1..$]; 332 } 333 foreach (part; p.split('/')) { 334 s ~= '/'; 335 s ~= part.percentEncode; 336 } 337 } 338 } 339 if (queryParams.length) { 340 s ~= '?'; 341 s ~= queryParams.toString; 342 } if (fragment) { 343 s ~= '#'; 344 s ~= fragment.percentEncode; 345 } 346 return s.data; 347 } 348 349 /// Implicitly convert URLs to strings. 350 alias toString this; 351 352 /** 353 Compare two URLs. 354 355 I tried to make the comparison produce a sort order that seems natural, so it's not identical 356 to sorting based on .toString(). For instance, username/password have lower priority than 357 host. The scheme has higher priority than port but lower than host. 358 359 While the output of this is guaranteed to provide a total ordering, and I've attempted to make 360 it human-friendly, it isn't guaranteed to be consistent between versions. The implementation 361 and its results can change without a minor version increase. 362 */ 363 int opCmp(const URL other) 364 { 365 return asTuple.opCmp(other.asTuple); 366 } 367 368 private auto asTuple() const 369 { 370 import std.typecons : tuple; 371 return tuple(host, scheme, port, user, pass, path); 372 } 373 374 int opEquals(const URL other) 375 { 376 return asTuple() == other.asTuple(); 377 } 378 379 unittest 380 { 381 import std.algorithm, std.array, std.format; 382 assert("http://example.org/some_path".parseURL > "http://example.org/other_path".parseURL); 383 alias sorted = std.algorithm.sort; 384 auto parsedURLs = 385 [ 386 "http://example.org/some_path", 387 "http://example.org:81/other_path", 388 "http://example.org/other_path", 389 "https://example.org/first_path", 390 "http://example.xyz/other_other_path", 391 "http://me:secret@blog.ikeran.org/wp_admin", 392 ].map!(x => x.parseURL).array; 393 auto urls = sorted(parsedURLs).map!(x => x.toHumanReadableString).array; 394 auto expected = 395 [ 396 "http://me:secret@blog.ikeran.org/wp_admin", 397 "http://example.org/other_path", 398 "http://example.org/some_path", 399 "http://example.org:81/other_path", 400 "https://example.org/first_path", 401 "http://example.xyz/other_other_path", 402 ]; 403 assert(cmp(urls, expected) == 0, "expected:\n%s\ngot:\n%s".format(expected, urls)); 404 } 405 406 /** 407 * The append operator (~). 408 * 409 * The append operator for URLs returns a new URL with the given string appended as a path 410 * element to the URL's path. It only adds new path elements (or sequences of path elements). 411 * 412 * Don't worry about path separators; whether you include them or not, it will just work. 413 * 414 * Query elements are copied. 415 * 416 * Examples: 417 * --- 418 * auto random = "http://testdata.org/random".parseURL; 419 * auto randInt = random ~ "int"; 420 * writeln(randInt); // prints "http://testdata.org/random/int" 421 * --- 422 */ 423 URL opBinary(string op : "~")(string subsequentPath) { 424 URL other = this; 425 other ~= subsequentPath; 426 other.queryParams = queryParams.dup; 427 return other; 428 } 429 430 /** 431 * The append-in-place operator (~=). 432 * 433 * The append operator for URLs adds a path element to this URL. It only adds new path elements 434 * (or sequences of path elements). 435 * 436 * Don't worry about path separators; whether you include them or not, it will just work. 437 * 438 * Examples: 439 * --- 440 * auto random = "http://testdata.org/random".parseURL; 441 * random ~= "int"; 442 * writeln(random); // prints "http://testdata.org/random/int" 443 * --- 444 */ 445 URL opOpAssign(string op : "~")(string subsequentPath) { 446 if (path.endsWith("/")) { 447 if (subsequentPath.startsWith("/")) { 448 path ~= subsequentPath[1..$]; 449 } else { 450 path ~= subsequentPath; 451 } 452 } else { 453 if (!subsequentPath.startsWith("/")) { 454 path ~= '/'; 455 } 456 path ~= subsequentPath; 457 } 458 return this; 459 } 460 461 /** 462 * Convert a relative URL to an absolute URL. 463 * 464 * This is designed so that you can scrape a webpage and quickly convert links within the 465 * page to URLs you can actually work with, but you're clever; I'm sure you'll find more uses 466 * for it. 467 * 468 * It's biased toward HTTP family URLs; as one quirk, "//" is interpreted as "same scheme, 469 * different everything else", which might not be desirable for all schemes. 470 * 471 * This only handles URLs, not URIs; if you pass in 'mailto:bob.dobbs@subgenius.org', for 472 * instance, this will give you our best attempt to parse it as a URL. 473 * 474 * Examples: 475 * --- 476 * auto base = "https://example.org/passworddb?secure=false".parseURL; 477 * 478 * // Download https://example.org/passworddb/by-username/dhasenan 479 * download(base.resolve("by-username/dhasenan")); 480 * 481 * // Download https://example.org/static/style.css 482 * download(base.resolve("/static/style.css")); 483 * 484 * // Download https://cdn.example.net/jquery.js 485 * download(base.resolve("https://cdn.example.net/jquery.js")); 486 * --- 487 */ 488 URL resolve(string other) 489 { 490 if (other.length == 0) return this; 491 if (other[0] == '/') 492 { 493 if (other.length > 1 && other[1] == '/') 494 { 495 // Uncommon syntax: a link like "//wikimedia.org" means "same scheme, switch URL" 496 return parseURL(this.scheme ~ ':' ~ other); 497 } 498 } 499 else if (other.indexOf("://") > other.indexOf("/")) 500 { 501 // separate URL 502 return other.parseURL; 503 } 504 505 URL ret = this; 506 ret.path = ""; 507 ret.queryParams = ret.queryParams.init; 508 if (other[0] != '/') 509 { 510 // relative to something 511 if (!this.path.length) 512 { 513 // nothing to be relative to 514 other = "/" ~ other; 515 } 516 else if (this.path[$-1] == '/') 517 { 518 // directory-style path for the current thing 519 // resolve relative to this directory 520 other = this.path ~ other; 521 } 522 else 523 { 524 // this is a file-like thing 525 // find the 'directory' and relative to that 526 other = this.path[0..this.path.lastIndexOf('/') + 1] ~ other; 527 } 528 } 529 parsePathAndQuery(ret, other); 530 return ret; 531 } 532 } 533 534 /** 535 * Parse a URL from a string. 536 * 537 * This attempts to parse a wide range of URLs as people might actually type them. Some mistakes 538 * may be made. However, any URL in a correct format will be parsed correctly. 539 */ 540 bool tryParseURL(string value, out URL url) 541 { 542 url = URL.init; 543 // scheme:[//[user:password@]host[:port]][/]path[?query][#fragment] 544 // Scheme is optional in common use. We infer 'http' if it's not given. 545 auto i = value.indexOf("//"); 546 if (i > -1) { 547 if (i > 1) { 548 url.scheme = value[0..i-1]; 549 } 550 value = value[i+2 .. $]; 551 } else { 552 url.scheme = "http"; 553 } 554 // Check for an ipv6 hostname. 555 // [user:password@]host[:port]][/]path[?query][#fragment 556 i = value.indexOfAny([':', '/', '[']); 557 if (i == -1) { 558 // Just a hostname. 559 url.host = value.fromPuny; 560 return true; 561 } 562 563 if (value[i] == ':') { 564 // This could be between username and password, or it could be between host and port. 565 auto j = value.indexOfAny(['@', '/']); 566 if (j > -1 && value[j] == '@') { 567 try { 568 url.user = value[0..i].percentDecode; 569 url.pass = value[i+1 .. j].percentDecode; 570 } catch (URLException) { 571 return false; 572 } 573 value = value[j+1 .. $]; 574 } 575 } 576 577 // It's trying to be a host/port, not a user/pass. 578 i = value.indexOfAny([':', '/', '[']); 579 if (i == -1) { 580 url.host = value.fromPuny; 581 return true; 582 } 583 584 // Find the hostname. It's either an ipv6 address (which has special rules) or not (which doesn't 585 // have special rules). -- The main sticking point is that ipv6 addresses have colons, which we 586 // handle specially, and are offset with square brackets. 587 if (value[i] == '[') { 588 auto j = value[i..$].indexOf(']'); 589 if (j < 0) { 590 // unterminated ipv6 addr 591 return false; 592 } 593 // includes square brackets 594 url.host = value[i .. i+j+1]; 595 value = value[i+j+1 .. $]; 596 if (value.length == 0) { 597 // read to end of string; we finished parse 598 return true; 599 } 600 if (value[0] != ':' && value[0] != '?' && value[0] != '/') { 601 return false; 602 } 603 } else { 604 // Normal host. 605 url.host = value[0..i].fromPuny; 606 value = value[i .. $]; 607 } 608 609 if (value[0] == ':') { 610 auto end = value.indexOf('/'); 611 if (end == -1) { 612 end = value.length; 613 } 614 try { 615 url.port = value[1 .. end].to!ushort; 616 } catch (ConvException) { 617 return false; 618 } 619 value = value[end .. $]; 620 if (value.length == 0) { 621 return true; 622 } 623 } 624 return parsePathAndQuery(url, value); 625 } 626 627 private bool parsePathAndQuery(ref URL url, string value) 628 { 629 auto i = value.indexOfAny("?#"); 630 if (i == -1) 631 { 632 url.path = value.percentDecode; 633 return true; 634 } 635 636 try 637 { 638 url.path = value[0..i].percentDecode; 639 } 640 catch (URLException) 641 { 642 return false; 643 } 644 645 auto c = value[i]; 646 value = value[i + 1 .. $]; 647 if (c == '?') 648 { 649 i = value.indexOf('#'); 650 string query; 651 if (i < 0) 652 { 653 query = value; 654 value = null; 655 } 656 else 657 { 658 query = value[0..i]; 659 value = value[i + 1 .. $]; 660 } 661 auto queries = query.split('&'); 662 foreach (q; queries) 663 { 664 auto j = q.indexOf('='); 665 string key, val; 666 if (j < 0) 667 { 668 key = q; 669 } 670 else 671 { 672 key = q[0..j]; 673 val = q[j + 1 .. $]; 674 } 675 try 676 { 677 key = key.percentDecode; 678 val = val.percentDecode; 679 } 680 catch (URLException) 681 { 682 return false; 683 } 684 url.queryParams.add(key, val); 685 } 686 } 687 688 try 689 { 690 url.fragment = value.percentDecode; 691 } 692 catch (URLException) 693 { 694 return false; 695 } 696 697 return true; 698 } 699 700 unittest { 701 { 702 // Basic. 703 URL url; 704 with (url) { 705 scheme = "https"; 706 host = "example.org"; 707 path = "/foo/bar"; 708 queryParams.add("hello", "world"); 709 queryParams.add("gibe", "clay"); 710 fragment = "frag"; 711 } 712 assert( 713 // Not sure what order it'll come out in. 714 url.toString == "https://example.org/foo/bar?hello=world&gibe=clay#frag" || 715 url.toString == "https://example.org/foo/bar?gibe=clay&hello=world#frag", 716 url.toString); 717 } 718 { 719 // Percent encoded. 720 URL url; 721 with (url) { 722 scheme = "https"; 723 host = "example.org"; 724 path = "/f☃o"; 725 queryParams.add("❄", "❀"); 726 queryParams.add("[", "]"); 727 fragment = "ş"; 728 } 729 assert( 730 // Not sure what order it'll come out in. 731 url.toString == "https://example.org/f%E2%98%83o?%E2%9D%84=%E2%9D%80&%5B=%5D#%C5%9F" || 732 url.toString == "https://example.org/f%E2%98%83o?%5B=%5D&%E2%9D%84=%E2%9D%80#%C5%9F", 733 url.toString); 734 } 735 { 736 // Port, user, pass. 737 URL url; 738 with (url) { 739 scheme = "https"; 740 host = "example.org"; 741 user = "dhasenan"; 742 pass = "itsasecret"; 743 port = 17; 744 } 745 assert( 746 url.toString == "https://dhasenan:itsasecret@example.org:17/", 747 url.toString); 748 } 749 { 750 // Query with no path. 751 URL url; 752 with (url) { 753 scheme = "https"; 754 host = "example.org"; 755 queryParams.add("hi", "bye"); 756 } 757 assert( 758 url.toString == "https://example.org/?hi=bye", 759 url.toString); 760 } 761 } 762 763 unittest 764 { 765 auto url = "//foo/bar".parseURL; 766 assert(url.host == "foo", "expected host foo, got " ~ url.host); 767 assert(url.path == "/bar"); 768 } 769 770 unittest 771 { 772 // ipv6 hostnames! 773 { 774 // full range of data 775 auto url = parseURL("https://bob:secret@[::1]:2771/foo/bar"); 776 assert(url.scheme == "https", url.scheme); 777 assert(url.user == "bob", url.user); 778 assert(url.pass == "secret", url.pass); 779 assert(url.host == "[::1]", url.host); 780 assert(url.port == 2771, url.port.to!string); 781 assert(url.path == "/foo/bar", url.path); 782 } 783 784 // minimal 785 { 786 auto url = parseURL("[::1]"); 787 assert(url.host == "[::1]", url.host); 788 } 789 790 // some random bits 791 { 792 auto url = parseURL("http://[::1]/foo"); 793 assert(url.scheme == "http", url.scheme); 794 assert(url.host == "[::1]", url.host); 795 assert(url.path == "/foo", url.path); 796 } 797 798 { 799 auto url = parseURL("https://[2001:0db8:0:0:0:0:1428:57ab]/?login=true#justkidding"); 800 assert(url.scheme == "https"); 801 assert(url.host == "[2001:0db8:0:0:0:0:1428:57ab]"); 802 assert(url.path == "/"); 803 assert(url.fragment == "justkidding"); 804 } 805 } 806 807 unittest 808 { 809 auto url = "localhost:5984".parseURL; 810 auto url2 = url ~ "db1"; 811 assert(url2.toString == "http://localhost:5984/db1", url2.toString); 812 auto url3 = url2 ~ "_all_docs"; 813 assert(url3.toString == "http://localhost:5984/db1/_all_docs", url3.toString); 814 } 815 816 /// 817 unittest { 818 { 819 // Basic. 820 URL url; 821 with (url) { 822 scheme = "https"; 823 host = "example.org"; 824 path = "/foo/bar"; 825 queryParams.add("hello", "world"); 826 queryParams.add("gibe", "clay"); 827 fragment = "frag"; 828 } 829 assert( 830 // Not sure what order it'll come out in. 831 url.toString == "https://example.org/foo/bar?hello=world&gibe=clay#frag" || 832 url.toString == "https://example.org/foo/bar?gibe=clay&hello=world#frag", 833 url.toString); 834 } 835 { 836 // Passing an array of query values. 837 URL url; 838 with (url) { 839 scheme = "https"; 840 host = "example.org"; 841 path = "/foo/bar"; 842 queryParams.add("hello", "world"); 843 queryParams.add("hello", "aether"); 844 fragment = "frag"; 845 } 846 assert( 847 // Not sure what order it'll come out in. 848 url.toString == "https://example.org/foo/bar?hello=world&hello=aether#frag" || 849 url.toString == "https://example.org/foo/bar?hello=aether&hello=world#frag", 850 url.toString); 851 } 852 { 853 // Percent encoded. 854 URL url; 855 with (url) { 856 scheme = "https"; 857 host = "example.org"; 858 path = "/f☃o"; 859 queryParams.add("❄", "❀"); 860 queryParams.add("[", "]"); 861 fragment = "ş"; 862 } 863 assert( 864 // Not sure what order it'll come out in. 865 url.toString == "https://example.org/f%E2%98%83o?%E2%9D%84=%E2%9D%80&%5B=%5D#%C5%9F" || 866 url.toString == "https://example.org/f%E2%98%83o?%5B=%5D&%E2%9D%84=%E2%9D%80#%C5%9F", 867 url.toString); 868 } 869 { 870 // Port, user, pass. 871 URL url; 872 with (url) { 873 scheme = "https"; 874 host = "example.org"; 875 user = "dhasenan"; 876 pass = "itsasecret"; 877 port = 17; 878 } 879 assert( 880 url.toString == "https://dhasenan:itsasecret@example.org:17/", 881 url.toString); 882 } 883 { 884 // Query with no path. 885 URL url; 886 with (url) { 887 scheme = "https"; 888 host = "example.org"; 889 queryParams.add("hi", "bye"); 890 } 891 assert( 892 url.toString == "https://example.org/?hi=bye", 893 url.toString); 894 } 895 } 896 897 unittest { 898 // Percent decoding. 899 900 // http://#:!:@ 901 auto urlString = "http://%23:%21%3A@example.org/%7B/%7D?%3B&%26=%3D#%23hash"; 902 auto url = urlString.parseURL; 903 assert(url.user == "#"); 904 assert(url.pass == "!:"); 905 assert(url.host == "example.org"); 906 assert(url.path == "/{/}"); 907 assert(url.queryParams[";"].front == ""); 908 assert(url.queryParams["&"].front == "="); 909 assert(url.fragment == "#hash"); 910 911 // Round trip. 912 assert(urlString == urlString.parseURL.toString, urlString.parseURL.toString); 913 assert(urlString == urlString.parseURL.toString.parseURL.toString); 914 } 915 916 unittest { 917 auto url = "https://xn--m3h.xn--n3h.org/?hi=bye".parseURL; 918 assert(url.host == "☂.☃.org", url.host); 919 } 920 921 unittest { 922 auto url = "https://☂.☃.org/?hi=bye".parseURL; 923 assert(url.toString == "https://xn--m3h.xn--n3h.org/?hi=bye"); 924 } 925 926 /// 927 unittest { 928 // There's an existing path. 929 auto url = parseURL("http://example.org/foo"); 930 URL url2; 931 // No slash? Assume it needs a slash. 932 assert((url ~ "bar").toString == "http://example.org/foo/bar"); 933 // With slash? Don't add another. 934 url2 = url ~ "/bar"; 935 assert(url2.toString == "http://example.org/foo/bar", url2.toString); 936 url ~= "bar"; 937 assert(url.toString == "http://example.org/foo/bar"); 938 939 // Path already ends with a slash; don't add another. 940 url = parseURL("http://example.org/foo/"); 941 assert((url ~ "bar").toString == "http://example.org/foo/bar"); 942 // Still don't add one even if you're appending with a slash. 943 assert((url ~ "/bar").toString == "http://example.org/foo/bar"); 944 url ~= "/bar"; 945 assert(url.toString == "http://example.org/foo/bar"); 946 947 // No path. 948 url = parseURL("http://example.org"); 949 assert((url ~ "bar").toString == "http://example.org/bar"); 950 assert((url ~ "/bar").toString == "http://example.org/bar"); 951 url ~= "bar"; 952 assert(url.toString == "http://example.org/bar"); 953 954 // Path is just a slash. 955 url = parseURL("http://example.org/"); 956 assert((url ~ "bar").toString == "http://example.org/bar"); 957 assert((url ~ "/bar").toString == "http://example.org/bar"); 958 url ~= "bar"; 959 assert(url.toString == "http://example.org/bar", url.toString); 960 961 // No path, just fragment. 962 url = "ircs://irc.freenode.com/#d".parseURL; 963 assert(url.toString == "ircs://irc.freenode.com/#d", url.toString); 964 } 965 unittest 966 { 967 // basic resolve() 968 { 969 auto base = "https://example.org/this/".parseURL; 970 assert(base.resolve("that") == "https://example.org/this/that"); 971 assert(base.resolve("/that") == "https://example.org/that"); 972 assert(base.resolve("//example.net/that") == "https://example.net/that"); 973 } 974 975 // ensure we don't preserve query params 976 { 977 auto base = "https://example.org/this?query=value&other=value2".parseURL; 978 assert(base.resolve("that") == "https://example.org/that"); 979 assert(base.resolve("/that") == "https://example.org/that"); 980 assert(base.resolve("//example.net/that") == "https://example.net/that"); 981 } 982 } 983 984 985 unittest 986 { 987 import std.net.curl; 988 auto url = "http://example.org".parseURL; 989 assert(is(typeof(std.net.curl.get(url)))); 990 } 991 992 /** 993 * Parse the input string as a URL. 994 * 995 * Throws: 996 * URLException if the string was in an incorrect format. 997 */ 998 URL parseURL(string value) { 999 URL url; 1000 if (tryParseURL(value, url)) { 1001 return url; 1002 } 1003 throw new URLException("failed to parse URL " ~ value); 1004 } 1005 1006 /// 1007 unittest { 1008 { 1009 // Infer scheme 1010 auto u1 = parseURL("example.org"); 1011 assert(u1.scheme == "http"); 1012 assert(u1.host == "example.org"); 1013 assert(u1.path == ""); 1014 assert(u1.port == 80); 1015 assert(u1.providedPort == 0); 1016 assert(u1.fragment == ""); 1017 } 1018 { 1019 // Simple host and scheme 1020 auto u1 = parseURL("https://example.org"); 1021 assert(u1.scheme == "https"); 1022 assert(u1.host == "example.org"); 1023 assert(u1.path == ""); 1024 assert(u1.port == 443); 1025 assert(u1.providedPort == 0); 1026 } 1027 { 1028 // With path 1029 auto u1 = parseURL("https://example.org/foo/bar"); 1030 assert(u1.scheme == "https"); 1031 assert(u1.host == "example.org"); 1032 assert(u1.path == "/foo/bar", "expected /foo/bar but got " ~ u1.path); 1033 assert(u1.port == 443); 1034 assert(u1.providedPort == 0); 1035 } 1036 { 1037 // With explicit port 1038 auto u1 = parseURL("https://example.org:1021/foo/bar"); 1039 assert(u1.scheme == "https"); 1040 assert(u1.host == "example.org"); 1041 assert(u1.path == "/foo/bar", "expected /foo/bar but got " ~ u1.path); 1042 assert(u1.port == 1021); 1043 assert(u1.providedPort == 1021); 1044 } 1045 { 1046 // With user 1047 auto u1 = parseURL("https://bob:secret@example.org/foo/bar"); 1048 assert(u1.scheme == "https"); 1049 assert(u1.host == "example.org"); 1050 assert(u1.path == "/foo/bar"); 1051 assert(u1.port == 443); 1052 assert(u1.user == "bob"); 1053 assert(u1.pass == "secret"); 1054 } 1055 { 1056 // With user, URL-encoded 1057 auto u1 = parseURL("https://bob%21:secret%21%3F@example.org/foo/bar"); 1058 assert(u1.scheme == "https"); 1059 assert(u1.host == "example.org"); 1060 assert(u1.path == "/foo/bar"); 1061 assert(u1.port == 443); 1062 assert(u1.user == "bob!"); 1063 assert(u1.pass == "secret!?"); 1064 } 1065 { 1066 // With user and port and path 1067 auto u1 = parseURL("https://bob:secret@example.org:2210/foo/bar"); 1068 assert(u1.scheme == "https"); 1069 assert(u1.host == "example.org"); 1070 assert(u1.path == "/foo/bar"); 1071 assert(u1.port == 2210); 1072 assert(u1.user == "bob"); 1073 assert(u1.pass == "secret"); 1074 assert(u1.fragment == ""); 1075 } 1076 { 1077 // With query string 1078 auto u1 = parseURL("https://example.org/?login=true"); 1079 assert(u1.scheme == "https"); 1080 assert(u1.host == "example.org"); 1081 assert(u1.path == "/", "expected path: / actual path: " ~ u1.path); 1082 assert(u1.queryParams["login"].front == "true"); 1083 assert(u1.fragment == ""); 1084 } 1085 { 1086 // With query string and fragment 1087 auto u1 = parseURL("https://example.org/?login=true#justkidding"); 1088 assert(u1.scheme == "https"); 1089 assert(u1.host == "example.org"); 1090 assert(u1.path == "/", "expected path: / actual path: " ~ u1.path); 1091 assert(u1.queryParams["login"].front == "true"); 1092 assert(u1.fragment == "justkidding"); 1093 } 1094 { 1095 // With URL-encoded values 1096 auto u1 = parseURL("https://example.org/%E2%98%83?%E2%9D%84=%3D#%5E"); 1097 assert(u1.scheme == "https"); 1098 assert(u1.host == "example.org"); 1099 assert(u1.path == "/☃", "expected path: /☃ actual path: " ~ u1.path); 1100 assert(u1.queryParams["❄"].front == "="); 1101 assert(u1.fragment == "^"); 1102 } 1103 } 1104 1105 unittest { 1106 assert(parseURL("http://example.org").port == 80); 1107 assert(parseURL("http://example.org:5326").port == 5326); 1108 1109 auto url = parseURL("redis://admin:password@redisbox.local:2201/path?query=value#fragment"); 1110 assert(url.scheme == "redis"); 1111 assert(url.user == "admin"); 1112 assert(url.pass == "password"); 1113 1114 assert(parseURL("example.org").toString == "http://example.org/"); 1115 assert(parseURL("http://example.org:80").toString == "http://example.org/"); 1116 1117 assert(parseURL("localhost:8070").toString == "http://localhost:8070/"); 1118 } 1119 1120 /** 1121 * Percent-encode a string. 1122 * 1123 * URL components cannot contain non-ASCII characters, and there are very few characters that are 1124 * safe to include as URL components. Domain names using Unicode values use Punycode. For 1125 * everything else, there is percent encoding. 1126 */ 1127 string percentEncode(string raw) { 1128 // We *must* encode these characters: :/?#[]@!$&'()*+,;=" 1129 // We *can* encode any other characters. 1130 // We *should not* encode alpha, numeric, or -._~. 1131 import std.utf : encode; 1132 import std.array : Appender; 1133 Appender!string app; 1134 foreach (dchar d; raw) { 1135 if (('a' <= d && 'z' >= d) || 1136 ('A' <= d && 'Z' >= d) || 1137 ('0' <= d && '9' >= d) || 1138 d == '-' || d == '.' || d == '_' || d == '~') { 1139 app ~= d; 1140 continue; 1141 } 1142 // Something simple like a space character? Still in 7-bit ASCII? 1143 // Then we get a single-character string out of it and just encode 1144 // that one bit. 1145 // Something not in 7-bit ASCII? Then we percent-encode each octet 1146 // in the UTF-8 encoding (and hope the server understands UTF-8). 1147 char[] c; 1148 encode(c, d); 1149 auto bytes = cast(ubyte[])c; 1150 foreach (b; bytes) { 1151 app ~= format("%%%02X", b); 1152 } 1153 } 1154 return cast(string)app.data; 1155 } 1156 1157 /// 1158 unittest { 1159 assert(percentEncode("IDontNeedNoPercentEncoding") == "IDontNeedNoPercentEncoding"); 1160 assert(percentEncode("~~--..__") == "~~--..__"); 1161 assert(percentEncode("0123456789") == "0123456789"); 1162 1163 string e; 1164 1165 e = percentEncode("☃"); 1166 assert(e == "%E2%98%83", "expected %E2%98%83 but got" ~ e); 1167 } 1168 1169 /** 1170 * Percent-decode a string. 1171 * 1172 * URL components cannot contain non-ASCII characters, and there are very few characters that are 1173 * safe to include as URL components. Domain names using Unicode values use Punycode. For 1174 * everything else, there is percent encoding. 1175 * 1176 * This explicitly ensures that the result is a valid UTF-8 string. 1177 */ 1178 string percentDecode(string encoded) 1179 { 1180 import std.utf : validate, UTFException; 1181 auto raw = percentDecodeRaw(encoded); 1182 auto s = cast(string) raw; 1183 try 1184 { 1185 validate(s); 1186 } 1187 catch (UTFException e) 1188 { 1189 throw new URLException( 1190 "The percent-encoded data `" ~ encoded ~ "` does not represent a valid UTF-8 sequence."); 1191 } 1192 return s; 1193 } 1194 1195 /// 1196 unittest { 1197 assert(percentDecode("IDontNeedNoPercentDecoding") == "IDontNeedNoPercentDecoding"); 1198 assert(percentDecode("~~--..__") == "~~--..__"); 1199 assert(percentDecode("0123456789") == "0123456789"); 1200 1201 string e; 1202 1203 e = percentDecode("%E2%98%83"); 1204 assert(e == "☃", "expected a snowman but got" ~ e); 1205 1206 e = percentDecode("%e2%98%83"); 1207 assert(e == "☃", "expected a snowman but got" ~ e); 1208 1209 try { 1210 // %ES is an invalid percent sequence: 'S' is not a hex digit. 1211 percentDecode("%es"); 1212 assert(false, "expected exception not thrown"); 1213 } catch (URLException) { 1214 } 1215 1216 try { 1217 percentDecode("%e"); 1218 assert(false, "expected exception not thrown"); 1219 } catch (URLException) { 1220 } 1221 } 1222 1223 /** 1224 * Percent-decode a string into a ubyte array. 1225 * 1226 * URL components cannot contain non-ASCII characters, and there are very few characters that are 1227 * safe to include as URL components. Domain names using Unicode values use Punycode. For 1228 * everything else, there is percent encoding. 1229 * 1230 * This yields a ubyte array and will not perform validation on the output. However, an improperly 1231 * formatted input string will result in a URLException. 1232 */ 1233 immutable(ubyte)[] percentDecodeRaw(string encoded) 1234 { 1235 // We're dealing with possibly incorrectly encoded UTF-8. Mark it down as ubyte[] for now. 1236 import std.array : Appender; 1237 Appender!(immutable(ubyte)[]) app; 1238 for (int i = 0; i < encoded.length; i++) { 1239 if (encoded[i] != '%') { 1240 app ~= encoded[i]; 1241 continue; 1242 } 1243 if (i >= encoded.length - 2) { 1244 throw new URLException("Invalid percent encoded value: expected two characters after " ~ 1245 "percent symbol. Error at index " ~ i.to!string); 1246 } 1247 if (isHex(encoded[i + 1]) && isHex(encoded[i + 2])) { 1248 auto b = fromHex(encoded[i + 1]); 1249 auto c = fromHex(encoded[i + 2]); 1250 app ~= cast(ubyte)((b << 4) | c); 1251 } else { 1252 throw new URLException("Invalid percent encoded value: expected two hex digits after " ~ 1253 "percent symbol. Error at index " ~ i.to!string); 1254 } 1255 i += 2; 1256 } 1257 return app.data; 1258 } 1259 1260 private bool isHex(char c) { 1261 return ('0' <= c && '9' >= c) || 1262 ('a' <= c && 'f' >= c) || 1263 ('A' <= c && 'F' >= c); 1264 } 1265 1266 private ubyte fromHex(char s) { 1267 enum caseDiff = 'a' - 'A'; 1268 if (s >= 'a' && s <= 'z') { 1269 s -= caseDiff; 1270 } 1271 return cast(ubyte)("0123456789ABCDEF".indexOf(s)); 1272 } 1273 1274 private string toPuny(string unicodeHostname) 1275 { 1276 bool mustEncode = false; 1277 foreach (i, dchar d; unicodeHostname) { 1278 auto c = cast(uint) d; 1279 if (c > 0x80) { 1280 mustEncode = true; 1281 break; 1282 } 1283 if (c < 0x2C || (c >= 0x3A && c <= 40) || (c >= 0x5B && c <= 0x60) || (c >= 0x7B)) { 1284 throw new URLException( 1285 format( 1286 "domain name '%s' contains illegal character '%s' at position %s", 1287 unicodeHostname, d, i)); 1288 } 1289 } 1290 if (!mustEncode) { 1291 return unicodeHostname; 1292 } 1293 import std.algorithm.iteration : map; 1294 return unicodeHostname.split('.').map!punyEncode.join("."); 1295 } 1296 1297 private string fromPuny(string hostname) 1298 { 1299 import std.algorithm.iteration : map; 1300 return hostname.split('.').map!punyDecode.join("."); 1301 } 1302 1303 private { 1304 enum delimiter = '-'; 1305 enum marker = "xn--"; 1306 enum ulong damp = 700; 1307 enum ulong tmin = 1; 1308 enum ulong tmax = 26; 1309 enum ulong skew = 38; 1310 enum ulong base = 36; 1311 enum ulong initialBias = 72; 1312 enum dchar initialN = cast(dchar)128; 1313 1314 ulong adapt(ulong delta, ulong numPoints, bool firstTime) { 1315 if (firstTime) { 1316 delta /= damp; 1317 } else { 1318 delta /= 2; 1319 } 1320 delta += delta / numPoints; 1321 ulong k = 0; 1322 while (delta > ((base - tmin) * tmax) / 2) { 1323 delta /= (base - tmin); 1324 k += base; 1325 } 1326 return k + (((base - tmin + 1) * delta) / (delta + skew)); 1327 } 1328 } 1329 1330 /** 1331 * Encode the input string using the Punycode algorithm. 1332 * 1333 * Punycode is used to encode UTF domain name segment. A Punycode-encoded segment will be marked 1334 * with "xn--". Each segment is encoded separately. For instance, if you wish to encode "☂.☃.com" 1335 * in Punycode, you will get "xn--m3h.xn--n3h.com". 1336 * 1337 * In order to puny-encode a domain name, you must split it into its components. The following will 1338 * typically suffice: 1339 * --- 1340 * auto domain = "☂.☃.com"; 1341 * auto encodedDomain = domain.splitter(".").map!(punyEncode).join("."); 1342 * --- 1343 */ 1344 string punyEncode(string input) 1345 { 1346 import std.array : Appender; 1347 ulong delta = 0; 1348 dchar n = initialN; 1349 auto i = 0; 1350 auto bias = initialBias; 1351 Appender!string output; 1352 output ~= marker; 1353 auto pushed = 0; 1354 auto codePoints = 0; 1355 foreach (dchar c; input) { 1356 codePoints++; 1357 if (c <= initialN) { 1358 output ~= c; 1359 pushed++; 1360 } 1361 } 1362 if (pushed < codePoints) { 1363 if (pushed > 0) { 1364 output ~= delimiter; 1365 } 1366 } else { 1367 // No encoding to do. 1368 return input; 1369 } 1370 bool first = true; 1371 while (pushed < codePoints) { 1372 auto best = dchar.max; 1373 foreach (dchar c; input) { 1374 if (n <= c && c < best) { 1375 best = c; 1376 } 1377 } 1378 if (best == dchar.max) { 1379 throw new URLException("failed to find a new codepoint to process during punyencode"); 1380 } 1381 delta += (best - n) * (pushed + 1); 1382 if (delta > uint.max) { 1383 // TODO better error message 1384 throw new URLException("overflow during punyencode"); 1385 } 1386 n = best; 1387 foreach (dchar c; input) { 1388 if (c < n) { 1389 delta++; 1390 } 1391 if (c == n) { 1392 ulong q = delta; 1393 auto k = base; 1394 while (true) { 1395 ulong t; 1396 if (k <= bias) { 1397 t = tmin; 1398 } else if (k >= bias + tmax) { 1399 t = tmax; 1400 } else { 1401 t = k - bias; 1402 } 1403 if (q < t) { 1404 break; 1405 } 1406 output ~= digitToBasic(t + ((q - t) % (base - t))); 1407 q = (q - t) / (base - t); 1408 k += base; 1409 } 1410 output ~= digitToBasic(q); 1411 pushed++; 1412 bias = adapt(delta, pushed, first); 1413 first = false; 1414 delta = 0; 1415 } 1416 } 1417 delta++; 1418 n++; 1419 } 1420 return cast(string)output.data; 1421 } 1422 1423 /** 1424 * Decode the input string using the Punycode algorithm. 1425 * 1426 * Punycode is used to encode UTF domain name segment. A Punycode-encoded segment will be marked 1427 * with "xn--". Each segment is encoded separately. For instance, if you wish to encode "☂.☃.com" 1428 * in Punycode, you will get "xn--m3h.xn--n3h.com". 1429 * 1430 * In order to puny-decode a domain name, you must split it into its components. The following will 1431 * typically suffice: 1432 * --- 1433 * auto domain = "xn--m3h.xn--n3h.com"; 1434 * auto decodedDomain = domain.splitter(".").map!(punyDecode).join("."); 1435 * --- 1436 */ 1437 string punyDecode(string input) { 1438 if (!input.startsWith(marker)) { 1439 return input; 1440 } 1441 input = input[marker.length..$]; 1442 1443 // let n = initial_n 1444 dchar n = cast(dchar)128; 1445 1446 // let i = 0 1447 // let bias = initial_bias 1448 // let output = an empty string indexed from 0 1449 size_t i = 0; 1450 auto bias = initialBias; 1451 dchar[] output; 1452 // This reserves a bit more than necessary, but it should be more efficient overall than just 1453 // appending and inserting volo-nolo. 1454 output.reserve(input.length); 1455 1456 // consume all code points before the last delimiter (if there is one) 1457 // and copy them to output, fail on any non-basic code point 1458 // if more than zero code points were consumed then consume one more 1459 // (which will be the last delimiter) 1460 auto end = input.lastIndexOf(delimiter); 1461 if (end > -1) { 1462 foreach (dchar c; input[0..end]) { 1463 output ~= c; 1464 } 1465 input = input[end+1 .. $]; 1466 } 1467 1468 // while the input is not exhausted do begin 1469 size_t pos = 0; 1470 while (pos < input.length) { 1471 // let oldi = i 1472 // let w = 1 1473 auto oldi = i; 1474 auto w = 1; 1475 // for k = base to infinity in steps of base do begin 1476 for (ulong k = base; k < uint.max; k += base) { 1477 // consume a code point, or fail if there was none to consume 1478 // Note that the input is all ASCII, so we can simply index the input string bytewise. 1479 auto c = input[pos]; 1480 pos++; 1481 // let digit = the code point's digit-value, fail if it has none 1482 auto digit = basicToDigit(c); 1483 // let i = i + digit * w, fail on overflow 1484 i += digit * w; 1485 // let t = tmin if k <= bias {+ tmin}, or 1486 // tmax if k >= bias + tmax, or k - bias otherwise 1487 ulong t; 1488 if (k <= bias) { 1489 t = tmin; 1490 } else if (k >= bias + tmax) { 1491 t = tmax; 1492 } else { 1493 t = k - bias; 1494 } 1495 // if digit < t then break 1496 if (digit < t) { 1497 break; 1498 } 1499 // let w = w * (base - t), fail on overflow 1500 w *= (base - t); 1501 // end 1502 } 1503 // let bias = adapt(i - oldi, length(output) + 1, test oldi is 0?) 1504 bias = adapt(i - oldi, output.length + 1, oldi == 0); 1505 // let n = n + i div (length(output) + 1), fail on overflow 1506 n += i / (output.length + 1); 1507 // let i = i mod (length(output) + 1) 1508 i %= (output.length + 1); 1509 // {if n is a basic code point then fail} 1510 // (We aren't actually going to fail here; it's clear what this means.) 1511 // insert n into output at position i 1512 import std.array : insertInPlace; 1513 (() @trusted { output.insertInPlace(i, cast(dchar)n); })(); // should be @safe but isn't marked 1514 // increment i 1515 i++; 1516 // end 1517 } 1518 return output.to!string; 1519 } 1520 1521 // Lifted from punycode.js. 1522 private dchar digitToBasic(ulong digit) { 1523 return cast(dchar)(digit + 22 + 75 * (digit < 26)); 1524 } 1525 1526 // Lifted from punycode.js. 1527 private uint basicToDigit(char c) { 1528 auto codePoint = cast(uint)c; 1529 if (codePoint - 48 < 10) { 1530 return codePoint - 22; 1531 } 1532 if (codePoint - 65 < 26) { 1533 return codePoint - 65; 1534 } 1535 if (codePoint - 97 < 26) { 1536 return codePoint - 97; 1537 } 1538 return base; 1539 } 1540 1541 unittest { 1542 { 1543 auto a = "b\u00FCcher"; 1544 assert(punyEncode(a) == "xn--bcher-kva"); 1545 } 1546 { 1547 auto a = "b\u00FCc\u00FCher"; 1548 assert(punyEncode(a) == "xn--bcher-kvab"); 1549 } 1550 { 1551 auto a = "ýbücher"; 1552 auto b = punyEncode(a); 1553 assert(b == "xn--bcher-kvaf", b); 1554 } 1555 1556 { 1557 auto a = "mañana"; 1558 assert(punyEncode(a) == "xn--maana-pta"); 1559 } 1560 1561 { 1562 auto a = "\u0644\u064A\u0647\u0645\u0627\u0628\u062A\u0643\u0644" 1563 ~ "\u0645\u0648\u0634\u0639\u0631\u0628\u064A\u061F"; 1564 auto b = punyEncode(a); 1565 assert(b == "xn--egbpdaj6bu4bxfgehfvwxn", b); 1566 } 1567 import std.stdio; 1568 } 1569 1570 unittest { 1571 { 1572 auto b = punyDecode("xn--egbpdaj6bu4bxfgehfvwxn"); 1573 assert(b == "ليهمابتكلموشعربي؟", b); 1574 } 1575 { 1576 assert(punyDecode("xn--maana-pta") == "mañana"); 1577 } 1578 } 1579 1580 unittest { 1581 import std.string, std.algorithm, std.array, std.range; 1582 { 1583 auto domain = "xn--m3h.xn--n3h.com"; 1584 auto decodedDomain = domain.splitter(".").map!(punyDecode).join("."); 1585 assert(decodedDomain == "☂.☃.com", decodedDomain); 1586 } 1587 { 1588 auto domain = "☂.☃.com"; 1589 auto decodedDomain = domain.splitter(".").map!(punyEncode).join("."); 1590 assert(decodedDomain == "xn--m3h.xn--n3h.com", decodedDomain); 1591 } 1592 }