Menu background

How to build a GraphQL diet planner app from scratch

Dec 8th·6 min read

TL;DR

The problem

Recently, I have been going to the gym several times a week and I was advised to try to follow a high protein diet. Since I have no knowledge about nutritions and I am a coder this gives me an opportunity to build a new app.

Features

  • As a user I would like to search recipes based on my preference (high protein, vegan, etc)
  • As a user I would like to save some recipes for later

Services and technologies

Prerequisites

Coding

Getting started

There is a UI provided by RapidApi to test the endpoints. (You can also grab your key from there.) After a couple experiments I decided to use the “Search Recipes” endpoint and the “Get Recipe Information”. My point now is to have a minimum working solution. Feel free to use other endpoints with more options.

To consume the RapidApi I will use WunderGraph.

  • It provides a type safe way to interact with the api
  • and a unified platform where I will be able to wire other services as well (will be interesting later).

Setup WunderGraph

  1. Next.js template is perfect to start our project: npx create-wundergraph-app diet-planner -E nextjs

  2. Replace template code with ours

In the template code there is an introspection for SpaceX GraphQL. The RapidApi is built on REST so we need to configure it differently.

Replace the spaceX configuration with the below in the wundergraph.config.ts file.

const food = introspect.openApi({
  apiNamespace: 'food',
  source: {
    kind: 'file',
    filePath: './food-nutrition.yaml'
  },
  headers: builder => 
    builder.addStaticHeader('X-RapidAPI-Key', new EnvironmentVariable('RAPID_API_KEY'))
    .addStaticHeader('X-RapidAPI-Host', 'spoonacular-recipe-food-nutrition-v1.p.rapidapi.com'),
})
  1. Update the configureWunderGraphApplication function’s api property.

  2. As you might noticed there is an environment variable in the header section of the config so create a .env file and add your RAPID_API_KEY. The configured header will be added to each call.

  3. Specify the search endpoint with Open Api standards. At this point we are aiming for the minimum.

openapi: 3.0.0
info:
  title: food
  version: '1.0'
servers:
  - url: 'https://spoonacular-recipe-food-nutrition-v1.p.rapidapi.com'
paths:
  '/recipes/complexSearch':
    parameters:
      - schema:
          type: string
        name: query
        in: query
        required: true
    get:
      summary: Your GET endpoint
      tags: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/Recipe'
      operationId: searchRecipes
components:
  schemas:
    Recipe:
      title: Recipe
      type: object
      properties:
        id:
          type: number
        title:
          type: string

So far only one request parameter is mapped and the result object is neither complete. Only id and title are returned.

As a last step a GraphQL query must be created in the Operations folder. Don’t forget to pass the parameter!

query Recipes($query: String!) {
  food_searchRecipes(query: $query) {
    results {
      id
      title
    }
  }
}

The referenced query’s name is concatenated from two parts:

  • food comes from the base configuration (how we named the api)
  • searchRecipes comes from the yaml file’s operationId property.

The query can be tested on this url: http://localhost:9991/operations/Recipes?query=pasta

A little frontend

index page

At this point I would like to just list the search results. I have reused what I could from the original template. Again, create something nicer if you’d like. An important change I have made is in the useQuery hook. There is a query state variable which passed to the query. The query parameter is bind to the input.

const [query, setQuery] = useState('')
const recipes = useQuery({
  operationName: 'Recipes',
  input: {
    query,
  }
})

Thanks to the WunderGraph setup now we have autocomplete on the paramater. With one parameter it is not much but imagine it with 10+ items.

Get recipe’s information

Since the search endpoint provides just few informations about a recipe somehow the details should be retrieved. There is a “get by id” endpoint for this purpose. It is worth to test again the endpoint on the UI first.

Based on the other endpoint the new endpoint configuration should look like this:

'/recipes/{id}/information':
  parameters:
    - schema:
        type: string
      name: id
      in: path
      required: true
  get:
    summary: get info endpoint
    tags: []
    responses:
      '200':
        description: OK
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Recipe'
    operationId: getRecipe

A property was added to the Recipe component as well:

instructions:
  type: string

And the associated query:

query Recipe($id: String!) {
  food_getRecipe(id: $id) {
    id
    title
    instructions
  }
}

The query can be tested on this url: http://localhost:9991/operations/Recipe?id=603414

Recipe’s details page

details page

  1. Add linking to the index.tsx. Each listed element should navigate to the /{id} route
  2. Create details page: [id].tsx
  3. Copy everything that we can from the index page
  4. Show the title and the instructions for the current recipe

Save and retrieve the recipes we like

A simple GraphQL API can be created in minutes with the help of SST. The goal is to be able to save recipes and later retrieve them.

  1. Start with a template:
npx create-sst@latest --template=minimal/typescript-starter diet-planner-backend
  1. Followed this guide to create a table for recipes

StorageStack.ts

import { StackContext, Table } from '@serverless-stack/resources'

export function StorageStack({ stack }: StackContext) {
  // Create the DynamoDB table
  const table = new Table(stack, 'Recipes', {
    fields: {
      recipeId: 'string',
    },
    primaryIndex: { partitionKey: 'recipeId' },
  })

  return {
    table,
  }
}
  1. Used this guide for api creation

  2. My project structure a bit different:

project structure 5. And I renamed lambda.ts to main.ts 6. Also I have faced an issue as the top level schema is required for AWS so schema file should start with:

schema {
  query:Query
  mutation: Mutation
}

ApiStack.ts

import { AppSyncApi, StackContext, use } from '@serverless-stack/resources'
import { StorageStack } from './StorageStack'

export function ApiStack({ stack }: StackContext) {
  const { table } = use(StorageStack)

  // Create the API
  const api = new AppSyncApi(stack, 'Api', {
    schema: 'services/graphql/schema.graphql',
    defaults: {
      function: {
        bind: [table],
      },
    },
    dataSources: {
      recipes: 'functions/main.handler',
    },
    resolvers: {
      'Query getRecipes': 'recipes',
      'Mutation saveRecipe': 'recipes',
    },
  })

  // Show the API endpoint in the output
  stack.addOutputs({
    ApiId: api.apiId,
    ApiKey: api.cdk.graphqlApi.apiKey ?? '',
    APiUrl: api.url,
  })

  // Return the API resource
  return {
    api,
  }
}

schema.graphql

schema {
  query:Query
  mutation: Mutation
}

type Recipe {
  recipeId: ID!
  title: String!
  instructions: String!
}

input RecipeInput {
  recipeId: ID!
  title: String!
  instructions: String!
}

type Query {
  getRecipes: [Recipe]
}

type Mutation {
  saveRecipe(recipe: RecipeInput!): Recipe
}

Recipe.ts

type Recipe = {
  recipeId: string;
  title: string;
  instructions: string;
};

export default Recipe;

getRecipes.ts

import { DynamoDB } from 'aws-sdk'
import { Table } from '@serverless-stack/node/table'

const dynamoDb = new DynamoDB.DocumentClient()

export default async function getRecipes(): Promise<Record<string, unknown>[] | undefined> {
  const params = {
    TableName: Table.Recipes.tableName,
  }

  const data = await dynamoDb.scan(params).promise()

  return data.Items
}

saveRecipe.ts

import { DynamoDB } from 'aws-sdk'
import { Table } from '@serverless-stack/node/table'
import Recipe from '../Recipe'

const dynamoDb = new DynamoDB.DocumentClient()

export default async function createNote(recipe: Recipe): Promise<Recipe> {
  const params = {
    Item: recipe as Record<string, unknown>,
    TableName: Table.Recipes.tableName,
  }

  await dynamoDb.put(params).promise()

  return recipe
}

main.ts

import Recipe from '../Recipe'
import saveRecipe from './saveRecipe'
import getRecipes from './getRecipes'

type AppSyncEvent = {
  info: {
    fieldName: string
  }
  arguments: {
    recipe: Recipe
  }
}

export async function handler(
  event: AppSyncEvent
): Promise<Record<string, unknown>[] | Recipe | string | null | undefined> {
  switch (event.info.fieldName) {
    case 'saveRecipe':
      return await saveRecipe(event.arguments.recipe)
    case 'getRecipes':
      return await getRecipes()
    default:
      return null
  }
}

And finally my index.tsx in stacks folder:

import { App } from '@serverless-stack/resources'
import { StorageStack } from './StorageStack'
import { ApiStack } from './ApiStack'
import { RemovalPolicy } from 'aws-cdk-lib'

export default function main(app: App) {
  app.setDefaultFunctionProps({
    runtime: 'nodejs16.x',
    srcPath: 'services',
    bundle: {
      format: 'esm',
    },
  })

  if (app.stage !== 'prod') {
    app.setDefaultRemovalPolicy(RemovalPolicy.DESTROY)
  }

  app.stack(StorageStack).stack(ApiStack)
}

Our stack can be tested in SST Console:

  1. yarn start
  2. Open SST Console (link is in the terminal where yarn start was used)
  3. Create item

create items 4. Retrieve items retrieve items

Wire the backend into WunderGraph

  1. Extend the configuration with introspecting the new GraphQL API
const dietplanner = introspect.graphql({
  apiNamespace: 'dietplanner',
  url: new EnvironmentVariable('DIET_PLANNER_BE_URL'),
  headers: (builder: IHeadersBuilder) =>
    builder.addStaticHeader('x-api-key', new EnvironmentVariable('DIET_PLANNER_BE_KEY')),
})

configureWunderGraphApplication({
  apis: [food, dietplanner],
  ...
  1. Add new environment variables, both can be copied from the terminal where the stack is running.

keys

  1. Add new operations
query MyRecipes {
  dietplanner_getRecipes {
    recipeId
    title
    instructions
  }
}

mutation SaveRecipe($recipe: dietplanner_RecipeInput!) {
  dietplanner_saveRecipe(recipe: $recipe) {
    recipeId
    title
    instructions
  }
}

In the logs you should see these lines:

2022-12-04T14:51:23+01:00 DEBUG: Registered (3) query operations {"component":"@wundergraph/server"}
2022-12-04T14:51:23+01:00 DEBUG: Registered (1) mutation operations {"component":"@wundergraph/server"}

Finish the frontend

  1. Add a button to the details page
  2. Use the mutation
const { trigger } = useMutation({
  operationName: 'SaveRecipe',
})
  1. Call it on click
const onSaveClick = () => {
  const { food_getRecipe } = recipe.data
  if (food_getRecipe) {
    const { id, title, instructions } = food_getRecipe
    trigger({
      recipe: {
        recipeId: String(id),
        title,
        instructions,
      },
    })
  }
}

If you would continue

  • list my recipes
  • error handling
  • save more properties
Background waves

Are you interested in working with us?

Get in touch

get in touch theme image