Write Requests by Hand · capstone
Capstone project: Project: Build Any Request
You now have every piece of a request: a urlsplit, a target encoded with make_target, a Host line from host_header, and format_headers. What is missing is one fetch that uses it. That is this project: build_request(method, url, headers=None, body=b""), and a fetch that takes a full URL. It is also what lets Chapter 4 point wire.py at a server you write yourself, on an address that is not practice-api’s.
A request can carry a body
Chapter 1 read bodies in responses, measured by Content-Length. A request can carry a body the same way: the head, the empty line, then exactly as many bytes as its Content-Length line announces. A PUT that sets book 7 to some read_body does on the client side.
Content-Length counts bytes, not characters. The JSON text {"title":"naïve"} is 17 characters, and its ï takes two. build_request receives the body as bytes already, so len(body) is the right count; a client that counted the characters of the text would announce one byte too few, and the server would stop reading one byte early.
When to send the line follows from what the method means:
When the body is not empty, send
Content-Length, whatever the method.For
POSTandPUT, send it even when the body is empty, asContent-Length: 0. These methods carry content, so a server reading one expects to be told the size, and 0 tells it there is none.For
GET,HEAD, andDELETEwith an empty body, send noContent-Lengthat all. These methods do not expect a body, andasks a client not to announce one that is not there.
The rules for build_request
build_request returns the whole request as bytes. Read the rules all the way through before you start:
Split the URL with
urlsplit. If its scheme is nothttp, raise: wire.pyspeaks plain HTTP only, and anhttpsURL needs thelayer Chapter 9 describes. The target is
parts.path, or"/"when the path is empty, as inhttp://books.test; then"?"andparts.querywhen the query is not empty. The fragment is dropped. Copy the path and query exactly as the URL has them: they should already be encoded, bymake_targetor by whoever wrote the URL, and encoding them a second time would turn every%into%25.The head starts with
request_line(method, target), which still refuses a target holding a space,\r, or\n.Then
host_header(parts.hostname, parts.port). A URL that names nogives parts.portasNone, and theHostline has no port.parts.hostnameis the host in lowercase, which is fine for names and IPv4 addresses, the only hosts this course uses.Then the caller’s headers, through
format_headers.build_requestwritesHostandContent-Lengthitself, so if the caller passes either of them, in any casing, raiseValueError: twoHostlines or two lengths would leave the server guessing which one to believe, the same ambiguity Chapter 1’sread_bodyrefuses in a response.Then
Content-Length: <len(body)>when the rules above say so.Then the empty line. Encode the head as ASCII, and add the body bytes after it, unchanged.
Header order between different names is up to you; putting Host first, as in Lesson 4, is what HTTP recommends.
fetch takes a URL
fetch keeps everything it does after connecting. What changes is where it connects and what it sends:
def fetch(method, url, *, headers=None, body=b"", connect=socket.create_connection, timeout=TIMEOUT):
It builds the request with build_request(method, url, headers, body) before it connects, so a refused URL never opens a connection. It connects to (parts.hostname, port), where port is parts.port, or 80 when that is None. Inside the with block, it sends the request and reads the reply as before.
main follows: rename the second build_parser from target to url, give it the default BASE_URL + HOOK_TARGET, and pass args.url to fetch. Then python wire.py with no arguments fetches the same page as always, and python wire.py HEAD http://practice-api:8080/v1/items/1 sends a HEAD to that URL.
ADDRESS is no longer used by fetch; make_request still uses it, and you can leave both as they are.
Your tests
fetch takes a URL now, so the tests in tests/test_wire.py that call wire.fetch("GET", "/v1/items/1", ...) will fail until you give them one. Add a constant near the top, ITEM_URL = "http://practice-api:8080/v1/items/1", and pass it in place of the target. The address the fake connect records stays ("practice-api", 8080), because that is where the URL points.
Then add three tests, each a plain test_ function with no
a request with a body outside ASCII, such as
"café".encode("utf-8"), whoseContent-Lengthline counts its bytes, and which ends with the empty line and the body;a URL with a port and a fragment, whose request line holds the path and query but no
#, and whoseHostline holds the port;an
httpsURL, whichbuild_requestrefuses withValueError. Write it the waytest_a_close_inside_the_body_is_an_error, already in the file, is written: callwire.build_requestinsidetry,from the test in the except ValueError:branch, and raiseAssertionErrorafter thetryif no error came.
Call every function through the wire.build_request(...) and wire.fetch(...), inside each test, and keep import wire without any from wire import line. When you press Check my work, it runs your tests against your own wire.py and then against versions with one broken rule each, and a name imported with from would keep your version and hide the broken one.
See it work
These runs reach practice-api. First, an item whose id is built with make_target:
python -c "import wire; url = wire.BASE_URL + wire.make_target(['v1', 'items', '1']); r = wire.fetch('GET', url, headers=wire.PRACTICE_HEADERS); print(r['status'], r['body'])"
200 b'{"category":"color","id":1,"name":"blue","value":10}\n'
Next, a query built with urlencode, from numbers, which encoding leaves as they are:
python -c "import wire; target = wire.make_target(['v1', 'items'], {'page': 1, 'page_size': 2}); print(target); print(wire.fetch('GET', wire.BASE_URL + target, headers=wire.PRACTICE_HEADERS)['status'])"
The target it prints is /v1/items?page=1&page_size=2, the hook target Chapter 1 typed by hand, and the status is 200.
Last, the same item again, with the 1 written as its percent-encoding, %31:
python -c "import wire; r = wire.fetch('GET', wire.BASE_URL + '/v1/items/%31', headers=wire.PRACTICE_HEADERS); print(r['status'], r['body'])"
You should see 404 with "route not found", where the first command found the item as /v1/items/1: practice-api matches the path byte for byte and decodes nothing in it, so %31 is a different path even though it decodes to 1. Encoding is a promise between the code that encodes and the code that decodes; this is why make_target encodes only what needs encoding. A digit never needs it, so make_target sends 1, and the server finds the item. If this reply is the item instead, practice-api decoded the segment before matching it; the bytes you sent were the same either way.
None of these replies is checked. They show your client working against a real server; your tests are the evidence.
Run your tests:
python -m pytest -q
With build_request, the new fetch, and the updated tests in place, pytest reports 9 passed: the six tests you started with and your three. When it does, press Check my work.
If a test fails and you cannot see why, ask Monty to read the failing assertion beside the rule it checks in this lesson and say which of the two is wrong, without fixing it for you.
wire.py can now build and send any request to any plain-HTTP server, and every line of it is yours. Chapter 3 hands the http.client and leaves the decisions to you.
Task
Finish wire.py so it can send any request to any plain-
Write build_request(method, url, headers=None, body=b""). It returns the request as bytes:
split
urlwithurlsplit, and raiseif the scheme is not http;the target is the path, or
"/"when the path is empty, then"?"and the query when there is one, copied as thehas them; the fragment is never sent; the head is
request_line(method, target), thenhost_header(parts.hostname, parts.port), thenformat_headers(headers), thenContent-Length: <len(body)>when the body is not empty or theis POSTorPUT, then the empty line, encoded as ASCII, with the body bytes added after it unchanged;a caller’s
HostorContent-Lengthheader, in any casing, raisesValueError.
Change fetch to fetch(method, url, *, headers=None, body=b"", connect=socket.create_connection, timeout=TIMEOUT): build the request with build_request first, connect to the URL’s host and connect((host, port), timeout=timeout), send the request, and read the reply as before. In build_parser, rename the second url with the default BASE_URL + HOOK_TARGET, and pass args.url to fetch in main.
In tests/test_wire.py, give the tests that call wire.fetch a full URL, such as "http://practice-api:8080/v1/items/1", and add three plain test_ Content-Length counts bytes; a URL with a port and a fragment, whose request line has no # and whose Host line has the port; and an https URL refused with ValueError. Keep import wire, call wire.build_request(...) and wire.fetch(...) inside each test, and add no from wire import line.
Run python -m pytest -q in the
Then press Check my work. The check calls build_request with methods, URLs, headers, and bodies you have not seen: a POST with a port, a query, a fragment, and a multibyte body; bytes that are not text; every Content-Length case; URLs with an empty path, a query and no path, and encoded segments; repeated header values; and six requests it must refuse. It calls fetch with URLs that are not practice-api’s through a stand-in connect function, with the reply cut at every position on a connection that closes or stays open, and checks the address, port 80, the timeout, the headers, and the body it sent. It calls main with fetch replaced, to see the URL it passes. Then it runs your tests against your own wire.py, where all must pass and at least nine must be collected, and against three versions whose build_request breaks one rule each: one counts Content-Length in characters, one keeps the fragment in the target, and one accepts an https URL. At least one of your tests must fail against each. Header order between different names is not checked. It never contacts practice-api, and it does not read your terminal.