[whatwg/fetch] String representations for Fetch API objects (Issue #1575)

Hello esteemed WhatWG Fetch authors,

I am a library author [helping to implement `fetch` support](https://github.com/axios/axios/pull/5146) downstream in the popular[^1] [Axios](https://github.com/axios/axios) library. For the uninitiated, Axios helps developers fetch data using various adapters (including XHR and Node's `http` module), and on various platforms (browsers and Node).

Now that Fetch is very broadly supported[^2] (congrats, and thank you for all your hard work!), it is seeing new support in libraries such as Axios (hi! 👋🏻). Before proceeding, I just wanted to say that the Fetch API is one of the _smoothest_ and clearest API interfaces offered by the web, in my humble opinion; it is spreading because it is easy to use, refreshingly obvious in its behaviors and assumptions, and general enough to cover an extremely broad set of cases, from `await fetch('...')` to more complex scenarios with cancellation and streams.

That being said, there is exactly _one_ place where frequent interaction with the Fetch API seems to fall short: **string representations for `Headers`, `Request`, and `Response`**. I'm writing today to see if the WhatWG can help clear this up across supporting implementations.

## Paragon case: `URL`

When developing with `URL`, `Request`, and `Response` (referred to herein as _ancillary Fetch API objects_), the developer may often need to obtain a stringified representation, either for use elsewhere in their software, or for debugging purposes. `URL` objects are a bright spot of support for this: they are interchangeable with `string` objects for many intents and purposes within the realm of Fetch, and indeed in the browser in general:

```js
const sample = new URL('https://github.com');  // you can create a URL from a string
console.log(sample);                           // logs the entire tree of components the URL
console.log(`${sample}`);                      // logs the URL as a string
const string = sample.toString();              // obtains the URL in absolute string form
```

```
const sample = new URL('https://github.com');
console.log(sample);
console.log(`${sample}`);
const string = sample.toString();
```

<img width="742" alt="Screenshot 2022-12-19 at 3 52 04 AM" src="https://user-images.githubusercontent.com/171897/208419971-efb4c9e3-9b50-4393-b548-bbe3d19c83ca.png">


This behavior is consistent across all three major browser vendors:
<img width="2315" alt="Screenshot 2022-12-19 at 3 49 39 AM" src="https://user-images.githubusercontent.com/171897/208419532-03e0fc89-4f98-4883-b05a-6d5c3bd14881.png">

> _From left to right: Firefox 107, Safari 16.2, Chrome 108_.

Immediately, in the debug window, I can see the URL itself, and even browse the components of the URL. Fantastic!

Server-side runtimes also nail this:
<img width="732" alt="Screenshot 2022-12-19 at 3 56 29 AM" src="https://user-images.githubusercontent.com/171897/208420695-ff073b63-c410-4d23-9573-eb3e573183bd.png">

> _Node 18.x on the left, Deno 1.x on the right._

Engines are remarkably consistent about `URL`, even across platforms and runtimes. Arguably, `URL` used in this way is a light _value class_, i.e. just a strongly-typed and validated shell around the well-formed string. This case remains sadly isolated from other Fetch API objects, though.

## Problem case 1: `Headers`

I first encountered problems with `Headers` when diagnosing issues in the early Fetch adapter code. Let's try this snippet:

```js
const headers = new Headers({x: 1});
console.log(headers);
console.log(`${headers}`);
console.log(JSON.stringify(headers));
```

What do we get?

<img width="330" alt="Screenshot 2022-12-19 at 4 09 46 AM" src="https://user-images.githubusercontent.com/171897/208423034-587e144b-d25c-4ce8-9130-1b981c681014.png">

> _Depicted: Chrome._

??

At first glance, the `Headers` object we just created looks empty. This is obviously not the case: the object has the header we put in it, it just isn't showing in the string representation. This can be confirmed with `headers.has("x")` or `headers.get("x")`, each of which return the expected value.

Platforms and browsers are not as consistent on this point: Chrome is arguably the worst example, but everyone fails this test except Firefox, with Node/Deno getting by (barely):

<img width="2097" alt="Screenshot 2022-12-19 at 4 15 37 AM" src="https://user-images.githubusercontent.com/171897/208424081-fcf46c7d-33aa-4ecf-a021-0f8c9da8d98d.png">

- **Firefox:** Shows the header when logged directly. Can't be stringified or JSONified.
- **Safari:** Shows the methods when logged, not the header. Can't be stringified or JSONified.
- **Chrome:** Shows nothing (`{}`) when logged (!!). Can't be stringified or JSONified.
- **Node:** Shows a rather verbose message which includes the header. Can't be stringified or JSONified.
- **Deno:** Roughly the same behavior as Node, but less verbose. Can't be stringified or JSONified.

## Problem case 2: `Request`/`Response`

I won't bore you guys with the setup, let's take a look at how they behave across platforms:

```js
const req = new Request('https://github.com');
console.log(req);
console.log(`${req}`);
console.log(JSON.stringify(req));
```

<img width="675" alt="Screenshot 2022-12-19 at 4 37 13 AM" src="https://user-images.githubusercontent.com/171897/208427788-151d0472-585c-4867-81cb-61c83cc35eb9.png">

Lovely debugging experience logging it directly, and, while I understand binary exchange is the norm with HTTP/2 in broad adoption, whatever happened to `Request[GET https://github.com]`? The virtue of HTTP1.x was easy debuggability, and there's no reason we have to give that up in a post-binary world.

<img width="2097" alt="Screenshot 2022-12-19 at 4 36 53 AM" src="https://user-images.githubusercontent.com/171897/208427729-bfcc5af2-9eb4-492a-9ca8-ff82e3ea3b86.png">

One more test, this time with `Response`:
```js
const res = new Response('hello whatwg you guys rock');
console.log(res);
console.log(`${res}`);
console.log(JSON.stringify(res));
```

<img width="668" alt="Screenshot 2022-12-19 at 4 40 03 AM" src="https://user-images.githubusercontent.com/171897/208428311-f73309ab-f43a-47ce-9469-a10b30754291.png">

No `HTTP 200 OK (51k bytes)`? Okay, okay. I've made my point.


## Why this is a problem

If `URL` is a value class around a well-formed `URL` string (you can't create a `URL` object from an invalid URL string), one might assume that `Headers`, too, behaves as a value class around a well-formed `map<!string, string[]>`. This would make sense, especially since, as we all know, HTTP headers can be repeated, and so you always need a bit of ceremony around a regular `Map` to handle headers properly.

This assumption, though, is broken in a number of ways:

1) The developer can't JSON-stringify the headers object, as one would expect to be the case with a regular object
2) Printing the object does not reliably show the contents the developer is most likely to be interested in, as one would expect with a regular object

These are arguably cosmetic assumption breakages, but this problem gets a bit deeper. In order to diagnose or assert based on the full state of the `Headers` object, users are forced to iterate, carry + sync a second object, or defer use of `Headers` until the moment before a request is fired, which sadly neutralizes the benefits the API surface can provide (looking at you, repeated headers).

Avoiding the `Headers` object is lame because it's so great. Iterating carries the risk of exploding input. Carrying a second object and keeping it in sync has many pitfalls. Thus, the developer experience is surprisingly hampered by this one API incongruity.

## Alternatives considered

1) **Additional observable API detail.** I understand this one, totally. Perhaps WhatWG doesn't want to mandate disclosure of these internal object details _unless and until_ the user explicitly accesses them: this allows implementors more latitude to privately cache or defer calculations with greater cover.

  - **Mitigation:** `Headers`, `Request`/`Response` metadata, all account for a very small footprint of data. Of course, I don't think the developer expects the entire response or request body to be printed to the terminal, but I would be very surprised if the internal structure of these objects isn't optimized for heavy read access already anyway as a matter of practicality (an assumption is made here that these objects are more likely to be read-from than written-to, by and large).

2) **Implementor freedom.** Perhaps consensus is too high a bar for this kind of functionality to be written directly into the specification, or perhaps implementors have voiced a desire to control this aspect of how the Fetch API behaves.

  - **Mitigation:** Perhaps WhatWG could consider mandating (or even recommending with a `SHOULD` or `MAY` clause) a minimum set of metadata for ancillary Fetch API objects. For example, the request method and URL is often sufficient to identify an in-flight request, and an HTTP status may be sufficient to identify the general disposition of a response.

3) **Maybe this has been discussed.** If so, my apologies, especially if a decision has been made on this topic. I was not able to find any related issues after a search on GitHub.


Thank you in advance for consideration of this request. ✌🏻 

[^1]: Publicly (on GitHub), 7.8 million projects use Axios. 103k of these are [other packages](https://github.com/novuhq/novu) which themselves constitute additional transitive addressable users.
[^2]: CanIUse places `fetch` support [at 97.05%](https://caniuse.com/fetch) at the time of this writing.

-- 
Reply to this email directly or view it on GitHub:
https://github.com/whatwg/fetch/issues/1575

You are receiving this because you are subscribed to this thread.

Message ID: <whatwg/fetch/issues/1575@github.com>

Received on Monday, 19 December 2022 12:49:37 UTC