• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ruby-grape / grape / 3750995127

pending completion
3750995127

Pull #2288

github

GitHub
Merge 67b791503 into 095c6e814
Pull Request #2288: Update rubocop to 1.41.0

10904 of 11056 relevant lines covered (98.63%)

230.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.83
/spec/grape/middleware/formatter_spec.rb
1
# frozen_string_literal: true
2

3
describe Grape::Middleware::Formatter do
2✔
4
  subject { described_class.new(app) }
557✔
5

6
  before { allow(subject).to receive(:dup).and_return(subject) }
557✔
7

8
  let(:body) { { 'foo' => 'bar' } }
144✔
9
  let(:app) { ->(_env) { [200, {}, [body]] } }
785✔
10

11
  context 'serialization' do
2✔
12
    let(:body) { { 'abc' => 'def' } }
4✔
13

14
    it 'looks at the bodies for possibly serializable data' do
2✔
15
      _, _, bodies = *subject.call('PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/json')
3✔
16
      bodies.each { |b| expect(b).to eq(::Grape::Json.dump(body)) }
6✔
17
    end
18

19
    context 'default format' do
2✔
20
      let(:body) { ['foo'] }
5✔
21

22
      it 'calls #to_json since default format is json' do
2✔
23
        body.instance_eval do
3✔
24
          def to_json(*_args)
3✔
25
            '"bar"'
3✔
26
          end
27
        end
28

29
        subject.call('PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/json').to_a.last.each { |b| expect(b).to eq('"bar"') }
6✔
30
      end
31
    end
32

33
    context 'jsonapi' do
2✔
34
      let(:body) { { 'foos' => [{ 'bar' => 'baz' }] } }
4✔
35

36
      it 'calls #to_json if the content type is jsonapi' do
2✔
37
        body.instance_eval do
3✔
38
          def to_json(*_args)
3✔
39
            '{"foos":[{"bar":"baz"}] }'
3✔
40
          end
41
        end
42

43
        subject.call('PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/vnd.api+json').to_a.last.each { |b| expect(b).to eq('{"foos":[{"bar":"baz"}] }') }
6✔
44
      end
45
    end
46

47
    context 'xml' do
2✔
48
      let(:body) { +'string' }
5✔
49

50
      it 'calls #to_xml if the content type is xml' do
2✔
51
        body.instance_eval do
3✔
52
          def to_xml
3✔
53
            '<bar/>'
3✔
54
          end
55
        end
56

57
        subject.call('PATH_INFO' => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json').to_a.last.each { |b| expect(b).to eq('<bar/>') }
6✔
58
      end
59
    end
60
  end
61

62
  context 'error handling' do
2✔
63
    let(:formatter) { double(:formatter) }
8✔
64

65
    before do
2✔
66
      allow(Grape::Formatter).to receive(:formatter_for) { formatter }
12✔
67
    end
68

69
    it 'rescues formatter-specific exceptions' do
2✔
70
      allow(formatter).to receive(:call) { raise Grape::Exceptions::InvalidFormatter.new(String, 'xml') }
6✔
71

72
      expect do
3✔
73
        catch(:error) { subject.call('PATH_INFO' => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json') }
5✔
74
      end.not_to raise_error
75
    end
76

77
    it 'does not rescue other exceptions' do
2✔
78
      allow(formatter).to receive(:call) { raise StandardError }
6✔
79

80
      expect do
3✔
81
        catch(:error) { subject.call('PATH_INFO' => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json') }
5✔
82
      end.to raise_error(StandardError)
83
    end
84
  end
85

86
  context 'detection' do
2✔
87
    it 'uses the xml extension if one is provided' do
2✔
88
      subject.call('PATH_INFO' => '/info.xml')
3✔
89
      expect(subject.env['api.format']).to eq(:xml)
3✔
90
    end
91

92
    it 'uses the json extension if one is provided' do
2✔
93
      subject.call('PATH_INFO' => '/info.json')
3✔
94
      expect(subject.env['api.format']).to eq(:json)
3✔
95
    end
96

97
    it 'uses the format parameter if one is provided' do
2✔
98
      subject.call('PATH_INFO' => '/info', 'QUERY_STRING' => 'format=json')
3✔
99
      expect(subject.env['api.format']).to eq(:json)
3✔
100
      subject.call('PATH_INFO' => '/info', 'QUERY_STRING' => 'format=xml')
3✔
101
      expect(subject.env['api.format']).to eq(:xml)
3✔
102
    end
103

104
    it 'uses the default format if none is provided' do
2✔
105
      subject.call('PATH_INFO' => '/info')
3✔
106
      expect(subject.env['api.format']).to eq(:txt)
3✔
107
    end
108

109
    it 'uses the requested format if provided in headers' do
2✔
110
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json')
3✔
111
      expect(subject.env['api.format']).to eq(:json)
3✔
112
    end
113

114
    it 'uses the file extension format if provided before headers' do
2✔
115
      subject.call('PATH_INFO' => '/info.txt', 'HTTP_ACCEPT' => 'application/json')
3✔
116
      expect(subject.env['api.format']).to eq(:txt)
3✔
117
    end
118
  end
119

120
  context 'accept header detection' do
2✔
121
    it 'detects from the Accept header' do
2✔
122
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/xml')
3✔
123
      expect(subject.env['api.format']).to eq(:xml)
3✔
124
    end
125

126
    it 'uses quality rankings to determine formats' do
2✔
127
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json; q=0.3,application/xml; q=1.0')
3✔
128
      expect(subject.env['api.format']).to eq(:xml)
3✔
129
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json; q=1.0,application/xml; q=0.3')
3✔
130
      expect(subject.env['api.format']).to eq(:json)
3✔
131
    end
132

133
    it 'handles quality rankings mixed with nothing' do
2✔
134
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json,application/xml; q=1.0')
3✔
135
      expect(subject.env['api.format']).to eq(:xml)
3✔
136
    end
137

138
    it 'parses headers with other attributes' do
2✔
139
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json; abc=2.3; q=1.0,application/xml; q=0.7')
3✔
140
      expect(subject.env['api.format']).to eq(:json)
3✔
141
    end
142

143
    it 'parses headers with vendor and api version' do
2✔
144
      subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test-v1+xml')
3✔
145
      expect(subject.env['api.format']).to eq(:xml)
3✔
146
    end
147

148
    context 'with custom vendored content types' do
2✔
149
      before do
2✔
150
        subject.options[:content_types] = {}
3✔
151
        subject.options[:content_types][:custom] = 'application/vnd.test+json'
3✔
152
      end
153

154
      it 'uses the custom type' do
2✔
155
        subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json')
3✔
156
        expect(subject.env['api.format']).to eq(:custom)
3✔
157
      end
158
    end
159

160
    it 'parses headers with symbols as hash keys' do
2✔
161
      subject.call('PATH_INFO' => '/info', 'http_accept' => 'application/xml', system_time: '091293')
3✔
162
      expect(subject.env[:system_time]).to eq('091293')
3✔
163
    end
164
  end
165

166
  context 'content-type' do
2✔
167
    it 'is set for json' do
2✔
168
      _, headers, = subject.call('PATH_INFO' => '/info.json')
3✔
169
      expect(headers['Content-type']).to eq('application/json')
3✔
170
    end
171

172
    it 'is set for xml' do
2✔
173
      _, headers, = subject.call('PATH_INFO' => '/info.xml')
3✔
174
      expect(headers['Content-type']).to eq('application/xml')
3✔
175
    end
176

177
    it 'is set for txt' do
2✔
178
      _, headers, = subject.call('PATH_INFO' => '/info.txt')
3✔
179
      expect(headers['Content-type']).to eq('text/plain')
3✔
180
    end
181

182
    it 'is set for custom' do
2✔
183
      subject.options[:content_types] = {}
3✔
184
      subject.options[:content_types][:custom] = 'application/x-custom'
3✔
185
      _, headers, = subject.call('PATH_INFO' => '/info.custom')
3✔
186
      expect(headers['Content-type']).to eq('application/x-custom')
3✔
187
    end
188

189
    it 'is set for vendored with registered type' do
2✔
190
      subject.options[:content_types] = {}
3✔
191
      subject.options[:content_types][:custom] = 'application/vnd.test+json'
3✔
192
      _, headers, = subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json')
3✔
193
      expect(headers['Content-type']).to eq('application/vnd.test+json')
3✔
194
    end
195

196
    it 'is set to closest generic for custom vendored/versioned without registered type' do
2✔
197
      _, headers, = subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json')
3✔
198
      expect(headers['Content-type']).to eq('application/json')
3✔
199
    end
200
  end
201

202
  context 'format' do
2✔
203
    it 'uses custom formatter' do
2✔
204
      subject.options[:content_types] = {}
3✔
205
      subject.options[:content_types][:custom] = "don't care"
3✔
206
      subject.options[:formatters][:custom] = ->(_obj, _env) { 'CUSTOM FORMAT' }
6✔
207
      _, _, body = subject.call('PATH_INFO' => '/info.custom')
3✔
208
      expect(read_chunks(body)).to eq(['CUSTOM FORMAT'])
3✔
209
    end
210

211
    context 'default' do
2✔
212
      let(:body) { ['blah'] }
5✔
213

214
      it 'uses default json formatter' do
2✔
215
        _, _, body = subject.call('PATH_INFO' => '/info.json')
3✔
216
        expect(read_chunks(body)).to eq(['["blah"]'])
3✔
217
      end
218
    end
219

220
    it 'uses custom json formatter' do
2✔
221
      subject.options[:formatters][:json] = ->(_obj, _env) { 'CUSTOM JSON FORMAT' }
6✔
222
      _, _, body = subject.call('PATH_INFO' => '/info.json')
3✔
223
      expect(read_chunks(body)).to eq(['CUSTOM JSON FORMAT'])
3✔
224
    end
225
  end
226

227
  context 'no content responses' do
2✔
228
    let(:no_content_response) { ->(status) { [status, {}, ['']] } }
614✔
229

230
    STATUSES_WITHOUT_BODY = if Gem::Version.new(Rack.release) >= Gem::Version.new('2.1.0')
4✔
231
                              Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.keys
3✔
232
                            else
233
                              Rack::Utils::STATUS_WITH_NO_ENTITY_BODY
×
234
                            end
235

236
    STATUSES_WITHOUT_BODY.each do |status|
3✔
237
      it "does not modify a #{status} response" do
306✔
238
        expected_response = no_content_response[status]
306✔
239
        allow(app).to receive(:call).and_return(expected_response)
306✔
240
        expect(subject.call({})).to eq(expected_response)
306✔
241
      end
242
    end
243
  end
244

245
  context 'input' do
2✔
246
    %w[POST PATCH PUT DELETE].each do |method|
3✔
247
      context 'when body is not nil or empty' do
8✔
248
        context 'when Content-Type is supported' do
8✔
249
          let(:io) { StringIO.new('{"is_boolean":true,"string":"thing"}') }
20✔
250
          let(:content_type) { 'application/json' }
20✔
251

252
          it "parses the body from #{method} and copies values into rack.request.form_hash" do
12✔
253
            subject.call(
12✔
254
              'PATH_INFO' => '/info',
255
              'REQUEST_METHOD' => method,
256
              'CONTENT_TYPE' => content_type,
257
              'rack.input' => io,
258
              'CONTENT_LENGTH' => io.length
259
            )
260
            expect(subject.env['rack.request.form_hash']['is_boolean']).to be true
12✔
261
            expect(subject.env['rack.request.form_hash']['string']).to eq('thing')
12✔
262
          end
263
        end
264

265
        context 'when Content-Type is not supported' do
8✔
266
          let(:io) { StringIO.new('{"is_boolean":true,"string":"thing"}') }
20✔
267
          let(:content_type) { 'application/atom+xml' }
20✔
268

269
          it 'returns a 415 HTTP error status' do
8✔
270
            error = catch(:error) do
12✔
271
              subject.call(
12✔
272
                'PATH_INFO' => '/info',
273
                'REQUEST_METHOD' => method,
274
                'CONTENT_TYPE' => content_type,
275
                'rack.input' => io,
276
                'CONTENT_LENGTH' => io.length
277
              )
278
            end
279
            expect(error[:status]).to eq(415)
12✔
280
            expect(error[:message]).to eq("The provided content-type 'application/atom+xml' is not supported.")
12✔
281
          end
282
        end
283
      end
284

285
      context 'when body is nil' do
8✔
286
        let(:io) { double }
20✔
287

288
        before do
8✔
289
          allow(io).to receive_message_chain(:rewind, :read).and_return(nil)
12✔
290
        end
291

292
        it 'does not read and parse the body' do
8✔
293
          expect(subject).not_to receive(:read_rack_input)
12✔
294
          subject.call(
12✔
295
            'PATH_INFO' => '/info',
296
            'REQUEST_METHOD' => method,
297
            'CONTENT_TYPE' => 'application/json',
298
            'rack.input' => io,
299
            'CONTENT_LENGTH' => 0
300
          )
301
        end
302
      end
303

304
      context 'when body is empty' do
8✔
305
        let(:io) { double }
20✔
306

307
        before do
8✔
308
          allow(io).to receive_message_chain(:rewind, :read).and_return('')
12✔
309
        end
310

311
        it 'does not read and parse the body' do
8✔
312
          expect(subject).not_to receive(:read_rack_input)
12✔
313
          subject.call(
12✔
314
            'PATH_INFO' => '/info',
315
            'REQUEST_METHOD' => method,
316
            'CONTENT_TYPE' => 'application/json',
317
            'rack.input' => io,
318
            'CONTENT_LENGTH' => 0
319
          )
320
        end
321
      end
322

323
      ['application/json', 'application/json; charset=utf-8'].each do |content_type|
12✔
324
        context content_type do
16✔
325
          it "parses the body from #{method} and copies values into rack.request.form_hash" do
24✔
326
            io = StringIO.new('{"is_boolean":true,"string":"thing"}')
24✔
327
            subject.call(
24✔
328
              'PATH_INFO' => '/info',
329
              'REQUEST_METHOD' => method,
330
              'CONTENT_TYPE' => content_type,
331
              'rack.input' => io,
332
              'CONTENT_LENGTH' => io.length
333
            )
334
            expect(subject.env['rack.request.form_hash']['is_boolean']).to be true
24✔
335
            expect(subject.env['rack.request.form_hash']['string']).to eq('thing')
24✔
336
          end
337
        end
338
      end
339
      it "parses the chunked body from #{method} and copies values into rack.request.from_hash" do
12✔
340
        io = StringIO.new('{"is_boolean":true,"string":"thing"}')
12✔
341
        subject.call(
12✔
342
          'PATH_INFO' => '/infol',
343
          'REQUEST_METHOD' => method,
344
          'CONTENT_TYPE' => 'application/json',
345
          'rack.input' => io,
346
          'HTTP_TRANSFER_ENCODING' => 'chunked'
347
        )
348
        expect(subject.env['rack.request.form_hash']['is_boolean']).to be true
12✔
349
        expect(subject.env['rack.request.form_hash']['string']).to eq('thing')
12✔
350
      end
351

352
      it 'rewinds IO' do
8✔
353
        io = StringIO.new('{"is_boolean":true,"string":"thing"}')
12✔
354
        io.read
12✔
355
        subject.call(
12✔
356
          'PATH_INFO' => '/infol',
357
          'REQUEST_METHOD' => method,
358
          'CONTENT_TYPE' => 'application/json',
359
          'rack.input' => io,
360
          'HTTP_TRANSFER_ENCODING' => 'chunked'
361
        )
362
        expect(subject.env['rack.request.form_hash']['is_boolean']).to be true
12✔
363
        expect(subject.env['rack.request.form_hash']['string']).to eq('thing')
12✔
364
      end
365

366
      it "parses the body from an xml #{method} and copies values into rack.request.from_hash" do
12✔
367
        io = StringIO.new('<thing><name>Test</name></thing>')
12✔
368
        subject.call(
12✔
369
          'PATH_INFO' => '/info.xml',
370
          'REQUEST_METHOD' => method,
371
          'CONTENT_TYPE' => 'application/xml',
372
          'rack.input' => io,
373
          'CONTENT_LENGTH' => io.length
374
        )
375
        if Object.const_defined? :MultiXml
12✔
376
          expect(subject.env['rack.request.form_hash']['thing']['name']).to eq('Test')
×
377
        else
378
          expect(subject.env['rack.request.form_hash']['thing']['name']['__content__']).to eq('Test')
12✔
379
        end
380
      end
381

382
      [Rack::Request::FORM_DATA_MEDIA_TYPES, Rack::Request::PARSEABLE_DATA_MEDIA_TYPES].flatten.each do |content_type|
12✔
383
        it "ignores #{content_type}" do
48✔
384
          io = StringIO.new('name=Other+Test+Thing')
48✔
385
          subject.call(
48✔
386
            'PATH_INFO' => '/info',
387
            'REQUEST_METHOD' => method,
388
            'CONTENT_TYPE' => content_type,
389
            'rack.input' => io,
390
            'CONTENT_LENGTH' => io.length
391
          )
392
          expect(subject.env['rack.request.form_hash']).to be_nil
48✔
393
        end
394
      end
395
    end
396
  end
397

398
  context 'send file' do
2✔
399
    let(:file) { double(File) }
5✔
400
    let(:file_body) { Grape::ServeStream::StreamResponse.new(file) }
5✔
401
    let(:app) { ->(_env) { [200, {}, file_body] } }
8✔
402

403
    it 'returns a file response' do
2✔
404
      expect(file).to receive(:each).and_yield('data')
3✔
405
      env = { 'PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/json' }
3✔
406
      status, headers, body = subject.call(env)
3✔
407
      expect(status).to be == 200
3✔
408
      expect(headers).to be == { 'Content-Type' => 'application/json' }
3✔
409
      expect(read_chunks(body)).to be == ['data']
3✔
410
    end
411
  end
412

413
  context 'inheritable formatters' do
2✔
414
    class InvalidFormatter
3✔
415
      def self.call(_, _)
3✔
416
        { message: 'invalid' }.to_json
3✔
417
      end
418
    end
419
    let(:app) { ->(_env) { [200, {}, ['']] } }
8✔
420

421
    before do
2✔
422
      Grape::Formatter.register :invalid, InvalidFormatter
3✔
423
      Grape::ContentTypes.register :invalid, 'application/x-invalid'
3✔
424
    end
425

426
    after do
2✔
427
      Grape::ContentTypes.default_elements.delete(:invalid)
3✔
428
      Grape::Formatter.default_elements.delete(:invalid)
3✔
429
    end
430

431
    it 'returns response by invalid formatter' do
2✔
432
      env = { 'PATH_INFO' => '/hello.invalid', 'HTTP_ACCEPT' => 'application/x-invalid' }
3✔
433
      _, _, body = *subject.call(env)
3✔
434
      expect(read_chunks(body).join).to eq({ message: 'invalid' }.to_json)
3✔
435
    end
436
  end
437

438
  context 'custom parser raises exception and rescue options are enabled for backtrace and original_exception' do
2✔
439
    it 'adds the backtrace and original_exception to the error output' do
2✔
440
      subject = described_class.new(
3✔
441
        app,
442
        rescue_options: { backtrace: true, original_exception: true },
443
        parsers: { json: ->(_object, _env) { raise StandardError, 'fail' } }
3✔
444
      )
445
      io = StringIO.new('{invalid}')
3✔
446
      error = catch(:error) do
3✔
447
        subject.call(
3✔
448
          'PATH_INFO' => '/info',
449
          'REQUEST_METHOD' => 'POST',
450
          'CONTENT_TYPE' => 'application/json',
451
          'rack.input' => io,
452
          'CONTENT_LENGTH' => io.length
453
        )
454
      end
455

456
      expect(error[:message]).to eq 'fail'
3✔
457
      expect(error[:backtrace].size).to be >= 1
3✔
458
      expect(error[:original_exception].class).to eq StandardError
3✔
459
    end
460
  end
461
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc