Devise is an awesome gem to add authentication for your rails application, but it may me a bit overkill solution if you are going to develop REST or GraphQL API for your SPA or mobile application. Today I'll show you how to create GraphQL API without devise. I'll use some methods that become available in rails 7.1.0.beta1, so please install this or higher version.
Project preparation
Firstly, let's create our rails application. Of course - you can use an --api
key to skip adding asset pipeline to your project, but I'll use basic template because I need graphiql-rails gem.
$ rails new graphql_from_scratch --database=postgresql --skip-test --skip-system-test -j bun
$ cd graphql_from_scratch/
$ rails db:create && rails db:migrate
I prefer to use UUID, so I'll create the following migration with rails g migration EnableUuidPsqlExtension
class EnableUuidPsqlExtension < ActiveRecord::Migration[7.0]
def change
enable_extension "pgcrypto"
enable_extension "uuid-ossp"
end
end
Also, I'll add some test-relates gems:
$ bundle add rspec-rails shoulda-matchers factory_bot_rails ffaker database_cleaner database_cleaner-active_record email_spec --group "development, test"
$ rails g rspec:install
Then I'll create config/initializers/generators.rb
file
Rails.application.config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
g.helper false
g.test_framework :rspec,
fixtures: false,
view_specs: false,
helper_specs: false,
routing_specs: false
end
update spec/rails_helper.rb
require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
require "rspec/rails"
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
config.include FactoryBot::Syntax::Methods
### Database Cleaner
config.before(:suite) { DatabaseCleaner.clean_with(:truncation) }
config.before(:each) { DatabaseCleaner.strategy = :transaction }
config.before(:each, js: true) { DatabaseCleaner.strategy = :truncation }
config.before(:each) { DatabaseCleaner.start }
config.after(:each) { DatabaseCleaner.clean }
end
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
and spec/spec_helper.rb
require "action_mailer"
require "email_spec"
require "email_spec/rspec"
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
end
config.shared_context_metadata_behavior = :apply_to_host_groups
end
Next, we need to add graphql to our Gemfile
$ bundle add graphql
$ bundle add graphiql-rails --group "development"
$ rails g graphql:install
You can start our rails server with ./bin/dev
and open http://localhost:3000/graphiql to test our API with following query
{
testField
}
you will see
{
"data": {
"testField": "Hello World!"
}
}
It means that our API works fine. Don't forget to remove from app/graphql/types/query_type.rb
following lines when you'll be ready for production.
field :test_field, String, null: false,
description: "An example field added by the generator"
def test_field
"Hello World!"
end
We also need to update app/graphql/mutations/base_mutation.rb
file and to add two additional fields - boolean success
and array of errors
. Now our file will look like
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
argument_class Types::BaseArgument
field_class Types::BaseField
input_object_class Types::BaseInputObject
object_class Types::BaseObject
field :success, Boolean # <-- add this line
field :errors, [String] # <-- add this line
end
end
Well, now we can start working on our API.
User model
Let's create our User model
$ rails g model User first_name last_name email password_digest
then update our migration
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users, id: :uuid do |t|
t.string :first_name, null: false, default: ""
t.string :last_name, null: false, default: ""
t.string :email, null: false, default: ""
t.string :password_digest
t.timestamps
end
end
end
and model
class User < ApplicationRecord
EMAIL_REGEXP = /\A[^@\s]+@[^@\s]+\z/
has_secure_password
validates :first_name, presence: true, on: :update
validates :last_name, presence: true, on: :update
validates :password, presence: {on: create}, length: {minimum: 8, maximum: 128}
validates :email, presence: true, uniqueness: {case_sensitive: false}, format: EMAIL_REGEXP
normalizes :email, with: -> { _1.strip.downcase }
### Name
def name=(full_name)
self.first_name, self.last_name = full_name.to_s.squish.split(/\s/, 2)
end
def name
[first_name, last_name].join(" ")
end
def to_s
name
end
end
I'd like to point you to the following line
normalizes :email, with: -> { _1.strip.downcase }
Earlier, before rails 7.1 we've used something like
before_validation do
email&.downcase!&.strip!
end
but now our code look more compact and readable.
Also, we need to create UserType
for our API
app/graphql/types/user_type.rb
# frozen_string_literal: true
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :first_name, String
field :last_name, String
field :name, String, null: false
field :email, String, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
end
end
Let's add some basic specs in spec/models/user_spec.rb
require "rails_helper"
RSpec.describe User, type: :model do
describe "validations" do
it { should validate_presence_of(:first_name).on(:update) }
it { should validate_presence_of(:last_name).on(:update) }
it { should validate_presence_of(:email) }
it { should validate_presence_of(:password) }
end
end
and some code to spec/factories/users.rb
FactoryBot.define do
factory :user do
email { FFaker::Internet.email }
password { SecureRandom.hex }
first_name { FFaker::Name.first_name }
last_name { FFaker::Name.last_name }
end
end
Don't forget to uncomment bcrypt
gem in Gemfile
and run bundle install
Now, our bundle exec rspec
execution should be successful.
Account registration - signUp method
First API method to develop - signUp.
- Input parameters:
name
,email
andpassword
- Result:
User
in json - Errors: Array of errors if any
- Description: Create User, then send email with confirmation link.
app/graphql/mutations/users/sign_up.rb
:
module Mutations::Users
class SignUp < Mutations::BaseMutation
graphql_name "signUp"
argument :name, String, required: true
argument :email, String, required: true
argument :password, String, required: true
field :user, Types::UserType, null: true
field :errors, Types::ValidationErrorsType, null: true
def resolve(args)
user = User.new(args)
if user.save
confirmation_token = user.generate_confirmation_token
UserMailer.confirmation(user, confirmation_token).deliver_now
{user: user, success: true}
else
{errors: user.errors, success: false}
end
end
end
end
As you may see - nothing complicated, but we need to add several things.
app/graphql/types/mutation_type.rb
- we need to update this file with reference to signUp
mutation:
module Types
class MutationType < Types::BaseObject
field :sign_up, mutation: Mutations::Users::SignUp # <-- add this line
end
end
app/graphql/types/validation_errors_type.rb
- This class is responsible for representing of error messages.
module Types
class ValidationErrorsType < Types::BaseObject
field :details, String, null: false
field :full_messages, [String], null: false
def details
object.details.to_json
end
end
end
Next - we need to add generate_confirmation_token
method to model User
:
class User < ApplicationRecord
#################### Add those lines
CONFIRMATION_TOKEN_EXP = 30.minutes
def generate_confirmation_token
signed_id expires_in: CONFIRMATION_TOKEN_EXP, purpose: :confirm_email
end
####################
end
I'd like to point you to signed_in
method, which is used to create expiable token with token purpose, we will use it in several other methods.
Also, we need to add UserMailer and related views.
app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def confirmation(user, confirmation_token)
@user = user
@confirmation_token = confirmation_token
mail to: @user.email, subject: "Confirmation Instructions"
end
end
app/views/user_mailer/confirmation.html.erb
<h1>Confirmation Instructions</h1>
<%= link_to "Click here to confirm your email.", confirmation_url(@confirmation_token) %>
app/views/user_mailer/confirmation.text.erb
Confirmation Instructions
<%= confirmation_url(@confirmation_token) %>
Let's try how it works in GraphQL console.
mutation {
signUp(
input: {
name: "Alexey Poimtsev"
email: "test@example.com"
password: "1234567890"
}
) {
user {
id
email
name
}
success
errors {
fullMessages
}
}
}
and voila - everything works fine:
{
"data": {
"signUp": {
"user": {
"id": "9375a213-cf56-4136-b8f8-38a5f8a8ba60",
"email": "test@example.com",
"name": "Alexey Poimtsev"
},
"success": true,
"errors": null
}
}
}
If we try to use incorrect input
mutation {
signUp(input: { name: "Alexey Poimtsev", email: "test@", password: "" }) {
user {
id
email
name
}
success
errors {
fullMessages
}
}
}
then we'll see following error message
{
"data": {
"signUp": {
"user": null,
"success": false,
"errors": {
"fullMessages": [
"Password can’t be blank",
"Password is too short (minimum is 8 characters)",
"Email is invalid"
]
}
}
}
}
And, of course we need to cover it with specs
spec/graphql/mutations/users/sign_up_spec.rb
require "rails_helper"
RSpec.describe "#signUp mutation" do
let(:mutation) do
<<~GQL
mutation signUp($name: String!, $email: String!, $password: String!) {
signUp(input: {
name: $name
email: $email
password: $password
}) {
user {
id
email
name
}
success
errors {
details
fullMessages
}
}
}
GQL
end
it "is successful with correct data" do
name = FFaker::Name.name
email = FFaker::Internet.email
result = GraphqlFromScratchSchema.execute(mutation, variables: {
name: name,
email: email,
password: SecureRandom.hex
})
expect(result.dig("data", "signUp", "user", "email")).to eq(email)
expect(result.dig("data", "signUp", "user", "name")).to eq(name)
expect(result.dig("data", "signUp", "success")).to eq(true)
expect(result.dig("data", "signUp", "errors")).to be_nil
end
it "fails in case of wrong email format" do
wrong_email = "test.user"
result = GraphqlFromScratchSchema.execute(mutation, variables: {
name: FFaker::Name.name,
email: wrong_email,
password: SecureRandom.hex
})
expect(result.dig("data", "signUp", "user")).to be_nil
expect(result.dig("data", "signUp", "success")).to eq(false)
expect(result.dig("data", "signUp", "errors", "details")).to eq("{\"email\":[{\"error\":\"invalid\",\"value\":\"#{wrong_email}\"}]}")
expect(result.dig("data", "signUp", "errors", "fullMessages")).to include("Email is invalid")
end
it "fails in case of no password" do
result = GraphqlFromScratchSchema.execute(mutation, variables: {
name: FFaker::Name.name,
email: FFaker::Internet.email,
password: ""
})
expect(result.dig("data", "signUp", "user")).to be_nil
expect(result.dig("data", "signUp", "success")).to eq(false)
expect(result.dig("data", "signUp", "errors", "details")).to eq("{\"password\":[{\"error\":\"blank\"},{\"error\":\"too_short\",\"count\":8}]}")
expect(result.dig("data", "signUp", "errors", "fullMessages")).to include("Password can’t be blank")
expect(result.dig("data", "signUp", "errors", "fullMessages")).to include("Password is too short (minimum is 8 characters)")
end
end
spec/mailers/user_mailer_spec.rb
require "rails_helper"
RSpec.describe UserMailer, type: :mailer do
describe "confirmation" do
let(:mail) { UserMailer.confirmation(FactoryBot.create(:user, email: "to@example.org"), "confirmation-token-0123456789") }
it "renders the headers" do
expect(mail.subject).to eq("Confirmation Instructions")
expect(mail.to).to eq(["to@example.org"])
expect(mail.from).to eq(["from@example.com"])
end
it "renders the body" do
expect(mail.body.encoded).to match("Click here to confirm your email.")
end
it "should be set to be delivered to the email passed in" do
expect(mail).to deliver_to("to@example.org")
end
it "should contain a link to the confirmation link" do
expect(mail).to have_body_text(/#{confirmation_url("confirmation-token-0123456789")}/)
end
end
end
spec/mailers/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user
class UserMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/user/confirmation
def confirmation
UserMailer.confirmation(User.first || FactoryBot.create(:user, email: "to@example.org"), "confirmation-token-0123456789")
end
end
Almost perfect, but when we run tests we will see NoMethodError: undefined method 'confirmation_url'
. To fix it, we need to add app/helpers/urls_helper.rb
module UrlsHelper
### We will use those helper methods to send requests to frontend
# Don't forget to replace `http://localhost:3000` with correct url.
def confirmation_url(token) = "http://localhost:3000/users/confirm/#{token}"
def confirmation_path(token) = "/users/confirm/#{token}"
end
and update app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
helper :urls # <-- add this line
end
Awesome! In next articles in the series, we will add new methods and improve existing code. Stay tuned!
Top comments (0)