The RedwoodJS Example Todo is one of two canonical RedwoodJS applications, the another being the RedwoodJS Example Blog. Both of these applications use Prisma Client to connect to a PostgreSQL database.

However, we can easily swap out Prisma and connect to another database such as Neo4j or Fauna via a GraphQL endpoint.

This article describes another option - how to use StepZen to swap in Airtable for the database in the Example Todo app.

Setup

Clone Example Todo repo

git clone https://github.com/redwoodjs/example-todo.git

Install dependencies and start development server

cd example-todo
yarn
yarn rw prisma migrate dev
yarn rw dev

This will open a browser to localhost:8910.

Redwood Example Todo on Localhost 8910 with Prisma

RedwoodJS Architecture

Beginning Project Structure

A Redwood application is split into two parts: a frontend and a backend. This is represented as two node projects within a single monorepo. The frontend project is called web and the backend project is called api. These are referred to as "sides", i.e. the "web side" and the "api side".

├── api
│   ├── src
│   │   ├── functions
│   │   │   └── graphql.js
│   │   ├── graphql
│   │   │   └── todos.sdl.js
│   │   ├── lib
│   │   │   └── db.js
│   │   └── services
│   │       └── todos
│   │           └── todos.js
│   └── db
│       ├── migrations
│       └── schema.prisma
└── web
    ├── public
    │   └── favicon.png
    └── src
        ├── components
        │   ├── AddTodo
        │   │   └── AddTodo.js
        │   ├── AddTodoControl
        │   │   └── AddTodoControl.js
        │   ├── Check
        │   │   └── Check.js
        │   ├── TodoItem
        │   │   └── TodoItem.js
        │   └── TodoListCell
        │       └── TodoListCell.js
        ├── pages
        │   ├── FatalErrorPage
        │   │   └── FatalErrorPage.js
        │   ├── HomePage
        │   │   └── HomePage.js
        │   └── NotFoundPage
        │       └── NotFoundPage.js
        ├── App.js
        ├── Routes.js
        ├── index.css
        └── index.html

They are separate projects because code on the web side will end up running in the user's browser while code on the api side will run on a server somewhere.

  • The api side is an implementation of a GraphQL API. Your business logic is organized into "services" that represent their own internal API and can be called both from external GraphQL requests and other internal services.
  • The web side is built with React and includes a router that maps URL paths to React "Page" components. Cells allow you to declaratively manage the lifecycle of a component that fetches and displays data.

Redwood API Side

Change id type in todos.sdl.js

Our schema is contained in the todos.sdl.js file. We only have to make a slight change to the schema definition language.

// api/src/graphql/todos.sdl.js

export const schema = gql`
  type Todo {
    id: Int!
    body: String!
    status: String!
  }

  type Query {
    todos: [Todo]
  }

  type Mutation {
    createTodo(body: String!): Todo
    updateTodoStatus(id: Int!, status: String!): Todo
    renameTodo(id: Int!, body: String!): Todo
  }
`

For each of the three instances of id, replace Int with ID.

// api/src/graphql/todos.sdl.js

export const schema = gql`
  type Todo {
    id: ID!
    body: String!
    status: String!
  }

  type Query {
    todos: [Todo]
  }

  type Mutation {
    createTodo(body: String!): Todo
    updateTodoStatus(id: ID!, status: String!): Todo
    renameTodo(id: ID!, body: String!): Todo
  }
`

Replace PrismaClient with node-fetch

If we look at our db.js file in api/src/lib we will see that we are importing PrismaClient from @prisma/client and initializing a db variable with PrismaClient().

// api/src/lib/db.js

import { PrismaClient } from '@prisma/client'

export const db = new PrismaClient()

Instead of using PrismaClient, we will install node-fetch so we can make fetch requests from the Redwood API as described in the Using a Third Party API cookbook.

yarn workspace api add node-fetch

We no longer need our db folder containing our Prisma migrations.

rm -rf api/db

We'll also rename the file to stepzen.js:

mv api/src/lib/db.js api/src/lib/stepzen.js

Delete all the code in the newly named stepzen.js file and replace it with the following:

// api/src/lib/stepzen.js

const fetch = require('node-fetch')

async function client(data) {
  const response = await fetch(process.env.API_ENDPOINT, {
    method: 'POST',
    body: data,
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Apikey ' + process.env.API_KEY,
    },
  })

  const r = await response.json()
  return r
}

async function todos() {
  const TODOS = JSON.stringify({
    query: `query Todos {
      todos {
        body
        id
        status
      }
    }`,
  })

  const res = await client(TODOS)
  return res.data.todos
}

async function createTodo(input) {
  const CREATE_TODO = JSON.stringify({
    query: `mutation CreateTodo {
      createTodo(body: "${input.data.body}") {
        id
        body
        status
      }
    }`,
  })

  const res = await client(CREATE_TODO)
  return res.data.createTodo
}

async function updateTodo(input) {
  if (input.data.status !== undefined) {
    const UPDATE_TODO_STATUS = JSON.stringify({
      query: `mutation UpdateTodo {
        updateTodoStatus(id: "${input.where.id}", status: "${input.data.status}") {
          body
          id
          status
        }
      }`,
    })

    const res = await client(UPDATE_TODO_STATUS)
    return res.data.updateTodoStatus
  }

  if (input.data.body !== undefined) {
    const RENAME_TODO = JSON.stringify({
      query: `mutation RenameTodo {
        renameTodo(body: "${input.data.body}", id: "${input.where.id}") {
          body
          id
          status
        }
      }`,
    })

    const res = await client(RENAME_TODO)
    return res.data.renameTodo
  }
}

Lastly, we export an sz object with the functions we need to import into our services: findMany, create, and update.

// api/src/lib/stepzen.js

export const sz = {
  todo: {
    findMany: () => {
      return getTodos()
    },
    create: (input) => {
      return createTodo(input)
    },
    update: (input) => {
      return updateTodo(input)
    },
  },
}

Replace db with sz in todos.js service

Redwood automatically connects your internal services with your frontend GraphQL client, reducing the amount of boilerplate you have to write. Your services can interact with a database via Prisma Client, or they can be written for any other backend database technology.

Redwood automatically imports and maps resolvers from the corresponding services file onto your SDL. These files are contained in the api/src/services directory. This lets you write those resolvers in a way that makes them easy to call as regular functions from other resolvers or services.

If we look at the current todos.js file for the Example Todo app we see that it imports db, which was initialized with the Prisma Client.

// api/src/services/todos/todos.js

import { db } from 'src/lib/db'

export const todos = () => db.todo.findMany()

export const createTodo = ({ body }) => db.todo.create({ data: { body } })

export const updateTodoStatus = ({ id, status }) =>
  db.todo.update({
    data: { status },
    where: { id },
  })

export const renameTodo = ({ id, body }) =>
  db.todo.update({
    data: { body },
    where: { id },
  })

We simply replace db with the sz object we created earlier.

// api/src/services/todos/todos.js

import { sz } from 'src/lib/stepzen'

export const todos = () => sz.todo.findMany()

export const createTodo = ({ body }) => sz.todo.create({ data: { body } })

export const updateTodoStatus = ({ id, status }) =>
  sz.todo.update({
    data: { status },
    where: { id },
  })

export const renameTodo = ({ id, body }) =>
  sz.todo.update({
    data: { body },
    where: { id },
  })

Redwood Web Side

Change id type in TodoListCell.js

On the web side we have to make the same change in TodoListCell.js.

We make the same change to our types that we made to our schema on the api side by changing Int to ID for the id type.

// web/src/components/TodoListCell/TodoListCell.js

const UPDATE_TODO_STATUS = gql`
  mutation TodoListCell_CheckTodo($id: ID!, $status: String!) {
    updateTodoStatus(id: $id, status: $status) {
      id
      __typename
      status
    }
  }
`

That's all it takes to completely rewire your Redwood project! But how is it connecting to Airtable? That's the magic of StepZen. We have a schema that allows us to directly query our REST endpoints from Airtable.

StepZen Side

A StepZen project contains the following files:

  • index.graphql tells StepZen how to assemble the various type definition files into a complete GraphQL schema.
  • One or more GraphQL Schema Definition Language (SDL) files ending in .graphql.
  • config.yaml contains the keys and other credential information that StepZen needs to access your backend data sources.

To setup our StepZen API, let's create a stepzen directory within our Redwood project's API folder. Within the stepzen folder, create a schema directory.

mkdir api/stepzen api/stepzen/schema

Every StepZen project requires an index.graphql that ties together all of our schemas. Create an index.graphql file for our schema and a todos.graphql file for our Todo type, Query type, and Mutation type.

touch api/stepzen/schema/todos.graphql api/stepzen/index.graphql

todos.graphql

Let's create a Todo GraphQL type that represents a todo being returned by the Airtable API. todos.graphql has a Todo type and a todos query that returns an array of Todo objects. Our Todo type has only a few properties.

# api/stepzen/schema/todos.graphql

type Todo {
  id: ID!
  body: String!
  status: String!
}

Our todos query is connected to the Airtable REST API using StepZen's custom @rest directive. The @rest directive accepts the URL of the REST endpoint that we want to connect.

# api/stepzen/schema/todos.graphql

type Query {
  todos: [Todo]
    @rest(
      resultroot: "records[]"
      setters: [
        { field: "body", path: "fields.Todo" }
        { field: "status", path: "fields.Status" }
      ]
      endpoint: "https://api.airtable.com/v0/$baseid/todos/"
      configuration: "myAirtable"
    )
}

We will also need mutations for createTodo, updateTodoStatus, and renameTodo.

# api/stepzen/schema/todos.graphql

type Mutation {
  createTodo(body: String!, status: String! = "off"): Todo
    @rest(
      resultroot: "records[]"
      setters: [
        { field: "body", path: "fields.Todo" }
        { field: "status", path: "fields.Status" }
      ]
      endpoint: "https://mutate-airtable-zlwadjbovq-uc.a.run.app/POST/$baseid/todos?Todo=$body;&Status=$status;"
      configuration: "myAirtable"
    )

  updateTodoStatus(id: ID!, status: String!): Todo
    @rest(
      resultroot: "records[]"
      setters: [
        { field: "body", path: "fields.Todo" }
        { field: "status", path: "fields.Status" }
      ]
      endpoint: "https://mutate-airtable-zlwadjbovq-uc.a.run.app/PATCH/$baseid/todos?Status=$status;&id=$id;"
      configuration: "myAirtable"
    )

  renameTodo(id: ID!, body: String!): Todo
    @rest(
      resultroot: "records[]"
      setters: [
        { field: "body", path: "fields.Todo" }
        { field: "status", path: "fields.Status" }
      ]
      endpoint: "https://mutate-airtable-zlwadjbovq-uc.a.run.app/PATCH/$baseid/todos?Todo=$body;&id=$id;"
      configuration: "myAirtable"
    )
}

index.graphql

Our schema in index.graphql ties together all of our other schema files. For this example, we have only one - the todos.graphql file, which is included in our @sdl directive.

# api/stepzen/index.graphql

schema @sdl(files: ["schema/todos.graphql"]) {
  query: Query
}

The index.graphql tells StepZen how to assemble the various type definition files into a complete GraphQL schema. The @sdl directive is a StepZen directive that specifies the list of files to assemble. It includes a comma-separated list of .graphql files in your project folder.

config.yaml

The config.yaml contains various configurations that can include the keys and other credential information that StepZen needs to access your backend data sources.

touch api/stepzen/config.yaml

WARNING! This file should be added to .gitignore as it likely contains secret information.

.idea
.DS_Store
.env
.netlify
.redwood
dev.db
dist
dist-babel
node_modules
yarn-error.log
web/public/mockServiceWorker.js
config.yaml

To connect an Airtable account, we need to supply our baseid, tablename, and authorization header.

configurationset:
  - configuration:
      name: myAirtable
      baseid: <YOUR_BASEID_TOKEN>
      tablename: todos
      authorization: Bearer <YOUR_BEARER_TOKEN>

You can get this information in your Airtable account.

Deploy Our StepZen Endpoint

Now that our schema has been created, we can use the StepZen CLI to deploy it. If you have not already installed and configured the StepZen CLI, you can follow the instructions here.

stepzen start

This command uploads and deploys your endpoint automatically. You are asked a few questions to configure your endpoint.

1. Name your endpoint

First, you are prompted to name your endpoint destination.

? What would you like your endpoint to be called? api/todos

2. Specify root directory

The CLI detects your stepzen directory.

? We have detected a schema in this directory. Set the schema root to "api/stepzen"? (Y/n) Y

This creates a file called stepzen.config.json.

{
  "endpoint": "api/todos",
  "root": "api/stepzen"
}

This also deploys the schema to StepZen and lets you explore the endpoint from the StepZen dashboard. It also watches the directory for changes so that any changes that you make to your schema code is automatically uploaded and redeployed.

3. Create .env file and restart the development server

Create the .env file in your Redwood project to hold your StepZen API key and endpoint URL.

touch .env
API_ENDPOINT=<YOUR_API_ENDPOINT>
API_KEY=<YOUR_API_KEY>

Restart your development server so Redwood can inject the environment variables and return to localhost:8910.

Redwood Example Todo on Localhost 8910 connected to Airtable

Final Project Structure

├── api
│   ├── src
│   │   ├── functions
│   │   │   └── graphql.js
│   │   ├── graphql
│   │   │   └── todos.sdl.js
│   │   ├── lib
│   │   │   └── stepzen.js
│   │   └── services
│   │       └── todos
│   │           └── todos.js
│   └── stepzen
│       ├── config.yaml
│       ├── index.graphql
│       ├── stepzen.config.json
│       └── todos
│           └── todos.graphql
└── web
    ├── public
    │   └── favicon.png
    └── src
        ├── components
        │   ├── AddTodo
        │   │   └── AddTodo.js
        │   ├── AddTodoControl
        │   │   └── AddTodoControl.js
        │   ├── Check
        │   │   └── Check.js
        │   ├── TodoItem
        │   │   └── TodoItem.js
        │   └── TodoListCell
        │       └── TodoListCell.js
        ├── pages
        │   ├── FatalErrorPage
        │   │   └── FatalErrorPage.js
        │   ├── HomePage
        │   │   └── HomePage.js
        │   └── NotFoundPage
        │       └── NotFoundPage.js
        ├── App.js
        ├── Routes.js
        ├── index.css
        └── index.html

Where to go from here

To learn more about StepZen and how to build a schema, visit the StepZen docs. To learn more about how to configure Redwood and build the frontend, visit the Redwood docs.

You can find more example repositories at the StepZen Github. If you're not already signed up for StepZen, you can sign up here.