SPA using Vue.js and Lumen - Avoiding preflight CORS requests.
When creating a Single Page Application (SPA) it is often required to interface with an API to access the data the SPA consumes. For a recent project we wanted to use Vue CLI with some presets for the front-end and Lumen for the back-end to expose the API.
At this point I can hear some of you shouting, "Hey brother, just use Nodejs, JavaScript on the front-end and JavaScript on the back-end, it does not get better than that." If that is you, this post is most probably not for you.
Having worked with SPA's in the past, we specifically wanted to avoid CORS preflight requests. In short, we needed to work off a single domain, serving the SPA from the vue-cli environment and exposing the API from the Lumen environment. Similar to, http://example.app/
for the front-end and http://example.app/api
for the back-end.
If that challenge intrigues you, please join me and come along for the ride. We will build a small app to demonstrate the concepts. The app will display the Astronomy Picture of the Day (APOD) from NASA.
NASA exposes an API that one can use with a demo key to return the APOD. It can return either an image or a video. If an image is returned our API will cache the image, for a video we just return the response from the APOD request.
What are CORS preflight requests all about.
Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional HTTP headers to tell a browser to let a web application running at one origin (domain) have permission to access selected resources from a server at a different origin. A web application makes a cross-origin HTTP request when it requests a resource that has a different origin (domain, protocol, and port) than its own origin. For security reasons, browsers restrict cross-origin HTTP requests initiated from within scripts. [1]
So XMLHttpRequest and the Fetch API requests will be affected.
Generally with SPA's if the frontend technology is different to the backend technology you have the result that one domain serves the frontend http://example.app/
and another domain exposes the backend http://api.example.app/data.json
. For example, if a script on the example.app
domain request the backend endpoint and the backend did not define CORS headers the following error would be thrown inside the browser console:
Failed to load http://api.example.app/data.json: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://example.app' is therefore not allowed access.
HTTP request methods that can cause side-effects, in particular HTTP methods other than GET, requires that browsers preflight the request, soliciting supported methods from the server with an HTTP OPTIONS request method. When the server approves the request, the actual HTTP request with its particular method is sent to the server. Seeing that our frontend consumes our own API we want to avoid two server requests for every non-GET request.
In short to achieved the required result, running two different platforms from the same domain, we will use server side configuration and the help of nginx.
Using Vue-cli for the frontend
Let's create a new folder for our little demo application, called cosmos
. We presume you have vue-cli
3.0 installed and you are comfortable working in a terminal.
Firstly, we create a new directory and then proceed to create a new vue application Manually selecting our features
$ cd /var/www
$ mkdir cosmos
$ vue create spa
For each step of the process we chose the following:
> Manually select features
> Select (Babel, Router)
> Use history mode for Router (Y)
> Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? (In package.json)
> Save this as a preset for future projects? (N)
When done, you should see something like the following:
Lets test the frontend piece we have so far!
$ cd spa
$ yarn serve
We should be able to access our frontend using http://localhost:8080/
.
That is a great start, we have a default vue environment now that does hot reloading, giving us realtime feedback of front-end changes also using vue-router for navigation.
Making some changes to spa/src/views/Home.vue
, spa/src/views/About.vue
and renaming spa/src/components/HelloWold.vue
to spa/src/components/Cosmos.vue
we have an empty home screen and the following About page:
spa/src/views/Home.vue
<template>
<div class="home">
<Cosmos/>
</div>
</template>
<script>
import Cosmos from '@/components/Cosmos.vue'
export default {
name: 'home',
components: {
Cosmos
}
}
</script>
spa/src/views/About.vue
<template>
<div class="about">
<h1 class="center">Welcome to Cosmos</h1>
<p>A simple demo SPA that uses Vue (using vue-cli) on the frontend and Lumen on the backend to cache the <a href="http://apod.nasa.gov/apod/astropix.html" target="APOD">Astronomy Picture of the Day</a>, courtesy of NASA.</p>
<p>The aim of the application is to demonstrate serving the frontend and backend from the same domain to avoid preflight CORS request.</p>
</div>
</template>
spa/src/components/Cosmos.vue
<template>
<div class="home">
</div>
</template>
<script>
export default {
name: 'Cosmos',
}
</script>
Creating our back-end using Lumen
For our back-end we will use Lumen, a micro-framework from Laravel.
Presuming you already have the lumen
command installed we create our api using:
$ cd /var/www/cosmos
$ lumen new api
Setting up nginx to point an http://api.localhost
domain to our cosmos/api/public
folder simply returns a page with the following default text after a lumen install.
Lumen (5.7.0) (Laravel Components 5.7.*)
Let's take a look at some code to fetch the APOD from NASA using their demo api key, https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY
.
To simplify HTTP requests from our API to the NASA API, lets install guzzle to help us.
$ cd cosmos/api
$ composer require guzzlehttp/guzzle
All we need is an endpoint on our side that accepts a POST request and returns a JSON response. We add that code in api/routes/web.php
$router->post('/apod', function () {
$date = \Carbon\Carbon::now();
$cachedFile = storage_path('app/' . $date->format('Y-m-d')) . '.json';
if (file_exists($cachedFile)) {
$data = json_decode(file_get_contents($cachedFile), JSON_OBJECT_AS_ARRAY);
if ($data['media_type'] == 'image') {
$data['url'] = url('cache/' . basename($data['url']));
}
return response()->json($data);
}
$client = new GuzzleHttp\Client();
$result = $client->request('GET', 'https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY');
$data = json_decode($result->getBody(), JSON_OBJECT_AS_ARRAY);
if ($data['media_type'] == 'image') {
$filename = basename($data['url']);
$fileToSave = base_path('public/cache/' . $filename);
$resource = fopen($fileToSave, 'w+');
$client->request('GET', $data['url'], ['sink' => $resource]);
}
file_put_contents($cachedFile, json_encode($data));
return response()->json($data);
});
We basically check if a cached file exists api/storage/app/{todaysDate}.json
, if it does not we make a call to the NASA API, checking if we are working with an image
. If it is an image we fetch the lowres image from NASA, cache the image on our side inside api/public/cache/{filenameFromNasa}
. We then save the JSON response from NASA on our side and return a response.
So a POST request to our endoint /apod
would return the following for an image and video:
{
"copyright":"Ruslan MerzlyakovRMS Photography",
"date":"2018-09-11",
"explanation":"You have to take a long hike to see the Troll's Tongue -- ten hours over rocky terrain. And in this case, it took three trips to capture the landform below a clear night sky. Trolltunga itself is a picturesque rock protrusion extending about 700 meters over mountainous cliffs near Lake Ringedalsvatnet in Norway. The overhang is made of billion-year-old Precambrian bedrock that was carved out by glaciers during an ice-age about 10,000 years ago. The featured picture is a composite of two exposures, a 15-second image of the foreground Earth followed 40 minutes later by an 87-second exposure of the background sky. Thousands of discernable stars dot the backdrop starscape in addition to billions of unresolved stars in the nearly vertical band of our Milky Way Galaxy.",
"hdurl":"https://apod.nasa.gov/apod/image/1809/MilkyWayTongue_Merzlyakov_1790.jpg",
"media_type":"image",
"service_version":"v1",
"title":"Milky Way over Troll's Tongue",
"url":"http://OUR_SERVER_ADDRESS/api/cache/MilkyWayTongue_Merzlyakov_960.jpg"
}
{
"date":"2018-09-12",
"explanation":"Our Moon's appearance changes nightly. As the Moon orbits the Earth, the half illuminated by the Sun first becomes increasingly visible, then decreasingly visible. The featured video animates images taken by NASA's Moon-orbiting Lunar Reconnaissance Orbiter to show all 12 lunations that appear this year, 2018. A single lunation describes one full cycle of our Moon, including all of its phases. A full lunation takes about 29.5 days, just under a month (moon-th). As each lunation progresses, sunlight reflects from the Moon at different angles, and so illuminates different features differently. During all of this, of course, the Moon always keeps the same face toward the Earth. What is less apparent night-to-night is that the Moon's apparent size changes slightly, and that a slight wobble called a libration occurs as the Moon progresses along its elliptical orbit.",
"media_type":"video",
"service_version":"v1",
"title":"Lunations",
"url":"https://www.youtube.com/embed/5Om7NbfUC6Y?rel=0"
}
Making sure our front-end and back-end share the same domain
Let's look at some server side configuration to allow these two environments to be served from the same domain. It will allow us to use our API and not be concerned with preflight requests.
For our purposes we have /var/www/cosmos/spa/public
that needs to respond to requests from http://cosmos.localhost
and then /var/www/cosmos/api/public
that needs to response to requests from http://cosmos.localhost/api
. (For your purposes you would substitute cosmos.localhost for your domain name).
The following is a commented nginx configuration that allows the frontend and backend to be served from the same domain.
server {
listen 80;
server_name cosmos.localhost;
index index.php index.html index.htm;
# Set the default character set.
charset utf-8;
# Turn off writing to the access log.
access_log off;
log_not_found off;
# The default application root.
root /var/www/cosmos/spa/dist;
# Handle the frontend part.
location / {
try_files $uri $uri/ @rewrites;
}
location @rewrites {
rewrite ^(.+)$ /index.html last;
}
# Handle the backend part.
location /api {
# Send the arguments as output for debugging. Example: /api?name=leon
# Any variable can be returned here: $uri, $args, $document_root, $request_uri
# return 200 $args; add_header Content-Type text/plain;
root /var/www/cosmos/api/public;
# Rewrite $uri=/api/action back to just $uri=/action
rewrite ^/api/(.*)$ /$1 break;
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
# At this piont we will have the following variables:
# $uri = /index.php
# $args = any GET ?key=value
# $request_uri = /api/action
# We do not want to pass /api/action to PHP-FPM but rather just /action.
# Setting "fastcgi_param REQUEST_URI" below allows Lumen to see route('/action')
set $lumenurl $request_uri;
if ($lumenurl ~ ^/api/(.*)$) {
set $lumenurl $1;
root /var/www/cosmos/api/public;
}
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# If you are using debian or ubuntu it might be something like.
# fastcgi_pass unix:/var/run/php/php7.0-fpm.sock;
# Using Laravel Valet on the Mac point to your home folder.
fastcgi_pass unix:/Users/lvismer/.valet/valet.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param REQUEST_URI $lumenurl;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_intercept_errors off;
}
# Serve our images from our backend cache/ directory.
location /cache {
root /Users/leonv/Cloud/Code/cosmos/api/public;
try_files $uri =404;
expires 90d;
add_header Pragma "public";
add_header Cache-Control "public, must-revalidate, proxy-revalidate";
}
server_tokens off;
}
Finishing up the frontend to use our new backend
Let's return to our frontend and finish up the Cosmos.vue
component to use our new API.
We start by installing axios
to help us with requests.
$ cd spa
$ yarn add axios
We use our API endpoint /api/apod
to return the json responses we cached from the NASA API. Inside the template we show the title and if the media_type
is a video we embed the YouTube video using an IFRAME, alternatively we display the image and the optional copyright notice if it exists.
spa/src/components/Cosmos.vue
<template>
<div class="home">
<h2 class="center">{{ data.title }}</h2>
<div v-if="data.media_type == 'video'">
<iframe width="600" height="338" frameborder="0" :src="data.url" allowfullscreen></iframe>
</div>
<div v-else>
<img width="600" :src="data.url" />
<p v-show="data.copyright">© {{ data.copyright }}</p>
</div>
<p>{{ data.explanation }}</p>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'Cosmos',
data () {
return {
data: {}
}
},
mounted () {
axios.post('/api/apod').then(response => {
this.data = response.data
})
}
}
</script>
Hope you managed to follow along and took something away from the post. Below is the result where we are able to share our frontend and backend from the same domain although using different technologies.
Access the GitHub repository. Until next time!
https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS ↩︎