Google Apps Script Async function execution on Server side - google-apps-script

I have a GMail add-on which uses CardService for UI. Some of the callback functions for the Card actions take over than 30 sec. Thus, I'm getting the following error.
Gmail could not perform this add-on action.
Is there, any way to run Google Apps Script functions on the Server side asynchronous way, so I can return to a user some notification and continue work behind the scenes.
I have tried using some libraries like this one but with no luck, I'm able to use syntactically the Promises but functionally it's still synchronous.

As of now, there is no asynchronous execution for the Gmail add-on. Even if there is something, there is no way to refresh the UI without user action.
But, there is a hack. What you can do is, if there is a long running process is there, just create a "openlink" action (set link), which should open a url (https://yourhtmlpageurl?redirect_uri=) with html response. This html can have jquery ajax call, which can wait for some time. Once you get the response in the html window, redirect the page to the redirect_uri that is passed by passing the response data. So, our add on will get a callback to the function with parameter as json object with all the query parameters to the redirect_uri. Once you get the expected response, cache the response by using CacheService. return some html success template, which should automatically close the window by itself.
For Creating openlink action:
For generating redirect script URI with state token:
function generateNewStateToken(callbackName, user_info) {
return ScriptApp.newStateToken()
.withMethod(callbackName)
.withArgument("user_info", JSON.stringify(user_info))
.withTimeout(3600)
.createToken();
}
function getRedirectURI() {
return "https://script.google.com/macros/d/" + ScriptApp.getScriptId() + "/usercallback";
}
var htmlUrl = <your_html_url> + "?redirect_uri="+ getRedirectURI() + "&state=" + generateNewStateToken("asyncCallback", {});
CardService.newOpenLink().setUrl(htmlUrl).setOpenAs(CardService.OpenAs.OVERLAY).setOnClose(CardService.OnClose.RELOAD_ADD_ON);
function asyncCallback(data) {
data.response; // response which you can pass from script
CacheService.getUserCache().put("long_running_process_resp", data.response);
data.user_info; // user_info passed during state token creation
// cache the data using cache service
return HtmlService.createHtmlOutputFromFile("success");
}
success.html:
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons1.css">
</head>
<body>
<div class="sidebar">
<p>Long running Process completed.</p>
</div>
</body>
<script>
setTimeout(function() {
top.window.close();
}, 2000);
</script>
</html>
Once the success.html is closed by itself, there will be a refresh of the gmail add on. So, you can lookup for the long running response data from CacheService.
Let me know if you have any more questions on this process.

Sabbu's answer is pretty good. But, if you want to trigger the long running process without expecting the user to click on the openlink, you can render an image using:
CardService.newImage().setImageUrl('https://yourservice.com/pixel/action.png?name=value&_nocache='+new Date().getTime())
On the server side, you can map the path '/pixel/action.png' to the long running code which returns a tiny transparent pixel (1x1) on complete:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==

Related

Why is doGet() failing without posting logs?

I suppose my question is twofold: doGet() in the following context will just fail after 0.1~0.2 seconds without posting logs, so I have no idea how to troubleshoot it by myself. Additionally, if I'm having the script execute on my behalf, do I have to push a request with my authorization token to a more "pertinent" area than just the sheet name, such as within the iteration itself? Read further for more details:
I have a source spreadsheet where I am cross-referencing user inputted data to validate the information we have "on file". Most of our clients are over the age of 55, so I am trying to reduce end-user complexity by having the script run on my behalf whenever they need to use it (to bypass the Authorization screen, with the big scary "This application could be unsafe!" message). The way I've read to accomplish this seems to be with doGet(), so I set up a low-level HTTP Get request that just pushes a doGet() with my OAuth token, returning the sheet name. I also set up a masking function specifically to do this, and linked it to the button originally used for the iteration logic. The doGet() looks like this:
const doGet = e => {
Logger.log(`Recieved HTTP request.`);
const content = ContentService.createTextOutput(iterator(e));
Logger.log(content);
return content;
}
and the button that uses UrlFetchApp looks like:
const runMask = () => {
const active = SpreadsheetApp.getActiveSheet().getSheetName();
const v4 = 'https://script.google.com/macros/s/<scriptid>/dev' // ScriptApp.getService().getUrl() posts 404
UrlFetchApp.fetch(`${v4}?sheetName='${active}'`, {
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
});
I have some logs set up within the real runMask() that proceed all the way to the end of the program, giving me real URLs and OAuth tokens, so I know it's making it through runMask() without an issue. However, the doGet() log doesn't post anything, even at the top of the function. I can see that it's executing the trigger in my execution log, but the log itself remains empty.
I've tried:
using ScriptApp.getService().getUrl() in place of v4: posts 404 in the log w/ truncated server response
replacing ${active} with the name of the sheet: same issue; logging ${active} also returns the correct name of the sheet.
Beyond this, I'm not even sure what to do. I have everything scoped correctly (auth/spreadsheets.currentonly, auth/script.external_request, and auth/userinfo.email), and I have no issues about operational security (as both the spreadsheet and script are written by me, the clients have no need to grant access to their entire drive). Before trying to implement doGet() and bypass the authorization screen, the iterator itself worked just fine. As such, I have chosen not to include it here, as it's hardly relevant (the function that executes the iteration function never makes it to that point).
I understand this has been quite the deluge of information; I'd be happy to provide more information or context as needed.
Getting ReferenceError: iterator is not defined (line 12, file "ag2")
With this:
const doGet = e => {
Logger.log(`Recieved HTTP request.`);
const content = ContentService.createTextOutput(iterator(e));
Logger.log(content);
return content;
}
Issued with url/exec?option=A
It runs with
const doGet = e => {
Logger.log(`Recieved HTTP request.`);
const content = ContentService.createTextOutput(JSON.stringify(e));
Logger.log(content);
return content;
}
and returns the appropriate stringified object
Only use the test URL (/dev) for testing the web app from a web browser.
Before doGet from a web browser using a versioned deployment (/exec) remember to publish a new version.
Assign a Google Cloud Project to your Google Apps Script project. For details see https://developers.google.com/apps-script/guides/cloud-platform-projects.
To make it easier to debug your avoid calling functions from a Google Apps Script method like createTextOutput, instead, assign the function result to a variable and use it as the method parameter, i.e. replace
const content = ContentService.createTextOutput(iterator(e));
by
const something = iterator(e);
const content = ContentService.createTextOutput(something);
For debugging purposes, create a function to call your doGet function, and check that it hasn't any problem to run, i.e.
function __test__doGet(){
const e = {
parameter: {}
}
doGet(e);
}
Related
Exception handling in google apps script web apps
Issue:
When I saw your question, I'm worried about I have everything scoped correctly (auth/spreadsheets.currentonly, auth/script.external_request, and auth/userinfo.email).
If you are using only the following scopes at oauthScopes of appsscript.json,
https://www.googleapis.com/auth/spreadsheets.currentonly
https://www.googleapis.com/auth/script.external_request
https://www.googleapis.com/auth/userinfo.email
Unfortunately, these scopes cannot be used for access to Web Apps. Although I'm not sure about the method for running your function of runMask, I thought that this might be the reason for your issue.
Solution:
If you want to access Web Apps of https://script.google.com/macros/s/<scriptid>/dev using the access token retrieved by ScriptApp.getOAuthToken(), please include the following scope.
https://www.googleapis.com/auth/drive.readonly
or
https://www.googleapis.com/auth/drive
After you include the above scope, please reauthorize the scopes, and test it again. When your function of iterator has already been declared and the script worked, by running runMask, you can see the log of Logger.log(Recieved HTTP request.) and Logger.log(content) at the log.
Reference:
Taking advantage of Web Apps with Google Apps Script

Building a card and updating it after fetching data in Google Apps Script

I am trying to build a Gmail addon which includes 2 external API calls. The first one is fast (~200ms) and the second one is slow (~5s). Because of this I would like to first build the card with the results of the first fetch, and then update the card after the second call finishes.
Would it be possible to either:
Call fetchAll and build and render the card each time a request finishes
Trigger a function after the initial rendering is done (after return card.build())
Update the root card without returning it (I tried CardService.newNavigation().popToRoot().updateCard(card.build()) without success)
Any preferred way to render a card and then update it after data is fetched would be appreciated!
Below is an example function if useful.
function onGmailMessage(e) {
// Fetching email
var messageId = e.gmail.messageId;
var accessToken = e.gmail.accessToken;
GmailApp.setCurrentMessageAccessToken(accessToken);
var message = GmailApp.getMessageById(messageId);
// Preparing requests
var data = {
'text': message.getPlainBody(),
};
var options = {
'method' : 'post',
'contentType': 'application/json',
'payload' : JSON.stringify(data)
};
// Fetching responses. Here I would love to first display
// createCard(response_1) and then when the second call finishes
// return createCard(response_1 + '/n' + response_2)
var response_1 = UrlFetchApp.fetch('http://API_1/', options);
var response_2 = UrlFetchApp.fetch('http://API_2/', options);
return createCard(response_1 + '/n' + response_2);
Answer:
Unfortunately, this is not possible to do.
More Information:
This is a bit tricky so I'll split this answer down into your three points:
[Is it possible to] call fetchAll and build and render the card each time a request finishes?
A fetchAll function could be made to get all API responses, but you'll still end up waiting for API 2 to respond before updating what can be seen in the card.
The problem with this is that in order to display the rendered card, you need to make a return of some kind. Once you return the response of the first API your second API won't be made at all as the function will have already executed. Which leads onto point two:
[Is it possible to] trigger a function after the initial rendering is done (after return card.build())
I did a test with this, instead of returning API 1's response directly I stored its value in a Script Property and made a trigger execute 200 ms later with the call to API 2:
function onGmailMessage(e) {
// previous code
var response_1 = UrlFetchApp.fetch('http://API_1/', options);
ScriptApp.newTrigger("getSecondResponse").timeBased().after(200).create();
PropertiesService.getScriptProperties().setProperty('response1', response_1);
return createCard(response_1);
}
function getSecondResponse() {
// options 2 definition here;
var response_1 = PropertiesService.getScriptProperties().getProperty("response1");
var response_2 = UrlFetchApp.fetch('http://API_2/', options);
return createCard(response_1 + '/n' + response_2);
}
and adding the correct scopes in the manifest:
{
"oauthScopes": [
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/script.locale",
"https://www.googleapis.com/auth/gmail.addons.current.action.compose",
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://mail.google.com/",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
And which this did call the first API, display the response in the card and make the trigger, the card didn't update. I presume this is because the trigger acts as a cron job being executed from somewhere which isn't the add-on itself, so the second card return is never seen in the UI.
[Is it possible to] update the root card without returning it (I tried CardService.newNavigation().popToRoot().updateCard(card.build()) without success)
updateCard() is a method of the Navigation class. There's a whole page in the documentation which details the uses of Card navigation but the important parts to take away here is that the navigation methods are used in response to user interaction. From the documentation:
If a user interaction or event should result in re-rendering cards in the same context, use Navigation.pushCard(), Navigation.popCard(), and Navigation.updateCard() methods to replace the existing cards.
The following are navigation examples:
If an interaction or event changes the state of the current card (for example, adding a task to a task list), use updateCard().
If an interaction or event provides further detail or prompts the user for further action (for example, clicking an item's title to see more details, or pressing a button to create a new Calendar event), use pushCard() to show the new page while allowing the user to exit the new page using the back-button.
If an interaction or event updates state in a previous card (for example, updating an item's title from with the detail view), use something like popCard(), popCard(), pushCard(previous), and pushCard(current) to update previous card and the current card.
You can create multiple cards which have different content - for example one which contains response_1 and one which contains response_1 + "\n" + response_2, but some kind of interaction from a user is still needed to switch between the two views, and it won't get around the wait time you need to get a response from API 2.
Feature Request:
You can however let Google know that this is a feature that is important and that you would like to request they implement it. Google's Issue Tracker is a place for developers to report issues and make feature requests for their development services. I would suggest using the feature request template for G Suite Add-ons for this, rather than Apps Script directly.
References:
Class Navigation | Apps Script | Google Developers
Card navigation | G Suite Add-ons | Google Developers

Service Worker not caching API content on first load

I've created a service worker enabled application that is intended to cache the response from an AJAX call so it's viewable offline. The issue I'm running into is that the service worker caches the page, but not the AJAX response the first time it's loaded.
If you visit http://ivesjames.github.io/pwa and switch to airplane mode after the SW toast it shows no API content. If you go back online and load the page and do it again it will load the API content offline on the second load.
This is what I'm using to cache the API response (Taken via the Polymer docs):
(function(global) {
global.untappdFetchHandler = function(request) {
// Attempt to fetch(request). This will always make a network request, and will include the
// full request URL, including the search parameters.
return global.fetch(request).then(function(response) {
if (response.ok) {
// If we got back a successful response, great!
return global.caches.open(global.toolbox.options.cacheName).then(function(cache) {
// First, store the response in the cache, stripping away the search parameters to
// normalize the URL key.
return cache.put(stripSearchParameters(request.url), response.clone()).then(function() {
// Once that entry is written to the cache, return the response to the controlled page.
return response;
});
});
}
// If we got back an error response, raise a new Error, which will trigger the catch().
throw new Error('A response with an error status code was returned.');
}).catch(function(error) {
// This code is executed when there's either a network error or a response with an error
// status code was returned.
return global.caches.open(global.toolbox.options.cacheName).then(function(cache) {
// Normalize the request URL by stripping the search parameters, and then return a
// previously cached response as a fallback.
return cache.match(stripSearchParameters(request.url));
});
});
}
})(self);
And then I define the handler in the sw-import:
<platinum-sw-import-script href="scripts/untappd-fetch-handler.js">
<platinum-sw-fetch handler="untappdFetchHandler"
path="/v4/user/checkins/jimouk?client_id=(apikey)&client_secret=(clientsecret)"
origin="https://api.untappd.com">
</platinum-sw-fetch>
<paper-toast id="caching-complete"
duration="6000"
text="Caching complete! This app will work offline.">
</paper-toast>
<platinum-sw-register auto-register
clients-claim
skip-waiting
base-uri="bower_components/platinum-sw/bootstrap"
on-service-worker-installed="displayInstalledToast">
<platinum-sw-cache default-cache-strategy="fastest"
cache-config-file="cache-config.json">
</platinum-sw-cache>
</platinum-sw-register>
Is there somewhere I'm going wrong? I'm not quite sure why it works on load #2 instead of load #1.
Any help would be appreciated.
While the skip-waiting + clients-claim attributes should cause your service worker to take control as soon as possible, it's still an asynchronous process that might not kick in until after your AJAX request is made. If you want to guarantee that the service worker will be in control of the page, then you'd need to either delay your AJAX request until the service worker has taken control (following, e.g., this technique), or alternatively, you can use the reload-on-install attribute.
Equally important, though, make sure that your <platinum-sw-import-script> and <platinum-sw-fetch> elements are children of your <platinum-sw-register> element, or else they won't have the intended effect. This is called out in the documentation, but unfortunately it's just a silent failure at runtime.

Accessing data from local storage by content scripts

I'm trying to access local storage from content scripts but even though the message passing is working, the output isn't as expected.
CONTENT SCRIPT
var varproxy = localStorage.getItem('proxy'); //gets data from options page saved to local storage
var proxy = "proxystring";
chrome.runtime.sendMessage({message:"hey"},
function(response) {
proxy = response.proxy;
console.log(response.proxy);
}
);
console.log(proxy);
BACKGROUND PAGE (For message passing)
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse)
{
if (request.message == "hey")
{
sendResponse({proxy: varproxy});
console.log('response sent');
}
else
{sendResponse({});}
});
The console logs the proxy as the value of varproxy and also "response sent" but the
console.log(proxy);
logs the proxy as "proxystring"
Why isn't the value of proxy getting changed? How do I change it as required?
Message sending -- among lots of chrome API function -- is an asynchronous function. The interpreter won't wait for the response, but jumps to the next line. So it can easily happen that log(proxy) will be evaluated first, since communicating with the background page takes some time. As soon as the response is received, the value of proxy changes.
Might I recommend you try out another implementation? What about Chrome Storage?
Then you don't need any message passing at all, because you can access chrome storage within content scripts.
Example, this is something I do in my extensions' content script to grab several values from chrome storage:
chrome.storage.sync.get({HFF_toolbar: 'yes',HFF_logging: 'yes',HFF_timer: '1 Minute'},
function (obj) {
toolbar_option = obj.HFF_toolbar;
logging_option = obj.HFF_logging;
timer_option = obj.HFF_timer;
/* the rest of my content script, using those options */
I personally found this approach much easier, for my purposes anyway, than message passing implementations.

Google Maps differences between async loading and general script tag

In an attempt to load google maps asynchronously I took a look at google's async page
Essentially I am looking for an alternative to document.write in the API and according to some users on this google group post Using the async version will handle this scenario.
My question is why would this script:
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&sensor=SET_TO_TRUE_OR_FALSE"
type="text/javascript"></script>
Be any different than:
var script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&sensor=TRUE_OR_FALSE&callback=initialize";
document.body.appendChild(script);
when the first and second both call the same js file which obviously has the document.write within it? Also why would an updated API want to consider using document.write over append if write generally goes against content security policy?
As a little background info I'm experimenting with Google's packaged apps and their csp doesn't allow for document.write.
One of the main advantage of loading scripts (or other resources) asynchronously/dynamically is that it can dramatically speed up your page load times.
From Google's Developer best practices:
https://developers.google.com/speed/docs/best-practices/rtt#PreferAsyncResources
When a browser parses a traditional script tag, it must wait for the
script to download, parse, and execute before rendering any HTML that
comes after it. With an asynchronous script, however, the browser can
continue parsing and rendering HTML that comes after the async script,
without waiting for that script to complete. When a script is loaded
asynchronously, it is fetched as soon as possible, but its execution
is deferred until the browser's UI thread is not busy doing something
else, such as rendering the web page.
Another trick I use to decide on whether or not to load a script (such as the Google Maps API) asynchronously is, I ask myself, "Is there a chance that the user will not see, benefit or interact with the results of the loaded script?". If the answer is yes, then I'll usually tie the loading of the script to some DOM event (such as button click etc).
In other words, if a user has to click a button on my web page to view my Google Map; why bother loading all that extra script if there's a chance the user will never even see it? Instead, load the script asynchronously when a button is clicked, and then load my map.
Actually the javascript file at maps.googleapis.com/maps/api/js is a dynamic one. The server responds with a different js file for different parameters. To know the difference, just load the following files from a browser address bar.
https://maps.googleapis.com/maps/api/js?v=3.exp
and
https://maps.googleapis.com/maps/api/js?v=3.exp&callback=initialize
You will notice that there is a "document.write" in the first js file as quoted below
function getScript(src) {
document.write('<' + 'script src="' + src + '"' +
' type="text/javascript"><' + '/script>');
}
whereas there is a document.createElement in the second case as follows
function getScript(src) {
var s = document.createElement('script');
s.src = src;
document.body.appendChild(s);
}
The difference is that, when a script is loaded synchronously, the browser waits for it to load completely and when the script calls document.write, the text is appended to the document being loaded. But asynchronous call is made once a document is fully loaded. As a result document.write would replace the existing document, and hence the browser ignores such a call from an asynchronously loaded script. When you load the js with "callback=initialize", the self executing function already contains the call back to initialize, and a modified function which can load further scripts asynchronously.
All you have to do is set a callback to be executed after the map script loads:
Then in your app's main .js file, define the callback:
window.myCallbackFuction = function() {
return console.log("Loaded Google Maps!");
// the rest of the maps initialization goes here, per the docs
};
The tricky part is refactoring your code so that any map-related code isn't executed until you're certain that the myCallbacFuction() was executed.
There is an example on how to load Google maps asynchronously. Basic idea is to create a script tag like you did and let this function be executed onload.