require "rack" module Shamu module JsonApi module Rails # Add support for writing resources as well-formed JSON API. module Controller extend ActiveSupport::Concern # Pattern to identify request params that hold 'ids' ID_PATTERN = /\A(id|.+_id)\z/ included do before_action do render json: json_error( "The 'include' parameter is not supported" ), status: :bad_request if params[:include] # rubocop:disable Metrics/LineLength request.formats = [ :json_api, :json ] end rescue_from Exception, with: :render_unhandled_exception unless ::Rails.env.test? end private # @!visibility public # # Builds a well-formed JSON API response for a single resource. # # @param [Object] resource to present as JSON. # @param [Class] presenter {Presenter} class to use when building the # response for the given resource. If not given, attempts to find a # presenter by calling {Context#find_presenter}. # @param (see #json_context) # @yield (response) write additional top-level links and meta # information. # @yieldparam [JsonApi::Response] response # @return [JsonApi::Response] the presented JSON response. def json_resource( resource, presenter = nil, **context, &block ) response = build_json_response( context ) response.resource resource, presenter yield response if block_given? response.as_json end # @!visibility public # # Present the `resource` as json and render it adding appropriate # HTTP response codes and headers for standard JSON API actions. # # @param [Symbol,Number] status the HTTP status code. # @param (see #json_resource) def render_resource( resource, presenter: nil, status: nil, location: nil, **context, &block ) json = json_resource( resource, presenter, **context, &block ) # Include canonical url to resource if present if data = json[ "data" ] if links = data[ "links" ] location ||= links[ "self" ] if links[ "self" ] end end render json: json, status: status, location: location end # @!visibility public # # Renders a {Shamu::Services::Result} presenting either the # validation errors or the entity. # # @param [Shamu::Services::Result] result of a service call # @param (see #json_resource) def render_result( result, presenter: nil, status: nil, **context, &block ) if result.valid? if result.entity status ||= case request.method when "POST" then :created when "DELETE" then :no_content else :ok end render_resource result.entity, presenter: presenter, status: status, **context, &block else head status || :no_content end else render json: json_validation_errors( result.errors, **context ), status: :unprocessable_entity end end # Builds a well-formed JSON API response for a collection of resources. # # @param [Enumerable] resources to present as a JSON array. # @param [Class] presenter {Presenter} class to use when building the # response for each of the resources. If not given, attempts to find # a presenter by calling {Context#find_presenter} # @param (see #json_context) # @yield (response) write additional top-level links and meta # information. # @yieldparam [JsonApi::Response] response # @return [JsonApi::Response] the presented JSON response. def json_collection( resources, presenter = nil, pagination: :auto, **context, &block ) response = build_json_response( context ) response.collection resources, presenter json_paginate_resources response, resources, pagination yield response if block_given? response.as_json end # Present the resources as json and render it adding appropriate HTTP # response codes and headers. def render_collection( resources, presenter: nil, pagination: :auto, **context, &block ) render json: json_collection( resources, presenter, pagination: pagination, **context, &block ) end # Write all the validation errors from a record to the response. # # @param (see Shamu::JsonApi::Response#validation_errors) # @yield (builder, attr, message) # @yieldparam (see Shamu::JsonApi::Response#validation_errors) # @return [JsonApi::Response] the presented JSON response. def json_validation_errors( errors, **context, &block ) response = build_json_response( context ) response.validation_errors errors, &block response.as_json end # @!visibility public # # Add page-based pagination links for the resources to the builder. # # @param [#current_page,#next_page,#previous_page] resources a collection that responds to `#current_page` # @param [JsonApi::BaseBuilder] builder to add links to. # @param [String] param the name of the key page parameter to adjust # @return [void] def json_paginate( resources, builder, param: :page ) page = resources.current_page if resources.respond_to?( :next_page ) ? resources.next_page : true builder.link :next, url_for( json_page_parameter( param, :number, page + 1 ) ) end if resources.respond_to?( :prev_page ) ? resources.prev_page : page > 1 builder.link :prev, url_for( json_page_parameter( param, :number, page - 1 ) ) end end def json_page_parameter( page_param_name, param, value ) params = self.params params = params.to_unsafe_hash if params.respond_to?( :to_unsafe_hash ) page_params = params.reverse_merge page_param_name => {} page_params[page_param_name][param] = value page_params end # @!visibility public # # Get the pagination request parameters. # # @param [Symbol] param the request parameter to read pagination # options from. # @return [Pagination] the pagination state def json_pagination( param: :page ) page_params = params[ param ] || {} Pagination.new( page_params.merge( param: param ) ) end # @!visibility public # # Write an error response. See {Shamu::JsonApi::Response#error} for details. # # @param (see Shamu::JsonApi::Response#error) # @yield (builder) # @yieldparam [Shamu::JsonApi::ErrorBuilder] builder to customize the # error response. # @return [JsonApi::Response] the presented JSON response. def json_error( error = nil, **context, &block ) response = build_json_response( context ) response.error error do |builder| builder.http_status json_http_status_code_from_error( error ) annotate_json_error( error, builder ) yield builder if block_given? end response.to_json end def render_unhandled_exception( exception ) render json: json_error( exception ), status: :internal_server_error end # @!visibility public # # Annotate an exception that is being rendered to the browser - for # example to add current user or security information if available. def annotate_json_error( error, builder ) if ::Rails.env.development? builder.meta :type, error.class.to_s builder.meta :backtrace, error.backtrace end end JSON_CONTEXT_KEYWORDS = [ :fields, :namespaces, :presenters ].freeze # @!visibility public # # Build a {JsonApi::Context} for the current request and controller. # # @param [Hash] fields to include in the response. If not # provided looks for a `fields` request argument and parses that. # See {JsonApi::Context#initialize}. # @param [Array] namespaces to look for {Presenter presenters}. # If not provided automatically adds the controller name and it's # namespace. # # For example in the `Users::AccountController` it will add the # `Users::Accounts` and `Users` namespaces. # # See {JsonApi::Context#find_presenter}. # @param [Hash] presenters a hash that maps resource classes # to the presenter class to use when building responses. See # {JsonApi::Context#find_presenter}. # @return [JsonApi::Context] the builder context honoring any filter # parameters sent by the client. def json_context( fields: :not_set, namespaces: :not_set, presenters: :not_set ) Shamu::JsonApi::Context.new \ fields: fields == :not_set ? json_context_fields : fields, namespaces: namespaces == :not_set ? json_context_namespaces : namespaces, presenters: presenters == :not_set ? json_context_presenters : presenters end # See (Shamu::Rails::Entity#request_params) def request_params( param_key ) if relationships = json_request_payload[ :relationships ] return map_json_resource_payload( relationships[ param_key ][ :data ] ) if relationships.key?( param_key ) end payload = map_json_resource_payload( json_request_payload ) request.params.each do |key, value| payload[ key.to_sym ] ||= value if ID_PATTERN =~ key end payload end def map_json_resource_payload( resource ) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength payload = resource[ :attributes ] ? resource[ :attributes ].dup : {} payload[ :id ] = resource[ :id ] if resource.key?( :id ) if relationships = resource[ :relationships ] relationships.each do |key, value| attr_key = "#{ key.to_s.singularize }_id" if value[ :data ].is_a?( Array ) attr_key += "s" if value[ :data ].is_a?( Array ) payload[ attr_key.to_sym ] = value[ :data ].map { |d| d[ :id ] } payload[ key ] = value[ :data ].map { |d| map_json_resource_payload( d ) } else payload[ attr_key.to_sym ] = value[ :data ][ :id ] payload[ key ] = map_json_resource_payload( value[ :data ] ) end end end payload end # @!visibility public # # Map a JSON body to a hash. # @return [Hash] the parsed JSON payload. def json_request_payload @json_request_payload ||= begin body = request.body.read || "{}" json = JSON.parse( body, symbolize_names: true ) unless json.blank? fail NoJsonBodyError unless json[ :data ] end json ? json[ :data ] : {} end end def json_context_fields params[:fields] end def json_context_namespaces name = self.class.name.sub( /Controller$/, "" ) namespaces = [ name.pluralize ] loop do name = name.deconstantize break if name.blank? namespaces << name end namespaces end def json_context_presenters end def json_paginate_resources( response, resources, pagination ) pagination = resources.respond_to?( :current_page ) if pagination == :auto return unless pagination json_paginate resources, response end def json_http_status_code_from_error( error ) case error when ActiveRecord::RecordNotFound then :not_found when ActiveRecord::RecordInvalid then :unprocessable_entity when /AccessDenied/ then :forbidden else if error.is_a?( Exception ) ActionDispatch::ExceptionWrapper.status_code_for_exception( error ) else :bad_request end end end def json_http_status_code_from_request case request.method when "POST" then :created when "HEAD" then :no_content else :ok end end def build_json_response( context ) Shamu::JsonApi::Response.new( json_context( **context.slice( *JSON_CONTEXT_KEYWORDS ) ) ) end end end end end