Grape Gem 教程:如何在 Ruby 中构建类似 REST 的 API
已发表: 2022-03-11作为 Ruby On Rails 开发人员,我们经常需要使用 API 端点扩展我们的应用程序,以支持大量 JavaScript 的富 Internet 客户端或原生 iPhone 和 Android 应用程序。 在某些情况下,应用程序的唯一目的是通过 JSON API 为 iPhone/Android 应用程序提供服务。
在本教程中,我将演示如何使用 Grape(一种用于 Ruby 的类似 REST 的 API 微框架)在 Rails 中为 JSON API 构建后端支持。 Grape 旨在作为可安装的机架引擎运行,以补充我们的 Web 应用程序,而不会干扰它们。
用例
我们将在本教程中关注的用例是一个能够捕获和查看结对编程会话的应用程序。 应用程序本身将使用 ObjectiveC 为 iOS 编写,并且需要与后端服务通信以存储和检索数据。 我们在本教程中的重点是创建支持 JSON API 的强大且安全的后端服务。
API 将支持以下方法:
- 登录系统
- 查询结对编程会话评论
注意:除了提供查询结对编程会话评论的能力之外,真正的 API 还需要提供一种工具来提交结对编程评论以包含在数据库中。 由于通过 API 支持这一点超出了本教程的范围,我们将简单地假设数据库已经填充了一组结对编程评论的示例。
关键技术要求包括:
- 每个 API 调用都必须返回有效的 JSON
- 每个失败的 API 调用都必须记录足够的上下文和信息,以便随后可重现,并在必要时进行调试
此外,由于我们的应用程序需要为外部客户端提供服务,因此我们需要关注安全性。 为此:
- 每个请求都应仅限于我们跟踪的一小部分开发人员
- 所有请求(登录/注册除外)都需要经过身份验证
测试驱动开发和 RSpec
我们将使用测试驱动开发 (TDD) 作为我们的软件开发方法,以帮助确保我们 API 的确定性行为。
出于测试目的,我们将使用 RSpec,这是 RubyOnRails 社区中众所周知的测试框架。 因此,我将在本文中提到“规范”而不是“测试”。
全面的测试方法包括“阳性”和“阴性”测试。 负面规范将指定,例如,如果某些参数丢失或不正确,API 端点的行为方式。 正面规范涵盖了正确调用 API 的情况。
入门
让我们为我们的后端 API 打下基础。 首先,我们需要创建一个新的 Rails 应用程序:
rails new toptal_grape_blog
接下来,我们将通过将rspec-rails
添加到我们的 gemfile 中来安装 RSpec:
group :development, :test do gem 'rspec-rails', '~> 3.2' end
然后从我们的命令行中,我们需要运行:
rails generate rspec:install
我们还可以将一些现有的开源软件用于我们的测试框架。 具体来说:
- Devise - 基于 Warden 的灵活的 Rails 身份验证解决方案
- factory_girl_rails - 为 factory_girl 提供 Rails 集成,这是一个用于将 Ruby 对象设置为测试数据的库
第 1 步:将这些添加到我们的 gemfile 中:
... gem 'devise' ... group :development, :test do ... gem 'factory_girl_rails', '~> 4.5' ... end
第 2 步:生成用户模型,初始化devise
gem,并将其添加到用户模型中(这使用户类能够用于身份验证)。
rails g model user rails generate devise:install rails generate devise user
第 3 步:在我们的rails_helper.rb
文件中包含factory_girl
语法方法,以便在我们的规范中使用用户创建的缩写版本:
RSpec.configure do |config| config.include FactoryGirl::Syntax::Methods
第 4 步:将葡萄 gem 添加到我们的 DSL 并安装它:
gem 'grape' bundle
用户登录用例和规范
我们的后端需要支持基本的登录功能。 让我们为login_spec
创建框架,假设有效的登录请求包含注册的电子邮件地址和密码对:
require 'rails_helper' describe '/api/login' do context 'negative tests' do context 'missing params' do context 'password' do end context 'email' do end end context 'invalid params' do context 'incorrect password' do end context 'with a non-existent login' do end end end context 'positive tests' do context 'valid params' do end end end
如果缺少任何一个参数,客户端应收到 HTTP 返回状态代码 400(即错误请求),以及“缺少电子邮件”或“缺少密码”的错误消息。
对于我们的测试,我们将创建一个有效用户并将用户的电子邮件和密码设置为此测试套件的原始参数。 然后我们将通过省略密码/电子邮件或覆盖它来为每个特定规范自定义此参数哈希。
让我们在规范的开头创建用户和参数哈希。 我们将把这段代码放在描述块之后:
describe '/api/login' do let(:email) { user.email } let(:password) { user.password } let!(:user) { create :user } let(:original_params) { { email: email, password: password } } let(:params) { original_params } ...
然后我们可以扩展我们的“缺少参数”/“密码”上下文,如下所示:
let(:params) { original_params.except(:password) } it_behaves_like '400' it_behaves_like 'json result' it_behaves_like 'contains error msg', 'password is missing'
但是,我们可以使用与期望相同的共享示例,而不是在“电子邮件”和“密码”上下文中重复期望。 为此,我们需要在rails_helper.rb
文件中取消注释这一行:
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
然后我们需要将 3 个 RSpec 共享示例添加到spec/support/shared.rb
:
RSpec.shared_examples 'json result' do specify 'returns JSON' do api_call params expect { JSON.parse(response.body) }.not_to raise_error end end RSpec.shared_examples '400' do specify 'returns 400' do api_call params expect(response.status).to eq(400) end end RSpec.shared_examples 'contains error msg' do |msg| specify "error msg is #{msg}" do api_call params json = JSON.parse(response.body) expect(json['error_msg']).to eq(msg) end end
这些共享示例调用了api_call
方法,这使我们能够在规范中只定义一次 API 端点(符合 DRY 原则)。 我们定义这个方法如下:
describe '/api/login' do ... def api_call *params post "/api/login", *params end ...
我们还需要为我们的用户定制工厂:
FactoryGirl.define do factory :user do password "Passw0rd" password_confirmation { |u| u.password } sequence(:email) { |n| "test#{n}@example.com" } end end
最后,在运行我们的规范之前,我们需要运行我们的迁移:
rake db:migrate
但是请记住,此时规范仍然会失败,因为我们还没有实现我们的 API 端点。 那是下一个。
实现登录 API 端点
首先,我们将为登录 API ( app/api/login.rb
) 编写一个空框架:
class Login < Grape::API format :json desc 'End-points for the Login' namespace :login do desc 'Login via email and password' params do requires :email, type: String, desc: 'email' requires :password, type: String, desc: 'password' end post do end end end
接下来,我们将编写一个聚合器类来聚合 API 端点( app/api/api.rb
):
class API < Grape::API prefix 'api' mount Login end
好的,现在我们可以在路由中挂载我们的 API:
Rails.application.routes.draw do ... mount API => '/' ... end
现在让我们添加代码来检查缺少的参数。 我们可以通过从Grape::Exceptions::ValidationErrors
中救援来将该代码添加到api.rb
中。
rescue_from Grape::Exceptions::ValidationErrors do |e| rack_response({ status: e.status, error_msg: e.message, }.to_json, 400) end
对于无效密码,我们会检查http响应码是否为401,表示未经授权的访问。 让我们将其添加到“不正确的密码”上下文中:
let(:params) { original_params.merge(password: 'invalid') } it_behaves_like '401' it_behaves_like 'json result' it_behaves_like 'contains error msg', 'Bad Authentication Parameters'
然后将相同的逻辑添加到“不存在登录”上下文中。
然后,我们在login.rb
中实现处理无效身份验证尝试的逻辑,如下所示:
post do user = User.find_by_email params[:email] if user.present? && user.valid_password?(params[:password]) else error_msg = 'Bad Authentication Parameters' error!({ 'error_msg' => error_msg }, 401) end end
此时登录 api 的所有负面规范都会正常运行,但我们仍然需要支持登录 api 的正面规范。 我们的正面规范将期望端点返回一个带有有效 JSON 和有效令牌的 HTTP 响应代码 200(即成功):
it_behaves_like '200' it_behaves_like 'json result' specify 'returns the token as part of the response' do api_call params expect(JSON.parse(response.body)['token']).to be_present end
让我们还将响应代码 200 的期望添加到spec/support/shared.rb
:
RSpec.shared_examples '200' do specify 'returns 200' do api_call params expect(response.status).to eq(200) end end
如果登录成功,我们将返回第一个有效的 authentication_token 以及用户的电子邮件,格式如下:
{'email':<the_email_of_the_user>, 'token':<the users first valid token>}
如果还没有这样的令牌,那么我们将为当前用户创建一个:
... if user.present? && user.valid_password?(params[:password]) token = user.authentication_tokens.valid.first || AuthenticationToken.generate(user) status 200 else ...
为了让它工作,我们需要一个属于用户的AuthenticationToken
类。 我们将生成这个模型,然后运行相应的迁移:
rails g model authentication_token token user:references expires_at:datetime rake db:migrate
我们还需要在我们的用户模型中添加相应的关联:
class User < ActiveRecord::Base has_many :authentication_tokens end
然后我们将有效范围添加到AuthenticationToken
类:
class AuthenticationToken < ActiveRecord::Base belongs_to :user validates :token, presence: true scope :valid, -> { where{ (expires_at == nil) | (expires_at > Time.zone.now) } } end
请注意,我们在where
语句中使用了 Ruby 语法。 这是通过我们使用squeel
gem 实现的,它支持在 activerecord 查询中支持 Ruby 语法。
对于经过验证的用户,我们将创建一个实体,我们将其称为“具有令牌实体的用户”,利用grape-entity
gem 的特性。
让我们为我们的实体编写规范并将其放入user_with_token_entity_spec.rb
文件中:
require 'rails_helper' describe Entities::UserWithTokenEntity do describe 'fields' do subject(:subject) { Entities::UserWithTokenEntity } specify { expect(subject).to represent(:email)} let!(:token) { create :authentication_token } specify 'presents the first available token' do json = Entities::UserWithTokenEntity.new(token.user).as_json expect(json[:token]).to be_present end end end
接下来让我们将实体添加到user_entity.rb
:
module Entities class UserEntity < Grape::Entity expose :email end end
最后,向user_with_token_entity.rb
添加另一个类:
module Entities class UserWithTokenEntity < UserEntity expose :token do |user, options| user.authentication_tokens.valid.first.token end end end
由于我们不希望令牌无限期地保持有效,我们让它们在一天后过期:
FactoryGirl.define do factory :authentication_token do token "MyString" expires_at 1.day.from_now user end end
完成这一切后,我们现在可以使用我们新编写的UserWithTokenEntity
返回预期的 JSON 格式:
... user = User.find_by_email params[:email] if user.present? && user.valid_password?(params[:password]) token = user.authentication_tokens.valid.first || AuthenticationToken.generate(user) status 200 present token.user, with: Entities::UserWithTokenEntity else ...
凉爽的。 现在我们所有的规范都通过了,基本登录 api 端点的功能要求都得到了支持。
结对编程会话审查 API 端点:入门
我们的后端将需要允许已登录的授权开发人员查询结对编程会话评论。
我们的新 API 端点将挂载到/api/pair_programming_session
并将返回属于项目的评论。 让我们首先为这个规范编写一个基本框架:
require 'rails_helper' describe '/api' do describe '/pair_programming_session' do def api_call *params get '/api/pair_programming_sessions', *params end context 'invalid params' do end context 'valid params' do end end end
我们还将编写一个相应的空 API 端点( app/api/pair_programming_sessions.rb
):
class PairProgrammingSessions < Grape::API format :json desc 'End-points for the PairProgrammingSessions' namespace :pair_programming_sessions do desc 'Retrieve the pairprogramming sessions' params do requires :token, type: String, desc: 'email' end get do end end end
然后让我们挂载我们的新 api ( app/api/api.rb
):
... mount Login mount PairProgrammingSessions end
让我们根据需求一一扩展规范和 API 端点。
结对编程会话审查 API 端点:验证
我们最重要的非功能性安全要求之一是将 API 访问限制为我们跟踪的一小部分开发人员,因此让我们指定:
... def api_call *params get '/api/pair_programming_sessions', *params end let(:token) { create :authentication_token } let(:original_params) { { token: token.token} } let(:params) { original_params } it_behaves_like 'restricted for developers' context 'invalid params' do ...
然后我们将在我们的shared.rb
中创建一个shared_example
来确认请求来自我们的一位注册开发者:
RSpec.shared_examples 'restricted for developers' do context 'without developer key' do specify 'should be an unauthorized call' do api_call params expect(response.status).to eq(401) end specify 'error code is 1001' do api_call params json = JSON.parse(response.body) expect(json['error_code']).to eq(ErrorCodes::DEVELOPER_KEY_MISSING) end end end
我们还需要创建一个ErrorCodes
类(在app/models/error_codes.rb
中):

module ErrorCodes DEVELOPER_KEY_MISSING = 1001 end
由于我们希望我们的 API 在未来扩展,我们将实现一个authorization_helper
可以在应用程序中的所有 API 端点重用,以限制仅注册开发人员的访问:
class PairProgrammingSessions < Grape::API helpers ApiHelpers::AuthenticationHelper before { restrict_access_to_developers }
我们将在ApiHelpers::AuthenticationHerlper
模块 ( app/api/api_helpers/authentication_helper.rb
) 中定义方法restrict_access_to_developers
。 此方法将简单地检查标题下的密钥Authorization
是否包含有效的ApiKey
。 (每个想要访问 API 的开发人员都需要一个有效的ApiKey
。这可以由系统管理员或通过一些自动注册过程提供,但该机制超出了本文的范围。)
module ApiHelpers module AuthenticationHelper def restrict_access_to_developers header_token = headers['Authorization'] key = ApiKey.where{ token == my{ header_token } } Rails.logger.info "API call: #{headers}\tWith params: #{params.inspect}" if ENV['DEBUG'] if key.blank? error_code = ErrorCodes::DEVELOPER_KEY_MISSING error_msg = 'please aquire a developer key' error!({ :error_msg => error_msg, :error_code => error_code }, 401) # LogAudit.new({env:env}).execute end end end end
然后我们需要生成 ApiKey 模型并运行迁移: rails g model api_key token rake db:migrate
完成后,在我们的spec/api/pair_programming_spec.rb
,我们可以检查用户是否已通过身份验证:
... it_behaves_like 'restricted for developers' it_behaves_like 'unauthenticated' ...
让我们还定义一个可以在所有规范中重用的未经身份unauthenticated
的共享示例( spec/support/shared.rb
):
RSpec.shared_examples 'unauthenticated' do context 'unauthenticated' do specify 'returns 401 without token' do api_call params.except(:token), developer_header expect(response.status).to eq(401) end specify 'returns JSON' do api_call params.except(:token), developer_header json = JSON.parse(response.body) end end end
这个共享示例需要开发者标头中的令牌,所以让我们将其添加到我们的规范( spec/api/pair_programming_spec.rb
)中:
... describe '/api' do let(:api_key) { create :api_key } let(:developer_header) { {'Authorization' => api_key.token} } ...
现在,在我们的app/api/pair_programming_session.rb
中,让我们尝试对用户进行身份验证:
... class PairProgrammingSessions < Grape::API helpers ApiHelpers::AuthenticationHelper before { restrict_access_to_developers } before { authenticate! } ...
让我们实现authenticate!
AuthenticationHelper
( app/api/api_helpers/authentication_helper.rb
) 中的方法:
... module ApiHelpers module AuthenticationHelper TOKEN_PARAM_NAME = :token def token_value_from_request(token_param = TOKEN_PARAM_NAME) params[token_param] end def current_user token = AuthenticationToken.find_by_token(token_value_from_request) return nil unless token.present? @current_user ||= token.user end def signed_in? !!current_user end def authenticate! unless signed_in? AuditLog.create data: 'unauthenticated user access' error!({ :error_msg => "authentication_error", :error_code => ErrorCodes::BAD_AUTHENTICATION_PARAMS }, 401) end end ...
(请注意,我们需要将错误代码BAD_AUTHENTICATION_PARAMS
添加到我们的ErrorCodes
类中。)
接下来,让我们来看看如果开发人员使用无效令牌调用 API 会发生什么。 在这种情况下,返回代码将是 401,表示“未经授权的访问”。 结果应该是 JSON 并且应该创建一个可审计的。 所以我们将它添加到spec/api/pair_programming_spec.rb
:
... context 'invalid params' do context 'incorrect token' do let(:params) { original_params.merge(token: 'invalid') } it_behaves_like '401' it_behaves_like 'json result' it_behaves_like 'auditable created' it_behaves_like 'contains error msg', 'authentication_error' it_behaves_like 'contains error code', ErrorCodes::BAD_AUTHENTICATION_PARAMS end end ...
我们将在spec/support/shared.rb
中添加“auditable created”、“contains error code”和“contains error msg”共享示例:
... RSpec.shared_examples 'contains error code' do |code| specify "error code is #{code}" do api_call params, developer_header json = JSON.parse(response.body) expect(json['error_code']).to eq(code) end end RSpec.shared_examples 'contains error msg' do |msg| specify "error msg is #{msg}" do api_call params, developer_header json = JSON.parse(response.body) expect(json['error_msg']).to eq(msg) end end RSpec.shared_examples 'auditable created' do specify 'creates an api call audit' do expect do api_call params, developer_header end.to change{ AuditLog.count }.by(1) end end ...
我们还需要创建一个 audit_log 模型:
rails g model audit_log backtrace data user:references rake db:migrate
结对编程会话审查 API 端点:返回结果
对于经过身份验证和授权的用户,对此 API 端点的调用应返回一组按项目分组的结对编程会话评论。 让我们相应地修改我们的spec/api/pair_programming_spec.rb
:
... context 'valid params' do it_behaves_like '200' it_behaves_like 'json result' end ...
这指定使用有效api_key
和有效参数提交的请求返回 HTTP 代码 200(即成功),并且结果以有效 JSON 的形式返回。
我们将查询并以 JSON 格式返回任何参与者都是当前用户的结对编程会话 ( app/api/pair_programming_sessions.rb
):
... get do sessions = PairProgrammingSession.where{(host_user == my{current_user}) | (visitor_user == my{current_user})} sessions = sessions.includes(:project, :host_user, :visitor_user, reviews: [:code_samples, :user] ) present sessions, with: Entities::PairProgrammingSessionsEntity end ...
结对编程会话在数据库中建模如下:
- 项目和结对编程会话之间的一对多关系
- 结对编程会话和评论之间的一对多关系
- 评论和代码示例之间的一对多关系
让我们相应地生成模型,然后运行迁移:
rails g model project name rails g model pair_programming_session project:references host_user:references visitor_user:references rails g model review pair_programming_session:references user:references comment rails g model code_sample review:references code:text rake db:migrate
然后我们需要修改我们的PairProgrammingSession
和Review
类以包含has_many
关联:
class Review < ActiveRecord::Base belongs_to :pair_programming_session belongs_to :user has_many :code_samples end class PairProgrammingSession < ActiveRecord::Base belongs_to :project belongs_to :host_user, class_name: :User belongs_to :visitor_user, class_name: 'User' has_many :reviews end
注意:在正常情况下,我会先为它们编写规范来生成这些类,但由于这超出了本文的范围,我将跳过这一步。
现在我们需要编写那些将我们的模型转换为它们的 JSON 表示的类(在葡萄术语中称为葡萄实体)。 为简单起见,我们将在模型和葡萄实体之间使用一对一的映射。
我们首先公开CodeSampleEntity
中的code
字段(在api/entities/code_sample_entity.rb
):
module Entities class CodeSampleEntity < Grape::Entity expose :code end end
然后我们通过重用已经定义的UserEntity
和code_samples
来公开user
和相关的CodeSampleEntity
:
module Entities class ReviewEntity < Grape::Entity expose :user, using: UserEntity expose :code_samples, using: CodeSampleEntity end end
我们还公开了ProjectEntity
的name
字段:
module Entities class ProjectEntity < Grape::Entity expose :name end end
最后,我们将实体组装成一个新的PairProgrammingSessionsEntity
,我们在其中公开project
、 host_user
、 visitor_user
和reviews
:
module Entities class PairProgrammingSessionsEntity < Grape::Entity expose :project, using: ProjectEntity expose :host_user, using: UserEntity expose :visitor_user, using: UserEntity expose :reviews, using: ReviewEntity end end
这样,我们的 API 就完全实现了!
生成测试数据
出于测试目的,我们将在db/seeds.rb
中创建一些示例数据。 该文件应包含使用其默认值为数据库播种所需的所有记录创建。 然后可以使用rake db:seed
加载数据(或在调用db:setup
时使用 db 创建)。 这是一个可能包括的示例:
user_1 = User.create email: '[email protected]', password: 'password', password_confirmation: 'password' user_2 = User.create email: '[email protected]', password: 'password', password_confirmation: 'password' user_3 = User.create email: '[email protected]', password: 'password', password_confirmation: 'password' ApiKey.create token: '12345654321' project_1 = Project.create name: 'Time Sheets' project_2 = Project.create name: 'Toptal Blog' project_3 = Project.create name: 'Hobby Project' session_1 = PairProgrammingSession.create project: project_1, host_user: user_1, visitor_user: user_2 session_2 = PairProgrammingSession.create project: project_2, host_user: user_1, visitor_user: user_3 session_3 = PairProgrammingSession.create project: project_3, host_user: user_2, visitor_user: user_3 review_1 = session_1.reviews.create user: user_1, comment: 'Please DRY a bit your code' review_2 = session_1.reviews.create user: user_1, comment: 'Please DRY a bit your specs' review_3 = session_2.reviews.create user: user_1, comment: 'Please DRY your view templates' review_4 = session_2.reviews.create user: user_1, comment: 'Please clean your N+1 queries' review_1.code_samples.create code: 'Lorem Ipsum' review_1.code_samples.create code: 'Do not abuse the single responsibility principle' review_2.code_samples.create code: 'Use some shared examples' review_2.code_samples.create code: 'Use at the beginning of specs'
现在我们的应用程序已经可以使用了,我们可以启动我们的 Rails 服务器了。
测试 API
我们将使用 Swagger 对我们的 API 进行一些基于浏览器的手动测试。 我们需要一些设置步骤才能使用 Swagger。
首先,我们需要在 gemfile 中添加几个 gem:
... gem 'grape-swagger' gem 'grape-swagger-ui' ...
然后我们运行bundle
来安装这些 gem。
我们还需要将这些添加到资产到我们的资产管道中(在config/initializers/assets.rb
):
Rails.application.config.assets.precompile += %w( swagger_ui.js ) Rails.application.config.assets.precompile += %w( swagger_ui.css )
最后,在app/api/api.rb
我们需要挂载 swagger 生成器:
... add_swagger_documentation end ...
现在我们可以通过访问http://localhost:3000/api/swagger
来利用 Swagger 的漂亮 UI 来探索我们的 API。
Swagger 以一种很好探索的方式展示了我们的 API 端点。 如果我们点击一个端点,Swagger 会列出它的操作。 如果我们单击一个操作,Swagger 会显示其必需和可选参数及其数据类型。
在我们继续之前,还有一个细节:由于我们使用有效的api_key
限制了 API 开发人员的使用,我们将无法直接从浏览器访问 API 端点,因为服务器将需要 HTTP 标头中的有效api_key
。 我们可以通过使用 Modify Headers for Google Chrome 插件在 Google Chrome 中完成此操作以进行测试。 这个插件将使我们能够编辑 HTTP 标头并添加一个有效的api_key
(我们将使用我们的数据库种子文件中包含的api_key
的虚拟12345654321
)。
好的,现在我们准备好测试了!
为了调用pair_programming_sessions
API 端点,我们首先需要登录。我们将使用数据库种子文件中的电子邮件和密码组合,并通过 Swagger 将其提交到登录 API 端点,如下所示。
正如您在上面看到的,属于该用户的令牌被返回,表明登录 API 正在按预期正常工作。 我们现在可以使用该令牌成功执行GET /api/pair_programming_sessions.json
操作。
如图所示,结果作为格式正确的分层 JSON 对象返回。 请注意,JSON 结构反映了两个嵌套的一对多关联,因为项目有多个评论,而一个评论有多个代码示例。 如果我们不以这种方式返回结构,那么我们 API 的调用者将需要单独请求每个项目的评论,这需要向我们的 API 端点提交 N 个查询。 通过这种结构,我们因此解决了 N+1 查询性能问题。
包起来
如本文所示,您的 API 的全面规范有助于确保实施的 API 正确且充分地解决预期(和非预期!)用例。
虽然本教程中介绍的示例 API 相当基本,但我们展示的方法和技术可以作为使用 Grape gem 的任意复杂度的更复杂 API 的基础。 本教程希望证明 Grape 是一个有用且灵活的 gem,可以帮助您在 Rails 应用程序中实现 JSON API。 享受!