From 9940b64055a4e1c062a3b2acc6ac705196638104 Mon Sep 17 00:00:00 2001 From: Diego Fidalgo Date: Mon, 1 Nov 2021 17:29:57 -0300 Subject: [PATCH 1/6] Added abort support for web --- package-lock.json | 4 ++-- src/definitions.ts | 7 +++++++ src/request.ts | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 420c4f4e..fff75a89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@capacitor-community/http", - "version": "1.1.1", + "version": "1.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@capacitor-community/http", - "version": "1.1.1", + "version": "1.3.0", "license": "MIT", "dependencies": { "@capacitor/android": "^3.0.0", diff --git a/src/definitions.ts b/src/definitions.ts index 5d182981..c900e636 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -64,6 +64,13 @@ export interface HttpOptions { * (already encoded, azure/firebase testing, etc.). The default is _true_. */ shouldEncodeUrlParams?: boolean; + /** + * This is used to bind an AbortSignal to the request being made so it can be + * aborted by the AbortController + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortController + */ + signal?: AbortSignal; } export interface HttpParams { diff --git a/src/request.ts b/src/request.ts index 8d139f30..60ee36ad 100644 --- a/src/request.ts +++ b/src/request.ts @@ -68,6 +68,7 @@ export const buildRequestInit = ( const output: RequestInit = { method: options.method || 'GET', headers: options.headers, + signal: options.signal, ...extra, }; From 8bd54135058f3385b0ae1f3b59dbcd816defb952 Mon Sep 17 00:00:00 2001 From: Diego Fidalgo Date: Mon, 1 Nov 2021 18:00:37 -0300 Subject: [PATCH 2/6] Added example with aborted request --- example/package-lock.json | 3 +- example/server/server.mjs | 23 +++++---- example/src/components/app-home/app-home.tsx | 51 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/example/package-lock.json b/example/package-lock.json index 3466cfa9..374e3df8 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "example", "version": "0.0.1", "license": "MIT", "dependencies": { @@ -19,7 +20,7 @@ }, "..": { "name": "@capacitor-community/http", - "version": "1.0.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@capacitor/android": "^3.0.0", diff --git a/example/server/server.mjs b/example/server/server.mjs index 37404e77..774267b4 100644 --- a/example/server/server.mjs +++ b/example/server/server.mjs @@ -1,17 +1,17 @@ -import express from 'express' -import compression from 'compression' -import bodyParser from 'body-parser' -import cors from 'cors' -import cookieParser from 'cookie-parser' -import multer from 'multer' -import path from 'path' +import express from 'express'; +import compression from 'compression'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import multer from 'multer'; +import path from 'path'; // __dirname workaround for .mjs file import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); -const upload = multer({ dest: 'uploads/' }) +const upload = multer({ dest: 'uploads/' }); const staticPath = path.join(__dirname, '/public'); @@ -35,7 +35,7 @@ app.get('/get', (req, res) => { res.send(); }); -app.get('/get-gzip', compression({ filter: (req, res) => true, threshold: 1,}), (req, res) => { +app.get('/get-gzip', compression({ filter: (req, res) => true, threshold: 1 }), (req, res) => { const headers = req.headers; const params = req.query; console.log('Got headers', headers); @@ -69,6 +69,11 @@ app.get('/head', (req, res) => { res.send(); }); +app.get('/abortable', (req, res) => { + res.status(200); + setTimeout(() => res.send(''), 2000); +}); + app.delete('/delete', (req, res) => { const headers = req.headers; console.log('DELETE'); diff --git a/example/src/components/app-home/app-home.tsx b/example/src/components/app-home/app-home.tsx index e78c47ad..1d5328a0 100644 --- a/example/src/components/app-home/app-home.tsx +++ b/example/src/components/app-home/app-home.tsx @@ -11,6 +11,8 @@ export class AppHome { @State() output: string = ''; + @State() abortController: AbortController = null; + loading: HTMLIonLoadingElement; async get(path = '/get', method = 'GET') { @@ -43,8 +45,43 @@ export class AppHome { } } + async abortable() { + this.output = 'Requesting... This can be aborted'; + + this.abortController = new AbortController(); + + // This request shouldn't show the loading modal, since it blocks the + // user from clicking the "abort" button, which defeats the purpouse of + // this demo + + try { + const ret = await Http.request({ + method: 'GET', + url: this.apiUrl('/abortable'), + headers: { + 'X-Fake-Header': 'Max was here', + }, + params: { + size: ['XL', 'L', 'M', 'S', 'XS'], + music: 'cool', + }, + signal: this.abortController.signal, + }); + console.log('Got ret', ret); + this.output = JSON.stringify(ret, null, 2); + } catch (e) { + this.output = `Error: ${e.message}, ${e.platformMessage}`; + console.error(e); + } finally { + this.abortController = null; + } + } + getDefault = () => this.get(); + getAbortable = () => this.abortable(); + abort = () => this.abortController.abort(); + getGzip = () => this.get('/get-gzip'); getJson = () => this.get('/get-json'); getHtml = () => this.get('/get-html'); @@ -233,6 +270,18 @@ export class AppHome { }; render() { + const getAbortButton = () => { + if (this.abortController) { + return ( + + Abort + + ); + } + + return Get Abortable; + }; + return [ @@ -265,6 +314,8 @@ export class AppHome { Upload File Download File + {getAbortButton()} +

Output

{this.output}
, From e3f1cc6927b5795687794fb4078adebd357d35b3 Mon Sep 17 00:00:00 2001 From: Diego Fidalgo Date: Mon, 1 Nov 2021 18:25:53 -0300 Subject: [PATCH 3/6] Added support for Android --- android/.idea/misc.xml | 3 +- android/.idea/runConfigurations.xml | 10 --- android/build.gradle | 4 + .../com/getcapacitor/plugin/http/Http.java | 13 +++ .../plugin/http/HttpRequestHandler.java | 53 +++++++++++- src/index.ts | 9 +- src/native.ts | 86 +++++++++++++++++++ 7 files changed, 164 insertions(+), 14 deletions(-) delete mode 100644 android/.idea/runConfigurations.xml create mode 100644 src/native.ts diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml index 860da66a..54d5acd7 100644 --- a/android/.idea/misc.xml +++ b/android/.idea/misc.xml @@ -1,6 +1,7 @@ - + + diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml deleted file mode 100644 index 797acea5..00000000 --- a/android/.idea/runConfigurations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 35ab45d5..89f5cdaf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -34,6 +34,10 @@ android { lintOptions { abortOnError false } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } repositories { diff --git a/android/src/main/java/com/getcapacitor/plugin/http/Http.java b/android/src/main/java/com/getcapacitor/plugin/http/Http.java index c42ddfe3..cdeb58b6 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/Http.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/Http.java @@ -14,6 +14,7 @@ import java.net.HttpCookie; import java.net.MalformedURLException; import java.net.URI; +import org.json.JSONException; /** * Native HTTP Plugin @@ -129,6 +130,18 @@ public void del(final PluginCall call) { this.http(call, "DELETE"); } + @PluginMethod + public void __abortRequest(final PluginCall call) { + try { + Integer abortCode = call.getInt("abortCode"); + + HttpRequestHandler.abortRequest(abortCode); + } catch (Exception e) { + System.out.println(e.toString()); + call.reject(e.getClass().getSimpleName(), e); + } + } + @PluginMethod public void downloadFile(PluginCall call) { try { diff --git a/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java b/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java index 6c4f345b..e0044138 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java @@ -18,9 +18,11 @@ import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.MalformedURLException; +import java.net.SocketException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -30,6 +32,8 @@ public class HttpRequestHandler { + private static final HashMap abortMap = new HashMap<>(); + /** * An enum specifying conventional HTTP Response Types * See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType @@ -356,6 +360,17 @@ private static String readStreamAsString(InputStream in) throws IOException { * @throws JSONException thrown when the incoming JSON is malformed */ public static JSObject request(PluginCall call, String httpMethod) throws IOException, URISyntaxException, JSONException { + JSObject signal = call.getObject("signal"); + + Integer abortCode = signal.getInteger("abortCode"); + Boolean aborted = signal.getBoolean("aborted", false); + + // If the passed signal was already aborted, the request shouldn't be made. This ensures + // compatibility with the web fetch behaviour + if (aborted != null && aborted) { + throw new SocketException(); + } + String urlString = call.getString("url", ""); JSObject headers = call.getObject("headers"); JSObject params = call.getObject("params"); @@ -392,7 +407,43 @@ public static JSObject request(PluginCall call, String httpMethod) throws IOExce connection.connect(); - return buildResponse(connection, responseType); + if (abortCode != null) { + Runnable aborter = new Runnable() { + @Override + public void run() { + connection.getHttpConnection().disconnect(); + // Remove the aborter from memory to avoid leakage + abortMap.remove(abortCode); + } + }; + + abortMap.put(abortCode, aborter); + } + + JSObject response = buildResponse(connection, responseType); + + if (abortCode != null) { + // Remove the aborter from memory to avoid leakage + abortMap.remove(abortCode); + } + + return response; + } + + /** + * Aborts a request based on its abort code, which is generated on client side + * @param abortCode Abort code for identifying the proper abort function + * @throws IllegalArgumentException thrown when the abort code is invalid, e.g. when the + * request has already been aborted + */ + public static void abortRequest(Integer abortCode) throws IllegalArgumentException { + Runnable aborter = abortMap.get(abortCode); + + if (aborter == null) { + throw new IllegalArgumentException("Invalid abort code provided"); + } + + aborter.run(); } /** diff --git a/src/index.ts b/src/index.ts index 92166bde..48435eeb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,15 @@ -import { registerPlugin } from '@capacitor/core'; +import { Capacitor, registerPlugin } from '@capacitor/core'; +import { nativeWrap } from './native'; import type { HttpPlugin } from './definitions'; -const Http = registerPlugin('Http', { +let Http = registerPlugin('Http', { web: () => import('./web').then(m => new m.HttpWeb()), electron: () => import('./web').then(m => new m.HttpWeb()), }); +if (Capacitor.isNativePlatform()) { + Http = nativeWrap(Http); +} + export * from './definitions'; export { Http }; diff --git a/src/native.ts b/src/native.ts new file mode 100644 index 00000000..e1f8cd62 --- /dev/null +++ b/src/native.ts @@ -0,0 +1,86 @@ +import { Capacitor } from '@capacitor/core'; +import type { HttpPlugin, HttpOptions } from './definitions'; + +type HttpNativePlugin = HttpPlugin & { + __abortRequest(options: { abortCode: number }): Promise; +}; + +export function nativeWrap(Http: HttpPlugin) { + // Original proxy from the registerPlugin function + const nativePlugin = Http as HttpNativePlugin; + + // Unique id counter for the abortion codes + let abortCodeCounter = 0; + + const request = async (options: HttpOptions) => { + if (options.signal) { + if (Capacitor.getPlatform() === 'ios') { + throw new Error('Cancelation is not implemented on iOS'); + } + + const { signal } = options; + + const abortCode = ++abortCodeCounter; + + // Action to perform when AbortController.abort is called + const onAbort = () => { + nativePlugin.__abortRequest({ abortCode }); + + signal.removeEventListener('abort', onAbort); + }; + + signal.addEventListener('abort', onAbort); + + options = { + ...options, + // Since the original AbortSignal object is not serializable, + // we need to create our own and add the `abortCode` property + signal: { + abortCode, + aborted: signal.aborted, + } as any, + }; + } + + return nativePlugin.request(options); + }; + + const makeRequestFn = (method: string) => (options: HttpOptions) => { + return request({ + ...options, + method, + }); + }; + + const methods = ['get', 'post', 'put', 'patch', 'del'] as const; + + const requestFnsByMethod = methods.reduce((mapping, method) => { + mapping[method] = makeRequestFn(method); + + return mapping; + }, {} as Record); + + // Proxy wrapper around the original plugin object + return new Proxy({} as HttpPlugin, { + get(_, prop: keyof HttpNativePlugin) { + switch (prop) { + // By doing this, we prevent users from accessing this method + case '__abortRequest': + return undefined; + + case 'request': + return request; + + case 'get': + case 'post': + case 'put': + case 'patch': + case 'del': + return requestFnsByMethod[prop]; + + default: + return nativePlugin[prop]; + } + }, + }); +} From 2e95fefc0bf1a1d84efa06f906b66056fffd76be Mon Sep 17 00:00:00 2001 From: Diego Fidalgo Date: Thu, 4 Nov 2021 13:05:01 -0300 Subject: [PATCH 4/6] Improved plugin proxying mechanism --- src/native.ts | 61 ++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/src/native.ts b/src/native.ts index e1f8cd62..872c0fea 100644 --- a/src/native.ts +++ b/src/native.ts @@ -1,9 +1,11 @@ import { Capacitor } from '@capacitor/core'; -import type { HttpPlugin, HttpOptions } from './definitions'; +import type { HttpPlugin, HttpOptions, HttpResponse } from './definitions'; -type HttpNativePlugin = HttpPlugin & { +interface HttpNativePlugin extends HttpPlugin { __abortRequest(options: { abortCode: number }): Promise; -}; +} + +type RequestFn = HttpPlugin['request']; export function nativeWrap(Http: HttpPlugin) { // Original proxy from the registerPlugin function @@ -12,25 +14,21 @@ export function nativeWrap(Http: HttpPlugin) { // Unique id counter for the abortion codes let abortCodeCounter = 0; - const request = async (options: HttpOptions) => { - if (options.signal) { + const makeSignalProxy = + (requestFn: RequestFn) => async (options: HttpOptions) => { + if (!options.signal) { + // If a signal is not passed, we can just call the default request function + return requestFn(options); + } + if (Capacitor.getPlatform() === 'ios') { - throw new Error('Cancelation is not implemented on iOS'); + throw new Error('Request cancelation is not implemented on iOS'); } const { signal } = options; const abortCode = ++abortCodeCounter; - // Action to perform when AbortController.abort is called - const onAbort = () => { - nativePlugin.__abortRequest({ abortCode }); - - signal.removeEventListener('abort', onAbort); - }; - - signal.addEventListener('abort', onAbort); - options = { ...options, // Since the original AbortSignal object is not serializable, @@ -40,25 +38,24 @@ export function nativeWrap(Http: HttpPlugin) { aborted: signal.aborted, } as any, }; - } - return nativePlugin.request(options); - }; + const onAbort = () => nativePlugin.__abortRequest({ abortCode }); - const makeRequestFn = (method: string) => (options: HttpOptions) => { - return request({ - ...options, - method, - }); - }; + signal.addEventListener('abort', onAbort); - const methods = ['get', 'post', 'put', 'patch', 'del'] as const; + let response: HttpResponse; - const requestFnsByMethod = methods.reduce((mapping, method) => { - mapping[method] = makeRequestFn(method); + try { + response = await requestFn(options); + } finally { + // The event listener must be removed regardless of the result + signal.removeEventListener('abort', onAbort); + } + + return response; + }; - return mapping; - }, {} as Record); + const requestFnsByMethod: Record = {}; // Proxy wrapper around the original plugin object return new Proxy({} as HttpPlugin, { @@ -69,14 +66,14 @@ export function nativeWrap(Http: HttpPlugin) { return undefined; case 'request': - return request; - case 'get': case 'post': case 'put': case 'patch': case 'del': - return requestFnsByMethod[prop]; + return (requestFnsByMethod[prop] ||= makeSignalProxy( + nativePlugin[prop], + )); default: return nativePlugin[prop]; From 46802040b329f93ae09861764d383c6d8b74e5b5 Mon Sep 17 00:00:00 2001 From: Diego Fidalgo Date: Wed, 10 Nov 2021 13:00:04 -0300 Subject: [PATCH 5/6] fix: __abortRequest call not being resolved --- android/src/main/java/com/getcapacitor/plugin/http/Http.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/getcapacitor/plugin/http/Http.java b/android/src/main/java/com/getcapacitor/plugin/http/Http.java index cdeb58b6..8b2d4d53 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/Http.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/Http.java @@ -14,7 +14,6 @@ import java.net.HttpCookie; import java.net.MalformedURLException; import java.net.URI; -import org.json.JSONException; /** * Native HTTP Plugin @@ -136,6 +135,8 @@ public void __abortRequest(final PluginCall call) { Integer abortCode = call.getInt("abortCode"); HttpRequestHandler.abortRequest(abortCode); + + call.resolve(); } catch (Exception e) { System.out.println(e.toString()); call.reject(e.getClass().getSimpleName(), e); From 6ffee2f4ac1e19eeb4f22f1b5e63dec74b789475 Mon Sep 17 00:00:00 2001 From: Diego Fidalgo <39748697+diegood12@users.noreply.github.com> Date: Wed, 10 Nov 2021 13:58:55 -0300 Subject: [PATCH 6/6] Re-added @PluginMethod decorator to __abortRequest --- android/src/main/java/com/getcapacitor/plugin/http/Http.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/getcapacitor/plugin/http/Http.java b/android/src/main/java/com/getcapacitor/plugin/http/Http.java index 861b58f1..cf62a416 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/Http.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/Http.java @@ -129,6 +129,7 @@ public void del(final PluginCall call) { this.http(call, "DELETE"); } + @PluginMethod public void __abortRequest(final PluginCall call) { try { Integer abortCode = call.getInt("abortCode"); @@ -143,7 +144,7 @@ public void __abortRequest(final PluginCall call) { } @PluginMethod - public void downloadFile(PluginCall call) { + public void downloadFile(final PluginCall call) { try { bridge.saveCall(call); String fileDirectory = call.getString("fileDirectory", FilesystemUtils.DIRECTORY_DOCUMENTS);