Building API URLs
In the last lesson, you saw the full request lifecycle and how Request and Response objects flow through the framework. Now let’s look at a convenience method that makes testing your routes much easier: app.request().
Under the hood, it builds a full Request using URL and URLSearchParams. Everything we’ve learned so far comes together here.
The app.request() method
When you create an app with setup(), you get back an object with two methods:
const app = setup({ routes: [...] });
// 1. app.fetch(request) — the raw handler, takes a Request object
// 2. app.request(path, options) — a convenience wrapper app.fetch() is the raw handler. You pass it a fully constructed Request object. app.request() is a shortcut that builds the Request for you from a path and some options.
Compare the two approaches:
// The manual way — you build the Request yourself
const request = new Request("http://localhost/users?page=2", {
method: "GET",
});
const response = await app.fetch(request);
// The shortcut — app.request() builds it for you
const response = await app.request("/users", {
query: { page: "2" },
}); Both do the same thing. But app.request() is much more readable when you’re writing tests or quickly checking your routes.
The buildRequest() function
Let’s look at how @hectoday/http builds the Request internally. This is the buildRequest() function from setup.ts:
function buildRequest(path, options = {}) {
const method = (options.method ?? "GET").toUpperCase();
const url = new URL(path, "http://localhost");
if (options.query) {
for (const [k, v] of Object.entries(options.query)) {
if (Array.isArray(v)) {
for (const item of v) {
url.searchParams.append(k, item);
}
continue;
}
url.searchParams.set(k, v);
}
}
const headers = new Headers(options.headers);
const init = { method, headers };
if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
}
return new Request(url, init);
} Let’s walk through this step by step.
Step 1: Determine the method. options.method ?? "GET" uses the nullish coalescing operator. If no method was provided, it defaults to "GET". Then .toUpperCase() normalizes it, so "post" becomes "POST".
Step 2: Create the URL. new URL(path, "http://localhost") is the base URL pattern we learned earlier. The path you pass (like "/users") is relative, so it needs a base. The framework uses "http://localhost" because the request is handled internally and never actually goes to localhost. The host doesn’t matter.
Step 3: Add query parameters. This is where it gets interesting. The function loops through options.query using Object.entries(), which gives [key, value] pairs. For each entry, it checks: is the value an array? If yes, it uses append() for each item so the key appears multiple times. If no, it uses set() for a single value. This is exactly the set() vs append() distinction from the URLSearchParams lessons.
Step 4: Build headers. new Headers(options.headers) creates a Headers object from whatever headers you passed in.
Step 5: Add the body. If you provided a body, it’s JSON-stringified. The function also checks whether you already set a content-type header. If you didn’t, it adds application/json for you automatically.
Step 6: Return the Request. Everything comes together in new Request(url, init). The url object is passed directly (the Request constructor accepts URL objects), and init contains the method, headers, and optional body.
Using app.request(): complete examples
Simple GET with query params
const response = await app.request("/products", {
query: { category: "shoes", sort: "price" },
});
// Internally builds: GET http://localhost/products?category=shoes&sort=price GET with array query params
const response = await app.request("/search", {
query: { tag: ["js", "ts", "react"] },
});
// Internally builds: GET http://localhost/search?tag=js&tag=ts&tag=react Because the value is an array, buildRequest() uses append() for each item. The key tag appears three times in the resulting URL.
POST with body
const response = await app.request("/users", {
method: "POST",
body: { name: "Alice", email: "[email protected]" },
});
// Internally builds: POST http://localhost/users
// Body: '{"name":"Alice","email":"[email protected]"}'
// Content-Type: application/json (set automatically) PUT with path params, query, and body
const response = await app.request("/users/42", {
method: "PUT",
query: { notify: "true" },
body: { name: "Alice Updated" },
});
// Internally builds: PUT http://localhost/users/42?notify=true DELETE
const response = await app.request("/users/42", {
method: "DELETE",
});
// Internally builds: DELETE http://localhost/users/42 Custom headers
const response = await app.request("/protected", {
headers: { Authorization: "Bearer my-token" },
}); Reading the response
app.request() returns a standard Response. You read it the same way you’d read a fetch() response:
const response = await app.request("/users");
response.status; // 200
response.ok; // true (status is 200-299)
const data = await response.json();
console.log(data); // { users: [...] } response.status gives you the HTTP status code. response.ok is a boolean that’s true when the status is in the 200-299 range. response.json() reads the response body and parses it as JSON, returning a promise that resolves to the parsed data.
This is the exact same API you’d use after a fetch() call in a browser. No new abstractions to learn.
We’ve now seen how the framework builds requests, matches routes, and returns responses. But there’s one piece missing: what happens when someone sends bad data? What if page isn’t a number, or a required field is missing? That’s where input validation comes in, and it’s the next lesson.
When you call app.request('/users', { query: { tag: ['a', 'b'] } }), which URLSearchParams method is used for the tag values?