Creating an iCal feed with Go

This post will show a short example of how to create an .ics feed from a Go web server. The data source will be a fake REST endpoint created with mocky.

The data will also be cached in memory, so we don’t have to call the API all the time and are able to provide fast response times.

To create the feed itself, the goics library will be used, which helps with the encoding.

There will be two endpoints on the server:

With all that out of the way, let’s get to it!

Implementation

First off, let’s take a look at our data source. We will use a fake-JSON response from mocky and the JSON looks like this:

[
    {
        "dateStart": "2018-02-01T13:30:01+00:00",
        "dateEnd": "2018-02-01T12:30:01+00:00",
        "description": "dentist"
    },
    {
        "dateStart": "2018-02-02T13:21:01+00:00",
        "dateEnd": "2018-02-02T15:51:01+00:00",
        "description": "gym"
    },
    {
        "dateStart": "2018-02-09T13:21:01+00:00",
        "dateEnd": "2018-02-09T14:21:01+00:00",
        "description": "meeting"
    },
    {
        "dateStart": "2018-02-09T15:21:01+00:00",
        "dateEnd": "2018-02-09T17:41:01+00:00",
        "description": "cooking class"
    },
    {
        "dateStart": "2018-02-11T13:21:01+00:00",
        "dateEnd": "2018-02-11T18:21:01+00:00",
        "description": "gym"
    },
    {
        "dateStart": "2018-02-12T13:21:01+00:00",
        "dateEnd": "2018-02-12T15:21:01+00:00",
        "description": "shopping"
    },
    {
        "dateStart": "2018-02-14T19:00:01+00:00",
        "dateEnd": "2018-02-14T21:00:01+00:00",
        "description": "valentines"
    }
]

To keep things simple, the data is already well formed and our aim will be to create calendar-entries with the description as the title.

With this data, we can create our data model. An Entry struct for parsing the JSON and a Feed struct as a container for caching a created feed should suffice:

// Feed is an iCal feed
type Feed struct {
    Content   string
    ExpiresAt time.Time
}

// Entry is a time entry
type Entry struct {
    DateStart   time.Time `json:"dateStart"`
    DateEnd     time.Time `json:"dateEnd"`
    Description string    `json:"description"`
}

// Entries is a collection of entries
type Entries []*Entry

There is also an Entries collection-type which we will need for the .ics encoding, but we will deal with this later on.

The next step is to create a simple Go webserver with the above mentioned routes:

const feedPrefix = "/feed/"
const expirationTime = 20 * time.Minute // caching time

func main() {
    cache := make(map[string]*Feed)

    mux := http.NewServeMux()
    mux.HandleFunc("/feedURL", feedURL(cache))
    mux.HandleFunc(feedPrefix, feed(cache))

    log.Print("Server started on localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

We create a simple “cache”, which in our case is just a map[string]*Feed and register the handler functions for the two routes.

Let’s take a look at feedURL first:

func feedURL(cache map[string]*Feed) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := randomToken(20)
        _, err := createFeedForToken(token, cache)
        if err != nil {
            writeError(http.StatusInternalServerError, "Could not create feed", w, err)
            return
        }
        writeSuccess(fmt.Sprintf("FeedToken: %s", token), w)
    })
}

This handler creates a random token using a simple helper function with crypto/rand, calls createFeedForToken, which we will look at later, creating a new feed for the given token and returns the token to the user.

The second handler, /feed/{token} is bit more in involved:

func feed(cache map[string]*Feed) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "text/calendar")
        w.Header().Set("charset", "utf-8")
        w.Header().Set("Content-Disposition", "inline")
        w.Header().Set("filename", "calendar.ics")

        var result string
        token := parseToken(r.URL.Path)

        feed, ok := cache[token]
        if !ok || feed == nil {
            writeError(http.StatusNotFound, "No Feed for this Token", w, errors.New("No Feed for this Token"))
            return
        }

        result = feed.Content
        if feed.ExpiresAt.Before(time.Now()) {
            newFeed, err := createFeedForToken(token, cache)
            if err != nil {
                writeError(http.StatusInternalServerError, "Could not create feed", w, err)
                return
            }
            result = newFeed.Content
        }

        writeSuccess(result, w)
    })
}

After setting the standard headers for the .ics response and parsing the provided token, we check if there is a cache-entry for the given token. If there is not, we return a 404 error.

If there is a feed for the token, but it is expired, we re-create the feed using createFeedForToken and return the newly created feed.

We just return the feed from the cache if there is an entry and it is still valid.

Now, let’s take a look at the createFeedForToken function:

func createFeedForToken(token string, cache map[string]*Feed) (*Feed, error) {
    res, err := fetchData()
    if err != nil {
        return nil, err 
    }

    b := bytes.Buffer{}
    goics.NewICalEncode(&b).Encode(res)

    feed := &Feed{
        Content: b.String(),
        ExpiresAt: time.Now().Add(expirationTime)
    }
    cache[token] = feed

    return feed, nil
}

The fetchData function does nothing fancy. It calls the mocky URL and unmarshals the resulting JSON to a list of Entry structs, handling all possible errors.

Then, we create the actual .ics feed using goics.NewICalEncode().Encode(), put it into the cache with the given token and return the feed.

In order to be able to use our list of Entry structs as a valid input for goics.NewICalEncode(&b).Encode(), the Entries type needs to implement the ICalEmitter interface:

// EmitICal implements the interface for goics
func (e Entries) EmitICal() goics.Componenter {
    c := goics.NewComponent()
    c.SetType("VCALENDAR")
    c.AddProperty("CALSCAL", "GREGORIAN")

    for _, entry := range e {
        s := goics.NewComponent()
        s.SetType("VEVENT")

        k, v := goics.FormatDateTimeField("DTSTART", entry.DateStart)
        s.AddProperty(k, v)
        k, v = goics.FormatDateTimeField("DTEND", entry.DateEnd)
        s.AddProperty(k, v)

        s.AddProperty("SUMMARY", entry.Description)
        c.AddComponent(s)
    }
    return c
}

The method uses goics helpers for creating the .ics output as shown in the library documentation.

Now, if we call /feedURL and use the resulting token as an input to /feed/{token}, we should get a valid calendar.ics file, which can be imported into apps like iCalendar, Google Calendar etc.

Also notice how the first request to /feedURL which initializes the feed takes about ~100 ms and the following requests to /feed/, which return the cached result, are very quick.

That’s it. You can find the full code here.

Conclusion

This was another short example in Go of a basic feature which is often implemented for web applications. I didn’t find many Go libraries for iCal, but the one I used worked well and was sufficient for this use-case.

More generally, I believe this example illustrates how powerful the Go standard library is and how simple a feature such as this can be implemented using it.

Resources


I work as a freelance software engineer and trainer and you can hire me. If you like my work, or my writing and if I sound like someone you'd like to work with, let's talk! :)