Skip to content

Instantly share code, notes, and snippets.

@JoshCheek
Last active July 17, 2024 08:23
Show Gist options
  • Save JoshCheek/93c1f909c8e7342dd2ddb4a57e2050b5 to your computer and use it in GitHub Desktop.
Save JoshCheek/93c1f909c8e7342dd2ddb4a57e2050b5 to your computer and use it in GitHub Desktop.
How to configure API only Rails (esp how to deal with param parsing).
# I submitted a bug report https://github.com/rails/rails/issues/34244
# b/c Rails was not honouring my `rescue_from` block which was causing my API to
# be inconsistent. I was told this is expected behaviour. I think it's probably
# fine for an HTML app where you control the form inputs. But for an API app,
# the API is public facing and very important, so Rails shouldn't have its own
# special errors that bypass my app's configuration and make my API inconsistent.
#
# Decided it shouldn't be too difficult to handle this myself. So, here is my
# solution. It contains most of the important lessons I've learned about how to
# get a Rails API app setup. It removes Rails' params parsing and adds its own.
# I haven't tried it out with a bunch of gems yet, I'm not expecting issues,
# but if it doesn't work with some gem I need, then I can probably move it into
# a mdidleware.
require "rails"
require 'action_controller/railtie'
# ===== Versions =====
Rails::VERSION::STRING # => "5.2.1"
RUBY_VERSION # => "2.5.1"
# ===== Configuration =====
Rails.logger = Logger.new $stdout
ActiveSupport::LogSubscriber.colorize_logging = false
class MahApp < Rails::Application
# Configure it to run in this file for this example
config.cache_classes = true
config.eager_load = false
config.consider_all_requests_local = true
config.serve_static_files = false
config.action_dispatch.show_exceptions = false
# Clear out irrelevant middleware
config.api_only = true
config.debug_exception_response_format = :api
# Configure Strong Params to raise. It can be annoying if you accept arbitrary
# JSON blobs (eg you recieve a JSON Schema document), but still nice to tell
# the API user what they're doing wrong.
config.action_controller.action_on_unpermitted_parameters = :raise
# To show that this respects param filters
config.filter_parameters += [:password]
initialize!
end
# ===== Routes =====
Rails.application.routes.draw { put '/users/:id', to: 'users#update' }
# ===== Custom Errors =====
module MyNamespace
# Good practice to have a single superclass for all our errors.
# Additionally, it supports multiple messages to facilitate our API
class BaseError < StandardError
attr_accessor :messages
def initialize(*messages)
self.messages = messages
super messages.join("\n")
end
end
class UnsupportedMediaType < BaseError
def initialize(expected, actual)
@expected, @actual = expected, actual
super "Expected content type #{expected.inspect}, but got #{actual.inspect}"
end
end
# Parsers should raise this to get the right error code.
class InvalidBody < BaseError
CODES = {syntax: 400, format: 422}
attr_reader :type
def initialize(*messages, type:)
CODES.key?(type) || raise(ArgumentError, "`type` must be one of #{CODES.keys.inspect}")
super(*messages)
@type = type
end
def status
CODES.fetch @type
end
end
end
# ===== Disable Rails Param Parsing =====
ActionDispatch::Request.parameter_parsers.clear
# ===== Add our own parsers =====
require 'oj'
MyNamespace::BODY_PARSERS = {
# Here, we'll use `oj` to parse JSON since it gives better errors, though we'll generacize them
# We'll also return 2 values, first must be a hash because the request expects this
# second can be whatever makes sense for our parsed body
json: lambda do |body|
parsed = Oj.safe_load(body)
params = parsed.kind_of?(Hash) ? parsed : {_json: parsed}
[params, parsed]
rescue Oj::ParseError => err # block level rescue syntax was introduced in Ruby 2.5
raise MyNamespace::InvalidBody.new(
[ err.message # => "Hash/Object not terminated at line 1, column 1 [parse.c:950]"
.sub(/^Hash\//, '') # => "Object not terminated at line 1, column 1 [parse.c:950]"
.sub(/ *\[.*?\]$/, '') # => "Object not terminated at line 1, column 1"
],
type: :syntax
)
end,
# And then you can put parsers for your custom mime types here
}
class ApplicationController < ActionController::Base
# ===== Add our own param parser that respects `rescue_from` =====
# Call this method to declare the action's content type which will parse its
# body and emit any appropriate errors
def self.content_type(type, strong_params: true, **options)
before_action options do
if Mime[type] != request.content_type
raise MyNamespace::UnsupportedMediaType.new(Mime[type].to_s, request.content_type)
else
# Read the body
raw_post = request.body.read(request.content_length)
# Rails uses this to create a StringIO wrapper for the body
request.set_header("RAW_POST_DATA", raw_post)
# Parse it
params_hash, parsed_body = MyNamespace::BODY_PARSERS.fetch(type).call(raw_post)
# Log it since Rails has already logged the params that it parsed
params_filters = ActionDispatch::Http::ParameterFilter.new(
request.fetch_header("action_dispatch.parameter_filter")
)
filtered_params = params_filters.filter(tmp: parsed_body)[:tmp]
logger.info " Body Parameters: #{filtered_params.inspect}" unless filtered_params.empty?
# Potentially wrap in strong params
if strong_params && !parsed_body.kind_of?(Hash)
raise MyNamespace::InvalidBody.new(
"Body could not be represented as an associative array",
type: :format
)
elsif strong_params
parsed_body = ActionController::Parameters.new parsed_body
end
# Save it for use by the controller
@body_params = parsed_body
# Add it to all the places Rails expects it to be
namespace = request.path_parameters[:controller].singularize
request.request_parameters.merge!(params_hash)
request.parameters[namespace] = params_hash
request.filtered_parameters[namespace] = params_filters.filter(params_hash)
end
end
end
private
# ===== Methods to access the params =====
# Do not use Rails `params` hash which squashes all params into the same
# namespace. Rails tries to fix this with "params wrapper", but it still
# pollutes the `params` hash, which doesn't work with strong params configured
# to raise, and it only wraps the params that are attributes on your model.
# The reality is I always know where I want to get the params from, and I don't
# want to worry about what happens if there is a namespace collision.
def query_params
@query_params ||= request.query_parameters
end
def path_params
@path_params ||= request.path_parameters
end
# This will be set by the `content_type` before filter up above
attr_reader :body_params
# ===== Convert errors into consistent API responses =====
rescue_from MyNamespace::InvalidBody do |exception|
render_error exception.status, exception.messages, body: request.raw_post
end
rescue_from ActionController::UnpermittedParameters do |exception|
render_error 422, "Unexpected parameters: #{exception.params.inspect}"
end
rescue_from MyNamespace::UnsupportedMediaType do |exception|
render_error 415, exception.message
end
def render_error(status, messages, **additional_keys)
render status: status, json: { errors: Array(messages), **additional_keys }
end
end
# ===== Concrete controllers =====
class UsersController < ApplicationController
content_type :json, strong_params: true, only: [:update]
def update
# We'll just return the various parsed params to show it works as expected
render json: {
path_params: path_params,
query_params: query_params,
body_params: body_params.permit(:name, :age, :password),
}
end
end
# ===== Helper to call the app =====
def self.put(body, content_type: 'application/json')
read, write = IO.pipe # to prove it doesn't read too far (if it does, it will lock up)
write.write body
uri = URI 'http://example.com/users/123?some_option=some_value'
code, headers, response_body = Rails.application.call(
"REQUEST_METHOD" => "PUT",
"QUERY_STRING" => uri.query,
"PATH_INFO" => uri.path,
"REQUEST_PATH" => uri.path,
"REQUEST_URI" => uri.request_uri,
"HTTP_HOST" => uri.host,
"HTTP_ACCEPT" => 'application/json',
"CONTENT_TYPE" => content_type,
"CONTENT_LENGTH" => body.bytesize.to_s,
"rack.input" => read,
)
status = Rack::Utils::HTTP_STATUS_CODES[code]
parsed_response = Oj.load(response_body.to_enum(&:each).to_a.join).symbolize_keys
[code, status, parsed_response]
ensure
[read, write].each { |io| io.close unless io.closed? }
end
# ===== Successful post =====
# Note that password is filtered out in the logs below
put '{"name":"Queen Akasha", "age":3012, "password": "The world is our garden"}'
# => [200,
# "OK",
# {:path_params=>{"controller"=>"users", "action"=>"update", "id"=>"123"},
# :query_params=>{"some_option"=>"some_value"},
# :body_params=>
# {"name"=>"Queen Akasha",
# "age"=>3012,
# "password"=>"The world is our garden"}}]
# ===== Syntax Error =====
put '{'
# => [400,
# "Bad Request",
# {:errors=>[["Object not terminated at line 1, column 1"]], :body=>"{"}]
# ===== Bad content type =====
put '<p>hi</p>', content_type: 'text/html'
# => [415,
# "Unsupported Media Type",
# {:errors=>
# ["Expected content type \"application/json\", but got \"text/html\""]}]
# ===== Misspelled key =====
put '{"nam":"Queen Akasha", "age":3012}'
# => [422,
# "Unprocessable Entity",
# {:errors=>["Unexpected parameters: [\"nam\"]"]}]
# ===== Bad format =====
put '["abcd", "efgh", "ijkl"]'
# => [422,
# "Unprocessable Entity",
# {:errors=>["Body could not be represented as an associative array"],
# :body=>"[\"abcd\", \"efgh\", \"ijkl\"]"}]
# ===== Logs =====
# >> I, [2018-10-21T14:34:29.936773 #57119] INFO -- : Started PUT "/users/123?some_option=some_value" for at 2018-10-21 14:34:29 -0500
# >> I, [2018-10-21T14:34:29.938459 #57119] INFO -- : Processing by UsersController#update as JSON
# >> I, [2018-10-21T14:34:29.938536 #57119] INFO -- : Parameters: {"some_option"=>"some_value", "id"=>"123"}
# >> I, [2018-10-21T14:34:29.938896 #57119] INFO -- : Body Parameters: {"name"=>"Queen Akasha", "age"=>3012, "password"=>"[FILTERED]"}
# >> I, [2018-10-21T14:34:29.939618 #57119] INFO -- : Completed 200 OK in 1ms (Views: 0.3ms)
# >>
# >>
# >> I, [2018-10-21T14:34:29.940849 #57119] INFO -- : Started PUT "/users/123?some_option=some_value" for at 2018-10-21 14:34:29 -0500
# >> I, [2018-10-21T14:34:29.941367 #57119] INFO -- : Processing by UsersController#update as JSON
# >> I, [2018-10-21T14:34:29.941481 #57119] INFO -- : Parameters: {"some_option"=>"some_value", "id"=>"123"}
# >> I, [2018-10-21T14:34:29.941932 #57119] INFO -- : Completed 400 Bad Request in 0ms (Views: 0.1ms)
# >>
# >>
# >> I, [2018-10-21T14:34:29.942642 #57119] INFO -- : Started PUT "/users/123?some_option=some_value" for at 2018-10-21 14:34:29 -0500
# >> I, [2018-10-21T14:34:29.943138 #57119] INFO -- : Processing by UsersController#update as JSON
# >> I, [2018-10-21T14:34:29.943192 #57119] INFO -- : Parameters: {"some_option"=>"some_value", "id"=>"123"}
# >> I, [2018-10-21T14:34:29.943552 #57119] INFO -- : Completed 415 Unsupported Media Type in 0ms (Views: 0.1ms)
# >>
# >>
# >> I, [2018-10-21T14:34:29.944229 #57119] INFO -- : Started PUT "/users/123?some_option=some_value" for at 2018-10-21 14:34:29 -0500
# >> I, [2018-10-21T14:34:29.944845 #57119] INFO -- : Processing by UsersController#update as JSON
# >> I, [2018-10-21T14:34:29.944901 #57119] INFO -- : Parameters: {"some_option"=>"some_value", "id"=>"123"}
# >> I, [2018-10-21T14:34:29.945069 #57119] INFO -- : Body Parameters: {"nam"=>"Queen Akasha", "age"=>3012}
# >> I, [2018-10-21T14:34:29.945575 #57119] INFO -- : Completed 422 Unprocessable Entity in 1ms (Views: 0.1ms)
# >>
# >>
# >> I, [2018-10-21T14:34:29.946219 #57119] INFO -- : Started PUT "/users/123?some_option=some_value" for at 2018-10-21 14:34:29 -0500
# >> I, [2018-10-21T14:34:29.946713 #57119] INFO -- : Processing by UsersController#update as JSON
# >> I, [2018-10-21T14:34:29.946766 #57119] INFO -- : Parameters: {"some_option"=>"some_value", "id"=>"123"}
# >> I, [2018-10-21T14:34:29.946953 #57119] INFO -- : Body Parameters: ["abcd", "efgh", "ijkl"]
# >> I, [2018-10-21T14:34:29.947280 #57119] INFO -- : Completed 422 Unprocessable Entity in 0ms (Views: 0.2ms)
# >>
# >>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment