0%

Asking to Switch Protocols · practice

Parse the Upgrade Request

The last lesson read the RFC’s upgrade request by eye. Your server has to read it with code: turn the bytes of a request head into a with the , the path, the version and the headers. You parsed a reply’s head this way in with Python. A request differs in two ways: it starts with a request line instead of a status line, and this course keeps each header as one instead of a .

The project

From this lesson on, the chapter’s code lives in a project on Python Land’s servers, and its files persist from lesson to lesson. The project editor shows two files:

  • ws_handshake.py is where you work. It holds the constants GUID and MAX_HEAD, two finished , and a stub for every function this chapter asks for, whose docstring names the lesson that writes it.

  • handshake_demo.py is the program the Run button starts. It calls each lesson’s function on the RFC’s example and prints the result, or not written yet for a stub. It opens no connection until Lesson 6.

The finished functions are ones you know. make_reader(sock) wraps a with a receive buffer, as in Networks with Python, and read_head(reader) is your function from HTTP with Python lesson 1.3: it returns the bytes before the empty line, without the \r\n\r\n, and keeps whatever follows in the buffer. That is the head your parser receives.

From bytes to lines

The first steps are the ones from HTTP with Python lesson 1.4. Decode the bytes with "iso-8859-1", which turns every byte into one character and never fails, and split the text at "\r\n". The first line is the request line; the rest are .

Cut the request line at single spaces into three parts with split(" ", 2): method, path and version. The count of 2 means at most two cuts, so a version with something after it, as in GET /chat HTTP/1.1 extra, stays in the third part, where you can see it and refuse it.

Headers into a dict

A header line is a name, a colon, and a . Cut it at the first colon only, because a value may contain colons of its own: a Host header with a , such as Host: example.com:8080, has one inside its value.

What does this print?

print("Host: example.com:8080".split(":", 1))

The value keeps the space that followed the colon, and it may have spaces at its end too, so .strip() it. Header names compare without regard to case, so store each name with .lower(). Leave the values’ case alone: Upgrade: WebSocket keeps its capitals in the dictionary, and Lesson 4 compares tokens without regard to case when it checks them.

parse_headers(lines) does this for a list of header lines. parse_upgrade_request(data) decodes and splits the head, cuts the request line, hands the other lines to parse_headers, and returns one dictionary:

{"method": ..., "path": ..., "version": ..., "headers": {...}}

A request with no header lines at all gives "headers": {}, so parse_headers([]) returns an empty dictionary.

A name that appears twice

HTTP lets a sender split one header across several lines with the same name. Connection: keep-alive on one line and Connection: Upgrade on another mean the same as Connection: keep-alive, Upgrade. RFC 9110 says a receiver may combine them that way: the values in the order they arrived, joined by a comma and a space.

In HTTP with Python, your reply parser kept a list of values for every name. This course keeps a single string instead and joins a repeated name’s values with ", ", because every header the reads is already a comma-separated list of tokens. A later line must never overwrite an earlier one: the Upgrade token in the second Connection line is the one that matters.

A request has Connection: keep-alive and, two lines later, Connection: Upgrade. What should headers["connection"] hold?

Joining has one more effect that RFC 6455 relies on. A request must carry Sec-WebSocket-Key only once. If a client sends it twice, your dictionary holds two keys joined by ", ", which is not a valid key, and Lesson 4’s check refuses it without any rule of its own.

What counts as malformed

Your parser refuses what it cannot read instead of guessing. Raise for:

  • a request line that does not cut into exactly three parts at single spaces, such as GET /chat, or GET /chat HTTP/1.1 extra, where the third part holds a space;

  • a version that does not start with HTTP/, such as FTP/1.0;

  • a header line with no colon;

  • a header line whose name is empty, or contains a space, such as : x or Bad Name: x.

Give each ValueError a message that quotes the line it refused. Real servers vary in what they tolerate; some accept two spaces in a row in the request line. A strict parser refuses with a reason, and nothing it accepts was guessed at.

Your task

In ws_handshake.py, write parse_headers(lines) and then parse_upgrade_request(data), as described above. Leave the functions for Lessons 3 to 6 as they are. Then press Run. With your parser in place, handshake_demo.py prints the dictionary for the RFC’s request, and the later lessons’ sections still say not written yet:

Lesson 2: parse_upgrade_request
{'method': 'GET', 'path': '/chat', 'version': 'HTTP/1.1', 'headers': {'host': 'server.example.com', 'upgrade': 'websocket', 'connection': 'Upgrade', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'origin': 'http://example.com', 'sec-websocket-protocol': 'chat, superchat', 'sec-websocket-version': '13'}}
Lesson 3: accept_value
  not written yet
Lesson 4: check_upgrade
  not written yet
Lesson 5: build_upgrade_request
  not written yet
Lesson 6: run_loopback is not written yet

That output is from a local run of the finished lesson. A name with capitals or a value with spaces around it points at .lower() or .strip().

When the dictionary looks right, press Submit. The check calls your two functions on request heads you have not seen, with unusual casing, padded values, a repeated name and a colon inside a value, and on heads that must be refused.

Nothing in the dictionary says yet whether the server should accept the request. The next lesson computes the one value the server’s answer cannot do without.

Task

Write two in ws_handshake.py, in this order. Submit names the first one that still raises NotImplementedError, so you meet one at a time.

  1. parse_headers(lines) takes a of () and returns a . Cut each line at its first colon only; the name goes in lowercase, the with the spaces around it stripped. When a name appears again, join the new value to the stored one with ", ", in order. Raise for a line with no colon, and for a name that is empty or contains a space. parse_headers([]) returns {}.

  2. parse_upgrade_request(data) takes the head as bytes, as read_head returns it: everything before the empty line, without the final \r\n\r\n. Decode it with "iso-8859-1", split it at "\r\n", and cut the first line with split(" ", 2). Raise ValueError unless that gives exactly three parts, the third holds no space, and it starts with HTTP/. Return {"method": ..., "path": ..., "version": ..., "headers": parse_headers(the other lines)}.

Leave the functions for Lessons 3 to 6 as they are. Press Run to see your dictionary for the RFC’s request.

Then press Submit. The check calls both functions directly, with no : on the RFC’s request, on a request head you have not seen whose names use odd casing, whose values carry extra spaces, with a repeated Connection header and a colon inside a value, and on request lines and header lines that must raise ValueError. It checks the values your dictionary holds, not the order of its keys. Importing ws_handshake.py must print nothing.