diff --git a/panel/src/panel/request.body.test.js b/panel/src/panel/request.body.test.js new file mode 100644 index 0000000000..8c6cec4f02 --- /dev/null +++ b/panel/src/panel/request.body.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { body } from "./request.js"; + +describe.concurrent("request globals", () => { + it("should create body from object", async () => { + const result = body({ + a: "A" + }); + + expect(result).toStrictEqual(JSON.stringify({ a: "A" })); + }); + + it("should create body from string", async () => { + const result = body("test"); + expect(result).toStrictEqual("test"); + }); + + it("should create body from FormData", async () => { + const formData = new FormData(); + formData.append("a", "A"); + + const result = body(formData); + + expect(result).toStrictEqual(JSON.stringify({ a: "A" })); + }); +}); diff --git a/panel/src/panel/request.globals.test.js b/panel/src/panel/request.globals.test.js new file mode 100644 index 0000000000..d0fa60a6c7 --- /dev/null +++ b/panel/src/panel/request.globals.test.js @@ -0,0 +1,23 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from "vitest"; +import { globals } from "./request.js"; + +describe.concurrent("request globals", () => { + it("should create globals from string", async () => { + const result = globals("$language"); + expect(result).toStrictEqual("$language"); + }); + + it("should create globals from array", async () => { + const result = globals(["$language"]); + expect(result).toStrictEqual("$language"); + }); + + it("should skip globals", async () => { + const result = globals(); + expect(result).toStrictEqual(false); + }); +}); diff --git a/panel/src/panel/request.headers.test.js b/panel/src/panel/request.headers.test.js new file mode 100644 index 0000000000..30031dfef3 --- /dev/null +++ b/panel/src/panel/request.headers.test.js @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from "vitest"; +import { headers } from "./request.js"; + +describe.concurrent("request headers", () => { + it("should create default headers", async () => { + const result = headers(); + const expected = { + "content-type": "application/json", + "x-csrf": false, + "x-fiber": true, + "x-fiber-globals": false, + "x-fiber-referrer": false + }; + + expect(result).toStrictEqual(expected); + }); + + it("should add custom headers", async () => { + const result = headers({ + "x-foo": "test" + }); + + const expected = { + "content-type": "application/json", + "x-csrf": false, + "x-fiber": true, + "x-fiber-globals": false, + "x-fiber-referrer": false, + "x-foo": "test" + }; + + expect(result).toStrictEqual(expected); + }); + + it("should set options", async () => { + const result = headers( + {}, + { + csrf: "dev", + globals: ["$language"], + referrer: "/test" + } + ); + + const expected = { + "content-type": "application/json", + "x-csrf": "dev", + "x-fiber": true, + "x-fiber-globals": "$language", + "x-fiber-referrer": "/test" + }; + + expect(result).toStrictEqual(expected); + }); +}); diff --git a/panel/src/panel/request.js b/panel/src/panel/request.js new file mode 100644 index 0000000000..b458260f01 --- /dev/null +++ b/panel/src/panel/request.js @@ -0,0 +1,142 @@ +import { buildUrl, isSameOrigin, makeAbsolute } from "@/helpers/url.js"; +import { toLowerKeys } from "../helpers/object.js"; +import JsonRequestError from "@/errors/JsonRequestError.js"; +import RequestError from "@/errors/RequestError.js"; + +/** + * Creates a proper request body + * + * @param {String|FormData|Object|Array} + * @returns {String} + */ +export const body = (body) => { + if (body instanceof HTMLFormElement) { + body = new FormData(body); + } + + if (body instanceof FormData) { + body = Object.fromEntries(body); + } + + if (typeof body === "object") { + return JSON.stringify(body); + } + + return body; +}; + +/** + * Convert globals to comma separated string + * @param {Array|String} globals + * @returns {String|false} + */ +export const globals = (globals) => { + if (globals) { + if (Array.isArray(globals) === false) { + return String(globals); + } + + return globals.join(","); + } + + return false; +}; + +/** + * Builds all required headers for a request + * + * @param {Object} headers + * @param {Object} options All request options + * @returns {Object} + */ +export const headers = (headers = {}, options = {}) => { + return { + "content-type": "application/json", + "x-csrf": options.csrf ?? false, + "x-fiber": true, + "x-fiber-globals": globals(options.globals), + "x-fiber-referrer": options.referrer ?? false, + ...toLowerKeys(headers) + }; +}; + +/** + * @param {string|URL} url + * @returns false + */ +export const redirect = (url) => { + window.location.href = makeAbsolute(url); + return false; +}; + +/** + * Sends a Panel request to the backend with + * all the right headers and other options. + * + * It also makes sure to redirect requests, + * which cannot be handled via fetch and + * throws more useful errors. + * + * @param {String} url + * @param {Object} options + * @returns {Object} {request, response} + */ +export const request = async (url, options = {}) => { + // merge with a few defaults + options = { + cache: "no-store", + credentials: "same-origin", + mode: "same-origin", + ...options + }; + + // those need a bit more work + options.body = body(options.body); + options.headers = headers(options.headers, options); + options.url = buildUrl(url, options.query); + + // The request object is a nice way to access all the + // important parts later in errors for example + const request = new Request(options.url, options); + + // Don't even try to request a + // cross-origin url. Redirect instead. + if (isSameOrigin(request.url) === false) { + return redirect(request.url); + } + + const response = await fetch(request); + + // redirect to non-fiber requests + if ( + response.headers.get("Content-Type").includes("application/json") === false + ) { + return redirect(response.url); + } + + // try to parse the response. + try { + response.text = await response.text(); + response.json = JSON.parse(response.text); + } catch (error) { + throw new JsonRequestError("Invalid JSON response", { + cause: error, + request, + response + }); + } + + if (response.ok === false) { + throw new RequestError(`The request to ${response.url} failed`, { + request, + response + }); + } + + return { + request, + response + }; +}; + +export default request;