diff options
Diffstat (limited to 'lib/gitlab/json.rb')
-rw-r--r-- | lib/gitlab/json.rb | 199 |
1 files changed, 182 insertions, 17 deletions
diff --git a/lib/gitlab/json.rb b/lib/gitlab/json.rb index 5b6689dbefe..21f837c58bb 100644 --- a/lib/gitlab/json.rb +++ b/lib/gitlab/json.rb @@ -1,59 +1,224 @@ # 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 - def parse(string, *args, **named_args) - legacy_mode = legacy_mode_enabled?(named_args.delete(:legacy_mode)) - data = adapter.parse(string, *args, **named_args) + # 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 - def parse!(string, *args, **named_args) - legacy_mode = legacy_mode_enabled?(named_args.delete(:legacy_mode)) - data = adapter.parse!(string, *args, **named_args) + 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 - handle_legacy_mode!(data) if legacy_mode + # 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 - data + # 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 - def dump(*args) - adapter.dump(*args) + # 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 - def generate(*args) - adapter.generate(*args) + 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 - def pretty_generate(*args) - adapter.pretty_generate(*args) + # 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 - private + # 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 ||= {} - def adapter - ::JSON + 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 end end |