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