Rails, React &GraphQL


GraphQL is a query language for APIs, as well as a server side runtime for executing said queries. The query language itself is universal and not tied to any frontend or backend technology. However, the server side implementations come in many flavors; in our case we're going to use the graphql-ruby gem (with rails) to parse incoming queries, make database calls, and respond with JSON. It's a full-on replacement for REST APIs, rails API controllers, and JSON serializers.

First, let's define some vocabulary I'll be using through this tutorial. 

  • Queries - Fetch specific data from the API. It's best practice to make queries read-only, like you would a GET request in REST. But queries are much more than just simple GETs!
  • Mutations - Any modification of data on the API. Think CREATE, UPDATE, DESTROY.
  • Types - Used to define datatypes, or in our case, Rails models. A type contains fields and functions that respond with data based on what's requested in a query / mutation. Types can also be static, like String or ID; these are built into the server side library.
  • Fields - Represent the attributes for a given type (like attributes on a model).
  • Functions - Supply the above fields with data (like methods on a model). 
These 5 Things all work together to fetch, create, mangle, and destroy data in an incredibly readable and intuitive way — If you can read JSON or Yaml, you can read and write GraphQL!

Setting up a Rails API

First, we're going to create a new api-only Rails app for our backend. I'm gonna skip testing for now for the sake of this tutorial. Next, create a couple models to test data with.

rails new graphql_api --skip-test --api
rails g model User email:string name:string
rails g model Book user:belongs_to title:string
rails db:migrate
Open app/models/user.rb and add the has_many :books association. Optionally, create some seed data using the faker gem in seeds.rb, then run rake db:seed.

Installing dependencies

# Gemfile

# The ruby implementation of the GraphQL language.
gem 'graphql'

group :development do
  # A development utility to test GraphQL queries.
  gem 'graphiql-rails'

  # Seed data generator
  gem 'faker'
end
Generating the GraphQL files

rails generate graphql:install
bundle
rails generate graphql:object user
rails generate graphql:object book
These generators create a graphql directory with types, mutations, and a schema. We also want to generate new custom types for our User and Book models we created above.

  ├─ controllers
+ │  └─ graphql_controller.rb
+ ├─ graphql
+ │  ├─ mutations
+ │  ├─ rails_graphql_demo_schema.rb
+ │  └─ types
+ │     ─ base_enum.rb
+ │     ─ base_input_object.rb
+ │     ─ base_interface.rb
+ │     ─ base_object.rb
+ │     ─ base_scalar.rb
+ │     ─ base_union.rb
+ │     ─ book_type.rb
+ │     ─ mutation_type.rb
+ │     ─ query_type.rb
+ │     ─ user_type.rb
The generator adds a new POST endpoint to our routes that's mapped to app/controllers/graphql_controller.rb#execute — this method serves as our main API entrypoint and is ready to go. For development, we need the additional endpoint for graphiql-rails.

# routes.rb

Rails.application.routes.draw do
  if Rails.env.development?
    mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "graphql#execute"
  end
  post "/graphql", to: "graphql#execute"
end
Testing queries with Graphiql

The final step to get graphiql running is to uncomment require "sprockets/railtie" in application.rb. Boot up your rails server with rails s and navigate to https://localhost:3000/graphiql to see the interface. Here we can run the following query to get a test response from the API.

Types

For the User and Book models, we need to create a series of types so GraphQL knows what kind of data to send back in the event of a request. Somewhat similar to Rails' activemodelserializers or JBuilder, these types make up the structure of our models from the API's point of view. Here we'll specifiy what columns, model methods, and more return to the client application. More info on declaring types can be found here.

User and Book Types

Open up the generated types and add the following fields. Notice each field gets an "object type" and a null option of whether or not it needs to be present for the query to succeed (e.g. an :id field should never be nil, but :name might be). This tells graphql what to expect from incoming and outgoing data, and gives us peace of mind in knowing exactly how to parse data on both the front and back end.

Also notice we didn't have to define functions for :id, :name, etc; Those are automatically mapped to the Rails model's attributes we created earlier. Then, we added a custom field, books_count. This method doesn't exist on the Rails model, so we define it below the list of fields. In these methods object refers to the Rails model, so we must call object.books.size.

# app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: true
    field :email, String, null: true
    field :books, [Types::BookType], null: true
    field :books_count, Integer, null: true

    def books_count
      object.books.size
    end
  end
end


# app/graphql/types/book_type.rb
module Types
  class BookType < Types::BaseObject
    field :title, String, null: false
  end
end
The Main Query Type

There are two main types that incoming requests are routed to: query_type.rb and mutation_type.rb. They are both already refrerenced in our schema file, and behave somewhat similarly to Rails routes & resources.

# app/graphql/RAILS_APP_NAME_schema.rb
class GraphqlApiSchema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)
end
In our main query type file, we define :users and :user fields, along with users and user functions. The users field returns an array of UserType objects, and can never be nil (but can be empty). The user field accepts a required argument :id that is of the type ID, and returns a single UserType object. (ID is a built-in type that acts just the same as the above User and Book type.) 

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject

    field :users, [Types::UserType], null: false

    def users
      User.all
    end

    field :user, Types::UserType, null: false do
      argument :id, ID, required: true
    end

    def user(id:)
      User.find(id)
    end
  end
end
Querying the User Fields

Visit https://localhost:3000/graphiql in your browser and paste in the following for the users and user query fields we added above. Here we specify exactly what we want the API to respond with; in this case, we only want a list of user names, emails, and the number of books they own.

query {
  users {
    name
    email
    booksCount
  }
}
We can also query a single user, along with all of their books, and each book's title.

query {
  user(id: 1) {
    name
    email
    books {
      title
    }
  }
}
Mutations

Mutations allow for creating, updating, and destroying data. More info on them can be found here. Let's set up a base class from which to extend a CreateUser mutation.

# app/graphql/mutations/base_mutation.rb
class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation
end
  • Arguments - Here we specify which arguments to accept as params, which are required, and what object types they are. This is somewhat similar to defining strong params in a Rails controller, but with more fine grained control of what's coming in.
  • Fields - Same concept as Query fields above. In our case, we accepted arguments to create a user, and we want to return a user field with our new model accompanied with an array of errors if present.
  • Resolver - The resolve method is where we execute our ActiveRecord commands. It returns a hash with keys that match the above field names. 
# app/graphql/mutations/create_user.rb
class Mutations::CreateUser < Mutations::BaseMutation
  argument :name, String, required: true
  argument :email, String, required: true

  field :user, Types::UserType, null: false
  field :errors, [String], null: false

  def resolve(name:, email:)
    user = User.new(name: name, email: email)
    if user.save
      # Successful creation, return the created object with no errors
      {
        user: user,
        errors: [],
      }
    else
      # Failed save, return the errors to the client
      {
        user: nil,
        errors: user.errors.full_messages
      }
    end
  end
end
Then finally, add the new mutation to the main mutation type class so it's exposed to our API.

# app/graphql/types/mutation_type.rb
module Types
  class MutationType < Types::BaseObject
    field :create_user, mutation: Mutations::CreateUser
  end
end
Creating a User

To test, open up https://localhost:3000/graphiql and paste in the following query. Notice we pass in an input: {} object to createUser; this maps to the :create_user field which accepts a single input argument. Learn more about this design in graphql-ruby's documentation.

mutation {
  createUser(input: {
    name: "Matt Boldt",
    email: "me@mattboldt.com"
  }) {
    user {
      id
      name
      email
    }
    errors
  }
}
Success! We just created our first model via GraphQL; no extra routes, controllers, or serializers needed. What's more, we only returned exactly the data we needed from the newly created model.

The Frontend



Rails API Setup

In order to use React with rails, we need to make some changes to the API.

# Gemfile
gem 'rack-cors'
Then, open up application.rb and add the following settings. This will allow us to accept AJAX requests coming from React.

# config/application.rb
# CORS config to allow ajax
config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'
    resource '*', headers: :any, methods: [:get, :post, :options]
  end
end
Note: you'll want to replace * with your domain if you're in a production environment!

Extra Seed Data

I added some extra data to the Book model so we'll have more to display in our frontend app. View commit

# app/graphql/types/book_type.rb
module Types
  class BookType < Types::BaseObject
    field :id, Integer, null: false
    field :title, String, null: false
    field :cover_url, String, null: true
    field :average_rating, Integer, null: true
  end
end
React

In the Rails app folder, we're going to create a new react app using create-react-app. See their README for setup guides. I'm using yarn here, but you can use npm if you'd like.

create-react-app frontend
cd frontend
yarn start
I deleted some the css, svg, and tests generated with create-react-app, as well as created components and styles folders to better organize things. Here's what my project looks like:

├── public
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── src
│   ├── components
│   │   └── App.js
│   ├── index.js
│   ├── serviceWorker.js
│   └── styles
│       └── index.css
├── package.json
└── yarn.lock
Be sure to remove references of App.css and update the paths to point to ./components/App

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './styles/index.css';
import App from './components/App'; // Updated path
import * as serviceWorker from './serviceWorker';// src/components/App.js
import React, { Component } from 'react';
-import logo from '../logo.svg';
-import '../styles/App.css';
Styling

For some quick styling, add a link to TailwindCSS.

// src/index.html
<link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
For user avatars, we'll use Gravatar with yarn add react-gravatar.

Apollo

  • react-apollo is a React-specific library for using Apollo in components
  • apollo-boost contains many utilities and libraries for Apollo to get you set up quickly
  • and finally the javascript graphql library itself
yarn add apollo-boost react-apollo graphql
Setting up ApolloClient

We need to configure ApolloClient with our API and wrap our root <App /> with the ApolloProvider higher-order component. 

// src/index.js
// [truncated]
import { ApolloProvider } from 'react-apollo';
import { ApolloClient } from 'apollo-client';
import { createHttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';

const link = createHttpLink({
  uri: 'https://localhost:3000/graphql'
});

const client = new ApolloClient({
  link: link,
  cache: new InMemoryCache()
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root')
);
Users Index View

Now that we have the client passed down from index.js, we can start writing queries. Create a new component in src/components/Users.js and import the following:

// src/components/Users.js
import React, { Component } from 'react';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';
import Gravatar from 'react-gravatar';
  • Query is another higher-order component that we'll wrap all of our Users index page with
  • gql will help us build queries to send to the API
  • Gravatar is for quick user avatars
Now we can write our query to fetch users and return id, name, email, booksCount (just like the test queries against the Rails server using graphiql!). Place it after the imports but before the class definition.

// Below imports in src/components/Users.js
const USERS_QUERY = gql`
  query {
    users {
      id
      name
      email
      booksCount
    }
  }
`;
And finally, we have the Users index component class with its render function. The Query component is passed the USERS_QUERY and it returns with loading states, error info (if any), and the API's response data rendered in jsx. Since this component is wrapped in Query, the API request will be sent immediately after it's rendered. Some styling from Tailwind has also been added to render users inside cards.

// Below user query in src/components/Users.js
class Users extends Component {
  render() {
    return (
      <Query query={USERS_QUERY}>
        {({ loading, error, data }) => {
          if (loading) return <div>Fetching..</div>
          if (error) return <div>Error!</div>
          return (
            <div className="flex flex-wrap mb-4">
              {data.users.map((user) => {
                return <div key={user.id} className="m-4 w-1/4 rounded overflow-hidden shadow-lg">
                  <Gravatar email={user.email} size={150} className="w-full" />
                  <div className="px-6 py-4">
                    <div className="font-bold text-xl mb-2">{user.name}</div>
                    <p className="text-grey-darker text-base">{user.email}</p>
                    <p className="text-grey-darker text-base">{user.booksCount} books</p>
                  </div>
                </div>
              })}
            </div>
          )
        }}
      </Query>
    )
  }
}
export default Users;
To see this component in action, we need to call it from our app's root component App.js

// src/components/App.js
import React, { Component } from 'react';
import Users from './Users';
class App extends Component {
  render() {
    return (
      <div className="container mx-auto px-4">
        <Users />
      </div>
    );
  }
}
export default App;
User Profile & Books View

Next, we'll need to create a user's profile page which includes their list of books. Since the index page only returns exactly what it needed to render, we'll need to send a new request to the API to get back all that user's data.

// src/components/User.js
import React, { Fragment } from 'react';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';
import UserAvatar from './UserAvatar';
import Books from './Books';

const USER_QUERY = gql`
  query User($id: ID!) {
    user(id: $id) {
      books {
        id
        title
        coverUrl
        averageRating
      }
    }
  }
`;// src/components/User.js
const User = ({ user, selectUser }) => (
  <Query query={USER_QUERY} variables={{ id: user.id }}>
    {({ loading, error, data }) => {
      if (loading) return <div>Fetching..</div>
      if (error) return <div>Error!</div>

      return (
        <Fragment>
          <div className="flex my-4">
            <button
              className="bg-grey-light hover:bg-grey text-grey-darkest font-bold py-2 px-4 rounded"
              onClick={selectUser.bind(this, null)}>
              Back
            </button>
          </div>
          <div className="flex mb-4">
            <div className="my-4 w-1/4 rounded overflow-hidden">
              <UserAvatar user={user} />
            </div>
            <div className="my-4 px-4 w-3/4">
              <Books books={data.user.books} />
            </div>
          </div>
        </Fragment>
      )
    }}
  </Query>
);
export default User;
User Avatar

In this User component, I've added a UserAvatar and a Books component to help display the data in a reusable way. I'll also refactor the user avatar code on the Users index page to use the new component.

// src/components/UserAvatar.js
import React, { Fragment } from 'react';
import Gravatar from 'react-gravatar';

const UserAvatar = ({ user }) => (
  <Fragment>
    <Gravatar email={user.email} size={150} className="w-full" />
    <div className="px-6 py-4">
      <div className="font-bold text-xl mb-2">{user.name}</div>
      <p className="text-grey-darker text-sm">{user.email}</p>
      <p className="text-grey-darker text-base">{user.booksCount} books</p>
    </div>
  </Fragment>
)
export default UserAvatar;// ....
// src/components/Users.js
<div className="flex flex-wrap mb-4">
  {data.users.map((user) => {
    return <div key={user.id}
                className="m-4 w-1/4 rounded overflow-hidden shadow-lg"
                onClick={this.props.selectUser.bind(this, user)}>
      <UserAvatar user={user} />
    </div>
  })}
</div>
// ...
Books

// src/components/Books.js
import React, { Fragment } from 'react';
const Books = ({ books }) => (
  <Fragment>
    {books.map((book) =>
      <div key={book.id} className="flex border-b border-solid border-grey-light">
        <div className="w-3/4 p-4">
          <h3>{book.title}</h3>
          <p className="text-grey-darker">
            {[...Array(book.averageRating).keys()].map((s) =>
              <span key={s}>&#9733;</span>
            )}
          </p>
        </div>
        <div className="w-1/4 p-4 text-right">
          <img src={book.coverUrl} alt={book.title} />
        </div>
      </div>
    )}
  </Fragment>
);
export default Books;
App

Then, we must call the User component from our main App component. In here, we can hook up the action to show & hide user profiles on click. We're also storing the selected customer profile in the state object.

// src/components/App.js
import React, { Component } from 'react';
import Users from './Users';
import User from './User';

class App extends Component {
  state = {
    selectedUser: null
  };

  selectUser = (user) => {
    this.setState({ selectedUser: user })
  }

  render() {
    return (
      <div className="container mx-auto px-4">
        {this.state.selectedUser ?
          <User user={this.state.selectedUser} selectUser={this.selectUser} /> :
          <Users selectUser={this.selectUser} />}
      </div>
    );
  }
}
export default App;
Creating a New User

To create users via mutations, we need a new component in src/components/CreateUser.js. Here we will need to import the Apollo Mutation and write the query.

// src/components/CreateUser.js
import React, { Component } from 'react';
import gql from "graphql-tag";
import { Mutation } from "react-apollo";

const CREATE_USER = gql`
  mutation CreateUser($name: String!, $email: String!) {
    createUser(input: { name: $name, email: $email }) {
      user {
        id
        name
        email
        booksCount
      }
      errors
    }
  }
`;
Then, we define our component and its initial state.

// src/components/CreateUser.js
class CreateUser extends Component {
  state = {
    name: '',
    email: ''
  }
  onSubmit = () => {
    // We'll implement this later
  }
  render() {
    return (
      <Mutation mutation={CREATE_USER}>
        <!-- implemented later -->
      </Mutation>
    );
  }
}
export default CreateUser;
The full mutation

<Mutation
  mutation={CREATE_USER}
  update={this.props.onCreateUser}>
  {createUserMutation => (
    <form className="px-8 pt-6 pb-8 mb-4" onSubmit={e => this.onSubmit(e, createUserMutation)}>
      <h4 className="mb-3">Create new user</h4>
      <div className="mb-4">
        <input
          className="border rounded w-full py-2 px-3"
          type="text"
          value={this.state.name}
          placeholder="Name"
          onChange={e => this.setState({ name: e.target.value })} />
      </div>
      <div className="mb-6">
        <input
          className="border rounded w-full py-2 px-3"
          type="email"
          value={this.state.email}
          placeholder="Email"
          onChange={e => this.setState({ email: e.target.value })} />
      </div>
      <button
        className="bg-blue text-white py-2 px-4 rounded"
        type="submit">
        Create
      </button>
    </form>
  )}
</Mutation>
Submitting the form

onSubmit = (e, createUser) => {
  e.preventDefault();
  createUser({ variables: this.state });
  this.setState({ name: '', email: '' });
}
Rendering the CreateUser component

// src/components/Users.js
import CreateUser from './CreateUser';
// ....
<div className="flex flex-wrap mb-4">
  <Fragment>
    {data.users.map((user) => {
      // truncated
    })}
    <div className="m-4 w-1/4 rounded overflow-hidden shadow-lg">
      <CreateUser onCreateUser={this.updateUsers} />
    </div>
  </Fragment>
</div>
Dynamically updating the cache for the list of users on the index page

// ....
// src/components/Users.js
class Users extends Component {
  updateUsers = (cache, { data: { createUser } }) => {
    const { users } = cache.readQuery({ query: USERS_QUERY });
    cache.writeQuery({
      query: USERS_QUERY,
      data: { users: users.concat([createUser.user]) },
    });
  }
Learning all this tech at once is a lot to take in, and it's difficult to package it all together in an easy to digest way. I hope these tutorials helped point you in the right direction. Feel free to reach out if you have any questions about what I went over here!

阅读量: 664
发布于:
修改于: