Skip to content

Commit 200103f

Browse files
feat: support specifying content-type with FilePart class
1 parent 0fb86bd commit 200103f

File tree

13 files changed

+203
-85
lines changed

13 files changed

+203
-85
lines changed

lib/orb.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
require_relative "orb/internal/type/converter"
3232
require_relative "orb/internal/type/unknown"
3333
require_relative "orb/internal/type/boolean"
34-
require_relative "orb/internal/type/io_like"
34+
require_relative "orb/internal/type/file_input"
3535
require_relative "orb/internal/type/enum"
3636
require_relative "orb/internal/type/union"
3737
require_relative "orb/internal/type/array_of"
@@ -41,6 +41,7 @@
4141
require_relative "orb/internal/type/request_parameters"
4242
require_relative "orb/internal"
4343
require_relative "orb/request_options"
44+
require_relative "orb/file_part"
4445
require_relative "orb/errors"
4546
require_relative "orb/internal/transport/base_client"
4647
require_relative "orb/internal/transport/pooled_net_requester"

lib/orb/file_part.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module Orb
4+
class FilePart
5+
# @return [Pathname, StringIO, IO, String]
6+
attr_reader :content
7+
8+
# @return [String, nil]
9+
attr_reader :content_type
10+
11+
# @return [String, nil]
12+
attr_reader :filename
13+
14+
# @api private
15+
#
16+
# @return [String]
17+
private def read
18+
case contents
19+
in Pathname
20+
contents.read(binmode: true)
21+
in StringIO
22+
contents.string
23+
in IO
24+
contents.read
25+
in String
26+
contents
27+
end
28+
end
29+
30+
# @param a [Object]
31+
#
32+
# @return [String]
33+
def to_json(*a) = read.to_json(*a)
34+
35+
# @param a [Object]
36+
#
37+
# @return [String]
38+
def to_yaml(*a) = read.to_yaml(*a)
39+
40+
# @param content [Pathname, StringIO, IO, String]
41+
# @param filename [String, nil]
42+
# @param content_type [String, nil]
43+
def initialize(content, filename: nil, content_type: nil)
44+
@content = content
45+
@filename =
46+
case content
47+
in Pathname
48+
filename.nil? ? content.basename.to_path : File.basename(filename)
49+
else
50+
filename.nil? ? nil : File.basename(filename)
51+
end
52+
@content_type = content_type
53+
end
54+
end
55+
end

lib/orb/internal/type/converter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def dump(value, state:)
4343
value.string
4444
in Pathname | IO
4545
state[:can_retry] = false if value.is_a?(IO)
46-
Orb::Internal::Util::SerializationAdapter.new(value)
46+
Orb::FilePart.new(value)
4747
else
4848
value
4949
end
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ module Type
77
#
88
# @abstract
99
#
10-
# Either `Pathname` or `StringIO`.
11-
class IOLike
10+
# Either `Pathname` or `StringIO`, or `IO`, or `Orb::Internal::Type::FileInput`.
11+
#
12+
# Note: when `IO` is used, all retries are disabled, since many IO` streams are
13+
# not rewindable.
14+
class FileInput
1215
extend Orb::Internal::Type::Converter
1316

1417
private_class_method :new
@@ -20,7 +23,7 @@ class IOLike
2023
# @return [Boolean]
2124
def self.===(other)
2225
case other
23-
in StringIO | Pathname | IO
26+
in Pathname | StringIO | IO | String | Orb::FilePart
2427
true
2528
else
2629
false
@@ -32,7 +35,7 @@ def self.===(other)
3235
# @param other [Object]
3336
#
3437
# @return [Boolean]
35-
def self.==(other) = other.is_a?(Class) && other <= Orb::Internal::Type::IOLike
38+
def self.==(other) = other.is_a?(Class) && other <= Orb::Internal::Type::FileInput
3639

3740
class << self
3841
# @api private

lib/orb/internal/util.rb

Lines changed: 49 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -348,27 +348,6 @@ def normalized_headers(*headers)
348348
end
349349
end
350350

351-
# @api private
352-
class SerializationAdapter
353-
# @return [Pathname, IO]
354-
attr_reader :inner
355-
356-
# @param a [Object]
357-
#
358-
# @return [String]
359-
def to_json(*a) = (inner.is_a?(IO) ? inner.read : inner.read(binmode: true)).to_json(*a)
360-
361-
# @param a [Object]
362-
#
363-
# @return [String]
364-
def to_yaml(*a) = (inner.is_a?(IO) ? inner.read : inner.read(binmode: true)).to_yaml(*a)
365-
366-
# @api private
367-
#
368-
# @param inner [Pathname, IO]
369-
def initialize(inner) = @inner = inner
370-
end
371-
372351
# @api private
373352
#
374353
# An adapter that satisfies the IO interface required by `::IO.copy_stream`
@@ -480,42 +459,35 @@ class << self
480459
# @api private
481460
#
482461
# @param y [Enumerator::Yielder]
483-
# @param boundary [String]
484-
# @param key [Symbol, String]
485462
# @param val [Object]
486463
# @param closing [Array<Proc>]
487-
private def write_multipart_chunk(y, boundary:, key:, val:, closing:)
488-
val = val.inner if val.is_a?(Orb::Internal::Util::SerializationAdapter)
464+
# @param content_type [String, nil]
465+
private def write_multipart_content(y, val:, closing:, content_type: nil)
466+
content_type ||= "application/octet-stream"
489467

490-
y << "--#{boundary}\r\n"
491-
y << "Content-Disposition: form-data"
492-
unless key.nil?
493-
name = ERB::Util.url_encode(key.to_s)
494-
y << "; name=\"#{name}\""
495-
end
496-
case val
497-
in Pathname | IO
498-
filename = ERB::Util.url_encode(File.basename(val.to_path))
499-
y << "; filename=\"#{filename}\""
500-
else
501-
end
502-
y << "\r\n"
503468
case val
469+
in Orb::FilePart
470+
return write_multipart_content(
471+
y,
472+
val: val.content,
473+
closing: closing,
474+
content_type: val.content_type
475+
)
504476
in Pathname
505-
y << "Content-Type: application/octet-stream\r\n\r\n"
477+
y << "Content-Type: #{content_type}\r\n\r\n"
506478
io = val.open(binmode: true)
507479
closing << io.method(:close)
508480
IO.copy_stream(io, y)
509481
in IO
510-
y << "Content-Type: application/octet-stream\r\n\r\n"
482+
y << "Content-Type: #{content_type}\r\n\r\n"
511483
IO.copy_stream(val, y)
512484
in StringIO
513-
y << "Content-Type: application/octet-stream\r\n\r\n"
485+
y << "Content-Type: #{content_type}\r\n\r\n"
514486
y << val.string
515487
in String
516-
y << "Content-Type: application/octet-stream\r\n\r\n"
488+
y << "Content-Type: #{content_type}\r\n\r\n"
517489
y << val.to_s
518-
in _ if primitive?(val)
490+
in -> { primitive?(_1) }
519491
y << "Content-Type: text/plain\r\n\r\n"
520492
y << val.to_s
521493
else
@@ -525,6 +497,36 @@ class << self
525497
y << "\r\n"
526498
end
527499

500+
# @api private
501+
#
502+
# @param y [Enumerator::Yielder]
503+
# @param boundary [String]
504+
# @param key [Symbol, String]
505+
# @param val [Object]
506+
# @param closing [Array<Proc>]
507+
private def write_multipart_chunk(y, boundary:, key:, val:, closing:)
508+
y << "--#{boundary}\r\n"
509+
y << "Content-Disposition: form-data"
510+
511+
unless key.nil?
512+
name = ERB::Util.url_encode(key.to_s)
513+
y << "; name=\"#{name}\""
514+
end
515+
516+
case val
517+
in Orb::FilePart unless val.filename.nil?
518+
filename = ERB::Util.url_encode(val.filename)
519+
y << "; filename=\"#{filename}\""
520+
in Pathname | IO
521+
filename = ERB::Util.url_encode(File.basename(val.to_path))
522+
y << "; filename=\"#{filename}\""
523+
else
524+
end
525+
y << "\r\n"
526+
527+
write_multipart_content(y, val: val, closing: closing)
528+
end
529+
528530
# @api private
529531
#
530532
# @param body [Object]
@@ -565,21 +567,21 @@ class << self
565567
# @return [Object]
566568
def encode_content(headers, body)
567569
content_type = headers["content-type"]
568-
body = body.inner if body.is_a?(Orb::Internal::Util::SerializationAdapter)
569-
570570
case [content_type, body]
571571
in [Orb::Internal::Util::JSON_CONTENT, Hash | Array | -> { primitive?(_1) }]
572572
[headers, JSON.fast_generate(body)]
573-
in [Orb::Internal::Util::JSONL_CONTENT, Enumerable] unless body.is_a?(StringIO) || body.is_a?(IO)
573+
in [Orb::Internal::Util::JSONL_CONTENT, Enumerable] unless body.is_a?(Orb::Internal::Type::FileInput)
574574
[headers, body.lazy.map { JSON.fast_generate(_1) }]
575-
in [%r{^multipart/form-data}, Hash | Pathname | StringIO | IO]
575+
in [%r{^multipart/form-data}, Hash | Orb::Internal::Type::FileInput]
576576
boundary, strio = encode_multipart_streaming(body)
577577
headers = {**headers, "content-type" => "#{content_type}; boundary=#{boundary}"}
578578
[headers, strio]
579579
in [_, Symbol | Numeric]
580580
[headers, body.to_s]
581581
in [_, StringIO]
582582
[headers, body.string]
583+
in [_, Orb::FilePart]
584+
[headers, body.content]
583585
else
584586
[headers, body]
585587
end

rbi/lib/orb/file_part.rbi

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# typed: strong
2+
3+
module Orb
4+
class FilePart
5+
sig { returns(T.any(Pathname, StringIO, IO, String)) }
6+
attr_reader :content
7+
8+
sig { returns(T.nilable(String)) }
9+
attr_reader :content_type
10+
11+
sig { returns(T.nilable(String)) }
12+
attr_reader :filename
13+
14+
# @api private
15+
sig { returns(String) }
16+
private def read; end
17+
18+
sig { params(a: T.anything).returns(String) }
19+
def to_json(*a); end
20+
21+
sig { params(a: T.anything).returns(String) }
22+
def to_yaml(*a); end
23+
24+
sig do
25+
params(
26+
content: T.any(Pathname, StringIO, IO, String),
27+
filename: T.nilable(String),
28+
content_type: T.nilable(String)
29+
)
30+
.returns(T.attached_class)
31+
end
32+
def self.new(content, filename: nil, content_type: nil); end
33+
end
34+
end
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ module Orb
55
module Type
66
# @api private
77
#
8-
# Either `Pathname` or `StringIO`.
9-
class IOLike
8+
# Either `Pathname` or `StringIO`, or `IO`, or `Orb::Internal::Type::FileInput`.
9+
#
10+
# Note: when `IO` is used, all retries are disabled, since many IO` streams are
11+
# not rewindable.
12+
class FileInput
1013
extend Orb::Internal::Type::Converter
1114

1215
abstract!

rbi/lib/orb/internal/util.rbi

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,6 @@ module Orb
140140
def normalized_headers(*headers); end
141141
end
142142

143-
# @api private
144-
class SerializationAdapter
145-
sig { returns(T.any(Pathname, IO)) }
146-
attr_reader :inner
147-
148-
sig { params(a: T.anything).returns(String) }
149-
def to_json(*a); end
150-
151-
sig { params(a: T.anything).returns(String) }
152-
def to_yaml(*a); end
153-
154-
# @api private
155-
sig { params(inner: T.any(Pathname, IO)).returns(T.attached_class) }
156-
def self.new(inner); end
157-
end
158-
159143
# @api private
160144
#
161145
# An adapter that satisfies the IO interface required by `::IO.copy_stream`
@@ -196,6 +180,18 @@ module Orb
196180
JSONL_CONTENT = T.let(%r{^application/(?:x-)?jsonl}, Regexp)
197181

198182
class << self
183+
# @api private
184+
sig do
185+
params(
186+
y: Enumerator::Yielder,
187+
val: T.anything,
188+
closing: T::Array[T.proc.void],
189+
content_type: T.nilable(String)
190+
)
191+
.void
192+
end
193+
private def write_multipart_content(y, val:, closing:, content_type: nil); end
194+
199195
# @api private
200196
sig do
201197
params(

sig/orb/file_part.rbs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module Orb
2+
class FilePart
3+
attr_reader content: Pathname | StringIO | IO | String
4+
5+
attr_reader content_type: String?
6+
7+
attr_reader filename: String?
8+
9+
private def read: -> String
10+
11+
def to_json: (*top a) -> String
12+
13+
def to_yaml: (*top a) -> String
14+
15+
def initialize: (
16+
Pathname | StringIO | IO | String content,
17+
?filename: String?,
18+
?content_type: String?
19+
) -> void
20+
end
21+
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module Orb
22
module Internal
33
module Type
4-
class IOLike
4+
class FileInput
55
extend Orb::Internal::Type::Converter
66

77
def self.===: (top other) -> bool

0 commit comments

Comments
 (0)