gitlab-org--gitlab-foss/lib/gitlab/json.rb

252 lines
8.9 KiB
Ruby

# frozen_string_literal: true
# This is a GitLab-specific JSON interface. You should use this instead
# of using `JSON` directly. This allows us to swap the adapter and handle
# legacy issues.
module Gitlab
module Json
INVALID_LEGACY_TYPES = [String, TrueClass, FalseClass].freeze
class << self
# Parse a string and convert it to a Ruby object
#
# @param string [String] the JSON string to convert to Ruby objects
# @param opts [Hash] an options hash in the standard JSON gem format
# @return [Boolean, String, Array, Hash]
# @raise [JSON::ParserError] raised if parsing fails
def parse(string, opts = {})
# First we should ensure this really is a string, not some other
# type which purports to be a string. This handles some legacy
# usage of the JSON class.
string = string.to_s unless string.is_a?(String)
legacy_mode = legacy_mode_enabled?(opts.delete(:legacy_mode))
data = adapter_load(string, **opts)
handle_legacy_mode!(data) if legacy_mode
data
end
alias_method :parse!, :parse
# Restricted method for converting a Ruby object to JSON. If you
# need to pass options to this, you should use `.generate` instead,
# as the underlying implementation of this varies wildly based on
# the adapter in use.
#
# @param object [Object] the object to convert to JSON
# @return [String]
def dump(object)
adapter_dump(object)
end
# Generates JSON for an object. In Oj this takes fewer options than .dump,
# in the JSON gem this is the only method which takes an options argument.
#
# @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
# @param opts [Hash] an options hash with fewer supported settings than .dump
# @return [String]
def generate(object, opts = {})
adapter_generate(object, opts)
end
# Generates JSON for an object and makes it look purdy
#
# The Oj variant in this looks seriously weird but these are the settings
# needed to emulate the style generated by the JSON gem.
#
# NOTE: This currently ignores Oj, because Oj doesn't generate identical
# formatting, issue: https://github.com/ohler55/oj/issues/608
#
# @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
# @param opts [Hash] an options hash with fewer supported settings than .dump
# @return [String]
def pretty_generate(object, opts = {})
::JSON.pretty_generate(object, opts)
end
# Feature detection for using Oj instead of the `json` gem.
#
# @return [Boolean]
def enable_oj?
return false unless feature_table_exists?
Feature.enabled?(:oj_json, default_enabled: true)
end
private
# Convert JSON string into Ruby through toggleable adapters.
#
# Must rescue adapter-specific errors and return `parser_error`, and
# must also standardize the options hash to support each adapter as
# they all take different options.
#
# @param string [String] the JSON string to convert to Ruby objects
# @param opts [Hash] an options hash in the standard JSON gem format
# @return [Boolean, String, Array, Hash]
# @raise [JSON::ParserError]
def adapter_load(string, *args, **opts)
opts = standardize_opts(opts)
if enable_oj?
Oj.load(string, opts)
else
::JSON.parse(string, opts)
end
rescue Oj::ParseError, Encoding::UndefinedConversionError => ex
raise parser_error.new(ex)
end
# Take a Ruby object and convert it to a string. This method varies
# based on the underlying JSON interpreter. Oj treats this like JSON
# treats `.generate`. JSON.dump takes no options.
#
# This supports these options to ensure this difference is recorded here,
# as it's very surprising. The public interface is more restrictive to
# prevent adapter-specific options being passed.
#
# @overload adapter_dump(object, opts)
# @param object [Object] the object to convert to JSON
# @param opts [Hash] options as named arguments, only supported by Oj
#
# @overload adapter_dump(object, anIO, limit)
# @param object [Object] the object, will have JSON.generate called on it
# @param anIO [Object] an IO-like object that responds to .write, default nil
# @param limit [Fixnum] the nested array/object limit, default nil
# @raise [ArgumentError] when depth limit exceeded
#
# @return [String]
def adapter_dump(object, *args, **opts)
if enable_oj?
Oj.dump(object, opts)
else
::JSON.dump(object, *args)
end
end
# Generates JSON for an object but with fewer options, using toggleable adapters.
#
# @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
# @param opts [Hash] an options hash with fewer supported settings than .dump
# @return [String]
def adapter_generate(object, opts = {})
opts = standardize_opts(opts)
if enable_oj?
Oj.generate(object, opts)
else
::JSON.generate(object, opts)
end
end
# Take a JSON standard options hash and standardize it to work across adapters
# An example of this is Oj taking :symbol_keys instead of :symbolize_names
#
# @param opts [Hash, Nil]
# @return [Hash]
def standardize_opts(opts)
opts ||= {}
if enable_oj?
opts[:mode] = :rails
opts[:symbol_keys] = opts[:symbolize_keys] || opts[:symbolize_names]
end
opts
end
# The standard parser error we should be returning. Defined in a method
# so we can potentially override it later.
#
# @return [JSON::ParserError]
def parser_error
::JSON::ParserError
end
# @param [Nil, Boolean] an extracted :legacy_mode key from the opts hash
# @return [Boolean]
def legacy_mode_enabled?(arg_value)
arg_value.nil? ? false : arg_value
end
# If legacy mode is enabled, we need to raise an error depending on the values
# provided in the string. This will be deprecated.
#
# @param data [Boolean, String, Array, Hash, Object]
# @return [Boolean, String, Array, Hash, Object]
# @raise [JSON::ParserError]
def handle_legacy_mode!(data)
return data unless feature_table_exists?
return data unless Feature.enabled?(:json_wrapper_legacy_mode, default_enabled: true)
raise parser_error if INVALID_LEGACY_TYPES.any? { |type| data.is_a?(type) }
end
# There are a variety of database errors possible when checking the feature
# flags at the wrong time during boot, e.g. during migrations. We don't care
# about these errors, we just need to ensure that we skip feature detection
# if they will fail.
#
# @return [Boolean]
def feature_table_exists?
Feature::FlipperFeature.table_exists?
rescue
false
end
end
# GrapeFormatter is a JSON formatter for the Grape API.
# This is set in lib/api/api.rb
class GrapeFormatter
# Convert an object to JSON.
#
# This will default to the built-in Grape formatter if either :oj_json or :grape_gitlab_json
# flags are disabled.
#
# The `env` param is ignored because it's not needed in either our formatter or Grape's,
# but it is passed through for consistency.
#
# @param object [Object]
# @return [String]
def self.call(object, env = nil)
if Gitlab::Json.enable_oj? && Feature.enabled?(:grape_gitlab_json, default_enabled: true)
Gitlab::Json.dump(object)
else
Grape::Formatter::Json.call(object, env)
end
end
end
class LimitedEncoder
LimitExceeded = Class.new(StandardError)
# Generates JSON for an object or raise an error if the resulting json string is too big
#
# @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
# @param limit [Integer] max size of the resulting json string
# @return [String]
# @raise [LimitExceeded] if the resulting json string is bigger than the specified limit
def self.encode(object, limit: 25.megabytes)
return ::Gitlab::Json.dump(object) unless Feature.enabled?(:json_limited_encoder)
buffer = []
buffer_size = 0
::Yajl::Encoder.encode(object) do |data_chunk|
chunk_size = data_chunk.bytesize
raise LimitExceeded if buffer_size + chunk_size > limit
buffer << data_chunk
buffer_size += chunk_size
end
buffer.join('')
end
end
end
end