summaryrefslogtreecommitdiff
path: root/lib/gitlab/json.rb
blob: 29cfec443e89282ded48d4e7b0f9e0576a29a25e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# 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