Skip to content

Commit 1bbee05

Browse files
committed
feat: Add native WebSocket support (ws:// and wss://)
Implements GitHub issue #1627 - WebSocket Support Testing - Add httpie/websocket.py with WebSocketSession class - Support ws:// and wss:// URL schemes - Interactive send/receive mode with Ctrl+C disconnect - Custom headers, auth, timeout, SSL verify options - Add comprehensive test suite (19 tests)
1 parent 36f497a commit 1bbee05

4 files changed

Lines changed: 469 additions & 0 deletions

File tree

httpie/core.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ def raw_main(
4343
if is_daemon_mode(args):
4444
return run_daemon_task(env, args)
4545

46+
# Handle WebSocket URLs early (ws:// and wss://)
47+
# WebSocket connections require a different protocol handler
48+
from httpie.websocket import is_websocket_url, extract_websocket_args, run_websocket_session
49+
for arg in args:
50+
if isinstance(arg, str) and is_websocket_url(arg):
51+
url, headers, auth, timeout, verify_ssl = extract_websocket_args(args)
52+
return run_websocket_session(
53+
url=url,
54+
env=env,
55+
headers=headers,
56+
auth=auth,
57+
timeout=timeout,
58+
verify_ssl=verify_ssl,
59+
)
60+
4661
# Handle --sequence mode early, before argument parsing
4762
# since --sequence doesn't require a URL on the command line
4863
if '--sequence' in args:

httpie/websocket.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""WebSocket support for HTTPie.
2+
3+
This module provides functionality to connect to WebSocket servers
4+
using ws:// and wss:// URL schemes.
5+
"""
6+
7+
import re
8+
import sys
9+
from typing import Optional, List, TYPE_CHECKING
10+
11+
if TYPE_CHECKING:
12+
from httpie.context import Environment
13+
14+
from httpie.status import ExitStatus
15+
16+
17+
# WebSocket URL scheme pattern
18+
WEBSOCKET_SCHEME_RE = re.compile(r'^wss?://', re.IGNORECASE)
19+
20+
21+
def is_websocket_url(url: str) -> bool:
22+
"""Check if the given URL uses WebSocket scheme (ws:// or wss://)."""
23+
return bool(WEBSOCKET_SCHEME_RE.match(url))
24+
25+
26+
class WebSocketSession:
27+
"""Manages a WebSocket connection session."""
28+
29+
def __init__(
30+
self,
31+
url: str,
32+
headers: Optional[dict] = None,
33+
timeout: Optional[float] = None,
34+
verify_ssl: bool = True,
35+
):
36+
self.url = url
37+
self.headers = headers or {}
38+
self.timeout = timeout
39+
self.verify_ssl = verify_ssl
40+
self._ws = None
41+
self._connected = False
42+
43+
def connect(self) -> dict:
44+
"""
45+
Establish WebSocket connection.
46+
47+
Returns the handshake response headers.
48+
"""
49+
try:
50+
import websocket
51+
except ImportError:
52+
raise ImportError(
53+
"WebSocket support requires the 'websocket-client' package. "
54+
"Install it with: pip install websocket-client"
55+
)
56+
57+
# Convert headers dict to list of tuples for websocket-client
58+
header_list = [f"{k}: {v}" for k, v in self.headers.items()]
59+
60+
# Create WebSocket connection
61+
self._ws = websocket.create_connection(
62+
self.url,
63+
header=header_list,
64+
timeout=self.timeout,
65+
sslopt={"cert_reqs": 0} if not self.verify_ssl else None,
66+
)
67+
self._connected = True
68+
69+
# Return handshake headers
70+
return dict(self._ws.getheaders()) if self._ws.getheaders() else {}
71+
72+
def send(self, message: str) -> None:
73+
"""Send a message to the WebSocket server."""
74+
if not self._connected or not self._ws:
75+
raise RuntimeError("WebSocket is not connected")
76+
self._ws.send(message)
77+
78+
def receive(self, timeout: Optional[float] = None) -> Optional[str]:
79+
"""
80+
Receive a message from the WebSocket server.
81+
82+
Returns None if connection is closed.
83+
"""
84+
if not self._connected or not self._ws:
85+
raise RuntimeError("WebSocket is not connected")
86+
87+
try:
88+
if timeout is not None:
89+
self._ws.settimeout(timeout)
90+
return self._ws.recv()
91+
except Exception:
92+
return None
93+
94+
def close(self, code: int = 1000, reason: str = "") -> tuple:
95+
"""
96+
Close the WebSocket connection.
97+
98+
Returns (close_code, close_reason).
99+
"""
100+
if self._ws:
101+
try:
102+
self._ws.close(status=code, reason=reason.encode() if reason else b"")
103+
except Exception:
104+
pass
105+
close_code = getattr(self._ws, 'close_status', code)
106+
close_reason = getattr(self._ws, 'close_reason', reason)
107+
self._connected = False
108+
return (close_code, close_reason)
109+
return (code, reason)
110+
111+
@property
112+
def connected(self) -> bool:
113+
"""Check if the WebSocket is connected."""
114+
return self._connected
115+
116+
117+
def run_websocket_session(
118+
url: str,
119+
env: 'Environment',
120+
headers: Optional[dict] = None,
121+
auth: Optional[tuple] = None,
122+
timeout: Optional[float] = None,
123+
verify_ssl: bool = True,
124+
) -> ExitStatus:
125+
"""
126+
Run an interactive WebSocket session.
127+
128+
Args:
129+
url: WebSocket URL (ws:// or wss://)
130+
env: HTTPie Environment instance
131+
headers: Optional custom headers
132+
auth: Optional (username, password) tuple for basic auth
133+
timeout: Connection timeout in seconds
134+
verify_ssl: Whether to verify SSL certificates
135+
136+
Returns:
137+
ExitStatus indicating success or failure.
138+
"""
139+
import base64
140+
141+
headers = dict(headers) if headers else {}
142+
143+
# Add basic auth header if provided
144+
if auth:
145+
username, password = auth
146+
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
147+
headers['Authorization'] = f'Basic {credentials}'
148+
149+
session = WebSocketSession(
150+
url=url,
151+
headers=headers,
152+
timeout=timeout,
153+
verify_ssl=verify_ssl,
154+
)
155+
156+
try:
157+
# Connect to WebSocket server
158+
env.stdout.write(f"> Connecting to {url}...\n")
159+
handshake_headers = session.connect()
160+
161+
# Display handshake response
162+
env.stdout.write(f"> Connected to {url}\n")
163+
if handshake_headers:
164+
env.stdout.write("> Handshake response headers:\n")
165+
for key, value in handshake_headers.items():
166+
env.stdout.write(f"> {key}: {value}\n")
167+
168+
env.stdout.write(">\n")
169+
env.stdout.write("> Type a message and press Enter to send.\n")
170+
env.stdout.write("> Use \\\\ at end of line for multi-line input.\n")
171+
env.stdout.write("> Press Ctrl+C to disconnect.\n")
172+
env.stdout.write(">\n")
173+
174+
# Interactive message loop
175+
while session.connected:
176+
try:
177+
# Read input from user
178+
env.stdout.write("< ")
179+
env.stdout.flush()
180+
181+
# Handle multi-line input (lines ending with \)
182+
message_lines = []
183+
while True:
184+
line = env.stdin.readline()
185+
if not line:
186+
# EOF
187+
raise KeyboardInterrupt
188+
189+
line = line.rstrip('\n\r')
190+
if line.endswith('\\'):
191+
message_lines.append(line[:-1])
192+
else:
193+
message_lines.append(line)
194+
break
195+
196+
message = '\n'.join(message_lines)
197+
198+
if message:
199+
# Send message
200+
session.send(message)
201+
202+
# Try to receive response (with short timeout for echo servers)
203+
response = session.receive(timeout=5.0)
204+
if response:
205+
env.stdout.write(f"> {response}\n")
206+
207+
except KeyboardInterrupt:
208+
env.stdout.write("\n> Disconnecting...\n")
209+
break
210+
211+
except ImportError as e:
212+
env.log_error(str(e))
213+
return ExitStatus.ERROR
214+
except Exception as e:
215+
env.log_error(f"WebSocket error: {e}")
216+
return ExitStatus.ERROR
217+
finally:
218+
# Close connection
219+
close_code, close_reason = session.close()
220+
env.stdout.write(f"> Connection closed (code: {close_code})\n")
221+
if close_reason:
222+
env.stdout.write(f"> Close reason: {close_reason}\n")
223+
224+
return ExitStatus.SUCCESS
225+
226+
227+
def extract_websocket_args(args: List[str]) -> tuple:
228+
"""
229+
Extract WebSocket-relevant arguments from command line args.
230+
231+
Returns (url, headers_dict, auth_tuple, timeout, verify_ssl).
232+
"""
233+
url = None
234+
headers = {}
235+
auth = None
236+
timeout = None
237+
verify_ssl = True
238+
239+
i = 0
240+
while i < len(args):
241+
arg = args[i]
242+
243+
# URL (first positional that looks like ws:// or wss://)
244+
if url is None and is_websocket_url(arg):
245+
url = arg
246+
# Header (Key:Value)
247+
elif ':' in arg and not arg.startswith('-') and '=' not in arg.split(':')[0]:
248+
key, value = arg.split(':', 1)
249+
if key and not key.startswith('-'):
250+
headers[key] = value
251+
# Auth
252+
elif arg in ('--auth', '-a') and i + 1 < len(args):
253+
auth_str = args[i + 1]
254+
if ':' in auth_str:
255+
auth = tuple(auth_str.split(':', 1))
256+
i += 1
257+
# Timeout
258+
elif arg == '--timeout' and i + 1 < len(args):
259+
try:
260+
timeout = float(args[i + 1])
261+
except ValueError:
262+
pass
263+
i += 1
264+
# Verify SSL
265+
elif arg == '--verify':
266+
if i + 1 < len(args) and args[i + 1].lower() in ('no', 'false', '0'):
267+
verify_ssl = False
268+
i += 1
269+
270+
i += 1
271+
272+
return url, headers, auth, timeout, verify_ssl

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ install_requires =
5858
importlib-metadata>=1.4.0; python_version<"3.8"
5959
rich>=9.10.0
6060
colorama>=0.2.4; sys_platform=="win32"
61+
websocket-client>=1.0.0
6162
python_requires = >=3.7
6263

6364

0 commit comments

Comments
 (0)