Last active
October 20, 2016 14:15
-
-
Save bf4/abd3ba8d0601f089817bf9ffabf91cc3 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# config/initializers/active_model_serializers.rb | |
ActiveModelSerializers.config.key_transform = :unaltered | |
ActiveModelSerializers.config.schema_path = 'docs/schema/schemata' | |
ActiveSupport.on_load(:action_controller) do | |
require 'active_model_serializers/register_jsonapi_renderer' | |
end | |
# Routing constraint: | |
# Request 'Content-Type' must be 'application/vnd.api+json' | |
# Response properties: | |
# 'Content-Type' header will be 'application/vnd.api+json' except for 'head' responses | |
# Usage: | |
# scope constraints: ApiConstraints.new(content_type: 'application/vnd.api+json'.freeze) do | |
class ApiConstraints | |
def initialize(options) | |
@content_type = options[:content_type] | |
end | |
def matches?(req) | |
content_type = req.headers['Content-Type'] | |
return false if content_type.nil? | |
content_type?(content_type) | |
end | |
# Handle 404 in exceptions_apps `respond_to do |format| format.jsonapi { render jsonapi: etc} end` block. | |
def content_type?(content_type) | |
return true if @content_type.nil? | |
content_type == @content_type) | |
end | |
end | |
class ApiController < ActionController::API | |
protect_from_forgery with: :null_session, if: proc { |c| c.request.format == 'application/vnd.api+json'.freeze } | |
private | |
def deserialized_params | |
ActionController::Parameters.new( | |
ActiveModelSerializers::Deserialization.jsonapi_parse!(params) | |
) | |
end | |
end | |
class User::ApiController < ApiController | |
# Not that `id` is not required in a JSON API create POST, but may be present. | |
def create | |
user = User.create(create_params) | |
if user.persisted? | |
render jsonapi: user, include: :posts, status: :created | |
else | |
render jsonapi: user, serializer: ActiveModel::Serializer::ErrorSerializer, status: :bad_request | |
end | |
end | |
# When the user is found and destroyed, the response content-type will be nil and the response body will be empty | |
def destroy | |
user_id = params.require(:id) | |
if user = User.find_by(id: user_id) | |
user.destroy! | |
head :no_content | |
else | |
error = { title: 'Not Found', detail: "User with id #{user_id} not found", status: "404" } | |
render jsonapi: { errors: [error]}.to_json, status: :not_found, location: nil | |
end | |
end | |
private | |
def create_params | |
deserialized_params.permit( | |
:name, | |
posts: [] | |
) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Adapted from https://blog.codelation.com/rails-restful-api-just-add-water/ | |
module Api::V3 | |
class ResourcesController < BaseController | |
class_attribute :find_resource_actions | |
self.find_resource_actions = [:destroy, :show, :update] | |
before_action :find_resource, only: find_resource_actions | |
hidden_actions = %w(resource resource_name resource_class resource_params resource_serializer error_response) | |
hide_action(*hidden_actions) | |
# GET /{plural_resource_name} | |
def index | |
self.resource = find_resources | |
yield resource if block_given? | |
render_options.reverse_merge!(jsonapi: resource, status: :ok, each_serializer: resource_serializer) | |
render render_options | |
end | |
# GET /{plural_resource_name}/:id | |
def show | |
render_options.reverse_merge!(jsonapi: resource, status: :ok, serializer: resource_serializer) | |
render render_options | |
end | |
# GET /{plural_resource_name}/new | |
# def new | |
# GET /{plural_resource_name}/edit | |
# def edit | |
# POST /{plural_resource_name} | |
def create | |
self.resource = resource_class_or_scope.new(resource_params) | |
if resource.save | |
yield resource if block_given? | |
render_options.reverse_merge!(jsonapi: resource, status: :created, serializer: resource_serializer) | |
render render_options | |
else | |
error_response | |
end | |
end | |
# PATCH/PUT /{plural_resource_name}/:id | |
def update | |
if resource.update(resource_params) | |
show | |
else | |
error_response | |
end | |
end | |
# DELETE /api/{plural_resource_name}/:id | |
def destroy | |
resource.destroy | |
yield resource if block_given? | |
head :no_content | |
end | |
def error_response | |
render_options.reverse_merge!(jsonapi: resource, status: :bad_request, serializer: ActiveModel::Serializer::ErrorSerializer) | |
render render_options | |
end | |
## Helpers | |
# Use callbacks to share common setup or constraints between actions. | |
# Sets the resource creating an instance variable | |
def resource=(new_resource) | |
instance_variable_set("@#{resource_name}", new_resource) | |
end | |
# Returns the resource from the created instance variable | |
# @return [Object] | |
def resource | |
instance_variable_get("@#{resource_name}") | |
end | |
# @return [ActiveRecord::Relation, ActiveRecord::Associations::CollectionProxy] | |
def find_resources | |
self.resource = resource_class_or_scope.all | |
end | |
# @return [ActiveRecord::Base, nil] nil if not found | |
# http://tenderlovemaking.com/2014/02/19/adequaterecord-pro-like-activerecord.html | |
def find_resource(id=(params[:id] || params[resource_class.primary_key])) | |
self.resource = resource_class_or_scope.find_by(resource_class.primary_key => id) | |
end | |
# Used by #find_resource, #find_resources | |
# Override for nested resources | |
# @example For /units/:unit_id/locations | |
# Locations need to be scoped by unit | |
# Then define this method in the controller as `@locations ||= Unit.find(params[:unit_id]).locations | |
# Which changes effective queries | |
# from Location.where to @locations.where and | |
# from Location.create to @locations.create. | |
def resource_class_or_scope | |
resource_class | |
end | |
# The resource class based on the controller | |
# @return [Class] | |
def resource_class | |
@resource_class ||= resource_name.classify.constantize | |
end | |
# The singular name for the resource class based on the controller | |
# @return [String] | |
def resource_name | |
@resource_name ||= controller_name.singularize | |
end | |
# Only allow a trusted parameter "white list" through. | |
# If a single resource is loaded for #create or #update, | |
# then the controller for the resource must implement | |
# the method "#{resource_name}_params" to limit permitted | |
# parameters for the individual model. | |
def resource_params | |
@resource_params ||= send("#{resource_name}_params") | |
end | |
def resource_serializer | |
instance_variable_get("@#{resource_name}_serializer") || self.resource_serializer = "#{self.class.parent}::#{resource_class.name}Serializer".constantize | |
end | |
def resource_serializer=(new_resource_serializer) | |
instance_variable_set("@#{resource_name}_serializer", new_resource_serializer) | |
end | |
def render_options | |
@render_options ||= {} | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Usage: | |
# let(:headers) { api_headers(3) } | |
# RSpec.describe Api::V3::BaseController, :jsonapi, type: :request do | |
# it "responds with 401 if user does not exist" do | |
# post '/thingee', create_params, headers | |
# assert_response :created | |
# expect(json_response).to eq(expected_body) | |
# end | |
# it 'responds with a 404 when the JSON API Accept header is missing', :show_exceptions, jsonapi: [:not_response, :not_request] do # etc. | |
# end | |
module ApiTesting | |
include ActiveModelSerializers::Test::Schema | |
def api_headers(version) | |
if version == 3 | |
{ | |
'Accept' => "application/vnd.api+json", | |
'Content-Type' => 'application/vnd.api+json' | |
} | |
else | |
{ | |
'Accept' => "application/vnd.example.v#{version}", | |
'Content-Type' => 'application/json' | |
} | |
end.merge!( | |
'HTTP_HOST' => 'api.example.com', | |
'connection' => 'close' | |
) | |
end | |
def request_payload | |
request.env["action_dispatch.request.request_parameters"].dup | |
end | |
def json_response | |
JSON.parse(response.body, symbolize_names: true) | |
end | |
def response_content_type | |
response.headers['Content-Type'] | |
end | |
def request_accepts | |
request.headers['Accept'] | |
end | |
def request_auth_header | |
request.headers['Authorization'] | |
end | |
def jsonapi_request? | |
# request.format == :jsonapi # Doesn't work for some reason | |
request.accepts.any?(&:jsonapi?) && | |
request_accepts.include?(jsonapi_content_type) | |
end | |
def jsonapi_response? | |
return true if response.body.empty? && response_content_type.nil? && jsonapi_request? | |
response_content_type == "#{jsonapi_content_type}; charset=utf-8" | |
end | |
# FIXME: bug in api schema that erroneously requires 'id' on POST create | |
def assert_jsonapi_request! | |
return unless jsonapi_request? && request_payload.present? | |
if request.post? | |
payload = request_payload | |
payload['data']['id'] = 'create_does_not_require_id' | |
assert_schema payload, api_schema | |
else | |
assert_request_schema api_schema | |
end | |
end | |
def assert_jsonapi_response! | |
return unless jsonapi_response? && response.body.present? | |
assert_response_schema api_schema | |
end | |
def assert_not_jsonapi_response! | |
return unless response.body.present? | |
error = assert_raises Minitest::Assertion do | |
assert_response_schema 'jsonapi.json' | |
end | |
assert_match(/failed schema/, error.message) | |
end | |
# from https://github.com/json-api/json-api/blob/c4df8dae8c8b1e97169b6d92193b62749240fbc5/schema | |
def api_schema | |
'jsonapi.json' | |
end | |
def jsonapi_content_type | |
'application/vnd.api+json' | |
end | |
def resource_object(object, attributes:, relationships: []) | |
resource = resource_object_identifier(object) | |
resource.merge!( | |
attributes: attributes.each_with_object({}) {|attribute, result| | |
result[attribute] = object.send(attribute) | |
} | |
) | |
if relationships.any? | |
resource.merge!( | |
relationships: relationships.map {|relationship| | |
rel = object.public_send(relationship) | |
data = resource_object_identifier(rel) | |
{ | |
relationship => { | |
data: data | |
} | |
} | |
} | |
) | |
resource[:relationships] = resource[:relationships].shift if resource[:relationships].size == 1 | |
end | |
resource | |
end | |
def resource_object_identifier(object, object_type=object_type(object)) | |
return nil if object.nil? | |
{id: object.id.to_s, type: object_type} | |
end | |
def resource_object_identifiers(objects) | |
object_type = object_type(objects.first) | |
objects.map { |object| resource_object_identifier(object, object_type) } | |
end | |
def object_type(object) | |
object.class.name.underscore.pluralize | |
end | |
end | |
RSpec.configure do |config| | |
config.include ApiTesting, type: :request | |
jsonapi_spec_missing_api_testing = ->(v) { !!v && !self.class.included_modules.include?(ApiTesting) } # rubocop:disable Style/DoubleNegation | |
config.include ApiTesting, jsonapi: jsonapi_spec_missing_api_testing | |
# When metadata has key `:jsonapi`, | |
# perform validations according to its values. Defaults to all validations. | |
# @usage any of: | |
# `:jsonapi`, | |
# `jsonapi: true` | |
run_all = ->(v) { v.nil? || v == true } | |
hashify = ->(input) { input.is_a?(Hash) ? input : (input == true ? {} : input.to_h) } # rubocop:disable Style/NestedTernaryOperator | |
includes = ->(input, value) { hashify.(input).key?(value) } | |
excludes = ->(input, value) { Array(hashify.(input)[:except]).include?(value) } | |
only = ->(input, value) { Array(hashify.(input)[:only]).include?(value) } | |
# @usage any of: | |
# `:jsonapi`, | |
# `jsonapi: true` | |
# `jsonapi: :request` | |
# `jsonapi: [:request]` | |
validate_request_schema = ->(v){ run_all.(v) || includes.(v, :request) } | |
config.after jsonapi: validate_request_schema do | |
next if request.nil? | |
assert_jsonapi_request! | |
end | |
# @usage | |
# disable request validation with any of: | |
# `jsonapi: { except: :request }` | |
# `jsonapi: { except: [:request] }` | |
validate_request_accepts_media_type = ->(v){ !excludes.(v, :request) } | |
config.after jsonapi: validate_request_accepts_media_type do | |
next if request.nil? | |
expect(jsonapi_request?).to eq(true) | |
end | |
# @usage any of: | |
# `:jsonapi` | |
# `jsonapi: true` | |
# `jsonapi: :response` | |
# `jsonapi: [:response]` | |
validate_response_schema = ->(v){ run_all.(v) || includes.(v, :response) } | |
config.after jsonapi: validate_response_schema do | |
next if response.nil? | |
assert_jsonapi_response! | |
end | |
# @usage disable with response validation with any of: | |
# `jsonapi: { except: :response }` | |
# `jsonapi: { except: [:response] }` | |
validate_response_content_type = ->(v){ !excludes.(v, :response) } | |
config.after jsonapi: validate_response_content_type do | |
next if response.nil? | |
expect(jsonapi_response?).to eq(true) | |
end | |
# @usage expect non-JSON API repsonse | |
# `jsonapi: { except: :response }` | |
# `jsonapi: { except: [:response] }` | |
assert_response_schema_not_jsonapi = ->(v){ excludes.(v, :response) } | |
config.after jsonapi: assert_response_schema_not_jsonapi do | |
next if response.nil? | |
assert_not_jsonapi_response! | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
or