From e2416fa3eb90ab4acefe04e4a87a0b6e4eaf3257 Mon Sep 17 00:00:00 2001 From: Andreas Fernandez 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 Tested-by: Benni Mack Tested-by: Garvin Hicking Reviewed-by: Andreas Fernandez Tested-by: Andreas Fernandez Reviewed-by: Garvin Hicking Reviewed-by: Benni Mack --- .../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 | GenericKeyValue): AjaxRequest { + public withQueryArguments(data: string | Array | 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 => { + (new AjaxRequest(new URL('https://example.com'))).get().then(async (response: AjaxResponse): Promise => { 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