hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses URL mastery with @hectoday/http

Understanding URLs

  • What is a URL?
  • The URL constructor
  • URL properties deep dive

URLSearchParams

  • URLSearchParams basics
  • Modifying search params
  • Iterating over params
  • URL and SearchParams together

Encoding

  • Encoding and special characters

Inside @hectoday/http

  • How @hectoday/http parses queries
  • Routing and URL patterns
  • The Request object and URLs
  • Building API URLs
  • Input validation and query schemas

Putting it all together

  • Capstone: bookmarks API

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?

← The Request object and URLs Input validation and query schemas →

© 2026 hectoday. All rights reserved.