Google Calendar API - Apps Script
Considering how long they've been around, working with iCal's in the browser is surprisingly difficult. When working with data from Google Calendar, we can use Google's Apps Script to create an API, that will let us pull exactly what we desire down from a Google Calendar.
There is actually a great JavaScript module called ical.js, but as I write this in June 2024 it seems to be broken. At first I thought it may be that Google had decided not to play nice, and changed their iCal format, but upon testing other ics files from other services, I was getting the same errors from the module I had built from source, as well as on various iCal JS validators online. So hopefully this reaches someone in a similar situation that I found myself in.
Use Case
A good static website should require as little work as possible, and that work should be as accessible as possible. I was making a static website, intended to show upcoming events for a youth group. Youth workers aren't necessarily techno-literate, and probably don't have a lot of time to do that kind of stuff anyway. So, I decided that the website should just update itself via a Google Calendar - easy! Or at least that's what I thought.
Aside from iCalJS, which I've already mentioned, the only really easy option here, at least to my knowledge, is to use an iFrame which Google provides in it's calendar settings and sharing options. But those things are ugly. Butt ugly. I've used Apps Script in the past to serve a web app designed just to do some very basic bookings management using a Google Sheet as the backend - roll your eyes if you must, but it worked and best of all it was free. So if you can serve HTML via Google's Apps Script service, then you should be able to serve some json data too, right?
Serving JSON
You can serve JSON using Google's Apps Script service. But, you can only access it in a Chromium type browser - it just won't work in firefox, and CORS makes it virtually impossible to work with in a static site.
To serve HTML web apps or JSON, you need to use the Apps Script Content Service. They do also have a third option in that, which is called JSONP. You're not alone if you've never heard of that, I hadn't either. It stands for JSON with Padding, and wikipedia describes it as
.. a historical JavaScript technique for requesting data
by loading a <script>
element, which is
an element intended to load ordinary javascript.
That sounds both fun and dangerous! Sensibly, Google issues a warning for when working with JSONP:
Warning: Be very careful when using this JSONP technique in your scripts. Because it's possible for anyone to embed the script tag in their web page, you can be tricked into executing the script when you visit a malicious website, which can then capture the data returned. Best practice is to ensure that JSONP scripts are read-only and only return non-sensitive information.
The script I am making will only be returning GET requests with information that is on a public Google calendar, so for me at least, it's safe to go on..
Creating the API
To begin, in your Google Drive, select New, then More, and if Google Apps Script isn't already on that menu, you will need to choose Connect More Apps and choose Apps Script from there.
To use the Apps Script Content Service, you need a
function called doGet
, which will send the
data we want to receive from our API as JSON content.
That function needs to return a
ContentService
class instance, with the
method createTextOutput
specifying the
JSON data we wish to return, here being our res
(or response
) object. And finally, we need
to also invoke the setMimeType
method,
and set it to JAVASCRIPT, so it returns as JSONP, instead
of JSON.
const doGet = () => {
const holidayHoursCalID = '<calendar-id>@group.calendar.google.com';
const eventsCalID = '<calendar-id>@group.calendar.google.com';
const res = {
'data': {
holidayHours: getEvents(holidayHoursCalID),
events: getEvents(eventsCalID),
}
};
return ContentService.createTextOutput(JSON.stringify(res))
.setMimeType(ContentService.MimeType.JAVASCRIPT);
};
The code I am using to get the calendar info to populate
the response
object in doGet
simply takes each calendar's ID as a parameter, defines
a start datetime (now), and an end (+1 year), and collects
and maps the start, end, title and description from
each calendar entry that is returned.
const getEvents = id => {
const cal = CalendarApp.getCalendarById(id);
const start = new Date();
const end = new Date();
end.setFullYear(end.getFullYear() + 1)
const calData = cal.getEvents(start, end).map(i => ({
start: i.getStartTime(),
end: i.getEndTime(),
title: i.getTitle(),
description: i.getDescription()
}));
return calData;
};
Next, we need to deploy the web app. Deploy it as a Web App, not as API executable. You will also need to set the access permissions to Anyone for it to be accessible wherever you want to call it from. It will provide you with a web app URL, that if you now enter that into your browser, should send you back your JSON data, assuming everyting's set up and working correctly. In my case, I receive something like this:
"data": {
"holidayHours":[
{
"start":"2024-07-24T23:00:00.000Z",
"end":"2024-08-30T23:00:00.000Z",
"title":"Summer Holiday",
"description":"Open 12 - 6pm everyday (except bank holidays)"
},
{
"start":"2024-08-25T23:00:00.000Z",
"end":"2024-08-26T23:00:00.000Z",
"title":"Bank holiday",
"description":"CLOSED"
}
],
"events":[
{
"start":"2024-06-20T23:00:00.000Z",
"end":"2024-06-21T23:00:00.000Z",
"title":"Pride event",
"description":"An art workshop for Pride!"
},
{
"start":"2024-07-06T15:00:00.000Z",
"end":"2024-07-06T17:00:00.000Z",
"title":"CLT event",
"description":"A community art project:\n\"Postcards From Our Future\""
}
]
}
Calling the API From a Webpage
So this method is free. It works, but you get what you pay for, and we can't always be sure it will work every time that it's called. There are limits on how many Apps Script calls can be made, so if the tik tok web crawler takes an interest in the web page this is being called from, things are going to break. With this in mind, I opted to use the (very ugly) iFrames as a fallback, in case for whatever reason the API call is unable to fetch data I need. It is likely that I will monitor the situation with iCalJS, and if that ends up working at some point, that will become my first port of call, with this Apps Script API becoming a fallback for that, and the iFrames becoming a final fallback if all else fails.
I'm not going to go through the in's and out's of formatting Date objects and manipulating the DOM, so you will have to design your own calendar entry cards or calendar table or however you want to display the data. Instead, I will just look at calling the API itself, and having the iFrames as a fallback.
I'm using an async function, to call the fetch
method on the URL provided for the Apps Script API that I've
set up. Apps Script calls work by being redirected by the
content service itself, so be sure to include
redirect: 'follow'
as an option.
const getEvents = async () => {
const res = await fetch(
'https://script.google.com/macros/s/<script-url>/exec',
{
redirect: 'follow'
}
);
const { data } = await res.json();
return data;
}
Finally, we just need to call that getEvents
function that returns the JSON data, and do something
with the data. I am only logging to the console in this
example. There are checks to see if there are events or not,
and then it is wrapped in a try
statement, so
if the API call fails, then the iFrames can be initiated
within the catch
statement. This entire
process gets initiated within a DOMContentLoaded
event listener, which, in my opinion, is good practise most
of the time when working with the DOM in JavaScript.
const populateLists = async () => {
try {
const data = await getEvents();
const { events, holidayHours } = data;
if (events.length) {
events.forEach(event => {
console.log(event);
});
} else {
console.log('No events information available');
};
if (holidayHours.length) {
holidayHours.forEach(event => {
console.log(event);
});
} else {
console.log('No holiday information available');
};
} catch (e) {
const eventFrame = `<iframe>COPY AND PASTED FROM GOOGLE</iframe>`;
const termDateFrame = `<iframe>COPY AND PASTED FROM GOOGLE</iframe>`;
eventList.innerHTML = eventFrame;
termDatesList.innerHTML = termDateFrame;
}
};
document.addEventListener('DOMContentLoaded', populateLists)
Hiding your script URL
Having your Apps Script url in your JavaScript file on a static website leaves it in plain view of anyone who knows how to get to it. To be able to use it in a website, we have had to allow access to it by anyone, so it is potentially open to being spammed by anyone who just plain doesn't like you. But, certain static website hosting providers, do provide serverless functions, which can allow us to call the script URL discreetly from a psuedo backend. I will be using Netlify. Although there is still an API link in our frontend JavaScript that could still potentially be spammed, it is a Netlify link, rather than a Google macro, which will give us a little extra in terms of load balancing and spamming protection. Other providers often offer Lambda Functions within their services, much to the same affect as Netlify's functions.
To begin, we will need to create a .env
file in the root of our webpage directory - but don't
worry, we don't need the dotenv node module here, so
no need to initialise a node project - it is however
worth running npm i -g netlify-cli
for
testing purposes. In the
.env
file, we do the usual, and list
our secret with a key and value.
APPS_SCRIPT="https://script.google.com/macros/s/<script-url>/exec"
In your Netlify project, you will also set this as an
environment variable. Now, to create the netlify
function, we need to create a netlify
directory, and a functions
directory
within that. Any JavaScript files within the
functions
directory will be treated as
a backend API url.
/
index.html
assets/
...
netlify/
functions/
api.js
For our API function, we just need to export one
default function, that takes request
and context
parameters, and returns
a Response
instance. This will be viewable
if your project repo is public, but by using the
Netlify.env.get()
method, we can collect
our secret from the dotenv file in development, and
from the Netlify environment variable we've already
set up while in production.
export default async (req, context) => {
const api = Netlify.env.get('APPS_SCRIPT');
const res = await fetch(api, {
redirect: 'follow'
});
const { data } = await res.json();
return new Response(JSON.stringify(data));
};
Now that we have done that, we just need to update the api
call in our app.js
file on the frontend
to reflect the changes. URL's for netlify functions
read as
<project-url>/.netlify/functions/<file-name>
.
Note that the .js
file extension is not
included in the url. Once that is completed, just
push the project, and you're done!
const getEvents = async () => {
const res = await fetch('/.netlify/functions/api');
const data = await res.json();
return data;
}
A Quick Word About Google
I'm not here to say Google is good or bad, it offers amazing services - a lot of them for free. But it is a very big entity. There's loads of amazing developers there that clearly have a passion for delivering fantastic products and services for people. And then there's also, well, all the other stuff. But politics aside, you ought to know that as an entity, Google can be fickle, and a service that exists today, could cease to exist tomorrow. There's even been some really popular and succesful services that have just been cut, seemingly overnight and for no reason - check out the Google Graveyard at Killed by Google to see for yourself. So while they can offer great solutions to those of us working on a tight budget, it's important to recognise that they are under no obligation to do so, and that we won't always have the same solutions available to us tomorrow.