From e2416fa3eb90ab4acefe04e4a87a0b6e4eaf3257 Mon Sep 17 00:00:00 2001
From: Andreas Fernandez <a.fernandez@scripting-base.de>
Date: Wed, 20 Sep 2023 12:33:49 +0200
Subject: [PATCH] [FEATURE] AJAX API accepts native `URL` and `URLSearchParams`
 objects as arguments

The AJAX API (`@typo3/core/ajax/ajax-request`) has been enhanced to
accept native URL-related objects, making usage of the API for
developers a little bit easier.

Resolves: #101970
Releases: main
Change-Id: Ica7c8d5ded7184c5aad6355dbdfa5c4f1f82b0ce
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/81100
Tested-by: core-ci <typo3@b13.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Garvin Hicking <gh@faktor-e.de>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Garvin Hicking <gh@faktor-e.de>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../TypeScript/core/ajax/ajax-request.ts      | 10 +++--
 .../core/tests/ajax/ajax-request-test.ts      |  6 +--
 ...RLAndURLSearchParamsObjectsAsArguments.rst | 42 +++++++++++++++++++
 .../Public/JavaScript/ajax/ajax-request.js    |  2 +-
 .../JavaScript/ajax/ajax-request-test.js      |  2 +-
 5 files changed, 53 insertions(+), 9 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/13.0/Feature-101970-AJAXAPIAcceptsNativeURLAndURLSearchParamsObjectsAsArguments.rst

diff --git a/Build/Sources/TypeScript/core/ajax/ajax-request.ts b/Build/Sources/TypeScript/core/ajax/ajax-request.ts
index 54a314aec0ac..8f6253cf06ea 100644
--- a/Build/Sources/TypeScript/core/ajax/ajax-request.ts
+++ b/Build/Sources/TypeScript/core/ajax/ajax-request.ts
@@ -37,8 +37,8 @@ class AjaxRequest {
   private readonly url: URL;
   private readonly abortController: AbortController;
 
-  constructor(url: string) {
-    this.url = new URL(url, window.location.origin + window.location.pathname);
+  constructor(url: URL|string) {
+    this.url = url instanceof URL ? url : new URL(url, window.location.origin + window.location.pathname);
     this.abortController = new AbortController();
   }
 
@@ -48,10 +48,12 @@ class AjaxRequest {
    * @param {string|array|GenericKeyValue} data
    * @return {AjaxRequest}
    */
-  public withQueryArguments(data: string | Array<string> | GenericKeyValue): AjaxRequest {
+  public withQueryArguments(data: string | Array<string> | GenericKeyValue | URLSearchParams): AjaxRequest {
     const clone = this.clone();
 
-    data = new URLSearchParams(InputTransformer.toSearchParams(data));
+    if (!(data instanceof URLSearchParams)) {
+      data = new URLSearchParams(InputTransformer.toSearchParams(data));
+    }
     for (const [key, value] of data.entries()) {
       this.url.searchParams.append(key, value);
     }
diff --git a/Build/Sources/TypeScript/core/tests/ajax/ajax-request-test.ts b/Build/Sources/TypeScript/core/tests/ajax/ajax-request-test.ts
index 4c2f5c8c03dc..ea42034a5297 100644
--- a/Build/Sources/TypeScript/core/tests/ajax/ajax-request-test.ts
+++ b/Build/Sources/TypeScript/core/tests/ajax/ajax-request-test.ts
@@ -121,7 +121,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
         const response = new Response(responseText, { headers: headers });
         promiseHelper.resolve(response);
 
-        (new AjaxRequest('https://example.com')).get().then(async (response: AjaxResponse): Promise<void> => {
+        (new AjaxRequest(new URL('https://example.com'))).get().then(async (response: AjaxResponse): Promise<void> => {
           const data = await response.resolve();
           expect(window.fetch).toHaveBeenCalledWith(new URL('https://example.com/'), jasmine.objectContaining({ method: 'GET' }));
           onfulfill(data, responseText);
@@ -135,13 +135,13 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
     function* urlInputDataProvider(): any {
       yield [
         'absolute url with domain',
-        'https://example.com',
+        new URL('https://example.com'),
         {},
         new URL('https://example.com/'),
       ];
       yield [
         'absolute url with domain, with query parameter',
-        'https://example.com',
+        new URL('https://example.com'),
         { foo: 'bar', bar: { baz: 'bencer' } },
         new URL('https://example.com/?foo=bar&bar%5Bbaz%5D=bencer'),
       ];
diff --git a/typo3/sysext/core/Documentation/Changelog/13.0/Feature-101970-AJAXAPIAcceptsNativeURLAndURLSearchParamsObjectsAsArguments.rst b/typo3/sysext/core/Documentation/Changelog/13.0/Feature-101970-AJAXAPIAcceptsNativeURLAndURLSearchParamsObjectsAsArguments.rst
new file mode 100644
index 000000000000..0c7df19ab33c
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/13.0/Feature-101970-AJAXAPIAcceptsNativeURLAndURLSearchParamsObjectsAsArguments.rst
@@ -0,0 +1,42 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-101970-1695205584:
+
+=======================================================================================
+Feature: #101970 - AJAX API accepts native URL and URLSearchParams objects as arguments
+=======================================================================================
+
+See :issue:`101970`
+
+Description
+===========
+
+The AJAX API (:js:`@typo3/core/ajax/ajax-request`) has been enhanced to accept
+native URL-related objects.
+
+
+Impact
+======
+
+The constructor now accepts a :js:`URL` object as argument, along with the
+already established `string` type. Also, the :js:`withQueryString()` method
+accepts an object of type :js:`URLSearchParams` as argument.
+
+Example
+-------
+
+.. code-block:: javascript
+
+    import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
+
+    const url = new URL('https://example.com/page/1/2/');
+    const queryArguments = new URLSearchParams({
+        foo: 'bar',
+        baz: 'bencer'
+    });
+
+    const request = new AjaxRequest(url).withQueryArguments(queryArguments);
+    request.get().then(/* ... */);
+
+
+.. index:: JavaScript, ext:core
diff --git a/typo3/sysext/core/Resources/Public/JavaScript/ajax/ajax-request.js b/typo3/sysext/core/Resources/Public/JavaScript/ajax/ajax-request.js
index abd8c7071839..2f96cc101410 100644
--- a/typo3/sysext/core/Resources/Public/JavaScript/ajax/ajax-request.js
+++ b/typo3/sysext/core/Resources/Public/JavaScript/ajax/ajax-request.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-import{AjaxResponse}from"@typo3/core/ajax/ajax-response.js";import{InputTransformer}from"@typo3/core/ajax/input-transformer.js";class AjaxRequest{constructor(e){this.url=new URL(e,window.location.origin+window.location.pathname),this.abortController=new AbortController}withQueryArguments(e){const t=this.clone();e=new URLSearchParams(InputTransformer.toSearchParams(e));for(const[t,n]of e.entries())this.url.searchParams.append(t,n);return t}async get(e={}){const t=await this.send({method:"GET",...e});return new AjaxResponse(t)}async post(e,t={}){const n={body:"string"==typeof e||e instanceof FormData?e:InputTransformer.byHeader(e,t?.headers),cache:"no-cache",method:"POST"},r=await this.send({...n,...t});return new AjaxResponse(r)}async put(e,t={}){const n={body:"string"==typeof e||e instanceof FormData?e:InputTransformer.byHeader(e,t?.headers),cache:"no-cache",method:"PUT"},r=await this.send({...n,...t});return new AjaxResponse(r)}async delete(e={},t={}){const n={cache:"no-cache",method:"DELETE"};"string"==typeof e&&e.length>0||e instanceof FormData?n.body=e:"object"==typeof e&&Object.keys(e).length>0&&(n.body=InputTransformer.byHeader(e,t?.headers));const r=await this.send({...n,...t});return new AjaxResponse(r)}abort(){this.abortController.abort()}clone(){return Object.assign(Object.create(this),this)}async send(e={}){const t=await fetch(this.url,this.getMergedOptions(e));if(!t.ok)throw new AjaxResponse(t);return t}getMergedOptions(e){return{...AjaxRequest.defaultOptions,...e,signal:this.abortController.signal}}}AjaxRequest.defaultOptions={credentials:"same-origin"};export default AjaxRequest;
\ No newline at end of file
+import{AjaxResponse}from"@typo3/core/ajax/ajax-response.js";import{InputTransformer}from"@typo3/core/ajax/input-transformer.js";class AjaxRequest{constructor(e){this.url=e instanceof URL?e:new URL(e,window.location.origin+window.location.pathname),this.abortController=new AbortController}withQueryArguments(e){const t=this.clone();e instanceof URLSearchParams||(e=new URLSearchParams(InputTransformer.toSearchParams(e)));for(const[t,n]of e.entries())this.url.searchParams.append(t,n);return t}async get(e={}){const t=await this.send({method:"GET",...e});return new AjaxResponse(t)}async post(e,t={}){const n={body:"string"==typeof e||e instanceof FormData?e:InputTransformer.byHeader(e,t?.headers),cache:"no-cache",method:"POST"},r=await this.send({...n,...t});return new AjaxResponse(r)}async put(e,t={}){const n={body:"string"==typeof e||e instanceof FormData?e:InputTransformer.byHeader(e,t?.headers),cache:"no-cache",method:"PUT"},r=await this.send({...n,...t});return new AjaxResponse(r)}async delete(e={},t={}){const n={cache:"no-cache",method:"DELETE"};"string"==typeof e&&e.length>0||e instanceof FormData?n.body=e:"object"==typeof e&&Object.keys(e).length>0&&(n.body=InputTransformer.byHeader(e,t?.headers));const r=await this.send({...n,...t});return new AjaxResponse(r)}abort(){this.abortController.abort()}clone(){return Object.assign(Object.create(this),this)}async send(e={}){const t=await fetch(this.url,this.getMergedOptions(e));if(!t.ok)throw new AjaxResponse(t);return t}getMergedOptions(e){return{...AjaxRequest.defaultOptions,...e,signal:this.abortController.signal}}}AjaxRequest.defaultOptions={credentials:"same-origin"};export default AjaxRequest;
\ No newline at end of file
diff --git a/typo3/sysext/core/Tests/JavaScript/ajax/ajax-request-test.js b/typo3/sysext/core/Tests/JavaScript/ajax/ajax-request-test.js
index d08ae90a2564..eb7a6d2c4c0a 100644
--- a/typo3/sysext/core/Tests/JavaScript/ajax/ajax-request-test.js
+++ b/typo3/sysext/core/Tests/JavaScript/ajax/ajax-request-test.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-import AjaxRequest from"@typo3/core/ajax/ajax-request.js";describe("@typo3/core/ajax/ajax-request",(()=>{let e;beforeEach((()=>{const t=new Promise(((t,o)=>{e={resolve:t,reject:o}}));spyOn(window,"fetch").and.returnValue(t)})),it("sends GET request",(()=>{new AjaxRequest("https://example.com").get(),expect(window.fetch).toHaveBeenCalledWith(new URL("https://example.com/"),jasmine.objectContaining({method:"GET"}))}));for(const e of["POST","PUT","DELETE"])describe(`send a ${e} request`,(()=>{for(const t of function*(){yield["object as payload",e,{foo:"bar",bar:"baz",nested:{works:"yes"}},()=>{const e=new FormData;return e.set("foo","bar"),e.set("bar","baz"),e.set("nested[works]","yes"),e},{}],yield["JSON object as payload",e,{foo:"bar",bar:"baz",nested:{works:"yes"}},()=>JSON.stringify({foo:"bar",bar:"baz",nested:{works:"yes"}}),{"Content-Type":"application/json"}],yield["JSON string as payload",e,JSON.stringify({foo:"bar",bar:"baz",nested:{works:"yes"}}),()=>JSON.stringify({foo:"bar",bar:"baz",nested:{works:"yes"}}),{"Content-Type":"application/json"}]}()){const[e,o,a,r,n]=t,s=o.toLowerCase();it(`with ${e}`,(e=>{new AjaxRequest("https://example.com")[s](a,{headers:n}),expect(window.fetch).toHaveBeenCalledWith(new URL("https://example.com/"),jasmine.objectContaining({method:o,body:r()})),e()}))}}));describe("send GET requests",(()=>{for(const t of function*(){yield["plaintext","foobar huselpusel",{},(e,t)=>{expect("string"==typeof e).toBeTruthy(),expect(e).toEqual(t)}],yield["JSON",JSON.stringify({foo:"bar",baz:"bencer"}),{"Content-Type":"application/json"},(e,t)=>{expect("object"==typeof e).toBeTruthy(),expect(JSON.stringify(e)).toEqual(t)}],yield["JSON with utf-8",JSON.stringify({foo:"bar",baz:"bencer"}),{"Content-Type":"application/json; charset=utf-8"},(e,t)=>{expect("object"==typeof e).toBeTruthy(),expect(JSON.stringify(e)).toEqual(t)}]}()){const[o,a,r,n]=t;it("receives a "+o+" response",(t=>{const o=new Response(a,{headers:r});e.resolve(o),new AjaxRequest("https://example.com").get().then((async e=>{const o=await e.resolve();expect(window.fetch).toHaveBeenCalledWith(new URL("https://example.com/"),jasmine.objectContaining({method:"GET"})),n(o,a),t()}))}))}})),describe("send requests with different input urls",(()=>{for(const e of function*(){yield["absolute url with domain","https://example.com",{},new URL("https://example.com/")],yield["absolute url with domain, with query parameter","https://example.com",{foo:"bar",bar:{baz:"bencer"}},new URL("https://example.com/?foo=bar&bar%5Bbaz%5D=bencer")],yield["absolute url without domain","/foo/bar",{},new URL(window.location.origin+"/foo/bar")],yield["absolute url without domain, with query parameter","/foo/bar",{foo:"bar",bar:{baz:"bencer"}},new URL(window.location.origin+"/foo/bar?foo=bar&bar%5Bbaz%5D=bencer")],yield["relative url without domain","foo/bar",{},new URL(window.location.origin+"/foo/bar")],yield["relative url without domain, with query parameter","foo/bar",{foo:"bar",bar:{baz:"bencer"}},new URL(window.location.origin+"/foo/bar?foo=bar&bar%5Bbaz%5D=bencer")],yield["fallback to current script if not defined","?foo=bar&baz=bencer",{},new URL(window.location.origin+window.location.pathname+"?foo=bar&baz=bencer")]}()){const[t,o,a,r]=e;it("with "+t,(()=>{new AjaxRequest(o).withQueryArguments(a).get(),expect(window.fetch).toHaveBeenCalledWith(r,jasmine.objectContaining({method:"GET"}))}))}})),describe("send requests with query arguments",(()=>{for(const e of function*(){yield["single level of arguments",{foo:"bar",bar:"baz"},new URL("https://example.com/?foo=bar&bar=baz")],yield["nested arguments",{foo:"bar",bar:{baz:"bencer"}},new URL("https://example.com/?foo=bar&bar%5Bbaz%5D=bencer")],yield["string argument","hello=world&foo=bar",new URL("https://example.com/?hello=world&foo=bar")],yield["array of arguments",["foo=bar","husel=pusel"],new URL("https://example.com/?foo=bar&husel=pusel")],yield["object with array",{foo:["bar","baz"]},new URL("https://example.com/?foo%5B0%5D=bar&foo%5B1%5D=baz")],yield["complex object",{foo:"bar",nested:{husel:"pusel",bar:"baz",array:["5","6"]},array:["1","2"]},new URL("https://example.com/?foo=bar&nested%5Bhusel%5D=pusel&nested%5Bbar%5D=baz&nested%5Barray%5D%5B0%5D=5&nested%5Barray%5D%5B1%5D=6&array%5B0%5D=1&array%5B1%5D=2")],yield["complex, deeply nested object",{foo:"bar",nested:{husel:"pusel",bar:"baz",array:["5","6"],deep_nested:{husel:"pusel",bar:"baz",array:["5","6"]}},array:["1","2"]},new URL("https://example.com/?foo=bar&nested%5Bhusel%5D=pusel&nested%5Bbar%5D=baz&nested%5Barray%5D%5B0%5D=5&nested%5Barray%5D%5B1%5D=6&nested%5Bdeep_nested%5D%5Bhusel%5D=pusel&nested%5Bdeep_nested%5D%5Bbar%5D=baz&nested%5Bdeep_nested%5D%5Barray%5D%5B0%5D=5&nested%5Bdeep_nested%5D%5Barray%5D%5B1%5D=6&array%5B0%5D=1&array%5B1%5D=2")]}()){const[t,o,a]=e;it("with "+t,(()=>{new AjaxRequest("https://example.com/").withQueryArguments(o).get(),expect(window.fetch).toHaveBeenCalledWith(a,jasmine.objectContaining({method:"GET"}))}))}}))}));
\ No newline at end of file
+import AjaxRequest from"@typo3/core/ajax/ajax-request.js";describe("@typo3/core/ajax/ajax-request",(()=>{let e;beforeEach((()=>{const t=new Promise(((t,o)=>{e={resolve:t,reject:o}}));spyOn(window,"fetch").and.returnValue(t)})),it("sends GET request",(()=>{new AjaxRequest("https://example.com").get(),expect(window.fetch).toHaveBeenCalledWith(new URL("https://example.com/"),jasmine.objectContaining({method:"GET"}))}));for(const e of["POST","PUT","DELETE"])describe(`send a ${e} request`,(()=>{for(const t of function*(){yield["object as payload",e,{foo:"bar",bar:"baz",nested:{works:"yes"}},()=>{const e=new FormData;return e.set("foo","bar"),e.set("bar","baz"),e.set("nested[works]","yes"),e},{}],yield["JSON object as payload",e,{foo:"bar",bar:"baz",nested:{works:"yes"}},()=>JSON.stringify({foo:"bar",bar:"baz",nested:{works:"yes"}}),{"Content-Type":"application/json"}],yield["JSON string as payload",e,JSON.stringify({foo:"bar",bar:"baz",nested:{works:"yes"}}),()=>JSON.stringify({foo:"bar",bar:"baz",nested:{works:"yes"}}),{"Content-Type":"application/json"}]}()){const[e,o,a,r,n]=t,s=o.toLowerCase();it(`with ${e}`,(e=>{new AjaxRequest("https://example.com")[s](a,{headers:n}),expect(window.fetch).toHaveBeenCalledWith(new URL("https://example.com/"),jasmine.objectContaining({method:o,body:r()})),e()}))}}));describe("send GET requests",(()=>{for(const t of function*(){yield["plaintext","foobar huselpusel",{},(e,t)=>{expect("string"==typeof e).toBeTruthy(),expect(e).toEqual(t)}],yield["JSON",JSON.stringify({foo:"bar",baz:"bencer"}),{"Content-Type":"application/json"},(e,t)=>{expect("object"==typeof e).toBeTruthy(),expect(JSON.stringify(e)).toEqual(t)}],yield["JSON with utf-8",JSON.stringify({foo:"bar",baz:"bencer"}),{"Content-Type":"application/json; charset=utf-8"},(e,t)=>{expect("object"==typeof e).toBeTruthy(),expect(JSON.stringify(e)).toEqual(t)}]}()){const[o,a,r,n]=t;it("receives a "+o+" response",(t=>{const o=new Response(a,{headers:r});e.resolve(o),new AjaxRequest(new URL("https://example.com")).get().then((async e=>{const o=await e.resolve();expect(window.fetch).toHaveBeenCalledWith(new URL("https://example.com/"),jasmine.objectContaining({method:"GET"})),n(o,a),t()}))}))}})),describe("send requests with different input urls",(()=>{for(const e of function*(){yield["absolute url with domain",new URL("https://example.com"),{},new URL("https://example.com/")],yield["absolute url with domain, with query parameter",new URL("https://example.com"),{foo:"bar",bar:{baz:"bencer"}},new URL("https://example.com/?foo=bar&bar%5Bbaz%5D=bencer")],yield["absolute url without domain","/foo/bar",{},new URL(window.location.origin+"/foo/bar")],yield["absolute url without domain, with query parameter","/foo/bar",{foo:"bar",bar:{baz:"bencer"}},new URL(window.location.origin+"/foo/bar?foo=bar&bar%5Bbaz%5D=bencer")],yield["relative url without domain","foo/bar",{},new URL(window.location.origin+"/foo/bar")],yield["relative url without domain, with query parameter","foo/bar",{foo:"bar",bar:{baz:"bencer"}},new URL(window.location.origin+"/foo/bar?foo=bar&bar%5Bbaz%5D=bencer")],yield["fallback to current script if not defined","?foo=bar&baz=bencer",{},new URL(window.location.origin+window.location.pathname+"?foo=bar&baz=bencer")]}()){const[t,o,a,r]=e;it("with "+t,(()=>{new AjaxRequest(o).withQueryArguments(a).get(),expect(window.fetch).toHaveBeenCalledWith(r,jasmine.objectContaining({method:"GET"}))}))}})),describe("send requests with query arguments",(()=>{for(const e of function*(){yield["single level of arguments",{foo:"bar",bar:"baz"},new URL("https://example.com/?foo=bar&bar=baz")],yield["nested arguments",{foo:"bar",bar:{baz:"bencer"}},new URL("https://example.com/?foo=bar&bar%5Bbaz%5D=bencer")],yield["string argument","hello=world&foo=bar",new URL("https://example.com/?hello=world&foo=bar")],yield["array of arguments",["foo=bar","husel=pusel"],new URL("https://example.com/?foo=bar&husel=pusel")],yield["object with array",{foo:["bar","baz"]},new URL("https://example.com/?foo%5B0%5D=bar&foo%5B1%5D=baz")],yield["complex object",{foo:"bar",nested:{husel:"pusel",bar:"baz",array:["5","6"]},array:["1","2"]},new URL("https://example.com/?foo=bar&nested%5Bhusel%5D=pusel&nested%5Bbar%5D=baz&nested%5Barray%5D%5B0%5D=5&nested%5Barray%5D%5B1%5D=6&array%5B0%5D=1&array%5B1%5D=2")],yield["complex, deeply nested object",{foo:"bar",nested:{husel:"pusel",bar:"baz",array:["5","6"],deep_nested:{husel:"pusel",bar:"baz",array:["5","6"]}},array:["1","2"]},new URL("https://example.com/?foo=bar&nested%5Bhusel%5D=pusel&nested%5Bbar%5D=baz&nested%5Barray%5D%5B0%5D=5&nested%5Barray%5D%5B1%5D=6&nested%5Bdeep_nested%5D%5Bhusel%5D=pusel&nested%5Bdeep_nested%5D%5Bbar%5D=baz&nested%5Bdeep_nested%5D%5Barray%5D%5B0%5D=5&nested%5Bdeep_nested%5D%5Barray%5D%5B1%5D=6&array%5B0%5D=1&array%5B1%5D=2")]}()){const[t,o,a]=e;it("with "+t,(()=>{new AjaxRequest("https://example.com/").withQueryArguments(o).get(),expect(window.fetch).toHaveBeenCalledWith(a,jasmine.objectContaining({method:"GET"}))}))}}))}));
\ No newline at end of file
-- 
GitLab