Class: Dommy::Response

Inherits:
Object
  • Object
show all
Includes:
Bridge::Methods
Defined in:
lib/dommy/fetch.rb

Overview

‘Response` polyfill — just enough surface for Fetchy: `[:status]` / `[:ok]` / `[:url]` / `[:headers]` (with `.entries()` / `.get(name)`) and `.text()` / `.json()` / `.body` / `.arrayBuffer()` which all return Promise-like values.

Constant Summary collapse

NULL_BODY_STATUSES =

WHATWG null-body statuses: a Response with one of these may not carry a body (constructing one with a body is a TypeError). 101/103 are also null-body but fall outside the 200–599 range the constructor accepts.

[204, 205, 304].freeze
REDIRECT_STATUSES =

Redirect statuses accepted by ‘Response.redirect(url, status)`.

[301, 302, 303, 307, 308].freeze

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Constructor Details

#initialize(window, body:, status: 200, status_text: "", headers: nil, url: "", redirected: false, type: "default", has_body: true) ⇒ Response

Returns a new instance of Response.



205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/dommy/fetch.rb', line 205

def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "",
               redirected: false, type: "default", has_body: true)
  @window = window
  @body = body.to_s
  @status = status
  @status_text = status_text.to_s
  @headers = Headers.new(headers || {})
  @url = url.to_s
  @redirected = redirected ? true : false
  @type = type
  @has_body = has_body ? true : false
  @body_used = false
  @body_stream = nil
end

Class Method Details

.__construct__(window, body, init) ⇒ Object

WHATWG ‘new Response(body, init)`. Validates the status (200–599, else a RangeError; a null-body status 204/205/304 with a body is a TypeError), defaults statusText to “” and status to 200, accepts `init.headers` as a plain object or a Headers instance, and — per the body-extraction step —defaults Content-Type to text/plain for a non-null body when none was supplied. A constructed response’s url is “”.



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
253
254
255
256
# File 'lib/dommy/fetch.rb', line 226

def self.__construct__(window, body, init)
  opts = init.is_a?(Hash) ? init : {}
  status = coerce_status(opts["status"] || opts[:status] || 200)
  unless status.between?(200, 599)
    raise Bridge::RangeError,
      "Failed to construct 'Response': The status provided (#{status}) is outside the range [200, 599]."
  end

  has_body = !(body.nil? || (defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED)))
  if has_body && NULL_BODY_STATUSES.include?(status)
    raise Bridge::TypeError,
      "Failed to construct 'Response': Response with null body status (#{status}) cannot have body."
  end

  # Extract a body: derive its bytes and the Content-Type it implies (Blob →
  # its MIME type, URLSearchParams → urlencoded, FormData → multipart, a
  # string → text/plain). The implied type is only the *default* — an
  # explicit init.headers Content-Type still wins.
  body_bytes, default_ct = has_body ? extract_body(body) : ["", nil]

  headers = coerce_headers(opts["headers"] || opts[:headers])
  if default_ct && headers.keys.none? { |k| k.to_s.downcase == "content-type" }
    headers = headers.merge("Content-Type" => default_ct)
  end

  new(window, body: body_bytes,
              status: status,
              status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""),
              headers: headers,
              has_body: has_body)
end

.__error__(window) ⇒ Object

Static ‘Response.error()` — a network-error response (status 0, not ok, type “error”). (WHATWG Fetch §Response.error)



314
315
316
317
318
319
# File 'lib/dommy/fetch.rb', line 314

def self.__error__(window)
  resp = new(window, body: "", status: 0, type: "error", has_body: false)
  # WHATWG: a network-error response's header guard is "immutable".
  resp.__js_get__("headers").make_immutable!
  resp
end

.__json__(window, data, init = nil) ⇒ Object

Static ‘Response.json(data, init)` — serialize `data` to JSON, defaulting Content-Type to application/json. (WHATWG Fetch §Response.json)



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/dommy/fetch.rb', line 260

def self.__json__(window, data, init = nil)
  # WHATWG: serialize `data` as JSON; if that yields `undefined` (the value
  # is JS `undefined` — or absent — or otherwise non-serializable), throw a
  # TypeError. JS `null` serializes to "null" and is allowed.
  if defined?(Bridge::UNDEFINED) && data.equal?(Bridge::UNDEFINED)
    raise Bridge::TypeError,
      "Failed to execute 'json' on 'Response': The data is not JSON-serializable."
  end

  opts = init.is_a?(Hash) ? init : {}
  status = coerce_status(opts["status"] || opts[:status] || 200)
  unless status.between?(200, 599)
    raise Bridge::RangeError,
      "Failed to execute 'json' on 'Response': The status provided (#{status}) is outside the range [200, 599]."
  end
  if NULL_BODY_STATUSES.include?(status)
    raise Bridge::TypeError,
      "Failed to execute 'json' on 'Response': Response with null body status (#{status}) cannot have body."
  end

  headers = coerce_headers(opts["headers"] || opts[:headers])
  unless headers.keys.any? { |k| k.to_s.downcase == "content-type" }
    headers = headers.merge("Content-Type" => "application/json")
  end

  new(window, body: JSON.generate(data),
              status: status,
              status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""),
              headers: headers)
end

.__redirect__(window, url, status = nil) ⇒ Object

Static ‘Response.redirect(url, status = 302)` — a redirect response whose `Location` header is the parsed-and-serialized `url`. Parsing failure is a TypeError; a non-redirect status is a RangeError. The url is resolved against the window’s base URL so a relative target works. (WHATWG Fetch §Response.redirect)



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/dommy/fetch.rb', line 296

def self.__redirect__(window, url, status = nil)
  base = window.respond_to?(:location) && window.location.respond_to?(:href) ? window.location.href : nil
  parsed = Dommy::URL.new(url.to_s, base) # raises Bridge::TypeError on failure

  status = coerce_status(status.nil? || (defined?(Bridge::UNDEFINED) && status.equal?(Bridge::UNDEFINED)) ? 302 : status)
  unless REDIRECT_STATUSES.include?(status)
    raise Bridge::RangeError,
      "Failed to execute 'redirect' on 'Response': Invalid status code #{status}."
  end

  resp = new(window, body: "", status: status, headers: {"Location" => parsed.href}, has_body: false)
  # WHATWG: a redirect response's header guard is "immutable".
  resp.__js_get__("headers").make_immutable!
  resp
end

.coerce_headers(raw) ⇒ Object



336
337
338
339
340
341
342
# File 'lib/dommy/fetch.rb', line 336

def self.coerce_headers(raw)
  case raw
  when Headers then raw.to_h
  when Hash then raw
  else {}
  end
end

.coerce_status(value) ⇒ Object



321
322
323
# File 'lib/dommy/fetch.rb', line 321

def self.coerce_status(value)
  value.is_a?(Numeric) ? value.to_i : value.to_s.to_i
end

.extract_body(body) ⇒ Object

WHATWG “extract a body”: map a body source to ‘[byte_string, default_content_type_or_nil]`. The default Content-Type is applied only when the caller supplied none.



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/dommy/fetch.rb', line 347

def self.extract_body(body)
  case body
  when Blob # File < Blob
    [body.__dommy_bytes__, (body.type.to_s.empty? ? nil : body.type)]
  when URLSearchParams
    [body.to_s, "application/x-www-form-urlencoded;charset=UTF-8"]
  when FormData
    multipart_body(body)
  when Bridge::Bytes # an ArrayBuffer / TypedArray body
    [body.pack_bytes, nil]
  when String
    [body, "text/plain;charset=UTF-8"]
  else
    if defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED)
      ["", nil]
    else
      [body.to_s, "text/plain;charset=UTF-8"]
    end
  end
end

.multipart_body(form_data) ⇒ Object

Serialize a FormData as a multipart/form-data body. Returns ‘[bytes, content_type]` where content_type carries the generated boundary.



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/dommy/fetch.rb', line 370

def self.multipart_body(form_data)
  boundary = "----DommyFormBoundary#{SecureRandom.hex(12)}"
  crlf = "\r\n"
  out = +""
  form_data.entries.each do |name, value|
    out << "--#{boundary}#{crlf}"
    if value.is_a?(Blob)
      filename = value.respond_to?(:name) ? value.name : "blob"
      out << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"#{crlf})
      content_type = value.type.to_s.empty? ? "application/octet-stream" : value.type
      out << "Content-Type: #{content_type}#{crlf}#{crlf}"
      out << value.__dommy_bytes__ << crlf
    else
      out << %(Content-Disposition: form-data; name="#{name}"#{crlf}#{crlf})
      out << value.to_s << crlf
    end
  end
  out << "--#{boundary}--#{crlf}"
  [out, "multipart/form-data; boundary=#{boundary}"]
end

.validate_status_text!(text) ⇒ Object

WHATWG reason-phrase: HTAB / SP / VCHAR (0x21–0x7E) / obs-text (0x80–0xFF). Any other byte (NUL, CR, LF, other controls, DEL) makes statusText invalid → TypeError.



328
329
330
331
332
333
334
# File 'lib/dommy/fetch.rb', line 328

def self.validate_status_text!(text)
  str = text.to_s
  if str.each_byte.any? { |b| (b < 0x20 && b != 0x09) || b == 0x7f }
    raise Bridge::TypeError, "Failed to construct 'Response': Invalid statusText."
  end
  str
end

Instance Method Details

#__js_call__(method, _args) ⇒ Object



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
# File 'lib/dommy/fetch.rb', line 426

def __js_call__(method, _args)
  case method
  when "text"
    consume_body { immediate(@body) }
  when "json"
    consume_body do
      immediate(JSON.parse(scrub_lone_surrogates(@body)))
    rescue JSON::ParserError => e
      rejected(ErrorValue.new("JSON parse: #{e.message}"))
    end
  when "arrayBuffer"
    # arrayBuffer()'s spec return type is ArrayBuffer — wrap so the host
    # bridge decodes it to a bare JS ArrayBuffer (not a Uint8Array view).
    consume_body { immediate(Bridge::ArrayBuffer.new(@body.bytes)) }
  when "blob"
    consume_body do
      immediate(Blob.new([@body], {"type" => @headers.__js_call__("get", ["content-type"]) || ""}, @window))
    end
  when "formData"
    consume_body { consume_form_data }
  when "clone"
    clone_response
  end
end

#__js_get__(key) ⇒ Object



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/dommy/fetch.rb', line 391

def __js_get__(key)
  case key
  when "status"
    @status
  when "ok"
    @status >= 200 && @status < 300
  when "statusText"
    @status_text
  when "url"
    @url
  when "redirected"
    # Fetch API: true when the response is the result of a followed
    # redirect (so `response.url` is the final, not requested, URL).
    @redirected
  when "type"
    # WHATWG response type: "default" (constructed), "error" (Response.error),
    # "basic" (a same-origin fetch), …
    @type
  when "headers"
    @headers
  when "body"
    # WHATWG: a ReadableStream of the body bytes, or null when there is no
    # body. Merely reading `.body` does not consume it (identity preserved).
    body_stream
  when "bodyUsed"
    body_used?
  end
end

#__js_set__(_key, _value) ⇒ Object



420
421
422
# File 'lib/dommy/fetch.rb', line 420

def __js_set__(_key, _value)
  Bridge::UNHANDLED
end