lib/flapjack/gateways/jsonapi.rb in flapjack-1.6.0 vs lib/flapjack/gateways/jsonapi.rb in flapjack-2.0.0b1

- old
+ new

@@ -1,454 +1,268 @@ #!/usr/bin/env ruby # A HTTP-based API server, which provides queries to determine the status of # entities and the checks that are reported against them. # -# There's a matching flapjack-diner gem at https://github.com/flapjack/flapjack-diner +# There's a matching flapjack-diner gem at https://github.com/flpjck/flapjack-diner # which consumes data from this API. require 'time' -require 'rack/fiber_pool' require 'sinatra/base' -require 'flapjack/rack_logger' -require 'flapjack/redis_pool' +require 'active_support/inflector' -require 'flapjack/gateways/jsonapi/rack/json_params_parser' +require 'swagger/blocks' -require 'flapjack/gateways/jsonapi/check_methods' -require 'flapjack/gateways/jsonapi/contact_methods' -require 'flapjack/gateways/jsonapi/entity_methods' -require 'flapjack/gateways/jsonapi/medium_methods' -require 'flapjack/gateways/jsonapi/metrics_methods' -require 'flapjack/gateways/jsonapi/notification_rule_methods' -require 'flapjack/gateways/jsonapi/pagerduty_credential_methods' -require 'flapjack/gateways/jsonapi/report_methods' +require 'flapjack' +require 'flapjack/utility' +require 'flapjack/data/acknowledgement' +require 'flapjack/data/check' +require 'flapjack/data/contact' +require 'flapjack/data/medium' +require 'flapjack/data/metrics' +require 'flapjack/data/rule' +require 'flapjack/data/scheduled_maintenance' +require 'flapjack/data/statistic' +require 'flapjack/data/state' +require 'flapjack/data/tag' +require 'flapjack/data/test_notification' +require 'flapjack/data/unscheduled_maintenance' + +require 'flapjack/gateways/jsonapi/middleware/array_param_fixer' +require 'flapjack/gateways/jsonapi/middleware/json_params_parser' +require 'flapjack/gateways/jsonapi/middleware/request_timestamp' + +%w[headers miscellaneous resources serialiser swagger_docs].each do |helper| + require "flapjack/gateways/jsonapi/helpers/#{helper}" +end + module Flapjack module Gateways class JSONAPI < Sinatra::Base include Flapjack::Utility - JSON_REQUEST_MIME_TYPES = ['application/vnd.api+json', 'application/json', 'application/json-patch+json'] + # TODO clean up media type handling for variable character sets + # append charset in use + + # http://jsonapi.org/extensions/bulk/ # http://www.iana.org/assignments/media-types/application/vnd.api+json - JSONAPI_MEDIA_TYPE = 'application/vnd.api+json' - # http://tools.ietf.org/html/rfc6902 - JSON_PATCH_MEDIA_TYPE = 'application/json-patch+json' + JSONAPI_MEDIA_TYPE = 'application/vnd.api+json' + JSONAPI_MEDIA_TYPE_BULK = 'application/vnd.api+json; ext=bulk' - class ContactNotFound < RuntimeError - attr_reader :contact_id - def initialize(contact_id) - @contact_id = contact_id - end - end + # # http://tools.ietf.org/html/rfc6902 + # JSON_PATCH_MEDIA_TYPE = 'application/json-patch+json; charset=utf-8' - class ContactsNotFound < RuntimeError - attr_reader :contact_ids - def initialize(contact_ids) - @contact_ids = contact_ids - end - end + RESOURCE_CLASSES = [ + Flapjack::Data::Acknowledgement, + Flapjack::Data::Check, + Flapjack::Data::Contact, + Flapjack::Data::Medium, + Flapjack::Data::Rule, + Flapjack::Data::ScheduledMaintenance, + Flapjack::Data::State, + Flapjack::Data::Statistic, + Flapjack::Data::Tag, + Flapjack::Data::TestNotification, + Flapjack::Data::UnscheduledMaintenance + ] - class NotificationRuleNotFound < RuntimeError - attr_reader :notification_rule_id - def initialize(notification_rule_id) - @notification_rule_id = notification_rule_id - end - end + set :root, File.dirname(__FILE__) - class NotificationRulesNotFound < RuntimeError - attr_reader :notification_rule_ids - def initialize(notification_rule_ids) - @notification_rule_ids = notification_rule_ids - end - end + set :raise_errors, false + set :show_exceptions, false - class EntityNotFound < RuntimeError - attr_reader :entity - def initialize(entity) - @entity = entity - end - end + set :protection, :except => :path_traversal - class EntitiesNotFound < RuntimeError - attr_reader :entity_ids - def initialize(entity_ids) - @entity_ids = entity_ids - end - end + use Flapjack::Gateways::JSONAPI::Middleware::RequestTimestamp + use ::Rack::MethodOverride + use Flapjack::Gateways::JSONAPI::Middleware::ArrayParamFixer + use Flapjack::Gateways::JSONAPI::Middleware::JsonParamsParser - class EntityCheckNotFound < RuntimeError - attr_reader :entity, :check - def initialize(entity, check) - @entity = entity - @check = check - end - end + class << self - class EntityChecksNotFound < RuntimeError - attr_reader :entity_checks - def initialize(entity_checks) - @entity_checks = entity_checks - end - end + @@lock = Monitor.new - class ResourceLocked < RuntimeError - attr_reader :resource - def initialize(resource) - @resource = resource - end - end + def start + Flapjack.logger.info "starting jsonapi - class" - set :dump_errors, false + if access_log = (@config && @config['access_log']) + unless File.directory?(File.dirname(access_log)) + raise "Parent directory for log file #{access_log} doesn't exist" + end - set :protection, :except => :path_traversal - - @rescue_exception = Proc.new {|env, e| - - rescue_error = Proc.new {|status, exception, request_info, *msg| - if !msg || msg.empty? - trace = exception.backtrace.join("\n") - msg = "#{exception.class} - #{exception.message}" - msg_str = "#{msg}\n#{trace}" - else - msg_str = msg.join(", ") + @access_log = ::Logger.new(@config['access_log']) + use Rack::CommonLogger, @access_log end - case - when status < 500 - @logger.warn "Error: #{msg_str}" - else - @logger.error "Error: #{msg_str}" - end - response_body = Flapjack.dump_json(:errors => msg) - - query_string = (request_info[:query_string].respond_to?(:length) && - request_info[:query_string].length > 0) ? "?#{request_info[:query_string]}" : "" - if @logger.debug? - @logger.debug("Returning #{status} for #{request_info[:request_method]} " + - "#{request_info[:path_info]}#{query_string}, body: #{response_body}") - elsif @logger.info? - @logger.info("Returning #{status} for #{request_info[:request_method]} " + - "#{request_info[:path_info]}#{query_string}") - end - - headers = if 'DELETE'.eql?(request_info[:request_method]) - # not set by default for delete, but the error structure is JSON - {'Content-Type' => "#{JSONAPI_MEDIA_TYPE}; charset=#{Encoding.default_external}"} - else - {} - end - - [status, headers, response_body] - } - - request_info = { - :path_info => env['REQUEST_PATH'], - :request_method => env['REQUEST_METHOD'], - :query_string => env['QUERY_STRING'] - } - case e - when Flapjack::Gateways::JSONAPI::ContactNotFound - rescue_error.call(404, e, request_info, "could not find contact '#{e.contact_id}'") - when Flapjack::Gateways::JSONAPI::ContactsNotFound - rescue_error.call(404, e, request_info, "could not find contacts '" + e.contact_ids.join(', ') + "'") - when Flapjack::Gateways::JSONAPI::NotificationRuleNotFound - rescue_error.call(404, e, request_info,"could not find notification rule '#{e.notification_rule_id}'") - when Flapjack::Gateways::JSONAPI::NotificationRulesNotFound - rescue_error.call(404, e, request_info, "could not find notification rules '" + e.notification_rule_ids.join(', ') + "'") - when Flapjack::Gateways::JSONAPI::EntityNotFound - rescue_error.call(404, e, request_info, "could not find entity '#{e.entity}'") - when Flapjack::Gateways::JSONAPI::EntitiesNotFound - entity_ids = "'" + e.entity_ids.join("', '") + "'" - rescue_error.call(404, e, request_info, "could not find entities: #{entity_ids}") - when Flapjack::Gateways::JSONAPI::EntityCheckNotFound - rescue_error.call(404, e, request_info, "could not find entity check '#{e.check}'") - when Flapjack::Gateways::JSONAPI::EntityChecksNotFound - checks = "'" + e.entity_checks.join("', '") + "'" - rescue_error.call(404, e, request_info, "could not find entity checks: #{checks}") - when Flapjack::Gateways::JSONAPI::ResourceLocked - rescue_error.call(423, e, request_info, "unable to obtain lock for resource '#{e.resource}'") - else - rescue_error.call(500, e, request_info) end - } - use ::Rack::FiberPool, :size => 25, :rescue_exception => @rescue_exception - use ::Rack::MethodOverride - use Flapjack::Gateways::JSONAPI::Rack::JsonParamsParser - - class << self - def start - @redis = Flapjack::RedisPool.new(:config => @redis_config, :size => 2, :logger => @logger) - - @logger.info "starting jsonapi - class" - - if @config && @config['access_log'] - access_logger = Flapjack::AsyncLogger.new(@config['access_log']) - use Flapjack::CommonLogger, access_logger + def media_type_produced(options = {}) + unless options[:with_charset].is_a?(TrueClass) + return 'application/vnd.api+json; supported-ext=bulk' end - @base_url = @config['base_url'] - dummy_url = "http://api.example.com" - if @base_url - @base_url = $1 if @base_url.match(/^(.+)\/$/) - else - @logger.error "base_url must be a valid http or https URI (not configured), setting to dummy value (#{dummy_url})" - # FIXME: at this point I'd like to stop this pikelet without bringing down the whole - @base_url = dummy_url + media_type = nil + @@lock.synchronize do + encoding = Encoding.default_external + media_type = if encoding.nil? + 'application/vnd.api+json; supported-ext=bulk' + else + "application/vnd.api+json; supported-ext=bulk; charset=#{encoding.name.downcase}" + end end - if (@base_url =~ /^#{URI::regexp(%w(http https))}$/).nil? - @logger.error "base_url must be a valid http or https URI (#{@base_url}), setting to dummy value (#{dummy_url})" - # FIXME: at this point I'd like to stop this pikelet without bringing down the whole - # flapjack process - # For now, set a dummy value - @base_url = dummy_url - end + media_type end end - def redis - self.class.instance_variable_get('@redis') + def config + self.class.instance_variable_get("@config") end - def logger - self.class.instance_variable_get('@logger') + def media_type_produced(options = {}) + self.class.media_type_produced(options) end - def base_url - self.class.instance_variable_get('@base_url') - end - before do + # needs to be done per-thread... + Flapjack.configure_log('jsonapi', config['logger']) + + # ... as does this + Zermelo.redis ||= Flapjack.redis + input = nil query_string = (request.query_string.respond_to?(:length) && - (request.query_string.length > 0)) ? "?#{request.query_string}" : "" - if logger.debug? + request.query_string.length > 0) ? "?#{request.query_string}" : "" + if Flapjack.logger.debug? input = env['rack.input'].read - logger.debug("#{request.request_method} #{request.path_info}#{query_string} Headers: #{headers.inspect}, Body: #{input}") - elsif logger.info? + Flapjack.logger.debug("#{request.request_method} #{request.path_info}#{query_string} Headers: #{headers.inspect}, Body: #{input}") + elsif Flapjack.logger.info? input = env['rack.input'].read input_short = input.gsub(/\n/, '').gsub(/\s+/, ' ') - logger.info("#{request.request_method} #{request.path_info}#{query_string} #{input_short[0..80]}") + Flapjack.logger.info("#{request.request_method} #{request.path_info}#{query_string} #{input_short[0..80]}") end env['rack.input'].rewind unless input.nil? end after do return if response.status == 500 query_string = (request.query_string.respond_to?(:length) && request.query_string.length > 0) ? "?#{request.query_string}" : "" - if logger.debug? + if Flapjack.logger.debug? body_debug = case when response.body.respond_to?(:each) response.body.each_with_index {|r, i| "body[#{i}]: #{r}"}.join(', ') else response.body.to_s end headers_debug = response.headers.to_s - logger.debug("Returning #{response.status} for #{request.request_method} " + + Flapjack.logger.debug("Returning #{response.status} for #{request.request_method} " + "#{request.path_info}#{query_string}, headers: #{headers_debug}, body: #{body_debug}") - elsif logger.info? - logger.info("Returning #{response.status} for #{request.request_method} " + + elsif Flapjack.logger.info? + Flapjack.logger.info("Returning #{response.status} for #{request.request_method} " + "#{request.path_info}#{query_string}") end end - module Helpers - - def cors_headers - allow_headers = %w(* Content-Type Accept AUTHORIZATION Cache-Control) - allow_methods = %w(GET POST PUT PATCH DELETE OPTIONS) - expose_headers = %w(Cache-Control Content-Language Content-Type Expires Last-Modified Pragma) - cors_headers = { - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => allow_methods.join(', '), - 'Access-Control-Allow-Headers' => allow_headers.join(', '), - 'Access-Control-Expose-Headers' => expose_headers.join(', '), - 'Access-Control-Max-Age' => '1728000' - } - headers(cors_headers) - end - - def err(status, *msg) - msg_str = msg.join(", ") - logger.info "Error: #{msg_str}" - [status, {}, Flapjack.dump_json(:errors => msg)] - end - - def charset_for_content_type(ct) - "#{ct}; charset=#{Encoding.default_external}" - end - - def is_json_request? - Flapjack::Gateways::JSONAPI::JSON_REQUEST_MIME_TYPES.include?(request.content_type.split(/\s*[;,]\s*/, 2).first) - end - - def is_jsonapi_request? - return false if request.content_type.nil? - 'application/vnd.api+json'.eql?(request.content_type.split(/\s*[;,]\s*/, 2).first) - end - - def is_jsonpatch_request? - return false if request.content_type.nil? - 'application/json-patch+json'.eql?(request.content_type.split(/\s*[;,]\s*/, 2).first) - end - - def wrapped_params(name, error_on_nil = true) - result = params[name.to_sym] - if result.nil? - if error_on_nil - logger.debug("No '#{name}' object found in the following supplied JSON:") - logger.debug(request.body.is_a?(StringIO) ? request.body.read : request.body) - halt err(403, "No '#{name}' object received") - else - result = [{}] - end - end - unless result.is_a?(Array) - halt err(403, "The received '#{name}'' object is not an Array") - end - result - end - - def find_contact(contact_id) - contact = Flapjack::Data::Contact.find_by_id(contact_id, :logger => logger, :redis => redis) - raise Flapjack::Gateways::JSONAPI::ContactNotFound.new(contact_id) if contact.nil? - contact - end - - def find_rule(rule_id) - rule = Flapjack::Data::NotificationRule.find_by_id(rule_id, :logger => logger, :redis => redis) - raise Flapjack::Gateways::JSONAPI::NotificationRuleNotFound.new(rule_id) if rule.nil? - rule - end - - def find_tags(tags) - halt err(400, "no tags given") if tags.nil? || tags.empty? - tags - end - - def find_entity(entity_name) - entity = Flapjack::Data::Entity.find_by_name(entity_name, :redis => redis) - raise Flapjack::Gateways::JSONAPI::EntityNotFound.new(entity_name) if entity.nil? - entity - end - - def find_entity_by_id(entity_id) - entity = Flapjack::Data::Entity.find_by_id(entity_id, :redis => redis) - raise Flapjack::Gateways::JSONAPI::EntityNotFound.new(entity_id) if entity.nil? - entity - end - - def find_entity_check(entity, check_name) - entity_check = Flapjack::Data::EntityCheck.for_entity(entity, check_name, :redis => redis) - raise Flapjack::Gateways::JSONAPI::EntityCheckNotFound.new(entity.name, check_name) if entity_check.nil? - entity_check - end - - def find_entity_check_by_name(entity_name, check_name) - entity_check = Flapjack::Data::EntityCheck.for_entity_name(entity_name, check_name, :redis => redis) - raise Flapjack::Gateways::JSONAPI::EntityCheckNotFound.new(entity_name, check_name) if entity_check.nil? - entity_check - end - - def apply_json_patch(object_path, &block) - ops = params[:ops] - - if ops.nil? || !ops.is_a?(Array) - halt err(400, "Invalid JSON-Patch request") - end - - ops.each do |operation| - linked = nil - property = nil - - op = operation['op'] - operation['path'] =~ /\A\/#{object_path}\/0\/([^\/]+)(?:\/([^\/]+)(?:\/([^\/]+))?)?\z/ - if 'links'.eql?($1) - linked = $2 - - value = case op - when 'add' - operation['value'] - when 'remove' - $3 - end - elsif 'replace'.eql?(op) - property = $1 - value = operation['value'] - else - next - end - - yield(op, property, linked, value) - end - end - - # NB: casts to UTC before converting to a timestamp - def validate_and_parsetime(value) - return unless value - Time.iso8601(value).getutc.to_i - rescue ArgumentError => e - logger.error "Couldn't parse time from '#{value}'" - nil - end - - end - options '*' do cors_headers 204 end + # FIXME enforce that Accept header must allow defined return type for the method + # The following catch-all routes act as impromptu filters for their method types get '*' do - content_type charset_for_content_type(JSONAPI_MEDIA_TYPE) cors_headers + content_type media_type_produced(:with_charset => true) pass end # bare 'params' may have splat/captures for regex route, see # https://github.com/sinatra/sinatra/issues/453 post '*' do - halt(405) unless request.params.empty? || is_json_request? || is_jsonapi_request? - content_type charset_for_content_type(JSONAPI_MEDIA_TYPE) + halt(405) unless request.params.empty? || is_jsonapi_request? cors_headers + content_type media_type_produced(:with_charset => true) pass end patch '*' do - halt(405) unless is_jsonpatch_request? - content_type charset_for_content_type(JSONAPI_MEDIA_TYPE) + halt(405) unless request.params.empty? || is_jsonapi_request? cors_headers + content_type media_type_produced(:with_charset => true) pass end delete '*' do cors_headers pass end - register Flapjack::Gateways::JSONAPI::CheckMethods - register Flapjack::Gateways::JSONAPI::ContactMethods - register Flapjack::Gateways::JSONAPI::EntityMethods - register Flapjack::Gateways::JSONAPI::MediumMethods - register Flapjack::Gateways::JSONAPI::MetricsMethods - register Flapjack::Gateways::JSONAPI::NotificationRuleMethods - register Flapjack::Gateways::JSONAPI::PagerdutyCredentialMethods - register Flapjack::Gateways::JSONAPI::ReportMethods + include Swagger::Blocks + include Flapjack::Gateways::JSONAPI::Helpers::SwaggerDocs - not_found do - err(404, "not routable") + # hacky, but trying to avoid too much boilerplate -- association paths + # must be before resource ones to avoid greedy path captures + %w[metrics association_post resource_post association_get resource_get + association_patch resource_patch association_delete + resource_delete].each do |method| + + require "flapjack/gateways/jsonapi/methods/#{method}" + eval "register Flapjack::Gateways::JSONAPI::Methods::#{method.camelize}" end - end + Flapjack::Gateways::JSONAPI::RESOURCE_CLASSES.each do |resource_class| + endpoint = resource_class.short_model_name.plural + swagger_wrappers(endpoint, resource_class) + end - end + SWAGGERED_CLASSES = [self] + Flapjack::Gateways::JSONAPI::RESOURCE_CLASSES + + [Flapjack::Data::Metrics] + get '/doc' do + Flapjack.dump_json(Swagger::Blocks.build_root_json(SWAGGERED_CLASSES)) + end + + # error Zermelo::LockNotAcquired do + # # TODO + # end + + error Zermelo::Records::Errors::RecordInvalid do + e = env['sinatra.error'] + err(403, *e.record.errors.full_messages) + end + + error Zermelo::Records::Errors::RecordNotSaved do + e = env['sinatra.error'] + err(403, *e.record.errors.full_messages) + end + + error Zermelo::Records::Errors::RecordNotFound do + e = env['sinatra.error'] + type = e.klass.name.split('::').last + err(404, "could not find #{type} record, id: '#{e.id}'") + end + + error Zermelo::Records::Errors::RecordsNotFound do + e = env['sinatra.error'] + type = e.klass.name.split('::').last + err_ids = e.ids.join("', '") + err(404, "could not find #{type} records, ids: '#{err_ids}'") + end + + error do + e = env['sinatra.error'] + # trace = e.backtrace.join("\n") + # puts trace + err(response.status, "#{e.class} - #{e.message}") + end + + end + end end