Add a Serverless GraphQL API to your Azure Static Web App

In the previous post, we bootstrapped a basic VueJS app and deployed it to Azure Static Web Apps. The app is currently using localStorage to persist data, but in this post, we'll create a serverless GraphQL API and update our VueJS app to use the new API for data queries and persistence.

Brian Johnson

By

Published on

This is part 2 of a tutorial series dedicated to getting you up & running with Azure Static Web Apps.

In part 1 of this tutorial series, we bootstrapped a basic VueJS app and deployed it to Azure Static Web Apps. The VueJS app is currently using localStorage to persist data, but we will change that. In this post, we'll create a serverless GraphQL API and update our VueJS app to use the new API for data queries and persistence.

Guided Tutorial

In this guided tutorial, we will add an API to the Azure Static Web App we created in the previous tutorial. After checking to ensure everything is wired up properly, we’ll convert the API endpoint to GraphQL using a special version of Apollo Server made for Azure Functions. Finally, we will update the front-end VueJS app to use the Vue Apollo Client for interacting with the serverless GraphQL API we built.

Step 1: Install tooling for Azure

Before we start, ensure you have the CLI tools for Azure Functions and Azure Static Web Apps installed globally. To this, issue the following commands in your terminal.


npm i -g azure-functions-core-tools@3 --unsafe-perm true
npm install -g @azure/static-web-apps-cli
 
azure static web apps extension for vs code

Step 2: Scaffold an API endpoint

Create and checkout a new git branch by entering git checkout -b add-api in VS Code's integrated terminal. Next, use Cmd+Shift+P (Mac) or Ctrl+Shift+P (Win) to open the command palette. Start typing Azure Static Web Apps and then select [Create HTTP Function] from the short list. In the following menus, select [TypeScript] for the language and enter graphql as the name.

If you haven’t yet signed into Azure from VS Code, you should select Azure: Sign In from the command palette, first.
open the command palette to create a new function

The extension will create an api directory and add a boilerplate serverless function in the ./api/graphql directory since we chose graphql as the function name.

switch to the api folder and build the boilerplate code

In your integrated terminal, change to the api directory and run yarn && yarn build to ensure the serverless function will build. This will install dependencies and get rid of the import errors identified by VS Code.

If you’re not using VS Code, you can see what files were added from this commit. All of the additions are in the ./api directory.

Checkpoint: Understanding the API folder & config files

If you take a look in the api directory, you'll notice it is a separate project with its own package.json file. This is because your static web app will be deployed to a CDN, whereas the API will be deployed to the Azure Functions serverless compute service. We don't need to concern ourselves with this since Azure Static Webs Apps will handle the deployment details for us. However, it is helpful to know insofar that it helps us understand why the api directory is a subproject of our project.

./api/

The api directory is the root directory for our API. Underneath it will be directories for each endpoint we create. Since we are building a GraphQL API, we'll only need one endpoint: /api/graphql. Azure Functions defaults to assigning routes by directory names, but we can override that in the api/graphql/function.json config file with the route key on the input binding object. However, since we named our directory graphql, this is not needed.

./api/graphql/function.json

Let’s first have a glance at the default config provided. Before moving on, please take a moment to review the comments in the file contents below so that you understand what each config key is for.

{
  // describe the input & output function bindings
  "bindings": [ 
    {
      // this is the func input binding
      "direction": "in", 
      // what event triggers this code
      "type": "httpTrigger",
      // variable name for the request object
      "name": "req",
      // allowed http methods
      "methods": ["get", "post"],
      // do not require an api token
      "authLevel": "anonymous"
    },
    {
      // this is the func output binding
      "direction": "out",
      // output object type
      "type": "http",
      // variable name for response (or use $return)
      "name": "res" 
    }
  ],
  // function entrypoint
  "scriptFile": "../dist/graphql/index.js"
}

./api/graphql/function.json

We’ll leave the function.json file as-is for now, presuming yours looks like the one above without comments.

./api/graphql/sample.dat

The sample.dat file is used for testing. Specifically, when invoking a function via the CLI but without mocking the API, this can be passed to the invoked function. Very helpful when writing tests. Since we're using an HTTP trigger, this would be the request body/payload passed to the function. This name value can be accessed in our api/graphql/index.ts script via req.query.name for GET requests and req.body.name for POST requests. See line 5 in the auto-generated index.ts script for an example.

./api/graphql/index.ts

The index.ts is our TypeScript entry point file. Once compiled, this file will become api/dist/graphql/index.js—which matches the scriptFile key value in our api/graphql/function.json config file.

./api/host.json

While each endpoint function has its own function.json to define the configuration, there is only one host.json file. This is because this configures the Azure Functions host deployment, in which the code for all of the functions is executed. It is not important for you to understand this file unless you bring your own Azure Functions (standard plan required). When using the API provided by Azure Static Web Apps, you can safely ignore this file.

./api/local.settings.json

This is the equivalent of a .env file for your serverless API. Everything in the Values object is loaded as environment variables at runtime. For example shown below, in your index.ts code, process.env.FUNCTIONS_WORKER_RUNTIME would contain the value node.

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "node"
  }
}

./api/local.settings.json

The local.settings.json file is for local development. To set values for your production environment, you can access the config sidebar menu item in the Azure Static Web App dashboard. In the example below, I defined a production environment variable SOME_KEY. I could access its value, "SOME_VALUE", from process.env.SOME_KEY within my index.ts file.

set api environment variables from the dashboard
Environment variables are only available to the API. If you would like to set environment variables for the VueJS front-end web app, you should do so via the configuration of your builder, such as webpack.

The remaining files are standard NodeJS and TypeScript files and not specific to Azure Functions or Azure Static Web Apps.


Step 3: Test locally with Azure CLI tools

While it may be tempting to begin converting this endpoint from REST to GraphQL, it makes sense to perform a few basic tests to ensure everything is configured properly. Let’s do that now.

Test the API

In the index.ts file, we can see that the function retrieves the parameter name from the request object (req) and then inserts it into a string ( responseMessage) which it then returns by assigning it to the response object (context.res).

If you would like to simply pass the response via the function’s return value, you can do so by changing the bindings.out.name from res to $return in the function.json file.

To test this function, we’ll start a local development server and check to see if the name parameter gets returned in the response.

import { AzureFunction, Context, HttpRequest } from '@azure/functions';
 
const httpTrigger: AzureFunction = async function(context: Context, req: HttpRequest): Promise<void> {
    context.log('HTTP trigger function processed a request.');
    const name = (req.query.name || (req.body && req.body.name));
    const responseMessage = name;
    context.res = {
        body: responseMessage
    };
};
export default httpTrigger;

./api/graphql/index.ts

Ensure you are in the ./api directory in your integrated terminal and enter yarn start to start a local development server. Since we're only testing the serverless API, the server is available on local port 7071 and the endpoint we created will be available at http://localhost:7071/api/graphql. Let's hit that endpoint to ensure it works as expected.

I like using Insomnia since it is lightweight but has good support for GraphQL and OIDC authentication. But you can use whatever API testing app you prefer, such as Postman. In my Insomnia app, I can see the function is working as expected. I passed it a payload with a name key and it successfully returned a message with "Brian J." contained within it.

screenshot of insomnia (api tester) app

Looks like we’re good-to-go. Next, we will repeat this same API call, but from inside the front-end VueJS app.

Add an API call to the VueJS app

Open your ./src/App.vue file in VS Code and add the created() function as shown in the code below. If syntax highlighting isn't working in the .vue file, install the Vetur Extension.

./src/App.vue

This will make a call to the API from our web app. To test, we should be able to open the web app and see the logs in the browser’s console.

Test the VueJS + API together

We can test our API with the func CLI on port 7071 and we can test our front-end app using the tooling provided by the framework. In this section, we'll use the swa CLI to load the front-end VueJS app and the serverless API together so they can be tested as one.

We need to make one quick tweak to our GitHub Actions workflow before proceeding. The swa CLI will look at our workflow file to determine which directory to load our API from. In the workflow file, change the api_location value to api, as shown on line 21 in the excerpt below.

# -- Begin Excerpt ---
jobs:
  build_and_deploy_job:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build and Deploy Job
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: true
      - name: Build And Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BLUE_PEBBLE_09B798E10 }}
          repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
          action: "upload"
          ###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
          # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
          app_location: "/" # App source code path
          api_location: "api" # Api source code path - optional
          output_location: "dist" # Built app content directory - optional
          ###### End of Repository/Build Configurations ######
# --- End Excerpt

./.github/workflows/<file-name>.yml

Also, to make our lives a little easier, we can add a script to ./package.json in the root folder of the project, like so:

// --- Begin Excerpt ---
"scripts": {
	"serve": "vue-cli-service serve",
	"swa": "cd api && yarn build && cd .. && yarn build && swa start dist --api api",
	"build": "vue-cli-service build --skip-plugins @vue/cli-plugin-eslint",
	"lint": "vue-cli-service lint"
},
// --- End Excerpt ---

./package.json

Once that script entry is made, you should be able to launch the app + API together by running yarn swa from the project root directory.

serving the swa locally using the cli

Once the app is built and the API is running, your app should be available at http://localhost:4280. Open the Chrome developer tools with Cmd+Opt+I (Mac) or Ctrl+Shift+I (Win) and, if all went well, you should see the message returned from the API.

app + api served locally by the swa cli

I see the message! This tells me that the serverless API endpoint /api/graphql is wired up properly and working.

Since everything looks good, go ahead and delete the created() function from the VueJS app, as we no longer need it. It's probably a good idea to commit all code changes before you move on.

Step 4: Convert the Static Web App to GraphQL

Now we get to the real work of adding GraphQL! I’ve included several screenshots and code snippets so this section looks a lot longer than it actually is. Once you’re finished, I think you’ll see that it really wasn’t much work. We’ll convert the API first, then the VueJS app. Let’s dive in.

To convert the API to GraphQL, we will install Apollo Server for Azure Functions, make a few changes to config files, and add/update a few files. Then we will test it with the func CLI (called from our package.json start script). After a successful test, we'll move on to updating the VueJS front-end.

Install dependencies & update config files

Ensure you are in your ./api directory and run the following command: yarn add graphql apollo-server-azure-functions to install the required dependencies for the API.

install dependencies screenshot

Next, in the ./api/graphql/function.json file, remove the get method from the bindings.in.methods array and change the bindings.out.name to $return. It should match the file below.

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "post"
      ]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "$return"
    }
  ],
  "scriptFile": "../dist/graphql/index.js"
}

./api/graphql/function.json

Lastly, in your GitHub workflow file, .github/workflows/<filename>.yml, update the api_location key to api. It will default to api in most scenarios, but I have run into problems with this so it's better to be explicit.

Create a schema.ts file

Create a new file named ./api/graphql/schema.ts and add the code below. In this file, I have defined a simple graphql schema for our "quotes" app.

import { gql } from 'apollo-server-azure-functions';
export default /* GraphQL */ gql`
# GraphQL Schema
type Quote {
  id: ID
  source: String
  text: String
}
input CreateQuoteInput {
  source: String!
  text: String!
}
type Query {
  listQuotes: [Quote]!
}
type Mutation {
  createQuote(input: CreateQuoteInput!): Quote
  deleteQuote(id: ID!): Quote 
}
# End Schema

./api/graphql/schema.ts

Create a handlers.ts file for our data logic

Next, create another file named ./api/graphql/handlers.ts. This is where we will keep the business logic that our resolvers call. In a larger, non-trivial app, you would probably split this up. However, since this is such a trivial app, we can keep it all in one file.

interface Quote {
  id: string
  source: string
  text: string
}
 
interface CreateQuoteInput {
  source: string
  text: string
}
// TERRIBLE IDEA to persist our data inside the serverless
// function, but we need something to hold us off until the
// next tutorial, where we add a serverless database.
let quotes: Quote[] = [
  {
    id: '1629335698593',
    source: 'Brian Johnson',
    text: 'Wherever you go, there you are.'
  },
  {
    id: '1629335712412',
    source: 'Nancy Johnson',
    text: 'Get off that damn computer!'
  }
];
 
export function listQuotes(): Quote[] {
  return quotes;
}
 
export function createQuote(_parent: any, args: { input: CreateQuoteInput }): Quote {
  const newQuote: Quote = {
    id: Date.now().toString(),
    source: args.input.source,
    text: args.input.text
  }
  quotes.push(newQuote);
  return newQuote;
}
 
export function deleteQuote(_parent: any, args: { id: string }): Quote {
  const quote = quotes.find(q => q.id === args.id);
  if (quote) {
    quotes = quotes.filter(q => q.id !== args.id);
    return quote;
  }
}

./api/graphql/handler.ts

Create a resolvers.ts file

The next file that you should create is ./api/graphql/resolvers.ts. This is a very small file that defines all of our resolvers and provides references to the logic that should be executed when each is called.

import { listQuotes, createQuote, deleteQuote } from './handlers';
 
export default {
  Query: {
    listQuotes
  },
  Mutation: {
    createQuote,
    deleteQuote
  }
};

./api/graphql/resolvers.ts

Update the index.ts file

Finally, we update the index.ts file. Since we’ve extracted a lot of the logic, the remaining code is quite small and simple.

import { ApolloServer } from 'apollo-server-azure-functions';
import typeDefs from './schema';
import resolvers from './resolvers';
 
const server = new ApolloServer({ typeDefs, resolvers });
export default server.createHandler();

./api/graphql/index.ts

In a nutshell, index.ts, our entry point script for the /api/graphql endpoint, imports ApolloServer from the Azure Functions-specific Apollo library. When we create the server on the second-from-last line, we pass in the schema (typeDefs) and resolvers (resolvers). We then return the output of server.createHandler().

Test the GraphQL API

Change to the ./api directory if you're not already there and start the API development server using the yarn start command.

launch the api dev server from the ./api folder

Once the function is in the ready state, as pictured above, use Insomnia to query the GraphQL endpoint.

send a query to test the graphql endpoint

With the API endpoint tested, it looks like we’re ready to move on to the VueJS front-end.

Convert the VueJS front-end app to GraphQL

The difficult part is over; we just need to update our VueJS app to use GraphQL. Easy-peasy. Follow the steps below to update your front-end to take advantage of our new API.

Install dependencies for Apollo

Switch to the project root directory and install the Apollo client and GraphQL dependencies with yarn add vue-apollo and yarn add -D graphql-tag vue-cli-plugin-apollo.

If you’re familiar with VueJS, you may be inclined to install Apollo with the Vue CLI, but it will generate JS boilerplate files instead of TS . It’s probably easier to just paste the TypeScript boilerplate that I provide below.
install the prod and dev dependencies for apollo graphql client

Add project files to VueJS app

Add or update the following files in your VueJS project. Pay attention to the instructions! You can simply copy/paste the new files, but for the .vue file updates, you will be instructed to replace a specific portion of code.

  1. Create/update the ./apollo.config.ts file.
import * as path from 'path';
import { loadEnv } from 'vue-cli-plugin-apollo/utils/load-env';
 
const env = loadEnv([
  path.resolve(__dirname, '.env'),
  path.resolve(__dirname, '.env.local')
]);
export const ApolloConfig = {
  client: {
    service: env.VUE_APP_APOLLO_ENGINE_SERVICE,
    includes: ['src/**/*.{js,jsx,ts,tsx,vue,gql}']
  },
  service: {
    name: env.VUE_APP_APOLLO_ENGINE_SERVICE,
    localSchemaFile: path.resolve(__dirname, './node_modules/.temp/graphql/schema.json')
  },
  engine: {
    endpoint: process.env.APOLLO_ENGINE_API_ENDPOINT,
    apiKey: env.VUE_APP_APOLLO_ENGINE_KEY
  }
}
export default ApolloConfig;

./apollo.config.ts

2. Create the ./src/vue-apollo.tsfile with the content shown below.

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { createApolloClient } from 'vue-cli-plugin-apollo/graphql-client';
 
Vue.use(VueApollo)
const AUTH_TOKEN = 'apollo-token'
const defaultOptions = {
  httpEndpoint: '/api/graphql',
  wsEndpoint: null,
  tokenName: AUTH_TOKEN,
  persisting: false,
  ssr: false,
}
 
// Call this in the Vue app file
export function createProvider (options = {}) {
  // Create apollo client
  const { apolloClient } = createApolloClient({
    ...defaultOptions,
    ...options,
  })
  const apolloProvider = new VueApollo({
    defaultClient: apolloClient,
    errorHandler (error) {
      // eslint-disable-next-line no-console
      console.log('%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message)
    },
  })
 
  return apolloProvider
}
 
export async function onLogin (apolloClient, token) {
  if (typeof localStorage !== 'undefined' && token) {
    localStorage.setItem(AUTH_TOKEN, token)
  }
 
export async function onLogout (apolloClient) {
  if (typeof localStorage !== 'undefined') {
    localStorage.removeItem(AUTH_TOKEN)
  }
}

./src/vue-apollo.ts

3. Update the ./src/main.tsfile with the contents below.

import Vue from 'vue';
import VModal from 'vue-js-modal';
import App from './App.vue';
import { createProvider } from './vue-apollo';
 
Vue.config.productionTip = false;
Vue.use(VModal, { dialog: true });
 
new Vue({
  apolloProvider: createProvider(),
  render: h => h(App)
}).$mount('#app');

4. Update the ./src/types/quotes.tsfile using the contents below.

export class Quote {
    constructor(
        public source: string, 
        public text: string,
        public id = '',
    ) {}
}

./src/types/quote.ts

5. Create the ./src/graphql/quotes.tsfile using the contents below.

import gql from 'graphql-tag';
 
const CREATE_QUOTE = gql`
  mutation createQuote($input: CreateQuoteInput!) {
    createQuote(input: $input) {
      id
      source
      text
    }
  }
`;
 
const DELETE_QUOTE = gql`
  mutation deleteQuote($id: ID!) {
    deleteQuote(id: $id) {
      id
      source
      text
    }
  }
`;
 
const LIST_QUOTES = gql`
  query {
    listQuotes {
      id
      source
      text
    }
  }
`;
 
export const GQL = {
  QUERY: {
    LIST_QUOTES,
  },
  MUTATION: {
    CREATE_QUOTE,
    DELETE_QUOTE,
  },
}
export default GQL;

./src/graphql/quotes.ts

6. Update the <script> and <template> sections of ./src/components/CreateQuote.vue with the boilerplate below. Leave the <style> tag as-is.

<template>
  <div class="add-form">
    <div class="form-input">
      <div>
        <label for="quote-input">Quote Text</label>
      </div>
      <div>
        <textarea id="quote-input" v-model="text" />
      </div>
    </div>
    <div class="form-input">
      <div>
        <label for="source-input">Author or Source</label>
      </div>
      <div>
        <input id="source-input" v-model="source" />
      </div>
    </div>
    <div class="form-input">
      <button @click="submitForm" :disabled="!source || !text">Add Quote</button>
    </div>
  </div>
</template>
 
<script lang="ts">
import Vue from 'vue';
import { GQL } from '@/graphql/quotes';
export default Vue.extend({
  name: 'CreateQuote',
  data: () => ({
    source: '',
    text: ''
  }),
  methods: {
    async submitForm() {
      console.log('submitForm()');
      const res = await this.$apollo.mutate({
        mutation: GQL.MUTATION.CREATE_QUOTE,
        variables: {
          input: {
            source: this.source,
            text: this.text,
          }
        },
        refetchQueries: [
          { query: GQL.QUERY.LIST_QUOTES },
        ]
      });
      console.log(res);
      this.source = '';
      this.text = '';
    }
  }
});
</script>

./src/components/CreateQuote.vue

7. Update the <script> and <template> sections of ./src/components/Quotes.vue with the boilerplate below. Leave the <style> tag as-is.

<template>
  <div class="hello">
    <v-dialog />
    <h1>Quotes</h1>
    <hr />
    <div v-for="q of listQuotes" :key="q.id" @click="confirmDelete(q)">
      <p>{{ q.text }}</p>
      <p>
        - <em>{{ q.source }}</em>
      </p>
      <hr />
    </div>
    <CreateQuote/>
  </div>
</template>
 
<script lang="ts">
import GQL from '@/graphql/quotes';
import { Quote } from '@/types/quote';
import Vue from 'vue';
import CreateQuote from './CreateQuote.vue';
export default Vue.extend({
  name: 'Quotes',
  components: {
    CreateQuote
  },
  apollo: {
    listQuotes: {
      query: GQL.QUERY.LIST_QUOTES
    }
  },
  methods: {
    confirmDelete(quote: Quote) {
      this.$modal.show('dialog', {
        title: 'Delete this quote?',
        text: 'Please confirm or cancel deletion of this quote.',
        buttons: [
          {
            title: 'Cancel',
            handler: () => this.$modal.hide('dialog')
          },
          {
            title: 'Delete',
            handler: async () => {
              this.$modal.hide('dialog');
              await this.deleteQuote(quote);
            }
          }
        ]
      });
    },
    async deleteQuote({ id }: Quote) {
      console.log(`deleteQuote(${id})`);
      await this.$apollo.mutate({
        mutation: GQL.MUTATION.DELETE_QUOTE,
        variables: { id },
        refetchQueries: [
          { query: GQL.QUERY.LIST_QUOTES },
        ]
      });
    }
  },
});
</script>

./src/components/Quotes.vue

Test the VueJS Apollo client

After adding and updating the files, spin up the development server with yarn swa from the project root directory. You should have a working, serverless GraphQL API now!

vue app using graphql for queries + mutations

Step 5: Deploy the app to production

Congratulations! If you made it this far in the tutorial, you already have a working VueJS + GraphQL app and it has been successfully tested locally with the swa CLI. Now we will push our code and open a pull request—which will automatically deploy the code to a staging environment. After checking the staging environment, we'll merge the pull request, which will kick off the CI/CD action to deploy to production. Once it is deployed to production, the app can be accessed from your custom domain.

Push code to GitHub repo

In the root project folder, issues git add . and git commit -m "convert to graphql" to commit the code. The push to your repo with git push.

add + commit changes then push to repo

Open pull request against master

Navigate to your GitHub repo in the browser and open a pull request (master <– add-api) and wait for the checks to pass.

pull request opened

Launch the staging environment from the Azure Portal

Next, navigate to your Azure Static Web App in the Azure Portal and select [Environment] from the sidebar menu. You should see the staging environment that was created by the pull request. Click the [Browse] link to open the app.

static web app ui in the azure portal

Take a moment to manually exercise the application to ensure there are no problems. Once you are ready to push the app to production, continue to the next task.

Merge pull request to kickoff CI/CD production deployment

Merging the pull request will automatically shutdown the staging environment and deploy the app to production. Check the [Actions] tab of your repo to check the deployment process.

merging the pull request kicks off production deployment

Once the process completes, continue to the next task.

Verify production deployment at your custom domain

At this point, you should be able to open the app using your custom domain. Navigate in your browser to your custom domain and verify that all is well.

api is deployed to production with custom domain

Hooray, we’re done! 🎉

Next Steps: Add a serverless database

Well, we’re done… but not quite done. This tutorial was long enough with only the API. So, if you noticed in the code above, we persisted the data to an in-memory array. This is an absolutely terrible approach when using serverless or otherwise operating in a distributed system. It served its purpose as a stop-gap measure, but it is time to move on to something more permanent.

Therefore, in the next post of this series, we will be adding a serverless database (CosmosDB) and use it in the serverless API to persist our data. If you want to use something other than CosmosDB, you can modify your API code to include your database client — just as you would do with any Node/TypeScript project.

Want to be notified when new content is published?

I'll never spam you or disclose your information to anyone else. You'll only receive a notification when I post new content.

Sign up for updates