uri.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. from __future__ import annotations
  2. import dataclasses
  3. import urllib.parse
  4. from .exceptions import InvalidURI
  5. __all__ = ["parse_uri", "WebSocketURI"]
  6. # All characters from the gen-delims and sub-delims sets in RFC 3987.
  7. DELIMS = ":/?#[]@!$&'()*+,;="
  8. @dataclasses.dataclass
  9. class WebSocketURI:
  10. """
  11. WebSocket URI.
  12. Attributes:
  13. secure: :obj:`True` for a ``wss`` URI, :obj:`False` for a ``ws`` URI.
  14. host: Normalized to lower case.
  15. port: Always set even if it's the default.
  16. path: May be empty.
  17. query: May be empty if the URI doesn't include a query component.
  18. username: Available when the URI contains `User Information`_.
  19. password: Available when the URI contains `User Information`_.
  20. .. _User Information: https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1
  21. """
  22. secure: bool
  23. host: str
  24. port: int
  25. path: str
  26. query: str
  27. username: str | None = None
  28. password: str | None = None
  29. @property
  30. def resource_name(self) -> str:
  31. if self.path:
  32. resource_name = self.path
  33. else:
  34. resource_name = "/"
  35. if self.query:
  36. resource_name += "?" + self.query
  37. return resource_name
  38. @property
  39. def user_info(self) -> tuple[str, str] | None:
  40. if self.username is None:
  41. return None
  42. assert self.password is not None
  43. return (self.username, self.password)
  44. def parse_uri(uri: str) -> WebSocketURI:
  45. """
  46. Parse and validate a WebSocket URI.
  47. Args:
  48. uri: WebSocket URI.
  49. Returns:
  50. Parsed WebSocket URI.
  51. Raises:
  52. InvalidURI: If ``uri`` isn't a valid WebSocket URI.
  53. """
  54. parsed = urllib.parse.urlparse(uri)
  55. if parsed.scheme not in ["ws", "wss"]:
  56. raise InvalidURI(uri, "scheme isn't ws or wss")
  57. if parsed.hostname is None:
  58. raise InvalidURI(uri, "hostname isn't provided")
  59. if parsed.fragment != "":
  60. raise InvalidURI(uri, "fragment identifier is meaningless")
  61. secure = parsed.scheme == "wss"
  62. host = parsed.hostname
  63. port = parsed.port or (443 if secure else 80)
  64. path = parsed.path
  65. query = parsed.query
  66. username = parsed.username
  67. password = parsed.password
  68. # urllib.parse.urlparse accepts URLs with a username but without a
  69. # password. This doesn't make sense for HTTP Basic Auth credentials.
  70. if username is not None and password is None:
  71. raise InvalidURI(uri, "username provided without password")
  72. try:
  73. uri.encode("ascii")
  74. except UnicodeEncodeError:
  75. # Input contains non-ASCII characters.
  76. # It must be an IRI. Convert it to a URI.
  77. host = host.encode("idna").decode()
  78. path = urllib.parse.quote(path, safe=DELIMS)
  79. query = urllib.parse.quote(query, safe=DELIMS)
  80. if username is not None:
  81. assert password is not None
  82. username = urllib.parse.quote(username, safe=DELIMS)
  83. password = urllib.parse.quote(password, safe=DELIMS)
  84. return WebSocketURI(secure, host, port, path, query, username, password)