HTTP, the language of requests
Clients and servers talk in a text protocol called HTTP (HyperText Transfer Protocol). A raw request is literally lines of text:
GET /users/42 HTTP/1.1 Host: api.example.com Accept: application/json
Line by line:
- The request line: a method (
GET), a path (/users/42), and the protocol version. - Headers:
Name: valuepairs carrying metadata.Hostnames the server the request is meant for, which during development islocalhost:3000, wherelocalhostmeans this very machine and 3000 is the port from lesson 1-2.Acceptsays what format the client wants back. - Optionally a blank line and then a body with data, used when sending things such as a signup form.
The method is the verb. The four you will use constantly are GET to read, POST to create, PUT or PATCH to update, and DELETE to remove.
And the response
The server answers in the same style:
HTTP/1.1 200 OK Content-Type: application/json {"id": 42, "name": "Ada"}
- The status line carries the status code:
200OK,404not found,500server crashed. Unit 3 covers the full families. - Headers again, like
Content-Typetelling the client the body is JSON. - The body: the actual content.
Frameworks will parse this text for you, but a backend engineer should be able to read it raw. Let's prove you can, by writing the parser yourself.
Parsing the request line
split(" ") cuts the string at each space, which is all the structure the request line has.
function parseRequestLine(line) { const parts = line.split(" "); return { method: parts[0], path: parts[1], version: parts[2] }; } const req = parseRequestLine("GET /users/42 HTTP/1.1"); console.log("method: " + req.method); console.log("path: " + req.path);
Output
method: GET
path: /users/42"GET /users/42 HTTP/1.1".split(" ") gives ["GET", "/users/42", "HTTP/1.1"], and the three positions have fixed meanings, so indexing into the array is enough.
The request line is deliberately simple, being exactly three space-separated fields. That is why a parser fits in one line, and it is also why a path can never contain a raw space, which is what percent-encoding exists to handle.
Node's http module does this parsing for you and hands you req.method and req.url, so the value here is knowing what those properties came from. When a request behaves strangely, being able to read the raw text is the difference between guessing and looking.
Note that version is captured and unused, which is honest about real life. Almost no application code branches on the HTTP version, and the field still has to be consumed to reach the two that matter.
Reading a verb and a path together
A client sending POST /orders HTTP/1.1 with a JSON body describing a pizza is most likely creating a new order.
POST is the create verb, and the path names the collection the new thing joins, which is /orders. The body carries the details of what to create, since a GET has nothing to describe and a create has everything.
The other verbs against the same path read differently. GET /orders lists them, GET /orders/42 reads one, DELETE /orders/42 removes one, and PATCH /orders/42 changes part of one.
| Request | Meaning |
|---|---|
GET /orders | list the orders |
POST /orders | create an order |
GET /orders/42 | read order 42 |
PATCH /orders/42 | update part of order 42 |
DELETE /orders/42 | delete order 42 |
The pattern is that a collection path takes list and create, and an item path takes read, update, and delete. Notice that the client never sends the new order's id, since the server assigns it and returns it in the response.
This verb-plus-path grammar is the heart of REST design, which unit 4 covers in depth.
Parsing a header line
parseHeader(line) turns "Content-Type: application/json" into a name and a value, lowercasing the name and splitting at the first colon only.
function parseHeader(line) { const i = line.indexOf(":"); return { name: line.slice(0, i).trim().toLowerCase(), value: line.slice(i + 1).trim(), }; } console.log(JSON.stringify(parseHeader("Content-Type: application/json"))); console.log(JSON.stringify(parseHeader("Host: localhost:3000")));
Output
{"name":"content-type","value":"application/json"}
{"name":"host","value":"localhost:3000"}line.indexOf(":") gives the position of the first colon only, which is why the second example survives. Using split(":") instead would cut localhost:3000 into two pieces and lose the port.
Lowercasing the name reflects the protocol, since HTTP header names are case-insensitive. A client may send Content-Type, content-type, or CONTENT-TYPE, and code that compares against one spelling would work in testing and fail against some real client.
The trim() on the value removes the single space that conventionally follows the colon, and that space is optional in the protocol. Trimming handles both the usual case and the odd sender that omits it or sends two.
Node normalizes headers the same way, which is why req.headers has lowercase keys. Knowing that saves the surprise of req.headers["Content-Type"] being undefined while the header was clearly sent.
Formatting a response
formatResponse(status, reason, body) builds the raw response text: a status line, a Content-Type header, a blank line, then the body.
function formatResponse(status, reason, body) { return ( "HTTP/1.1 " + status + " " + reason + "\n" + "Content-Type: application/json" + "\n\n" + body ); } console.log(formatResponse(200, "OK", '{"id":42}')); console.log(formatResponse(404, "Not Found", '{"error":{"code":"user_not_found"}}'));
Output
HTTP/1.1 200 OK Content-Type: application/json {"id":42} HTTP/1.1 404 Not Found Content-Type: application/json {"error":{"code":"user_not_found"}}
The blank line separating headers from the body is two \n in a row, and it is the most important character in the whole format. Without it a client reads the first line of the body as another header, and with it the parser knows the header section has ended.
Real HTTP uses \r\n line endings rather than \n, and the course uses \n to keep the output readable. That detail matters the day you write a client by hand, since a server may reject a request whose lines end the wrong way.
Both examples produce the same headers regardless of status, which is a small lesson in itself. An error response is a normal response with a different number and body, not a special channel, so the same formatting code serves both.
Note that a real implementation would also send Content-Length, telling the client how many bytes the body has. Without it the client cannot know where the body ends short of the connection closing, which is why Node adds it for you when you write a plain string.