Skip to content

Commit 82dbfaf

Browse files
authored
Improve the url parsing when using relative URLs (#133)
1 parent 30c6634 commit 82dbfaf

File tree

11 files changed

+362
-48
lines changed

11 files changed

+362
-48
lines changed

.changeset/strong-pens-camp.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@envyjs/core': patch
3+
'@envyjs/node': patch
4+
'@envyjs/web': patch
5+
---
6+
7+
Improve url parsing when using relative URLs

examples/next/utils/query.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import { CatFact, Cocktail, Dog } from './types';
22

33
export async function fetchCatFact(): Promise<CatFact> {
4-
const res = await fetch('https://cat-fact.herokuapp.com/facts');
4+
const res = await fetch('https://catfact.ninja/fact');
55
const data = await res.json();
6-
const allFacts = data.map((fact: CatFact) => ({
7-
id: fact._id,
8-
text: fact.text,
9-
}));
10-
11-
const randomIdx = Math.floor(Math.random() * allFacts.length);
12-
return allFacts[randomIdx];
6+
return {
7+
text: data.fact,
8+
};
139
}
1410

1511
export async function fetchRandomCocktail(): Promise<Cocktail> {

examples/next/utils/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type CatFact = { _id: string; text: string };
1+
export type CatFact = { text: string };
22
export type Cocktail = {
33
id: string;
44
name: string;

packages/core/src/fetch.test.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import {
2+
getEventFromFetchRequest,
3+
getEventFromFetchResponse,
4+
getUrlFromFetchRequest,
5+
parseFetchHeaders,
6+
} from './fetch';
7+
8+
describe('fetch', () => {
9+
beforeAll(() => {
10+
jest.useFakeTimers();
11+
jest.setSystemTime(new Date(2023, 10, 1));
12+
});
13+
14+
describe('getEventFromFetchRequest', () => {
15+
it('should map a fetch request from a string url', () => {
16+
const request = getEventFromFetchRequest('1', 'http://localhost/api/path');
17+
expect(request).toEqual({
18+
http: {
19+
host: 'localhost',
20+
method: 'GET',
21+
path: '/api/path',
22+
port: NaN,
23+
requestHeaders: {},
24+
state: 'sent',
25+
url: 'http://localhost/api/path',
26+
},
27+
id: '1',
28+
timestamp: expect.any(Number),
29+
});
30+
});
31+
32+
it('should parse the host port number', () => {
33+
const request = getEventFromFetchRequest('1', 'http://localhost:5671/api/path');
34+
expect(request).toEqual({
35+
http: {
36+
host: 'localhost:5671',
37+
method: 'GET',
38+
path: '/api/path',
39+
port: 5671,
40+
requestHeaders: {},
41+
state: 'sent',
42+
url: 'http://localhost:5671/api/path',
43+
},
44+
id: '1',
45+
timestamp: expect.any(Number),
46+
});
47+
});
48+
49+
it('should map the fetch request headers', () => {
50+
const init: RequestInit = {
51+
headers: {
52+
'x-array': 'value1',
53+
'x-key': 'key1',
54+
},
55+
};
56+
57+
const request = getEventFromFetchRequest('1', 'http://localhost/api/path', init);
58+
expect(request).toEqual({
59+
http: {
60+
host: 'localhost',
61+
method: 'GET',
62+
path: '/api/path',
63+
port: NaN,
64+
requestHeaders: {
65+
'x-array': 'value1',
66+
'x-key': 'key1',
67+
},
68+
state: 'sent',
69+
url: 'http://localhost/api/path',
70+
},
71+
id: '1',
72+
timestamp: expect.any(Number),
73+
});
74+
});
75+
76+
it('should map the fetch request body', () => {
77+
const init: RequestInit = {
78+
body: new URLSearchParams('key=value&name=test'),
79+
};
80+
81+
const request = getEventFromFetchRequest('1', 'http://localhost/api/path', init);
82+
expect(request).toEqual({
83+
http: {
84+
host: 'localhost',
85+
method: 'GET',
86+
path: '/api/path',
87+
port: NaN,
88+
requestHeaders: {},
89+
requestBody: 'key=value&name=test',
90+
state: 'sent',
91+
url: 'http://localhost/api/path',
92+
},
93+
id: '1',
94+
timestamp: expect.any(Number),
95+
});
96+
});
97+
});
98+
99+
describe('getEventFromFetchRequest', () => {
100+
it('should map a fetch response', async () => {
101+
const request = getEventFromFetchRequest('1', 'http://localhost/api/path');
102+
const response = await getEventFromFetchResponse(request, {
103+
headers: new MockHeaders({ key: 'value' }),
104+
status: 200,
105+
statusText: 'OK',
106+
type: 'default',
107+
text: () => Promise.resolve('test'),
108+
});
109+
110+
expect(response).toEqual({
111+
http: {
112+
host: 'localhost',
113+
httpVersion: 'default',
114+
method: 'GET',
115+
path: '/api/path',
116+
port: NaN,
117+
requestBody: undefined,
118+
requestHeaders: {},
119+
responseBody: 'test',
120+
responseHeaders: {
121+
key: 'value',
122+
},
123+
state: 'received',
124+
statusCode: 200,
125+
statusMessage: 'OK',
126+
url: 'http://localhost/api/path',
127+
},
128+
id: '1',
129+
parentId: undefined,
130+
timestamp: expect.any(Number),
131+
});
132+
});
133+
});
134+
135+
describe('getUrlFromFetchRequest', () => {
136+
it('should parse a Request type', () => {
137+
const info = new MockRequest('http://localhost/api/path');
138+
expect(getUrlFromFetchRequest(info)?.href).toEqual('http://localhost/api/path');
139+
});
140+
141+
it('should parse a URL type', () => {
142+
const info = new URL('http://localhost/web');
143+
expect(getUrlFromFetchRequest(info)?.href).toEqual('http://localhost/web');
144+
});
145+
146+
it('should parse a fully qualified string type', () => {
147+
const info = 'http://localhost/api/path';
148+
expect(getUrlFromFetchRequest(info)?.href).toEqual('http://localhost/api/path');
149+
});
150+
151+
it('should return a default for unqualified relative urls', () => {
152+
const info = '/api/path';
153+
expect(getUrlFromFetchRequest(info)?.href).toBe('http://localhost/');
154+
});
155+
156+
describe('jsDom', () => {
157+
beforeAll(() => {
158+
globalThis.location = {
159+
origin: 'http://localhost:2700',
160+
} as any;
161+
});
162+
163+
afterAll(() => {
164+
globalThis.location = {} as any;
165+
});
166+
167+
it('should parse a relative url when window is set', () => {
168+
const info = '/api/path';
169+
expect(getUrlFromFetchRequest(info)?.href).toEqual('http://localhost:2700/api/path');
170+
});
171+
});
172+
});
173+
174+
describe('parseFetchHeaders', () => {
175+
it('should map header value pairs', () => {
176+
const headers = parseFetchHeaders([
177+
['key', 'value'],
178+
['name', 'test'],
179+
]);
180+
181+
expect(headers).toEqual({
182+
key: 'value',
183+
name: 'test',
184+
});
185+
});
186+
187+
it('should map header record', () => {
188+
const headers = parseFetchHeaders({
189+
key: 'value',
190+
name: 'test',
191+
});
192+
193+
expect(headers).toEqual({
194+
key: 'value',
195+
name: 'test',
196+
});
197+
});
198+
199+
it('should map header object', () => {
200+
const headers = parseFetchHeaders(
201+
new MockHeaders({
202+
key: 'value',
203+
name: 'test',
204+
}),
205+
);
206+
207+
expect(headers).toEqual({
208+
key: 'value',
209+
name: 'test',
210+
});
211+
});
212+
});
213+
});
214+
215+
// mock the libdom Headers class
216+
class MockHeaders {
217+
constructor(private values?: Record<string, string>) {}
218+
219+
*entries(): IterableIterator<[string, string]> {
220+
for (const k in this.values) {
221+
yield [k, this.values[k]];
222+
}
223+
}
224+
}
225+
226+
// mock the libdom Request class
227+
class MockRequest {
228+
constructor(private _url: string) {}
229+
get headers(): MockHeaders {
230+
return new MockHeaders();
231+
}
232+
get method(): string {
233+
return 'get';
234+
}
235+
get url(): string {
236+
return this._url;
237+
}
238+
}

packages/core/src/fetch.ts

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,13 @@
11
import { Event } from './event';
2+
import { HeadersInit, RequestInfo, RequestInit, Response } from './fetchTypes';
23
import { HttpRequest, HttpRequestState } from './http';
4+
import { tryParseURL } from './url';
35

4-
// TODO: the types in this file are from lib/dom
5-
// we need to replace them with a platform agnostic version
6-
7-
function formatFetchHeaders(headers: HeadersInit | Headers | undefined): HttpRequest['requestHeaders'] {
8-
if (headers) {
9-
if (Array.isArray(headers)) {
10-
return headers.reduce<HttpRequest['requestHeaders']>((acc, [key, value]) => {
11-
acc[key] = value;
12-
return acc;
13-
}, {});
14-
} else if (headers instanceof Headers) {
15-
return Object.fromEntries(headers.entries());
16-
} else {
17-
return headers;
18-
}
19-
}
20-
21-
return {};
22-
}
23-
24-
export function fetchRequestToEvent(id: string, input: RequestInfo | URL, init?: RequestInit): Event {
25-
let url: URL;
26-
if (typeof input === 'string') {
27-
url = new URL(input);
28-
} else if (input instanceof Request) {
29-
url = new URL(input.url);
30-
} else {
31-
url = input;
32-
}
6+
/**
7+
* Returns an {@link Event} from fetch request arguments
8+
*/
9+
export function getEventFromFetchRequest(id: string, input: RequestInfo | URL, init?: RequestInit): Event {
10+
const url = getUrlFromFetchRequest(input);
3311

3412
return {
3513
id,
@@ -42,13 +20,16 @@ export function fetchRequestToEvent(id: string, input: RequestInfo | URL, init?:
4220
port: parseInt(url.port, 10),
4321
path: url.pathname,
4422
url: url.toString(),
45-
requestHeaders: formatFetchHeaders(init?.headers),
23+
requestHeaders: parseFetchHeaders(init?.headers),
4624
requestBody: init?.body?.toString() ?? undefined,
4725
},
4826
};
4927
}
5028

51-
export async function fetchResponseToEvent(req: Event, response: Response): Promise<Event> {
29+
/**
30+
* Returns an {@link Event} from a fetch response
31+
*/
32+
export async function getEventFromFetchResponse(req: Event, response: Response): Promise<Event> {
5233
return {
5334
...req,
5435

@@ -58,8 +39,47 @@ export async function fetchResponseToEvent(req: Event, response: Response): Prom
5839
httpVersion: response.type,
5940
statusCode: response.status,
6041
statusMessage: response.statusText,
61-
responseHeaders: formatFetchHeaders(response.headers),
42+
responseHeaders: parseFetchHeaders(response.headers),
6243
responseBody: await response.text(),
6344
},
6445
};
6546
}
47+
48+
/**
49+
* Returns a {@link URL} from fetch request arguments.
50+
* Fallback to localhost if the fetch arguments could not be parsed.
51+
*/
52+
export function getUrlFromFetchRequest(input: RequestInfo | URL): URL {
53+
if (input instanceof URL) {
54+
return input;
55+
}
56+
57+
const url = (input as any).url ? (input as any).url : input;
58+
59+
// parse absolute and relative urls
60+
const parsedUrl = tryParseURL(url) || tryParseURL(url, globalThis?.location?.origin);
61+
if (parsedUrl) {
62+
return parsedUrl;
63+
}
64+
65+
// this library is for instrumentation, so we use a fallback
66+
// to prevent throwing errors in consumer applications
67+
return new URL('http://localhost/');
68+
}
69+
70+
export function parseFetchHeaders(headers?: HeadersInit): HttpRequest['requestHeaders'] {
71+
if (headers) {
72+
if (Array.isArray(headers)) {
73+
return headers.reduce<HttpRequest['requestHeaders']>((acc, [key, value]) => {
74+
acc[key] = value;
75+
return acc;
76+
}, {});
77+
} else if (typeof headers.entries === 'function') {
78+
return Object.fromEntries(headers.entries());
79+
} else {
80+
return headers as Record<string, string>;
81+
}
82+
}
83+
84+
return {};
85+
}

0 commit comments

Comments
 (0)