Last active
July 17, 2024 08:23
-
-
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).
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
# 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