Skip to content

Instantly share code, notes, and snippets.

@bf4
Last active October 20, 2016 14:15
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bf4/abd3ba8d0601f089817bf9ffabf91cc3 to your computer and use it in GitHub Desktop.
Save bf4/abd3ba8d0601f089817bf9ffabf91cc3 to your computer and use it in GitHub Desktop.
# 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
# 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
# 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
@bf4
Copy link
Author

bf4 commented Aug 29, 2016

or

    protect_from_forgery with: :null_session, if: proc { |c| c.request.format =~ /\Aapplication\/(?>[a-zA-Z0-9.]+\+)json\z/ }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment