A few weeks ago, I was go­ing through my Vercel pro­jects when I found a pro­ject I made 4 years ago. It was so old it still used the now.sh do­main when Vercel used to be ZEIT. It was a proxy for the xkcd API be­cause the ac­tual API had CORS is­sues on the browser.

Cross-origin re­source shar­ing

Cross-origin re­source shar­ing (CORS) is a mech­a­nism that al­lows re­stricted re­sources on a web page to be ac­cessed from an­other do­main out­side the do­main from which the first re­source was served.
[Wikipedia] - [MDN]

According to my Vercel dash­board, it was used more than I thought it’d be, with 12,000 calls to the API be­tween September 11 and January 27.

xkcd API usage analytics on my Vercel dashboard

However, af­ter 4 years, the code was a bit of a mess with du­pli­cate files from try­ing out dif­fer­ent server­less providers. The API URLs were also a bit too long which was not to my lik­ing, and it was in dire need of refac­tor­ing.

Couldn't decide on which cloud provider

The ini­tial code (written in JavaScript) was pretty straight­for­ward.

const fetch = require('node-fetch');

module.exports = async (request, response) => {
  const { num } = request.query;
  
  // in retrospect
  // this was a bit dumb
  // !num would also return true for `num == ""`
  // num == " " would never happen either
  if (!num || num == "" || num == " " || num == "latest") {
    let req = await fetch("http://xkcd.com/info.0.json");
    let res = await req.json();
    response.status(200).json(res);
  } else {
    let req = await fetch(`http://xkcd.com/${num}/info.0.json`);
    let res = await req.json();
    response.status(200).json(res);
  }
}

I’d been want­ing to prac­tice my Go skills for a while, and this was the per­fect op­por­tu­nity be­cause Vercel’s Serverless Functions sup­ported Go, among other lan­guages.

At the time of writ­ing, the Go run­time is in Beta.

the es­sen­tials

To use Go with Vercel, cre­ate a folder named api with all your rel­e­vant Go code in­side that folder. By de­fault, Vercel will serve them at yourapp.vercel.app/api/filename (which we will change).

Alternatively, you can spec­ify what files you want Vercel to treat as Serverless Functions by con­fig­ur­ing the functions prop­erty in your vercel.json.

// vercel.json

{
"functions": {
"api/test.js": {
"memory": 3008, // optional config
"maxDuration": 30 // optional config
},
"api/*.js": {
"memory": 3008,
"maxDuration": 30
}
}
}

get­ting started

We start it like any other Go pro­ject.

$ go mod init vercel-functions-demo

This cre­ates a go.mod file that you can use to spec­ify your de­pen­den­cies and more im­por­tantly, the Go ver­sion that you want Vercel to use. Vercel de­faults to Go v1.20.

writ­ing go code

The xkcd API re­quired two routes - one to fetch the lat­est comic and an­other one to fetch a spe­cific comic by its num­ber. To make it back­ward-com­pat­i­ble with my pre­vi­ous API be­cause I am a con­sid­er­ate de­vel­oper who cares about his users’ feel­ings, I de­cided to have two dif­fer­ent files for both of these routes. While I could per­form all the magic in one file, it would­n’t be con­sid­er­ate to me as my mas­sive air-de­prived brain would not be able to han­dle it.

Ignoring the non-es­sen­tial files which will later be­come es­sen­tial, this is what the file struc­ture looks like.

.
├── api/
│   ├── latest.go
│   └── number.go
├── go.mod
├── vercel.json
└── ...

long be­fore time be­gan
there was the `func Handler` #

All great things have to start some­where. While my code is­n’t that great, it has to start some­where re­gard­less.

package handler
 
import (
  "fmt"
  "net/http"
)
 
func Handler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "<h1>Hello from Go!</h1>")
}

If you’re one of those old peo­ple who use JavaScript like me, the above code is sim­i­lar to the fol­low­ing.

export default function handler(request, response) {
  const { name = 'World' } = request.query;
  return response.send(`Hello ${name}!`);
}

First, let’s do the one to fetch the lat­est xkcd comic, aptly named latest.go.

// latest.go

package handler

import (
	"fmt"
	"io"
	"log"
	"net/http"
)

func LatestComic(w http.ResponseWriter, r *http.Request) {
	...
}

While I was work­ing on this, I no­ticed dif­fer­ent sources set­ting a WriteHeader be­fore send­ing the re­sponse. However, it re­sulted in the re­sponse be­ing sent as plain text rather than JSON even though I had set the right Content-Type.

If the code works, don’t ques­tion it.

I don’t know if this is a law, but if it is­n’t, I’d like to claim it.

func LatestComic(w http.ResponseWriter, r *http.Request) {

  // contrary to what you might find online,
  // do not send a status code
  // because it sends the data as text/plain
  // idk why
  // w.WriteHeader(http.StatusCreated)

  // set Content-Type so that we send JSON
  w.Header().Set("Content-Type", "application/json")
}

Onto the fetch­ing now!

  // get the latest xkcd comic
  resp, err := http.Get("https://xkcd.com/info.0.json")
  if err != nil {
    log.Fatalln(err)
  }

  // make it readable or something i really don't know
  body, err := io.ReadAll(resp.Body)
  if err != nil {
    log.Fatalln(err)
  }

And then SEND IT.

  if err != nil {
    fmt.Println("Error happened somewhere along the way. Err:", err)
  } else {
    // 200 send it lads
    w.Write(body)
  }

Putting it all to­gether, it looks like this:

that was easy

Now onto the slightly more dif­fi­cult part. For the spe­cific comic API route, we have a few con­di­tions. The code should ex­tract the comic num­ber from the URL if it is in ei­ther of these forms:

// extract comic number from path
comic := r.URL.Path[1:]

// reverse compatibility with old API
// /api/comic?num=1481
queryParams := r.URL.Query().Get("num")
if queryParams != "" {
  comic = queryParams
}

Reading this code now I re­al­ize that if some­one sent a re­quest to /1481?num=1337, 1337 would take prece­dence over 1481. However, it’s the user’s fault for not read­ing the doc­u­men­ta­tion.

ver­cel.json (very im­por­tant!!1!1!!)

This is the most im­por­tant part of the whole thing. It’s more im­por­tant than any­thing you’ve ever con­sid­ered im­por­tant in your life. Without the cus­tom redi­rects that we need to spec­ify in vercel.json, our API would­n’t be all slick and cool.

We also need to en­able CORS - the whole rea­son why we’re here in the first place.

// vercel.json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Credentials", "value": "true" },
        { "key": "Access-Control-Allow-Origin", "value": "*" },
        {
          "key": "Access-Control-Allow-Methods",
          "value": "GET"
        },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version"
        }
      ]
    }
  ],
}

I hope you un­der­stand the im­pli­ca­tions of CORS. Normally, you would­n’t have re­sources such as APIs ac­ces­si­ble from ex­ter­nal do­mains, but in this case, we need it to re­ceive re­quests from every­one and every­where.

We uti­lize some Vercel magic called rewrites to serve our API at short URLs. This is a pretty sweet fea­ture be­cause I can show dif­fer­ent pages for dif­fer­ent routes and even dif­fer­ent re­quest head­ers. I used this to make it so that if some­one di­rectly vis­its the root URL with an Accept header of application/json, the lat­est comic de­tails will be re­turned. If the header is ab­sent, a nice land­ing page with the API doc­u­men­ta­tion is dis­played.

It’s im­por­tant to note that rewrites are dif­fer­ent from redirects as the for­mer di­rectly serves the spec­i­fied con­tent for a par­tic­u­lar route, whereas the lat­ter sends a 308 Permanent Redirect (or 307 Temporary Redirect) to the spec­i­fied con­tent.

// vercel.json

{
  "rewrites": [
    // rewrite / to /api/latest
    // if it has an Accept `application/json`
    {
      "source": "/",
      "destination": "/api/latest",
      "has": [
        {
          "type": "header",
          "key": "Accept",
          "value": "application/json"
        }
      ]
    },

    // if the header is missing
    // send the html page instead
    // NOTE: i used `app.html` instead of `index.html`
    // because file names take priority
    // over rewrites
    {
      "source": "/",
      "destination": "/app.html",
      "missing": [
        {
          "type": "header",
          "key": "Accept",
          "value": "application/json"
        }
      ]
    },

    // /latest and /0 to the latest comic
    {
      "source": "/latest",
      "destination": "/api/latest"
    },
    {
      "source": "/0",
      "destination": "/api/latest"
    },

    // the `source` property also supports regular expressions
    // here, \d{1,} matches a number
    // https://regexr.com/7s75m
    {
      "source": "/:number(\\d{1,})",
      "destination": "/api/number"
    },

    // compatible with
    // /api/comic?num=latest
    {
      "source": "/api/comic",
      "has": [
        {
          "type": "query",
          "key": "num",
          "value": "latest"
        }
      ],
      "destination": "/api/latest"
    },

    // compatible with
    // /api/comic?num=1481
    {
      "source": "/api/comic",
      "has": [
        {
          "type": "query",
          "key": "num",
          "value": "(\\d{1,})"
        }
      ],
      "destination": "/api/number"
    }
  ]
}

Finally, the com­plete vercel.json.

con­clu­sion

Live - https://​getxkcd.ver­cel.app/
GitHub - https://​github.com/​khal­by786/​getxkcd

Deploy with Vercel
getxkcd.vercel.app screenshot

Vercel’s Serverless Functions are re­ally pow­er­ful pieces of in­fra­struc­ture (is that the right word?) that you can use to host nifty util­ity APIs like this one. I said pow­er­ful be­cause you can do more than just send JSON data. Vercel sup­ports a va­ri­ety of data­base op­tions in­clud­ing a Redis cache which can be handy in caching ex­ter­nal re­sponses.

getxkcd stats

ad­den­dum

xkcd 1481 - API

ACCESS LIMITS: Clients may maintain connections to the server for no more than 86,400 seconds per day. If you need additional time, you may contact IERS to file a request for up to one additional second.