A few weeks ago, I was going through my Vercel projects when I found a project I made 4 years ago. It was so old it still used the now.sh domain when Vercel used to be ZEIT. It was a proxy for the xkcd API because the actual API had CORS issues on the browser.
Cross-origin resource sharing
Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources on a web page to be accessed from another domain outside the domain from which the first resource was served.
[Wikipedia] - [MDN]
According to my Vercel dashboard, it was used more than I thought it’d be, with 12,000 calls to the API between September 11 and January 27.
However, after 4 years, the code was a bit of a mess with duplicate files from trying out different serverless providers. The API URLs were also a bit too long which was not to my liking, and it was in dire need of refactoring.
The initial code (written in JavaScript) was pretty straightforward.
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 wanting to practice my Go skills for a while, and this was the perfect opportunity because Vercel’s Serverless Functions supported Go, among other languages.
At the time of writing, the Go runtime is in Beta.
the essentials
To use Go with Vercel, create a folder named api
with all your relevant Go code inside that folder. By default, Vercel will serve them at yourapp.vercel.app/api/filename
(which we will change).
Alternatively, you can specify what files you want Vercel to treat as Serverless Functions by configuring the functions
property in your vercel.json
.
// vercel.json
{
"functions": {
"api/test.js": {
"memory": 3008, // optional config
"maxDuration": 30 // optional config
},
"api/*.js": {
"memory": 3008,
"maxDuration": 30
}
}
}
getting started
We start it like any other Go project.
$ go mod init vercel-functions-demo
This creates a go.mod
file that you can use to specify your dependencies and more importantly, the Go version that you want Vercel to use. Vercel defaults to Go v1.20.
writing go code
The xkcd API required two routes - one to fetch the latest comic and another one to fetch a specific comic by its number. To make it backward-compatible with my previous API because I am a considerate developer who cares about his users’ feelings, I decided to have two different files for both of these routes. While I could perform all the magic in one file, it wouldn’t be considerate to me as my massive air-deprived brain would not be able to handle it.
Ignoring the non-essential files which will later become essential, this is what the file structure looks like.
.
├── api/
│ ├── latest.go
│ └── number.go
├── go.mod
├── vercel.json
└── ...
long before time began
there was the `func Handler` #
All great things have to start somewhere. While my code isn’t that great, it has to start somewhere regardless.
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 people who use JavaScript like me, the above code is similar to the following.
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 latest 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 working on this, I noticed different sources setting a WriteHeader
before sending the response. However, it resulted in the response being sent as plain text rather than JSON even though I had set the right Content-Type
.
If the code works, don’t question it.
I don’t know if this is a law, but if it isn’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 fetching 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 together, it looks like this:
that was easy
Now onto the slightly more difficult part. For the specific comic API route, we have a few conditions. The code should extract the comic number from the URL if it is in either of these forms:
/1481
/api/comic?num=1481
(backwards-compatibility)
// 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 realize that if someone sent a request to /1481?num=1337
, 1337 would take precedence over 1481. However, it’s the user’s fault for not reading the documentation.
vercel.json (very important!!1!1!!)
This is the most important part of the whole thing. It’s more important than anything you’ve ever considered important in your life. Without the custom redirects that we need to specify in vercel.json
, our API wouldn’t be all slick and cool.
We also need to enable CORS - the whole reason 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 understand the implications of CORS. Normally, you wouldn’t have resources such as APIs accessible from external domains, but in this case, we need it to receive requests from everyone and everywhere.
We utilize some Vercel magic called rewrites
to serve our API at short URLs. This is a pretty sweet feature because I can show different pages for different routes and even different request headers. I used this to make it so that if someone directly visits the root URL with an Accept
header of application/json
, the latest comic details will be returned. If the header is absent, a nice landing page with the API documentation is displayed.
It’s important to note that rewrites
are different from redirects
as the former directly serves the specified content for a particular route, whereas the latter sends a 308 Permanent Redirect (or 307 Temporary Redirect) to the specified content.
// 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 complete vercel.json
.
conclusion
Live - https://getxkcd.vercel.app/
GitHub - https://github.com/khalby786/getxkcd
Vercel’s Serverless Functions are really powerful pieces of infrastructure (is that the right word?) that you can use to host nifty utility APIs like this one. I said powerful because you can do more than just send JSON data. Vercel supports a variety of database options including a Redis cache which can be handy in caching external responses.