mirror of
https://github.com/denoland/deno.git
synced 2025-03-03 17:34:47 -05:00
Make fetch API more standards compliant (#3667)
This commit is contained in:
parent
fba40d86c4
commit
2b0cf74a8f
5 changed files with 194 additions and 9 deletions
|
@ -530,8 +530,8 @@ type RequestDestination =
|
||||||
| "worker"
|
| "worker"
|
||||||
| "xslt";
|
| "xslt";
|
||||||
type RequestMode = "navigate" | "same-origin" | "no-cors" | "cors";
|
type RequestMode = "navigate" | "same-origin" | "no-cors" | "cors";
|
||||||
type RequestRedirect = "follow" | "error" | "manual";
|
type RequestRedirect = "follow" | "nofollow" | "error" | "manual";
|
||||||
type ResponseType =
|
export type ResponseType =
|
||||||
| "basic"
|
| "basic"
|
||||||
| "cors"
|
| "cors"
|
||||||
| "default"
|
| "default"
|
||||||
|
|
116
cli/js/fetch.ts
116
cli/js/fetch.ts
|
@ -252,11 +252,11 @@ class Body implements domTypes.Body, domTypes.ReadableStream, io.ReadCloser {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Response implements domTypes.Response {
|
export class Response implements domTypes.Response {
|
||||||
readonly type = "basic"; // TODO
|
readonly type: domTypes.ResponseType;
|
||||||
readonly redirected: boolean;
|
readonly redirected: boolean;
|
||||||
headers: domTypes.Headers;
|
headers: domTypes.Headers;
|
||||||
readonly trailer: Promise<domTypes.Headers>;
|
readonly trailer: Promise<domTypes.Headers>;
|
||||||
readonly body: Body;
|
readonly body: null | Body;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly url: string,
|
readonly url: string,
|
||||||
|
@ -265,6 +265,7 @@ export class Response implements domTypes.Response {
|
||||||
headersList: Array<[string, string]>,
|
headersList: Array<[string, string]>,
|
||||||
rid: number,
|
rid: number,
|
||||||
redirected_: boolean,
|
redirected_: boolean,
|
||||||
|
readonly type_: null | domTypes.ResponseType = "default",
|
||||||
body_: null | Body = null
|
body_: null | Body = null
|
||||||
) {
|
) {
|
||||||
this.trailer = createResolvable();
|
this.trailer = createResolvable();
|
||||||
|
@ -277,27 +278,112 @@ export class Response implements domTypes.Response {
|
||||||
this.body = body_;
|
this.body = body_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type_ == null) {
|
||||||
|
this.type = "default";
|
||||||
|
} else {
|
||||||
|
this.type = type_;
|
||||||
|
if (type_ == "error") {
|
||||||
|
// spec: https://fetch.spec.whatwg.org/#concept-network-error
|
||||||
|
this.status = 0;
|
||||||
|
this.statusText = "";
|
||||||
|
this.headers = new Headers();
|
||||||
|
this.body = null;
|
||||||
|
/* spec for other Response types:
|
||||||
|
https://fetch.spec.whatwg.org/#concept-filtered-response-basic
|
||||||
|
Please note that type "basic" is not the same thing as "default".*/
|
||||||
|
} else if (type_ == "basic") {
|
||||||
|
for (const h of this.headers) {
|
||||||
|
/* Forbidden Response-Header Names:
|
||||||
|
https://fetch.spec.whatwg.org/#forbidden-response-header-name */
|
||||||
|
if (["set-cookie", "set-cookie2"].includes(h[0].toLowerCase())) {
|
||||||
|
this.headers.delete(h[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type_ == "cors") {
|
||||||
|
/* CORS-safelisted Response-Header Names:
|
||||||
|
https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name */
|
||||||
|
const allowedHeaders = [
|
||||||
|
"Cache-Control",
|
||||||
|
"Content-Language",
|
||||||
|
"Content-Length",
|
||||||
|
"Content-Type",
|
||||||
|
"Expires",
|
||||||
|
"Last-Modified",
|
||||||
|
"Pragma"
|
||||||
|
].map((c: string) => c.toLowerCase());
|
||||||
|
for (const h of this.headers) {
|
||||||
|
/* Technically this is still not standards compliant because we are
|
||||||
|
supposed to allow headers allowed in the
|
||||||
|
'Access-Control-Expose-Headers' header in the 'internal response'
|
||||||
|
However, this implementation of response doesn't seem to have an
|
||||||
|
easy way to access the internal response, so we ignore that
|
||||||
|
header.
|
||||||
|
TODO(serverhiccups): change how internal responses are handled
|
||||||
|
so we can do this properly. */
|
||||||
|
if (!allowedHeaders.includes(h[0].toLowerCase())) {
|
||||||
|
this.headers.delete(h[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* TODO(serverhiccups): Once I fix the 'internal response' thing,
|
||||||
|
these actually need to treat the internal response differently */
|
||||||
|
} else if (type_ == "opaque" || type_ == "opaqueredirect") {
|
||||||
|
this.url = "";
|
||||||
|
this.status = 0;
|
||||||
|
this.statusText = "";
|
||||||
|
this.headers = new Headers();
|
||||||
|
this.body = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.redirected = redirected_;
|
this.redirected = redirected_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bodyViewable(): boolean {
|
||||||
|
if (
|
||||||
|
this.type == "error" ||
|
||||||
|
this.type == "opaque" ||
|
||||||
|
this.type == "opaqueredirect" ||
|
||||||
|
this.body == undefined
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||||
|
/* You have to do the null check here and not in the function because
|
||||||
|
* otherwise TS complains about this.body potentially being null */
|
||||||
|
if (this.bodyViewable() || this.body == null) {
|
||||||
|
return Promise.reject(new Error("Response body is null"));
|
||||||
|
}
|
||||||
return this.body.arrayBuffer();
|
return this.body.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async blob(): Promise<domTypes.Blob> {
|
async blob(): Promise<domTypes.Blob> {
|
||||||
|
if (this.bodyViewable() || this.body == null) {
|
||||||
|
return Promise.reject(new Error("Response body is null"));
|
||||||
|
}
|
||||||
return this.body.blob();
|
return this.body.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async formData(): Promise<domTypes.FormData> {
|
async formData(): Promise<domTypes.FormData> {
|
||||||
|
if (this.bodyViewable() || this.body == null) {
|
||||||
|
return Promise.reject(new Error("Response body is null"));
|
||||||
|
}
|
||||||
return this.body.formData();
|
return this.body.formData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
async json(): Promise<any> {
|
async json(): Promise<any> {
|
||||||
|
if (this.bodyViewable() || this.body == null) {
|
||||||
|
return Promise.reject(new Error("Response body is null"));
|
||||||
|
}
|
||||||
return this.body.json();
|
return this.body.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async text(): Promise<string> {
|
async text(): Promise<string> {
|
||||||
|
if (this.bodyViewable() || this.body == null) {
|
||||||
|
return Promise.reject(new Error("Response body is null"));
|
||||||
|
}
|
||||||
return this.body.text();
|
return this.body.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -306,6 +392,7 @@ export class Response implements domTypes.Response {
|
||||||
}
|
}
|
||||||
|
|
||||||
get bodyUsed(): boolean {
|
get bodyUsed(): boolean {
|
||||||
|
if (this.body === null) return false;
|
||||||
return this.body.bodyUsed;
|
return this.body.bodyUsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -329,9 +416,28 @@ export class Response implements domTypes.Response {
|
||||||
headersList,
|
headersList,
|
||||||
-1,
|
-1,
|
||||||
this.redirected,
|
this.redirected,
|
||||||
|
this.type,
|
||||||
this.body
|
this.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
redirect(url: URL | string, status: number): domTypes.Response {
|
||||||
|
if (![301, 302, 303, 307, 308].includes(status)) {
|
||||||
|
throw new RangeError(
|
||||||
|
"The redirection status must be one of 301, 302, 303, 307 and 308."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
"",
|
||||||
|
status,
|
||||||
|
"",
|
||||||
|
[["Location", typeof url === "string" ? url : url.toString()]],
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
"default",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchResponse {
|
interface FetchResponse {
|
||||||
|
@ -445,9 +551,11 @@ export async function fetch(
|
||||||
// We're in a redirect status
|
// We're in a redirect status
|
||||||
switch ((init && init.redirect) || "follow") {
|
switch ((init && init.redirect) || "follow") {
|
||||||
case "error":
|
case "error":
|
||||||
throw notImplemented();
|
/* I suspect that deno will probably crash if you try to use that
|
||||||
|
rid, which suggests to me that Response needs to be refactored */
|
||||||
|
return new Response("", 0, "", [], -1, false, "error", null);
|
||||||
case "manual":
|
case "manual":
|
||||||
throw notImplemented();
|
return new Response("", 0, "", [], -1, false, "opaqueredirect", null);
|
||||||
case "follow":
|
case "follow":
|
||||||
default:
|
default:
|
||||||
let redirectUrl = response.headers.get("Location");
|
let redirectUrl = response.headers.get("Location");
|
||||||
|
|
|
@ -5,7 +5,8 @@ import {
|
||||||
assert,
|
assert,
|
||||||
assertEquals,
|
assertEquals,
|
||||||
assertStrContains,
|
assertStrContains,
|
||||||
assertThrows
|
assertThrows,
|
||||||
|
fail
|
||||||
} from "./test_util.ts";
|
} from "./test_util.ts";
|
||||||
|
|
||||||
testPerm({ net: true }, async function fetchConnectionError(): Promise<void> {
|
testPerm({ net: true }, async function fetchConnectionError(): Promise<void> {
|
||||||
|
@ -360,3 +361,75 @@ testPerm({ net: true }, async function fetchPostBodyTypedArray():Promise<void> {
|
||||||
assertEquals(actual, expected);
|
assertEquals(actual, expected);
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
testPerm({ net: true }, async function fetchWithManualRedirection(): Promise<
|
||||||
|
void
|
||||||
|
> {
|
||||||
|
const response = await fetch("http://localhost:4546/", {
|
||||||
|
redirect: "manual"
|
||||||
|
}); // will redirect to http://localhost:4545/
|
||||||
|
assertEquals(response.status, 0);
|
||||||
|
assertEquals(response.statusText, "");
|
||||||
|
assertEquals(response.url, "");
|
||||||
|
assertEquals(response.type, "opaqueredirect");
|
||||||
|
try {
|
||||||
|
await response.text();
|
||||||
|
fail(
|
||||||
|
"Reponse.text() didn't throw on a filtered response without a body (type opaqueredirect)"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testPerm({ net: true }, async function fetchWithErrorRedirection(): Promise<
|
||||||
|
void
|
||||||
|
> {
|
||||||
|
const response = await fetch("http://localhost:4546/", {
|
||||||
|
redirect: "error"
|
||||||
|
}); // will redirect to http://localhost:4545/
|
||||||
|
assertEquals(response.status, 0);
|
||||||
|
assertEquals(response.statusText, "");
|
||||||
|
assertEquals(response.url, "");
|
||||||
|
assertEquals(response.type, "error");
|
||||||
|
try {
|
||||||
|
await response.text();
|
||||||
|
fail(
|
||||||
|
"Reponse.text() didn't throw on a filtered response without a body (type error)"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(function responseRedirect(): void {
|
||||||
|
const response = new Response(
|
||||||
|
"example.com/beforeredirect",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
[["This-Should", "Disappear"]],
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const redir = response.redirect("example.com/newLocation", 301);
|
||||||
|
assertEquals(redir.status, 301);
|
||||||
|
assertEquals(redir.statusText, "");
|
||||||
|
assertEquals(redir.url, "");
|
||||||
|
assertEquals(redir.headers.get("Location"), "example.com/newLocation");
|
||||||
|
assertEquals(redir.type, "default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(function responseConstructionHeaderRemoval(): void {
|
||||||
|
const res = new Response(
|
||||||
|
"example.com",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
[["Set-Cookie", "mysessionid"]],
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
"basic",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert(res.headers.get("Set-Cookie") != "mysessionid");
|
||||||
|
});
|
||||||
|
|
5
cli/js/lib.deno.shared_globals.d.ts
vendored
5
cli/js/lib.deno.shared_globals.d.ts
vendored
|
@ -1083,7 +1083,7 @@ declare namespace __fetch {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
readonly type = "basic";
|
readonly type: __domTypes.ResponseType;
|
||||||
readonly redirected: boolean;
|
readonly redirected: boolean;
|
||||||
headers: __domTypes.Headers;
|
headers: __domTypes.Headers;
|
||||||
readonly trailer: Promise<__domTypes.Headers>;
|
readonly trailer: Promise<__domTypes.Headers>;
|
||||||
|
@ -1092,9 +1092,11 @@ declare namespace __fetch {
|
||||||
constructor(
|
constructor(
|
||||||
url: string,
|
url: string,
|
||||||
status: number,
|
status: number,
|
||||||
|
statusText: string,
|
||||||
headersList: Array<[string, string]>,
|
headersList: Array<[string, string]>,
|
||||||
rid: number,
|
rid: number,
|
||||||
redirected_: boolean,
|
redirected_: boolean,
|
||||||
|
type_?: null | __domTypes.ResponseType,
|
||||||
body_?: null | Body
|
body_?: null | Body
|
||||||
);
|
);
|
||||||
arrayBuffer(): Promise<ArrayBuffer>;
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
@ -1104,6 +1106,7 @@ declare namespace __fetch {
|
||||||
text(): Promise<string>;
|
text(): Promise<string>;
|
||||||
readonly ok: boolean;
|
readonly ok: boolean;
|
||||||
clone(): __domTypes.Response;
|
clone(): __domTypes.Response;
|
||||||
|
redirect(url: URL | string, status: number): __domTypes.Response;
|
||||||
}
|
}
|
||||||
/** Fetch a resource from the network. */
|
/** Fetch a resource from the network. */
|
||||||
export function fetch(
|
export function fetch(
|
||||||
|
|
|
@ -17,7 +17,8 @@ export {
|
||||||
assertNotEquals,
|
assertNotEquals,
|
||||||
assertStrictEq,
|
assertStrictEq,
|
||||||
assertStrContains,
|
assertStrContains,
|
||||||
unreachable
|
unreachable,
|
||||||
|
fail
|
||||||
} from "../../std/testing/asserts.ts";
|
} from "../../std/testing/asserts.ts";
|
||||||
|
|
||||||
interface TestPermissions {
|
interface TestPermissions {
|
||||||
|
|
Loading…
Add table
Reference in a new issue