0%

Anfragen von Hand schreiben · Abschlussprojekt

Abschlussprojekt: Projekt: jede Anfrage bauen

Du hast jetzt jedes Teil einer Anfrage: eine , gewählt nach dem, was sie verspricht, eine mit urlsplit auseinandergenommene , ein mit make_target kodiertes Ziel, eine Zeile Host von host_header und von format_headers. Was fehlt, ist eine , die sie für jede beliebige Anfrage zusammensetzt, und ein fetch, das sie benutzt. Das ist dieses Projekt: build_request(method, url, headers=None, body=b"") und ein fetch, das eine vollständige URL nimmt. Damit kann Kapitel 4 wire.py auch auf einen Server richten, den du selbst schreibst, auf einer Adresse, die nicht die von practice-api ist.

Eine Anfrage kann einen Body tragen

Kapitel 1 hat Bodys in Antworten gelesen, abgemessen mit Content-Length. Eine Anfrage kann auf dieselbe Weise einen Body tragen: den Kopf, die Leerzeile, dann genau so viele Bytes, wie ihre Zeile Content-Length ankündigt. Ein PUT, das Buch 7 auf etwas setzt, sendet dieses JSON als seinen Body. Der Server liest den Kopf, findet die Länge und liest genau so viele Bytes, so wie dein read_body es auf der Client-Seite tut.

Content-Length zählt Bytes, nicht Zeichen. Der JSON-Text {"title":"naïve"} hat 17 Zeichen, und seine hat 18 Bytes, weil ï zwei braucht. build_request bekommt den Body schon als Bytes, also ist len(body) die richtige Zahl; ein Client, der die Zeichen des Texts zählt, würde ein Byte zu wenig ankündigen, und der Server würde ein Byte zu früh aufhören zu lesen.

Wann die Zeile gesendet wird, folgt aus der Bedeutung der Methode:

  • Wenn der Body nicht leer ist, sende Content-Length, egal bei welcher Methode.

  • Bei POST und PUT sende es auch dann, wenn der Body leer ist, als Content-Length: 0. Diese Methoden tragen Inhalt, also erwartet ein Server, der eine davon liest, dass ihm die Größe genannt wird, und 0 sagt ihm, dass es keinen gibt.

  • Bei GET, HEAD und DELETE mit leerem Body sende überhaupt kein Content-Length. Diese Methoden erwarten keinen Body, und bittet einen Client, keinen anzukündigen, der nicht da ist.

Die Regeln für build_request

build_request gibt die ganze Anfrage als Bytes zurück. Lies die Regeln vollständig durch, bevor du anfängst:

  1. Zerlege die URL mit urlsplit. Wenn ihr Schema nicht http ist, löse aus: wire.py spricht nur einfaches HTTP, und eine URL mit https braucht die -Schicht, die Kapitel 9 beschreibt.

  2. Das Ziel ist parts.path, oder "/", wenn der Pfad leer ist, wie bei http://books.test; dann "?" und parts.query, wenn die Query nicht leer ist. Das Fragment fällt weg. Übernimm Pfad und Query genau so, wie die URL sie enthält: Sie sollten schon kodiert sein, von make_target oder von wem auch immer die URL geschrieben hat, und sie ein zweites Mal zu kodieren würde jedes % in %25 verwandeln.

  3. Der Kopf beginnt mit request_line(method, target), das ein Ziel mit einem Leerzeichen, \r oder \n weiterhin ablehnt.

  4. Dann host_header(parts.hostname, parts.port). Eine URL, die keinen nennt, ergibt parts.port als None, und die Zeile Host hat keinen Port. parts.hostname ist der Host in Kleinbuchstaben, was für Namen und in Ordnung ist, die einzigen Hosts, die dieser Kurs benutzt.

  5. Dann die Header des Aufrufers, über format_headers. build_request schreibt Host und Content-Length selbst, also löse ValueError aus, wenn der Aufrufer einen davon übergibt, in beliebiger Schreibweise: Zwei Zeilen Host oder zwei Längen würden den Server raten lassen, welcher er glauben soll, dieselbe Mehrdeutigkeit, die read_body aus Kapitel 1 in einer Antwort ablehnt.

  6. Dann Content-Length: <len(body)>, wenn die Regeln oben das sagen.

  7. Dann die Leerzeile. Kodiere den Kopf als ASCII und füge die Body-Bytes unverändert danach an.

Die Reihenfolge zwischen Headern mit verschiedenen Namen bleibt dir überlassen; Host an den Anfang zu setzen, wie in Lektion 4, ist das, was HTTP empfiehlt.

fetch nimmt eine URL

fetch behält alles, was es nach dem Verbinden tut. Was sich ändert, ist, wohin es sich verbindet und was es sendet:

def fetch(method, url, *, headers=None, body=b"", connect=socket.create_connection, timeout=TIMEOUT):

Es baut die Anfrage mit build_request(method, url, headers, body), bevor es sich verbindet, sodass eine abgelehnte URL nie eine Verbindung öffnet. Es verbindet sich mit (parts.hostname, port), wobei port gleich parts.port ist, oder 80, wenn das None ist. Im with-Block sendet es die Anfrage und liest die Antwort wie vorher.

main zieht nach: Benenne das zweite in build_parser von target in url um, gib ihm den Standardwert BASE_URL + HOOK_TARGET und übergib args.url an fetch. Dann holt python wire.py ohne Argumente dieselbe Seite ab wie immer, und python wire.py HEAD http://practice-api:8080/v1/items/1 sendet ein HEAD an diese URL.

ADDRESS wird von fetch nicht mehr benutzt; make_request benutzt es noch, und du kannst beide so lassen, wie sie sind.

Deine Tests

fetch nimmt jetzt eine URL, also werden die Tests in tests/test_wire.py, die wire.fetch("GET", "/v1/items/1", ...) aufrufen, fehlschlagen, bis du ihnen eine gibst. Füge oben eine Konstante hinzu, ITEM_URL = "http://practice-api:8080/v1/items/1", und übergib sie anstelle des Ziels. Die Adresse, die das Fake-connect aufzeichnet, bleibt ("practice-api", 8080), weil die URL dorthin zeigt.

Füge dann drei Tests hinzu, jeder eine einfache test_-Funktion ohne :

  • eine Anfrage mit einem Body außerhalb von ASCII, etwa "café".encode("utf-8"), deren Zeile Content-Length seine Bytes zählt und die mit der Leerzeile und dem Body endet;

  • eine URL mit einem Port und einem Fragment, deren Anfragezeile Pfad und Query enthält, aber kein #, und deren Zeile Host den Port enthält;

  • eine URL mit https, die build_request mit ValueError ablehnt. Schreibe ihn so, wie test_a_close_inside_the_body_is_an_error, das schon in der Datei steht, geschrieben ist: Rufe wire.build_request innerhalb von try auf, kehre im Zweig except ValueError: mit aus dem Test zurück und löse nach dem try AssertionError aus, wenn kein Fehler kam.

Rufe in jedem Test jede Funktion über das auf, als wire.build_request(...) und wire.fetch(...), und behalte import wire ohne jede Zeile from wire import. Wenn du Check my work drückst, führt es deine Tests gegen dein eigenes wire.py aus und dann gegen Versionen mit je einer kaputten Regel, und ein mit from importierter Name würde deine Version behalten und die kaputte verstecken.

Sieh es funktionieren

Diese Läufe erreichen practice-api. Zuerst ein Eintrag, dessen id mit make_target gebaut wird:

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'

Als Nächstes eine Query, die mit urlencode aus Zahlen gebaut wird, die die Kodierung unverändert lässt:

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'])"

Das Ziel, das ausgegeben wird, ist /v1/items?page=1&page_size=2, das Hook-Ziel, das Kapitel 1 von Hand eingetippt hat, und der Status ist 200.

Zum Schluss noch einmal derselbe Eintrag, wobei die 1 in ihrer Prozentkodierung geschrieben ist, %31:

python -c "import wire; r = wire.fetch('GET', wire.BASE_URL + '/v1/items/%31', headers=wire.PRACTICE_HEADERS); print(r['status'], r['body'])"

Du solltest 404 mit "route not found" sehen, wo der erste Befehl den Eintrag als /v1/items/1 gefunden hat: practice-api vergleicht den Pfad Byte für Byte und dekodiert nichts darin, also ist %31 ein anderer Pfad, auch wenn es zu 1 dekodiert. Kodierung ist ein Versprechen zwischen dem Code, der kodiert, und dem Code, der dekodiert; deshalb kodiert make_target nur, was kodiert werden muss. Eine Ziffer muss das nie, also sendet make_target die 1, und der Server findet den Eintrag. Wenn diese Antwort stattdessen der Eintrag ist, hat practice-api das Segment vor dem Vergleich dekodiert; die Bytes, die du gesendet hast, waren in beiden Fällen dieselben.

Keine dieser Antworten wird geprüft. Sie zeigen, wie dein Client gegen einen echten Server arbeitet; deine Tests sind der Beleg.

Führe deine Tests aus:

python -m pytest -q

Wenn build_request, das neue fetch und die aktualisierten Tests fertig sind, meldet pytest 9 passed: die sechs Tests, mit denen du angefangen hast, und deine drei. Wenn das so ist, drücke Check my work.

Wenn ein Test fehlschlägt und du nicht siehst, warum, bitte Monty, die fehlschlagende Zusicherung neben der Regel zu lesen, die sie in dieser Lektion prüft, und zu sagen, welche der beiden falsch ist, ohne es für dich zu korrigieren.

wire.py kann jetzt jede Anfrage bauen und an jeden Server mit einfachem HTTP senden, und jede Zeile davon ist deine. Kapitel 3 übergibt den , die Anfragezeile und das Framing an Pythons eigenes http.client und überlässt dir die Entscheidungen.

Aufgabe

Stelle wire.py so fertig, dass es jede Anfrage an jeden Server mit einfachem senden kann.

Schreibe build_request(method, url, headers=None, body=b""). Die gibt die Anfrage als Bytes zurück:

  • Zerlege url mit urlsplit und löse aus, wenn das Schema nicht http ist;

  • das Ziel ist der Pfad, oder "/", wenn der Pfad leer ist, dann "?" und die Query, wenn es eine gibt, übernommen so, wie die sie enthält; das Fragment wird nie gesendet;

  • der Kopf ist request_line(method, target), dann host_header(parts.hostname, parts.port), dann format_headers(headers), dann Content-Length: <len(body)>, wenn der Body nicht leer ist oder die POST oder PUT ist, dann die Leerzeile, kodiert als ASCII, mit den Body-Bytes unverändert danach;

  • ein Header Host oder Content-Length des Aufrufers, in beliebiger Schreibweise, löst ValueError aus.

Ändere fetch zu fetch(method, url, *, headers=None, body=b"", connect=socket.create_connection, timeout=TIMEOUT): Baue die Anfrage zuerst mit build_request, verbinde dich mit connect((host, port), timeout=timeout) mit Host und der URL (80, wenn die URL keinen nennt), sende die Anfrage und lies die Antwort wie vorher. Benenne in build_parser das zweite in url um, mit dem Standardwert BASE_URL + HOOK_TARGET, und übergib in main args.url an fetch.

Gib in tests/test_wire.py den Tests, die wire.fetch aufrufen, eine vollständige URL, etwa "http://practice-api:8080/v1/items/1", und füge drei einfache test_-Funktionen ohne hinzu: einen Body außerhalb von ASCII, dessen Content-Length Bytes zählt; eine URL mit einem Port und einem Fragment, deren Anfragezeile kein # hat und deren Zeile Host den Port hat; und eine URL mit https, die mit ValueError abgelehnt wird. Behalte import wire, rufe in jedem Test wire.build_request(...) und wire.fetch(...) auf und füge keine Zeile from wire import hinzu.

Führe im python -m pytest -q aus, und wenn du möchtest, die Live-Läufe aus der Lektion.

Drücke dann Check my work. Die Prüfung ruft build_request mit Methoden, URLs, Headern und Bodys auf, die du nicht gesehen hast: ein POST mit einem Port, einer Query, einem Fragment und einem Body aus Mehrbyte-Zeichen; Bytes, die kein Text sind; jeden Fall von Content-Length; URLs mit leerem Pfad, mit einer Query und ohne Pfad und mit kodierten Segmenten; wiederholte Headerwerte; und sechs Anfragen, die sie ablehnen muss. Sie ruft fetch mit URLs, die nicht die von practice-api sind, über eine Ersatz-Funktion für connect auf, wobei die Antwort an jeder Position geschnitten wird, auf einer Verbindung, die schließt oder offen bleibt, und prüft die Adresse, Port 80, das Timeout, die Header und den Body, die es gesendet hat. Sie ruft main mit ersetztem fetch auf, um die URL zu sehen, die es übergibt. Dann führt sie deine Tests gegen dein eigenes wire.py aus, wo alle bestehen und mindestens neun gesammelt werden müssen, und gegen drei Versionen, deren build_request je eine Regel bricht: eine zählt Content-Length in Zeichen, eine behält das Fragment im Ziel, und eine akzeptiert eine URL mit https. Mindestens einer deiner Tests muss gegen jede davon fehlschlagen. Die Reihenfolge zwischen Headern mit verschiedenen Namen wird nicht geprüft. Sie nimmt nie Kontakt mit practice-api auf und liest dein Terminal nicht.