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 return tuple(host, scheme, port, user, pass, path); 371 } 372 373 int opEquals(const URL other) 374 { 375 return asTuple() == other.asTuple(); 376 } 377 378 unittest 379 { 380 import std.algorithm, std.array, std.format; 381 assert("http://example.org/some_path".parseURL > "http://example.org/other_path".parseURL); 382 alias sorted = std.algorithm.sort; 383 auto parsedURLs = 384 [ 385 "http://example.org/some_path", 386 "http://example.org:81/other_path", 387 "http://example.org/other_path", 388 "https://example.org/first_path", 389 "http://example.xyz/other_other_path", 390 "http://me:secret@blog.ikeran.org/wp_admin", 391 ].map!(x => x.parseURL).array; 392 auto urls = sorted(parsedURLs).map!(x => x.toHumanReadableString).array; 393 auto expected = 394 [ 395 "http://me:secret@blog.ikeran.org/wp_admin", 396 "http://example.org/other_path", 397 "http://example.org/some_path", 398 "http://example.org:81/other_path", 399 "https://example.org/first_path", 400 "http://example.xyz/other_other_path", 401 ]; 402 assert(cmp(urls, expected) == 0, "expected:\n%s\ngot:\n%s".format(expected, urls)); 403 } 404 405 /** 406 * The append operator (~). 407 * 408 * The append operator for URLs returns a new URL with the given string appended as a path 409 * element to the URL's path. It only adds new path elements (or sequences of path elements). 410 * 411 * Don't worry about path separators; whether you include them or not, it will just work. 412 * 413 * Query elements are copied. 414 * 415 * Examples: 416 * --- 417 * auto random = "http://testdata.org/random".parseURL; 418 * auto randInt = random ~ "int"; 419 * writeln(randInt); // prints "http://testdata.org/random/int" 420 * --- 421 */ 422 URL opBinary(string op : "~")(string subsequentPath) { 423 URL other = this; 424 other ~= subsequentPath; 425 other.queryParams = queryParams.dup; 426 return other; 427 } 428 429 /** 430 * The append-in-place operator (~=). 431 * 432 * The append operator for URLs adds a path element to this URL. It only adds new path elements 433 * (or sequences of path elements). 434 * 435 * Don't worry about path separators; whether you include them or not, it will just work. 436 * 437 * Examples: 438 * --- 439 * auto random = "http://testdata.org/random".parseURL; 440 * random ~= "int"; 441 * writeln(random); // prints "http://testdata.org/random/int" 442 * --- 443 */ 444 URL opOpAssign(string op : "~")(string subsequentPath) { 445 if (path.endsWith("/")) { 446 if (subsequentPath.startsWith("/")) { 447 path ~= subsequentPath[1..$]; 448 } else { 449 path ~= subsequentPath; 450 } 451 } else { 452 if (!subsequentPath.startsWith("/")) { 453 path ~= '/'; 454 } 455 path ~= subsequentPath; 456 } 457 return this; 458 } 459 460 /** 461 * Convert a relative URL to an absolute URL. 462 * 463 * This is designed so that you can scrape a webpage and quickly convert links within the 464 * page to URLs you can actually work with, but you're clever; I'm sure you'll find more uses 465 * for it. 466 * 467 * It's biased toward HTTP family URLs; as one quirk, "//" is interpreted as "same scheme, 468 * different everything else", which might not be desirable for all schemes. 469 * 470 * This only handles URLs, not URIs; if you pass in 'mailto:bob.dobbs@subgenius.org', for 471 * instance, this will give you our best attempt to parse it as a URL. 472 * 473 * Examples: 474 * --- 475 * auto base = "https://example.org/passworddb?secure=false".parseURL; 476 * 477 * // Download https://example.org/passworddb/by-username/dhasenan 478 * download(base.resolve("by-username/dhasenan")); 479 * 480 * // Download https://example.org/static/style.css 481 * download(base.resolve("/static/style.css")); 482 * 483 * // Download https://cdn.example.net/jquery.js 484 * download(base.resolve("https://cdn.example.net/jquery.js")); 485 * --- 486 */ 487 URL resolve(string other) 488 { 489 if (other.length == 0) return this; 490 if (other[0] == '/') 491 { 492 if (other.length > 1 && other[1] == '/') 493 { 494 // Uncommon syntax: a link like "//wikimedia.org" means "same scheme, switch URL" 495 return parseURL(this.scheme ~ ':' ~ other); 496 } 497 } 498 else if (other.indexOf("://") > other.indexOf("/")) 499 { 500 // separate URL 501 return other.parseURL; 502 } 503 504 URL ret = this; 505 ret.path = ""; 506 ret.queryParams = ret.queryParams.init; 507 if (other[0] != '/') 508 { 509 // relative to something 510 if (!this.path.length) 511 { 512 // nothing to be relative to 513 other = "/" ~ other; 514 } 515 else if (this.path[$-1] == '/') 516 { 517 // directory-style path for the current thing 518 // resolve relative to this directory 519 other = this.path ~ other; 520 } 521 else 522 { 523 // this is a file-like thing 524 // find the 'directory' and relative to that 525 other = this.path[0..this.path.lastIndexOf('/') + 1] ~ other; 526 } 527 } 528 parsePathAndQuery(ret, other); 529 return ret; 530 } 531 } 532 533 /** 534 * Parse a URL from a string. 535 * 536 * This attempts to parse a wide range of URLs as people might actually type them. Some mistakes 537 * may be made. However, any URL in a correct format will be parsed correctly. 538 */ 539 bool tryParseURL(string value, out URL url) 540 { 541 url = URL.init; 542 // scheme:[//[user:password@]host[:port]][/]path[?query][#fragment] 543 // Scheme is optional in common use. We infer 'http' if it's not given. 544 auto i = value.indexOf("//"); 545 if (i > -1) { 546 if (i > 1) { 547 url.scheme = value[0..i-1]; 548 } 549 value = value[i+2 .. $]; 550 } else { 551 url.scheme = "http"; 552 } 553 // Check for an ipv6 hostname. 554 // [user:password@]host[:port]][/]path[?query][#fragment 555 i = value.indexOfAny([':', '/', '[']); 556 if (i == -1) { 557 // Just a hostname. 558 url.host = value.fromPuny; 559 return true; 560 } 561 562 if (value[i] == ':') { 563 // This could be between username and password, or it could be between host and port. 564 auto j = value.indexOfAny(['@', '/']); 565 if (j > -1 && value[j] == '@') { 566 try { 567 url.user = value[0..i].percentDecode; 568 url.pass = value[i+1 .. j].percentDecode; 569 } catch (URLException) { 570 return false; 571 } 572 value = value[j+1 .. $]; 573 } 574 } 575 576 // It's trying to be a host/port, not a user/pass. 577 i = value.indexOfAny([':', '/', '[']); 578 if (i == -1) { 579 url.host = value.fromPuny; 580 return true; 581 } 582 583 // Find the hostname. It's either an ipv6 address (which has special rules) or not (which doesn't 584 // have special rules). -- The main sticking point is that ipv6 addresses have colons, which we 585 // handle specially, and are offset with square brackets. 586 if (value[i] == '[') { 587 auto j = value[i..$].indexOf(']'); 588 if (j < 0) { 589 // unterminated ipv6 addr 590 return false; 591 } 592 // includes square brackets 593 url.host = value[i .. i+j+1]; 594 value = value[i+j+1 .. $]; 595 if (value.length == 0) { 596 // read to end of string; we finished parse 597 return true; 598 } 599 if (value[0] != ':' && value[0] != '?' && value[0] != '/') { 600 return false; 601 } 602 } else { 603 // Normal host. 604 url.host = value[0..i].fromPuny; 605 value = value[i .. $]; 606 } 607 608 if (value[0] == ':') { 609 auto end = value.indexOf('/'); 610 if (end == -1) { 611 end = value.length; 612 } 613 try { 614 url.port = value[1 .. end].to!ushort; 615 } catch (ConvException) { 616 return false; 617 } 618 value = value[end .. $]; 619 if (value.length == 0) { 620 return true; 621 } 622 } 623 return parsePathAndQuery(url, value); 624 } 625 626 private bool parsePathAndQuery(ref URL url, string value) 627 { 628 auto i = value.indexOfAny("?#"); 629 if (i == -1) 630 { 631 url.path = value.percentDecode; 632 return true; 633 } 634 635 try 636 { 637 url.path = value[0..i].percentDecode; 638 } 639 catch (URLException) 640 { 641 return false; 642 } 643 644 auto c = value[i]; 645 value = value[i + 1 .. $]; 646 if (c == '?') 647 { 648 i = value.indexOf('#'); 649 string query; 650 if (i < 0) 651 { 652 query = value; 653 value = null; 654 } 655 else 656 { 657 query = value[0..i]; 658 value = value[i + 1 .. $]; 659 } 660 auto queries = query.split('&'); 661 foreach (q; queries) 662 { 663 auto j = q.indexOf('='); 664 string key, val; 665 if (j < 0) 666 { 667 key = q; 668 } 669 else 670 { 671 key = q[0..j]; 672 val = q[j + 1 .. $]; 673 } 674 try 675 { 676 key = key.percentDecode; 677 val = val.percentDecode; 678 } 679 catch (URLException) 680 { 681 return false; 682 } 683 url.queryParams.add(key, val); 684 } 685 } 686 687 try 688 { 689 url.fragment = value.percentDecode; 690 } 691 catch (URLException) 692 { 693 return false; 694 } 695 696 return true; 697 } 698 699 unittest { 700 { 701 // Basic. 702 URL url; 703 with (url) { 704 scheme = "https"; 705 host = "example.org"; 706 path = "/foo/bar"; 707 queryParams.add("hello", "world"); 708 queryParams.add("gibe", "clay"); 709 fragment = "frag"; 710 } 711 assert( 712 // Not sure what order it'll come out in. 713 url.toString == "https://example.org/foo/bar?hello=world&gibe=clay#frag" || 714 url.toString == "https://example.org/foo/bar?gibe=clay&hello=world#frag", 715 url.toString); 716 } 717 { 718 // Percent encoded. 719 URL url; 720 with (url) { 721 scheme = "https"; 722 host = "example.org"; 723 path = "/f☃o"; 724 queryParams.add("❄", "❀"); 725 queryParams.add("[", "]"); 726 fragment = "ş"; 727 } 728 assert( 729 // Not sure what order it'll come out in. 730 url.toString == "https://example.org/f%E2%98%83o?%E2%9D%84=%E2%9D%80&%5B=%5D#%C5%9F" || 731 url.toString == "https://example.org/f%E2%98%83o?%5B=%5D&%E2%9D%84=%E2%9D%80#%C5%9F", 732 url.toString); 733 } 734 { 735 // Port, user, pass. 736 URL url; 737 with (url) { 738 scheme = "https"; 739 host = "example.org"; 740 user = "dhasenan"; 741 pass = "itsasecret"; 742 port = 17; 743 } 744 assert( 745 url.toString == "https://dhasenan:itsasecret@example.org:17/", 746 url.toString); 747 } 748 { 749 // Query with no path. 750 URL url; 751 with (url) { 752 scheme = "https"; 753 host = "example.org"; 754 queryParams.add("hi", "bye"); 755 } 756 assert( 757 url.toString == "https://example.org/?hi=bye", 758 url.toString); 759 } 760 } 761 762 unittest 763 { 764 auto url = "//foo/bar".parseURL; 765 assert(url.host == "foo", "expected host foo, got " ~ url.host); 766 assert(url.path == "/bar"); 767 } 768 769 unittest 770 { 771 // ipv6 hostnames! 772 { 773 // full range of data 774 auto url = parseURL("https://bob:secret@[::1]:2771/foo/bar"); 775 assert(url.scheme == "https", url.scheme); 776 assert(url.user == "bob", url.user); 777 assert(url.pass == "secret", url.pass); 778 assert(url.host == "[::1]", url.host); 779 assert(url.port == 2771, url.port.to!string); 780 assert(url.path == "/foo/bar", url.path); 781 } 782 783 // minimal 784 { 785 auto url = parseURL("[::1]"); 786 assert(url.host == "[::1]", url.host); 787 } 788 789 // some random bits 790 { 791 auto url = parseURL("http://[::1]/foo"); 792 assert(url.scheme == "http", url.scheme); 793 assert(url.host == "[::1]", url.host); 794 assert(url.path == "/foo", url.path); 795 } 796 797 { 798 auto url = parseURL("https://[2001:0db8:0:0:0:0:1428:57ab]/?login=true#justkidding"); 799 assert(url.scheme == "https"); 800 assert(url.host == "[2001:0db8:0:0:0:0:1428:57ab]"); 801 assert(url.path == "/"); 802 assert(url.fragment == "justkidding"); 803 } 804 } 805 806 unittest 807 { 808 auto url = "localhost:5984".parseURL; 809 auto url2 = url ~ "db1"; 810 assert(url2.toString == "http://localhost:5984/db1", url2.toString); 811 auto url3 = url2 ~ "_all_docs"; 812 assert(url3.toString == "http://localhost:5984/db1/_all_docs", url3.toString); 813 } 814 815 /// 816 unittest { 817 { 818 // Basic. 819 URL url; 820 with (url) { 821 scheme = "https"; 822 host = "example.org"; 823 path = "/foo/bar"; 824 queryParams.add("hello", "world"); 825 queryParams.add("gibe", "clay"); 826 fragment = "frag"; 827 } 828 assert( 829 // Not sure what order it'll come out in. 830 url.toString == "https://example.org/foo/bar?hello=world&gibe=clay#frag" || 831 url.toString == "https://example.org/foo/bar?gibe=clay&hello=world#frag", 832 url.toString); 833 } 834 { 835 // Passing an array of query values. 836 URL url; 837 with (url) { 838 scheme = "https"; 839 host = "example.org"; 840 path = "/foo/bar"; 841 queryParams.add("hello", "world"); 842 queryParams.add("hello", "aether"); 843 fragment = "frag"; 844 } 845 assert( 846 // Not sure what order it'll come out in. 847 url.toString == "https://example.org/foo/bar?hello=world&hello=aether#frag" || 848 url.toString == "https://example.org/foo/bar?hello=aether&hello=world#frag", 849 url.toString); 850 } 851 { 852 // Percent encoded. 853 URL url; 854 with (url) { 855 scheme = "https"; 856 host = "example.org"; 857 path = "/f☃o"; 858 queryParams.add("❄", "❀"); 859 queryParams.add("[", "]"); 860 fragment = "ş"; 861 } 862 assert( 863 // Not sure what order it'll come out in. 864 url.toString == "https://example.org/f%E2%98%83o?%E2%9D%84=%E2%9D%80&%5B=%5D#%C5%9F" || 865 url.toString == "https://example.org/f%E2%98%83o?%5B=%5D&%E2%9D%84=%E2%9D%80#%C5%9F", 866 url.toString); 867 } 868 { 869 // Port, user, pass. 870 URL url; 871 with (url) { 872 scheme = "https"; 873 host = "example.org"; 874 user = "dhasenan"; 875 pass = "itsasecret"; 876 port = 17; 877 } 878 assert( 879 url.toString == "https://dhasenan:itsasecret@example.org:17/", 880 url.toString); 881 } 882 { 883 // Query with no path. 884 URL url; 885 with (url) { 886 scheme = "https"; 887 host = "example.org"; 888 queryParams.add("hi", "bye"); 889 } 890 assert( 891 url.toString == "https://example.org/?hi=bye", 892 url.toString); 893 } 894 } 895 896 unittest { 897 // Percent decoding. 898 899 // http://#:!:@ 900 auto urlString = "http://%23:%21%3A@example.org/%7B/%7D?%3B&%26=%3D#%23hash"; 901 auto url = urlString.parseURL; 902 assert(url.user == "#"); 903 assert(url.pass == "!:"); 904 assert(url.host == "example.org"); 905 assert(url.path == "/{/}"); 906 assert(url.queryParams[";"].front == ""); 907 assert(url.queryParams["&"].front == "="); 908 assert(url.fragment == "#hash"); 909 910 // Round trip. 911 assert(urlString == urlString.parseURL.toString, urlString.parseURL.toString); 912 assert(urlString == urlString.parseURL.toString.parseURL.toString); 913 } 914 915 unittest { 916 auto url = "https://xn--m3h.xn--n3h.org/?hi=bye".parseURL; 917 assert(url.host == "☂.☃.org", url.host); 918 } 919 920 unittest { 921 auto url = "https://☂.☃.org/?hi=bye".parseURL; 922 assert(url.toString == "https://xn--m3h.xn--n3h.org/?hi=bye"); 923 } 924 925 /// 926 unittest { 927 // There's an existing path. 928 auto url = parseURL("http://example.org/foo"); 929 URL url2; 930 // No slash? Assume it needs a slash. 931 assert((url ~ "bar").toString == "http://example.org/foo/bar"); 932 // With slash? Don't add another. 933 url2 = url ~ "/bar"; 934 assert(url2.toString == "http://example.org/foo/bar", url2.toString); 935 url ~= "bar"; 936 assert(url.toString == "http://example.org/foo/bar"); 937 938 // Path already ends with a slash; don't add another. 939 url = parseURL("http://example.org/foo/"); 940 assert((url ~ "bar").toString == "http://example.org/foo/bar"); 941 // Still don't add one even if you're appending with a slash. 942 assert((url ~ "/bar").toString == "http://example.org/foo/bar"); 943 url ~= "/bar"; 944 assert(url.toString == "http://example.org/foo/bar"); 945 946 // No path. 947 url = parseURL("http://example.org"); 948 assert((url ~ "bar").toString == "http://example.org/bar"); 949 assert((url ~ "/bar").toString == "http://example.org/bar"); 950 url ~= "bar"; 951 assert(url.toString == "http://example.org/bar"); 952 953 // Path is just a slash. 954 url = parseURL("http://example.org/"); 955 assert((url ~ "bar").toString == "http://example.org/bar"); 956 assert((url ~ "/bar").toString == "http://example.org/bar"); 957 url ~= "bar"; 958 assert(url.toString == "http://example.org/bar", url.toString); 959 960 // No path, just fragment. 961 url = "ircs://irc.freenode.com/#d".parseURL; 962 assert(url.toString == "ircs://irc.freenode.com/#d", url.toString); 963 } 964 unittest 965 { 966 // basic resolve() 967 { 968 auto base = "https://example.org/this/".parseURL; 969 assert(base.resolve("that") == "https://example.org/this/that"); 970 assert(base.resolve("/that") == "https://example.org/that"); 971 assert(base.resolve("//example.net/that") == "https://example.net/that"); 972 } 973 974 // ensure we don't preserve query params 975 { 976 auto base = "https://example.org/this?query=value&other=value2".parseURL; 977 assert(base.resolve("that") == "https://example.org/that"); 978 assert(base.resolve("/that") == "https://example.org/that"); 979 assert(base.resolve("//example.net/that") == "https://example.net/that"); 980 } 981 } 982 983 984 unittest 985 { 986 import std.net.curl; 987 auto url = "http://example.org".parseURL; 988 assert(is(typeof(std.net.curl.get(url)))); 989 } 990 991 /** 992 * Parse the input string as a URL. 993 * 994 * Throws: 995 * URLException if the string was in an incorrect format. 996 */ 997 URL parseURL(string value) { 998 URL url; 999 if (tryParseURL(value, url)) { 1000 return url; 1001 } 1002 throw new URLException("failed to parse URL " ~ value); 1003 } 1004 1005 /// 1006 unittest { 1007 { 1008 // Infer scheme 1009 auto u1 = parseURL("example.org"); 1010 assert(u1.scheme == "http"); 1011 assert(u1.host == "example.org"); 1012 assert(u1.path == ""); 1013 assert(u1.port == 80); 1014 assert(u1.providedPort == 0); 1015 assert(u1.fragment == ""); 1016 } 1017 { 1018 // Simple host and scheme 1019 auto u1 = parseURL("https://example.org"); 1020 assert(u1.scheme == "https"); 1021 assert(u1.host == "example.org"); 1022 assert(u1.path == ""); 1023 assert(u1.port == 443); 1024 assert(u1.providedPort == 0); 1025 } 1026 { 1027 // With path 1028 auto u1 = parseURL("https://example.org/foo/bar"); 1029 assert(u1.scheme == "https"); 1030 assert(u1.host == "example.org"); 1031 assert(u1.path == "/foo/bar", "expected /foo/bar but got " ~ u1.path); 1032 assert(u1.port == 443); 1033 assert(u1.providedPort == 0); 1034 } 1035 { 1036 // With explicit port 1037 auto u1 = parseURL("https://example.org:1021/foo/bar"); 1038 assert(u1.scheme == "https"); 1039 assert(u1.host == "example.org"); 1040 assert(u1.path == "/foo/bar", "expected /foo/bar but got " ~ u1.path); 1041 assert(u1.port == 1021); 1042 assert(u1.providedPort == 1021); 1043 } 1044 { 1045 // With user 1046 auto u1 = parseURL("https://bob:secret@example.org/foo/bar"); 1047 assert(u1.scheme == "https"); 1048 assert(u1.host == "example.org"); 1049 assert(u1.path == "/foo/bar"); 1050 assert(u1.port == 443); 1051 assert(u1.user == "bob"); 1052 assert(u1.pass == "secret"); 1053 } 1054 { 1055 // With user, URL-encoded 1056 auto u1 = parseURL("https://bob%21:secret%21%3F@example.org/foo/bar"); 1057 assert(u1.scheme == "https"); 1058 assert(u1.host == "example.org"); 1059 assert(u1.path == "/foo/bar"); 1060 assert(u1.port == 443); 1061 assert(u1.user == "bob!"); 1062 assert(u1.pass == "secret!?"); 1063 } 1064 { 1065 // With user and port and path 1066 auto u1 = parseURL("https://bob:secret@example.org:2210/foo/bar"); 1067 assert(u1.scheme == "https"); 1068 assert(u1.host == "example.org"); 1069 assert(u1.path == "/foo/bar"); 1070 assert(u1.port == 2210); 1071 assert(u1.user == "bob"); 1072 assert(u1.pass == "secret"); 1073 assert(u1.fragment == ""); 1074 } 1075 { 1076 // With query string 1077 auto u1 = parseURL("https://example.org/?login=true"); 1078 assert(u1.scheme == "https"); 1079 assert(u1.host == "example.org"); 1080 assert(u1.path == "/", "expected path: / actual path: " ~ u1.path); 1081 assert(u1.queryParams["login"].front == "true"); 1082 assert(u1.fragment == ""); 1083 } 1084 { 1085 // With query string and fragment 1086 auto u1 = parseURL("https://example.org/?login=true#justkidding"); 1087 assert(u1.scheme == "https"); 1088 assert(u1.host == "example.org"); 1089 assert(u1.path == "/", "expected path: / actual path: " ~ u1.path); 1090 assert(u1.queryParams["login"].front == "true"); 1091 assert(u1.fragment == "justkidding"); 1092 } 1093 { 1094 // With URL-encoded values 1095 auto u1 = parseURL("https://example.org/%E2%98%83?%E2%9D%84=%3D#%5E"); 1096 assert(u1.scheme == "https"); 1097 assert(u1.host == "example.org"); 1098 assert(u1.path == "/☃", "expected path: /☃ actual path: " ~ u1.path); 1099 assert(u1.queryParams["❄"].front == "="); 1100 assert(u1.fragment == "^"); 1101 } 1102 } 1103 1104 unittest { 1105 assert(parseURL("http://example.org").port == 80); 1106 assert(parseURL("http://example.org:5326").port == 5326); 1107 1108 auto url = parseURL("redis://admin:password@redisbox.local:2201/path?query=value#fragment"); 1109 assert(url.scheme == "redis"); 1110 assert(url.user == "admin"); 1111 assert(url.pass == "password"); 1112 1113 assert(parseURL("example.org").toString == "http://example.org/"); 1114 assert(parseURL("http://example.org:80").toString == "http://example.org/"); 1115 1116 assert(parseURL("localhost:8070").toString == "http://localhost:8070/"); 1117 } 1118 1119 /** 1120 * Percent-encode a string. 1121 * 1122 * URL components cannot contain non-ASCII characters, and there are very few characters that are 1123 * safe to include as URL components. Domain names using Unicode values use Punycode. For 1124 * everything else, there is percent encoding. 1125 */ 1126 string percentEncode(string raw) { 1127 // We *must* encode these characters: :/?#[]@!$&'()*+,;=" 1128 // We *can* encode any other characters. 1129 // We *should not* encode alpha, numeric, or -._~. 1130 import std.utf : encode; 1131 import std.array : Appender; 1132 Appender!string app; 1133 foreach (dchar d; raw) { 1134 if (('a' <= d && 'z' >= d) || 1135 ('A' <= d && 'Z' >= d) || 1136 ('0' <= d && '9' >= d) || 1137 d == '-' || d == '.' || d == '_' || d == '~') { 1138 app ~= d; 1139 continue; 1140 } 1141 // Something simple like a space character? Still in 7-bit ASCII? 1142 // Then we get a single-character string out of it and just encode 1143 // that one bit. 1144 // Something not in 7-bit ASCII? Then we percent-encode each octet 1145 // in the UTF-8 encoding (and hope the server understands UTF-8). 1146 char[] c; 1147 encode(c, d); 1148 auto bytes = cast(ubyte[])c; 1149 foreach (b; bytes) { 1150 app ~= format("%%%02X", b); 1151 } 1152 } 1153 return cast(string)app.data; 1154 } 1155 1156 /// 1157 unittest { 1158 assert(percentEncode("IDontNeedNoPercentEncoding") == "IDontNeedNoPercentEncoding"); 1159 assert(percentEncode("~~--..__") == "~~--..__"); 1160 assert(percentEncode("0123456789") == "0123456789"); 1161 1162 string e; 1163 1164 e = percentEncode("☃"); 1165 assert(e == "%E2%98%83", "expected %E2%98%83 but got" ~ e); 1166 } 1167 1168 /** 1169 * Percent-decode a string. 1170 * 1171 * URL components cannot contain non-ASCII characters, and there are very few characters that are 1172 * safe to include as URL components. Domain names using Unicode values use Punycode. For 1173 * everything else, there is percent encoding. 1174 * 1175 * This explicitly ensures that the result is a valid UTF-8 string. 1176 */ 1177 string percentDecode(string encoded) 1178 { 1179 import std.utf : validate, UTFException; 1180 auto raw = percentDecodeRaw(encoded); 1181 auto s = cast(string) raw; 1182 try 1183 { 1184 validate(s); 1185 } 1186 catch (UTFException e) 1187 { 1188 throw new URLException( 1189 "The percent-encoded data `" ~ encoded ~ "` does not represent a valid UTF-8 sequence."); 1190 } 1191 return s; 1192 } 1193 1194 /// 1195 unittest { 1196 assert(percentDecode("IDontNeedNoPercentDecoding") == "IDontNeedNoPercentDecoding"); 1197 assert(percentDecode("~~--..__") == "~~--..__"); 1198 assert(percentDecode("0123456789") == "0123456789"); 1199 1200 string e; 1201 1202 e = percentDecode("%E2%98%83"); 1203 assert(e == "☃", "expected a snowman but got" ~ e); 1204 1205 e = percentDecode("%e2%98%83"); 1206 assert(e == "☃", "expected a snowman but got" ~ e); 1207 1208 try { 1209 // %ES is an invalid percent sequence: 'S' is not a hex digit. 1210 percentDecode("%es"); 1211 assert(false, "expected exception not thrown"); 1212 } catch (URLException) { 1213 } 1214 1215 try { 1216 percentDecode("%e"); 1217 assert(false, "expected exception not thrown"); 1218 } catch (URLException) { 1219 } 1220 } 1221 1222 /** 1223 * Percent-decode a string into a ubyte array. 1224 * 1225 * URL components cannot contain non-ASCII characters, and there are very few characters that are 1226 * safe to include as URL components. Domain names using Unicode values use Punycode. For 1227 * everything else, there is percent encoding. 1228 * 1229 * This yields a ubyte array and will not perform validation on the output. However, an improperly 1230 * formatted input string will result in a URLException. 1231 */ 1232 immutable(ubyte)[] percentDecodeRaw(string encoded) 1233 { 1234 // We're dealing with possibly incorrectly encoded UTF-8. Mark it down as ubyte[] for now. 1235 import std.array : Appender; 1236 Appender!(immutable(ubyte)[]) app; 1237 for (int i = 0; i < encoded.length; i++) { 1238 if (encoded[i] != '%') { 1239 app ~= encoded[i]; 1240 continue; 1241 } 1242 if (i >= encoded.length - 2) { 1243 throw new URLException("Invalid percent encoded value: expected two characters after " ~ 1244 "percent symbol. Error at index " ~ i.to!string); 1245 } 1246 if (isHex(encoded[i + 1]) && isHex(encoded[i + 2])) { 1247 auto b = fromHex(encoded[i + 1]); 1248 auto c = fromHex(encoded[i + 2]); 1249 app ~= cast(ubyte)((b << 4) | c); 1250 } else { 1251 throw new URLException("Invalid percent encoded value: expected two hex digits after " ~ 1252 "percent symbol. Error at index " ~ i.to!string); 1253 } 1254 i += 2; 1255 } 1256 return app.data; 1257 } 1258 1259 private bool isHex(char c) { 1260 return ('0' <= c && '9' >= c) || 1261 ('a' <= c && 'f' >= c) || 1262 ('A' <= c && 'F' >= c); 1263 } 1264 1265 private ubyte fromHex(char s) { 1266 enum caseDiff = 'a' - 'A'; 1267 if (s >= 'a' && s <= 'z') { 1268 s -= caseDiff; 1269 } 1270 return cast(ubyte)("0123456789ABCDEF".indexOf(s)); 1271 } 1272 1273 private string toPuny(string unicodeHostname) 1274 { 1275 bool mustEncode = false; 1276 foreach (i, dchar d; unicodeHostname) { 1277 auto c = cast(uint) d; 1278 if (c > 0x80) { 1279 mustEncode = true; 1280 break; 1281 } 1282 if (c < 0x2C || (c >= 0x3A && c <= 40) || (c >= 0x5B && c <= 0x60) || (c >= 0x7B)) { 1283 throw new URLException( 1284 format( 1285 "domain name '%s' contains illegal character '%s' at position %s", 1286 unicodeHostname, d, i)); 1287 } 1288 } 1289 if (!mustEncode) { 1290 return unicodeHostname; 1291 } 1292 import std.algorithm.iteration : map; 1293 return unicodeHostname.split('.').map!punyEncode.join("."); 1294 } 1295 1296 private string fromPuny(string hostname) 1297 { 1298 import std.algorithm.iteration : map; 1299 return hostname.split('.').map!punyDecode.join("."); 1300 } 1301 1302 private { 1303 enum delimiter = '-'; 1304 enum marker = "xn--"; 1305 enum ulong damp = 700; 1306 enum ulong tmin = 1; 1307 enum ulong tmax = 26; 1308 enum ulong skew = 38; 1309 enum ulong base = 36; 1310 enum ulong initialBias = 72; 1311 enum dchar initialN = cast(dchar)128; 1312 1313 ulong adapt(ulong delta, ulong numPoints, bool firstTime) { 1314 if (firstTime) { 1315 delta /= damp; 1316 } else { 1317 delta /= 2; 1318 } 1319 delta += delta / numPoints; 1320 ulong k = 0; 1321 while (delta > ((base - tmin) * tmax) / 2) { 1322 delta /= (base - tmin); 1323 k += base; 1324 } 1325 return k + (((base - tmin + 1) * delta) / (delta + skew)); 1326 } 1327 } 1328 1329 /** 1330 * Encode the input string using the Punycode algorithm. 1331 * 1332 * Punycode is used to encode UTF domain name segment. A Punycode-encoded segment will be marked 1333 * with "xn--". Each segment is encoded separately. For instance, if you wish to encode "☂.☃.com" 1334 * in Punycode, you will get "xn--m3h.xn--n3h.com". 1335 * 1336 * In order to puny-encode a domain name, you must split it into its components. The following will 1337 * typically suffice: 1338 * --- 1339 * auto domain = "☂.☃.com"; 1340 * auto encodedDomain = domain.splitter(".").map!(punyEncode).join("."); 1341 * --- 1342 */ 1343 string punyEncode(string input) 1344 { 1345 import std.array : Appender; 1346 ulong delta = 0; 1347 dchar n = initialN; 1348 auto i = 0; 1349 auto bias = initialBias; 1350 Appender!string output; 1351 output ~= marker; 1352 auto pushed = 0; 1353 auto codePoints = 0; 1354 foreach (dchar c; input) { 1355 codePoints++; 1356 if (c <= initialN) { 1357 output ~= c; 1358 pushed++; 1359 } 1360 } 1361 if (pushed < codePoints) { 1362 if (pushed > 0) { 1363 output ~= delimiter; 1364 } 1365 } else { 1366 // No encoding to do. 1367 return input; 1368 } 1369 bool first = true; 1370 while (pushed < codePoints) { 1371 auto best = dchar.max; 1372 foreach (dchar c; input) { 1373 if (n <= c && c < best) { 1374 best = c; 1375 } 1376 } 1377 if (best == dchar.max) { 1378 throw new URLException("failed to find a new codepoint to process during punyencode"); 1379 } 1380 delta += (best - n) * (pushed + 1); 1381 if (delta > uint.max) { 1382 // TODO better error message 1383 throw new URLException("overflow during punyencode"); 1384 } 1385 n = best; 1386 foreach (dchar c; input) { 1387 if (c < n) { 1388 delta++; 1389 } 1390 if (c == n) { 1391 ulong q = delta; 1392 auto k = base; 1393 while (true) { 1394 ulong t; 1395 if (k <= bias) { 1396 t = tmin; 1397 } else if (k >= bias + tmax) { 1398 t = tmax; 1399 } else { 1400 t = k - bias; 1401 } 1402 if (q < t) { 1403 break; 1404 } 1405 output ~= digitToBasic(t + ((q - t) % (base - t))); 1406 q = (q - t) / (base - t); 1407 k += base; 1408 } 1409 output ~= digitToBasic(q); 1410 pushed++; 1411 bias = adapt(delta, pushed, first); 1412 first = false; 1413 delta = 0; 1414 } 1415 } 1416 delta++; 1417 n++; 1418 } 1419 return cast(string)output.data; 1420 } 1421 1422 /** 1423 * Decode the input string using the Punycode algorithm. 1424 * 1425 * Punycode is used to encode UTF domain name segment. A Punycode-encoded segment will be marked 1426 * with "xn--". Each segment is encoded separately. For instance, if you wish to encode "☂.☃.com" 1427 * in Punycode, you will get "xn--m3h.xn--n3h.com". 1428 * 1429 * In order to puny-decode a domain name, you must split it into its components. The following will 1430 * typically suffice: 1431 * --- 1432 * auto domain = "xn--m3h.xn--n3h.com"; 1433 * auto decodedDomain = domain.splitter(".").map!(punyDecode).join("."); 1434 * --- 1435 */ 1436 string punyDecode(string input) { 1437 if (!input.startsWith(marker)) { 1438 return input; 1439 } 1440 input = input[marker.length..$]; 1441 1442 // let n = initial_n 1443 dchar n = cast(dchar)128; 1444 1445 // let i = 0 1446 // let bias = initial_bias 1447 // let output = an empty string indexed from 0 1448 size_t i = 0; 1449 auto bias = initialBias; 1450 dchar[] output; 1451 // This reserves a bit more than necessary, but it should be more efficient overall than just 1452 // appending and inserting volo-nolo. 1453 output.reserve(input.length); 1454 1455 // consume all code points before the last delimiter (if there is one) 1456 // and copy them to output, fail on any non-basic code point 1457 // if more than zero code points were consumed then consume one more 1458 // (which will be the last delimiter) 1459 auto end = input.lastIndexOf(delimiter); 1460 if (end > -1) { 1461 foreach (dchar c; input[0..end]) { 1462 output ~= c; 1463 } 1464 input = input[end+1 .. $]; 1465 } 1466 1467 // while the input is not exhausted do begin 1468 size_t pos = 0; 1469 while (pos < input.length) { 1470 // let oldi = i 1471 // let w = 1 1472 auto oldi = i; 1473 auto w = 1; 1474 // for k = base to infinity in steps of base do begin 1475 for (ulong k = base; k < uint.max; k += base) { 1476 // consume a code point, or fail if there was none to consume 1477 // Note that the input is all ASCII, so we can simply index the input string bytewise. 1478 auto c = input[pos]; 1479 pos++; 1480 // let digit = the code point's digit-value, fail if it has none 1481 auto digit = basicToDigit(c); 1482 // let i = i + digit * w, fail on overflow 1483 i += digit * w; 1484 // let t = tmin if k <= bias {+ tmin}, or 1485 // tmax if k >= bias + tmax, or k - bias otherwise 1486 ulong t; 1487 if (k <= bias) { 1488 t = tmin; 1489 } else if (k >= bias + tmax) { 1490 t = tmax; 1491 } else { 1492 t = k - bias; 1493 } 1494 // if digit < t then break 1495 if (digit < t) { 1496 break; 1497 } 1498 // let w = w * (base - t), fail on overflow 1499 w *= (base - t); 1500 // end 1501 } 1502 // let bias = adapt(i - oldi, length(output) + 1, test oldi is 0?) 1503 bias = adapt(i - oldi, output.length + 1, oldi == 0); 1504 // let n = n + i div (length(output) + 1), fail on overflow 1505 n += i / (output.length + 1); 1506 // let i = i mod (length(output) + 1) 1507 i %= (output.length + 1); 1508 // {if n is a basic code point then fail} 1509 // (We aren't actually going to fail here; it's clear what this means.) 1510 // insert n into output at position i 1511 import std.array : insertInPlace; 1512 (() @trusted { output.insertInPlace(i, cast(dchar)n); })(); // should be @safe but isn't marked 1513 // increment i 1514 i++; 1515 // end 1516 } 1517 return output.to!string; 1518 } 1519 1520 // Lifted from punycode.js. 1521 private dchar digitToBasic(ulong digit) { 1522 return cast(dchar)(digit + 22 + 75 * (digit < 26)); 1523 } 1524 1525 // Lifted from punycode.js. 1526 private uint basicToDigit(char c) { 1527 auto codePoint = cast(uint)c; 1528 if (codePoint - 48 < 10) { 1529 return codePoint - 22; 1530 } 1531 if (codePoint - 65 < 26) { 1532 return codePoint - 65; 1533 } 1534 if (codePoint - 97 < 26) { 1535 return codePoint - 97; 1536 } 1537 return base; 1538 } 1539 1540 unittest { 1541 { 1542 auto a = "b\u00FCcher"; 1543 assert(punyEncode(a) == "xn--bcher-kva"); 1544 } 1545 { 1546 auto a = "b\u00FCc\u00FCher"; 1547 assert(punyEncode(a) == "xn--bcher-kvab"); 1548 } 1549 { 1550 auto a = "ýbücher"; 1551 auto b = punyEncode(a); 1552 assert(b == "xn--bcher-kvaf", b); 1553 } 1554 1555 { 1556 auto a = "mañana"; 1557 assert(punyEncode(a) == "xn--maana-pta"); 1558 } 1559 1560 { 1561 auto a = "\u0644\u064A\u0647\u0645\u0627\u0628\u062A\u0643\u0644" 1562 ~ "\u0645\u0648\u0634\u0639\u0631\u0628\u064A\u061F"; 1563 auto b = punyEncode(a); 1564 assert(b == "xn--egbpdaj6bu4bxfgehfvwxn", b); 1565 } 1566 import std.stdio; 1567 } 1568 1569 unittest { 1570 { 1571 auto b = punyDecode("xn--egbpdaj6bu4bxfgehfvwxn"); 1572 assert(b == "ليهمابتكلموشعربي؟", b); 1573 } 1574 { 1575 assert(punyDecode("xn--maana-pta") == "mañana"); 1576 } 1577 } 1578 1579 unittest { 1580 import std.string, std.algorithm, std.array, std.range; 1581 { 1582 auto domain = "xn--m3h.xn--n3h.com"; 1583 auto decodedDomain = domain.splitter(".").map!(punyDecode).join("."); 1584 assert(decodedDomain == "☂.☃.com", decodedDomain); 1585 } 1586 { 1587 auto domain = "☂.☃.com"; 1588 auto decodedDomain = domain.splitter(".").map!(punyEncode).join("."); 1589 assert(decodedDomain == "xn--m3h.xn--n3h.com", decodedDomain); 1590 } 1591 }