From fcf3fc504336825d910d3afa4ecb4c4eda742bfa Mon Sep 17 00:00:00 2001 From: andreatp Date: Mon, 12 Oct 2026 11:31:11 +0100 Subject: [PATCH 1/9] feat: add multipart bodies and byte array values to the abstractions --- .../lib/microsoft_kiota_abstractions.rb | 1 + .../multipart_body.rb | 114 ++++++++++ .../request_information.rb | 4 + .../serialization/parse_node.rb | 4 + .../serialization/serialization_writer.rb | 4 + .../abstractions/spec/multipart_body_spec.rb | 203 ++++++++++++++++++ .../spec/request_information_spec.rb | 21 ++ .../json_parse_node.rb | 4 + .../json_serialization_writer.rb | 8 + .../json/spec/json_byte_array_spec.rb | 50 +++++ 10 files changed, 413 insertions(+) create mode 100644 components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb create mode 100644 components/abstractions/spec/multipart_body_spec.rb create mode 100644 components/serialization/json/spec/json_byte_array_spec.rb diff --git a/components/abstractions/lib/microsoft_kiota_abstractions.rb b/components/abstractions/lib/microsoft_kiota_abstractions.rb index cafb8e8..c26e8e0 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions.rb @@ -12,6 +12,7 @@ require_relative 'microsoft_kiota_abstractions/request_option' require_relative 'microsoft_kiota_abstractions/request_headers' require_relative 'microsoft_kiota_abstractions/request_information' +require_relative 'microsoft_kiota_abstractions/multipart_body' require_relative 'microsoft_kiota_abstractions/version' require_relative 'microsoft_kiota_abstractions/serialization/parsable' require_relative 'microsoft_kiota_abstractions/serialization/iso_duration' diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb b/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb new file mode 100644 index 0000000..06158c5 --- /dev/null +++ b/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'securerandom' +require_relative 'serialization/parsable' + +module MicrosoftKiotaAbstractions + class MultipartBody + include Parsable + + Part = Struct.new(:name, :content_type, :value, :filename) + private_constant :Part + + attr_reader :boundary + attr_accessor :request_adapter + + def initialize + @boundary = SecureRandom.uuid + @parts = {} + end + + def add_or_replace_part(part_name, content_type, part_value, filename = nil) + raise ArgumentError, 'Part name cannot be null' if blank?(part_name) + raise ArgumentError, 'Content type cannot be null' if blank?(content_type) + raise ArgumentError, 'Part value cannot be null' if part_value.nil? + + @parts[normalize(part_name)] = Part.new(part_name, content_type, part_value, filename) + end + + def get_part_value(part_name) + raise ArgumentError, 'Part name cannot be null' if blank?(part_name) + + @parts[normalize(part_name)]&.value + end + + def remove_part(part_name) # rubocop:disable Naming/PredicateMethod + raise ArgumentError, 'Part name cannot be null' if blank?(part_name) + + !@parts.delete(normalize(part_name)).nil? + end + + def serialize(writer) + raise ArgumentError, 'Serialization writer cannot be null' if writer.nil? + raise ArgumentError, 'Request adapter or serialization writer factory cannot be null' if request_adapter&.get_serialization_writer_factory.nil? + raise ArgumentError, 'No parts to serialize' if @parts.empty? + + @parts.each_value.with_index do |part, index| + add_new_line(writer) unless index.zero? + writer.write_string_value('', "--#{boundary}") + writer.write_string_value('Content-Type', part.content_type) + writer.write_string_value('Content-Disposition', content_disposition(part)) + add_new_line(writer) + write_part_value(writer, part) + end + add_new_line(writer) + writer.write_string_value('', "--#{boundary}--") + end + + private + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + + def normalize(part_name) + part_name.downcase + end + + def add_new_line(writer) + writer.write_string_value('', '') + end + + def content_disposition(part) + disposition = %(form-data; name="#{escape(part.name)}") + disposition += %(; filename="#{escape(part.filename)}") unless part.filename.nil? + disposition + end + + # as browsers escape names and file names in multipart/form-data + def escape(value) + value.to_s.gsub(/["\r\n]/, '"' => '%22', "\r" => '%0D', "\n" => '%0A') + end + + def write_part_value(writer, part) + value = part.value + if value.is_a?(Parsable) + writer.write_byte_array_value('', serialize_parsable(part)) + elsif value.is_a?(String) + writer.write_byte_array_value('', value) + elsif value.respond_to?(:read) + rewind(value) + writer.write_byte_array_value('', value.read) + else + raise ArgumentError, "Unsupported type #{value.class} for part #{part.name}" + end + end + + # read from the start each time the body is written, as .NET does for a seekable stream + def rewind(stream) + stream.rewind if stream.respond_to?(:rewind) + rescue Errno::ESPIPE + nil + end + + def serialize_parsable(part) + part_writer = request_adapter.get_serialization_writer_factory.get_serialization_writer(part.content_type) + part_writer.write_object_value(nil, part.value) + content = part_writer.get_serialized_content + return content unless content.respond_to?(:read) + + content.rewind if content.respond_to?(:rewind) + content.read + end + end +end diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb index 7462863..aad0e2e 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb @@ -114,6 +114,10 @@ def set_content_from_scalar(request_adapter, content_type, values) def set_content_from_parsable(request_adapter, content_type, values) writer = request_adapter.get_serialization_writer_factory.get_serialization_writer(content_type) + if values.is_a?(MultipartBody) + content_type = "#{content_type}; boundary=#{values.boundary}" + values.request_adapter = request_adapter + end @headers.try_add(@@content_type_header, content_type) if !values.nil? && values.is_a?(Array) writer.write_collection_of_object_values(nil, values) diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/serialization/parse_node.rb b/components/abstractions/lib/microsoft_kiota_abstractions/serialization/parse_node.rb index ca4c87f..fb74b76 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/serialization/parse_node.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/serialization/parse_node.rb @@ -37,6 +37,10 @@ def get_duration_value raise NotImplementedError end + def get_byte_array_value + raise NotImplementedError + end + def get_collection_of_primitive_values raise NotImplementedError end diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/serialization/serialization_writer.rb b/components/abstractions/lib/microsoft_kiota_abstractions/serialization/serialization_writer.rb index 9b7201b..f3209d6 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/serialization/serialization_writer.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/serialization/serialization_writer.rb @@ -49,6 +49,10 @@ def write_duration_value(_key, _value) raise NotImplementedError end + def write_byte_array_value(_key, _value) + raise NotImplementedError + end + def write_collection_of_primitive_values(_key, _value) raise NotImplementedError end diff --git a/components/abstractions/spec/multipart_body_spec.rb b/components/abstractions/spec/multipart_body_spec.rb new file mode 100644 index 0000000..3a383e0 --- /dev/null +++ b/components/abstractions/spec/multipart_body_spec.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require 'stringio' +require 'microsoft_kiota_abstractions' + +RSpec.describe MicrosoftKiotaAbstractions::MultipartBody do + subject(:body) { described_class.new } + + let(:calls) { [] } + let(:writer) do + recorded = calls + double('writer').tap do |w| + allow(w).to receive(:write_string_value) { |key, value| recorded << [:string, key, value] } + allow(w).to receive(:write_byte_array_value) { |key, value| recorded << [:bytes, key, value] } + end + end + let(:part_writer) { double('part writer') } + let(:factory) { double('factory') } + let(:adapter) { double('adapter', get_serialization_writer_factory: factory) } + let(:model) { Class.new { include MicrosoftKiotaAbstractions::Parsable }.new } + + def part_header(name, content_type, disposition = %(form-data; name="#{name}")) + [ + [:string, '', "--#{body.boundary}"], + [:string, 'Content-Type', content_type], + [:string, 'Content-Disposition', disposition], + [:string, '', ''] + ] + end + + def closing + [[:string, '', ''], [:string, '', "--#{body.boundary}--"]] + end + + it 'adds or replaces a part' do + body.add_or_replace_part('text', 'text/plain', 'Hello, World!') + expect(body.get_part_value('text')).to eq('Hello, World!') + body.add_or_replace_part('text', 'text/plain', 'Hello, World! 2') + expect(body.get_part_value('text')).to eq('Hello, World! 2') + end + + it 'removes a part' do + body.add_or_replace_part('text', 'text/plain', 'Hello, World!') + expect(body.get_part_value('text')).to eq('Hello, World!') + expect(body.remove_part('text')).to be(true) + expect(body.get_part_value('text')).to be_nil + expect(body.remove_part('text')).to be(false) + end + + it 'looks parts up ignoring case' do + body.add_or_replace_part('Text', 'text/plain', 'Hello') + expect(body.get_part_value('TEXT')).to eq('Hello') + expect(body.remove_part('text')).to be(true) + expect(body.get_part_value('Text')).to be_nil + end + + it 'does not deserialize' do + expect { body.get_field_deserializers }.to raise_error(NotImplementedError) + end + + it 'gives every body its own boundary' do + expect(body.boundary).not_to be_empty + expect(body.boundary).not_to eq(described_class.new.boundary) + end + + describe '#serialize' do + before { body.request_adapter = adapter } + + it 'writes a string part' do + body.add_or_replace_part('message', 'text/plain', 'Hello world') + body.serialize(writer) + expect(calls).to eq(part_header('message', 'text/plain') + [[:bytes, '', 'Hello world']] + closing) + end + + it 'writes an empty part, as an empty file upload is valid' do + body.add_or_replace_part('file', 'application/octet-stream', '', 'empty.bin') + body.serialize(writer) + expect(calls).to eq(part_header('file', 'application/octet-stream', 'form-data; name="file"; filename="empty.bin"') + + [[:bytes, '', '']] + closing) + end + + it 'writes the part name with its original casing' do + body.add_or_replace_part('Message', 'text/plain', 'Hello world') + body.serialize(writer) + expect(calls).to include([:string, 'Content-Disposition', 'form-data; name="Message"']) + end + + it 'keeps one part when it is replaced' do + body.add_or_replace_part('message', 'text/plain', 'Hello') + body.add_or_replace_part('Message', 'text/plain', 'Bye') + body.serialize(writer) + expect(calls).to eq(part_header('Message', 'text/plain') + [[:bytes, '', 'Bye']] + closing) + end + + it 'writes the file name when set' do + body.add_or_replace_part('file', 'application/octet-stream', 'data', 'file.txt') + body.serialize(writer) + expect(calls).to include([:string, 'Content-Disposition', 'form-data; name="file"; filename="file.txt"']) + end + + it 'escapes quotes and line breaks in part names and file names, as browsers do' do + body.add_or_replace_part('a"b', 'text/plain', 'data', %(my "best"\r\nnotes.txt)) + body.serialize(writer) + expect(calls).to include([:string, 'Content-Disposition', 'form-data; name="a%22b"; filename="my %22best%22%0D%0Anotes.txt"']) + end + + it 'reads a stream part from the start each time the body is written' do + body.add_or_replace_part('file', 'application/octet-stream', StringIO.new('payload')) + 2.times { body.serialize(writer) } + expect(calls.count([:bytes, '', 'payload'])).to eq(2) + end + + it 'reads a stream part that cannot be rewound' do + reader, writer_end = IO.pipe + writer_end.write('piped') + writer_end.close + body.add_or_replace_part('file', 'application/octet-stream', reader) + body.serialize(writer) + expect(calls).to include([:bytes, '', 'piped']) + end + + it 'reads a stream part' do + body.add_or_replace_part('file', 'application/octet-stream', StringIO.new("\x00\x01".b)) + body.serialize(writer) + expect(calls).to include([:bytes, '', "\x00\x01".b]) + end + + it 'writes a model part with a writer for the part content type' do + allow(factory).to receive(:get_serialization_writer).with('application/json').and_return(part_writer) + expect(part_writer).to receive(:write_object_value).with(nil, model) + allow(part_writer).to receive(:get_serialized_content).and_return('{"id":1}') + body.add_or_replace_part('user', 'application/json', model) + body.add_or_replace_part('message', 'text/plain', 'Hello') + body.serialize(writer) + expect(calls).to eq( + part_header('user', 'application/json') + [[:bytes, '', '{"id":1}'], [:string, '', '']] + + part_header('message', 'text/plain') + [[:bytes, '', 'Hello']] + closing + ) + end + + it 'reads a model part serialized to a stream' do + allow(factory).to receive(:get_serialization_writer).and_return(part_writer) + allow(part_writer).to receive(:write_object_value) + content = StringIO.new('{"id":1}') + content.read + allow(part_writer).to receive(:get_serialized_content).and_return(content) + body.add_or_replace_part('user', 'application/json', model) + body.serialize(writer) + expect(calls).to include([:bytes, '', '{"id":1}']) + end + + it 'rejects a part of an unsupported type' do + body.add_or_replace_part('count', 'text/plain', 42) + expect { body.serialize(writer) }.to raise_error(ArgumentError, 'Unsupported type Integer for part count') + end + end +end + +RSpec.describe MicrosoftKiotaAbstractions::MultipartBody, 'defensive checks' do + subject(:body) { described_class.new } + + let(:writer) { double('writer') } + let(:adapter) { double('adapter', get_serialization_writer_factory: double('factory')) } + + it 'rejects a missing part name, content type or value' do + expect { body.add_or_replace_part(nil, 'text/plain', 'Hello, World!') } + .to raise_error(ArgumentError, 'Part name cannot be null') + expect { body.add_or_replace_part('text', nil, 'Hello, World!') } + .to raise_error(ArgumentError, 'Content type cannot be null') + expect { body.add_or_replace_part('text', 'text/plain', nil) } + .to raise_error(ArgumentError, 'Part value cannot be null') + end + + it 'rejects an empty part name or content type' do + expect { body.add_or_replace_part('', 'text/plain', 'Hello') }.to raise_error(ArgumentError, 'Part name cannot be null') + expect { body.add_or_replace_part('text', '', 'Hello') }.to raise_error(ArgumentError, 'Content type cannot be null') + end + + it 'rejects a missing part name when reading or removing a part' do + [nil, ''].each do |name| + expect { body.get_part_value(name) }.to raise_error(ArgumentError, 'Part name cannot be null') + expect { body.remove_part(name) }.to raise_error(ArgumentError, 'Part name cannot be null') + end + end + + it 'rejects a missing writer' do + expect { body.serialize(nil) }.to raise_error(ArgumentError, 'Serialization writer cannot be null') + end + + it 'rejects a missing request adapter or serialization writer factory' do + body.add_or_replace_part('text', 'text/plain', 'Hello') + expect { body.serialize(writer) } + .to raise_error(ArgumentError, 'Request adapter or serialization writer factory cannot be null') + body.request_adapter = double('adapter', get_serialization_writer_factory: nil) + expect { body.serialize(writer) } + .to raise_error(ArgumentError, 'Request adapter or serialization writer factory cannot be null') + end + + it 'rejects a body without parts' do + body.request_adapter = adapter + expect { body.serialize(writer) }.to raise_error(ArgumentError, 'No parts to serialize') + end +end diff --git a/components/abstractions/spec/request_information_spec.rb b/components/abstractions/spec/request_information_spec.rb index 3ad5c9c..da91bdb 100644 --- a/components/abstractions/spec/request_information_spec.rb +++ b/components/abstractions/spec/request_information_spec.rb @@ -42,4 +42,25 @@ request_info.set_query_string_parameters_from_raw_object(query_parameters) expect(request_info.query_parameters.length).to eq(0) end + + it 'sets the boundary on a multipart request body' do + writer = double('writer', write_object_value: nil, get_serialized_content: 'content') + factory = double('factory') + allow(factory).to receive(:get_serialization_writer).with('multipart/form-data').and_return(writer) + adapter = double('adapter', get_serialization_writer_factory: factory) + body = MicrosoftKiotaAbstractions::MultipartBody.new + request_info = MicrosoftKiotaAbstractions::RequestInformation.new + request_info.set_content_from_parsable(adapter, 'multipart/form-data', body) + expect(request_info.headers.get('Content-Type')).to eq(["multipart/form-data; boundary=#{body.boundary}"]) + expect(body.request_adapter).to be(adapter) + expect(writer).to have_received(:write_object_value).with(nil, body) + end + + it 'leaves the content type of any other body alone' do + writer = double('writer', write_object_value: nil, get_serialized_content: '{}') + adapter = double('adapter', get_serialization_writer_factory: double('factory', get_serialization_writer: writer)) + request_info = MicrosoftKiotaAbstractions::RequestInformation.new + request_info.set_content_from_parsable(adapter, 'application/json', Class.new { include MicrosoftKiotaAbstractions::Parsable }.new) + expect(request_info.headers.get('Content-Type')).to eq(['application/json']) + end end diff --git a/components/serialization/json/lib/microsoft_kiota_serialization_json/json_parse_node.rb b/components/serialization/json/lib/microsoft_kiota_serialization_json/json_parse_node.rb index a6aaec8..23b9d39 100644 --- a/components/serialization/json/lib/microsoft_kiota_serialization_json/json_parse_node.rb +++ b/components/serialization/json/lib/microsoft_kiota_serialization_json/json_parse_node.rb @@ -55,6 +55,10 @@ def get_duration_value MicrosoftKiotaAbstractions::ISODuration.new(@current_node) if @current_node.is_a?(String) end + def get_byte_array_value + @current_node.unpack1('m') if @current_node.is_a?(String) + end + # The generator passes the type as a class, except for booleans which it passes as a plain # string. A `case` cannot dispatch on that: `when String` asks whether the type is an instance # of String, and a class is an instance of Class, so every branch fell through to the string diff --git a/components/serialization/json/lib/microsoft_kiota_serialization_json/json_serialization_writer.rb b/components/serialization/json/lib/microsoft_kiota_serialization_json/json_serialization_writer.rb index c0151a5..539e963 100644 --- a/components/serialization/json/lib/microsoft_kiota_serialization_json/json_serialization_writer.rb +++ b/components/serialization/json/lib/microsoft_kiota_serialization_json/json_serialization_writer.rb @@ -102,6 +102,14 @@ def write_duration_value(key, value) @writer[key] = value.string end + def write_byte_array_value(key, value) + raise StandardError, 'no key or value included in write_byte_array_value(key, value)' if key.nil? && value.nil? + return set_root_value([value].pack('m0')) if key.nil? + return if value.nil? + + @writer[key] = [value].pack('m0') + end + def write_collection_of_primitive_values(key, values) return unless values return set_root_value(values.map { |v| serialized_scalar(v) }) unless key diff --git a/components/serialization/json/spec/json_byte_array_spec.rb b/components/serialization/json/spec/json_byte_array_spec.rb new file mode 100644 index 0000000..254f296 --- /dev/null +++ b/components/serialization/json/spec/json_byte_array_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'microsoft_kiota_abstractions' + +RSpec.describe 'byte array values' do + let(:writer) { MicrosoftKiotaSerializationJson::JsonSerializationWriter.new } + let(:bytes) { "\x00\xFFHello".b } + + it 'writes a byte array as base64' do + writer.write_byte_array_value('data', bytes) + expect(writer.get_serialized_content).to eq('{"data":"AP9IZWxsbw=="}') + end + + it 'writes a byte array as the root value without a key' do + writer.write_byte_array_value(nil, bytes) + expect(writer.get_serialized_content).to eq('"AP9IZWxsbw=="') + end + + it 'leaves an unset byte array off the wire' do + writer.write_byte_array_value('data', nil) + expect(writer.writer).to eq({}) + end + + it 'raises when both the key and the byte array are missing' do + expect { writer.write_byte_array_value(nil, nil) }.to raise_error(StandardError, /no key or value/) + end + + it 'reads a base64 string as a byte array' do + value = MicrosoftKiotaSerializationJson::JsonParseNode.new('AP9IZWxsbw==').get_byte_array_value + expect(value).to eq(bytes) + expect(value.encoding).to eq(Encoding::BINARY) + end + + it 'reads base64 split across lines, as Python and .NET do' do + expect(MicrosoftKiotaSerializationJson::JsonParseNode.new("AP9I\nZWxsbw==").get_byte_array_value).to eq(bytes) + end + + it 'reads nothing from a node that is not a string' do + expect(MicrosoftKiotaSerializationJson::JsonParseNode.new(42).get_byte_array_value).to be_nil + expect(MicrosoftKiotaSerializationJson::JsonParseNode.new(nil).get_byte_array_value).to be_nil + end + + it 'reads back what it writes' do + writer.write_byte_array_value('data', bytes) + factory = MicrosoftKiotaSerializationJson::JsonParseNodeFactory.new + node = factory.get_parse_node('application/json', writer.get_serialized_content) + expect(node.get_child_node('data').get_byte_array_value).to eq(bytes) + end +end From 0d5e47a32e495a1c447258e3b8805d2bd0d4d4b5 Mon Sep 17 00:00:00 2001 From: andreatp Date: Mon, 12 Oct 2026 11:31:11 +0100 Subject: [PATCH 2/9] feat: add multipart serialization --- components/serialization/multipart/Gemfile | 15 ++ components/serialization/multipart/Rakefile | 8 + ...microsoft_kiota_serialization_multipart.rb | 8 + .../multipart_serialization_writer.rb | 61 +++++++ .../multipart_serialization_writer_factory.rb | 21 +++ .../version.rb | 5 + ...soft_kiota_serialization_multipart.gemspec | 31 ++++ .../multipart/spec/files/test_entity.rb | 28 +++ .../spec/multipart_request_body_spec.rb | 36 ++++ ...ipart_serialization_writer_factory_spec.rb | 31 ++++ .../multipart_serialization_writer_spec.rb | 162 ++++++++++++++++++ .../multipart/spec/spec_helper.rb | 17 ++ 12 files changed, 423 insertions(+) create mode 100644 components/serialization/multipart/Gemfile create mode 100644 components/serialization/multipart/Rakefile create mode 100644 components/serialization/multipart/lib/microsoft_kiota_serialization_multipart.rb create mode 100644 components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb create mode 100644 components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer_factory.rb create mode 100644 components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/version.rb create mode 100644 components/serialization/multipart/microsoft_kiota_serialization_multipart.gemspec create mode 100644 components/serialization/multipart/spec/files/test_entity.rb create mode 100644 components/serialization/multipart/spec/multipart_request_body_spec.rb create mode 100644 components/serialization/multipart/spec/multipart_serialization_writer_factory_spec.rb create mode 100644 components/serialization/multipart/spec/multipart_serialization_writer_spec.rb create mode 100644 components/serialization/multipart/spec/spec_helper.rb diff --git a/components/serialization/multipart/Gemfile b/components/serialization/multipart/Gemfile new file mode 100644 index 0000000..6a051bc --- /dev/null +++ b/components/serialization/multipart/Gemfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +ruby '>= 3.3.0' + +gem 'microsoft_kiota_abstractions', path: '../../abstractions' +gem 'rake', '~> 13.0' +gem 'rspec', '~> 3.0' + +group :test do + gem 'microsoft_kiota_serialization_json', path: '../json' +end + +gemspec diff --git a/components/serialization/multipart/Rakefile b/components/serialization/multipart/Rakefile new file mode 100644 index 0000000..82bb534 --- /dev/null +++ b/components/serialization/multipart/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart.rb b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart.rb new file mode 100644 index 0000000..55214bb --- /dev/null +++ b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative 'microsoft_kiota_serialization_multipart/multipart_serialization_writer' +require_relative 'microsoft_kiota_serialization_multipart/multipart_serialization_writer_factory' +require_relative 'microsoft_kiota_serialization_multipart/version' + +module MicrosoftKiotaSerializationMultipart +end diff --git a/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb new file mode 100644 index 0000000..ade1494 --- /dev/null +++ b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'microsoft_kiota_abstractions' + +module MicrosoftKiotaSerializationMultipart + class MultipartSerializationWriter + include MicrosoftKiotaAbstractions::SerializationWriter + + attr_accessor :on_before_object_serialization, :on_after_object_serialization, :on_start_object_serialization + + def initialize + @content = String.new(encoding: Encoding::BINARY) + end + + def write_string_value(key, value) + has_key = present?(key) + has_value = present?(value) + append(key) if has_key + append(': ') if has_key && has_value + append(value) if has_value + append("\r\n") + end + + def write_byte_array_value(_key, value) + append(value) unless value.nil? + end + + def write_object_value(_key, value, *_additional_values_to_merge) + raise ArgumentError, "Expected a MultipartBody instance but got #{value.class}" unless value.is_a?(MicrosoftKiotaAbstractions::MultipartBody) + + serialize_body(value) + end + + def write_null_value(_key) + raise NotImplementedError + end + + def get_serialized_content + @content + end + + private + + def present?(value) + !value.nil? && !value.empty? + end + + # every chunk is appended as bytes so UTF-8 text and raw binary parts can share the body + def append(chunk) + @content << chunk.b + end + + def serialize_body(value) + on_before_object_serialization&.call(value) + on_start_object_serialization&.call(value, self) + value.serialize(self) + ensure + on_after_object_serialization&.call(value) + end + end +end diff --git a/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer_factory.rb b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer_factory.rb new file mode 100644 index 0000000..02a20a6 --- /dev/null +++ b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer_factory.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'microsoft_kiota_abstractions' +require_relative 'multipart_serialization_writer' + +module MicrosoftKiotaSerializationMultipart + class MultipartSerializationWriterFactory + include MicrosoftKiotaAbstractions::SerializationWriterFactory + + def get_valid_content_type + 'multipart/form-data' + end + + def get_serialization_writer(content_type) + raise ArgumentError, 'content type cannot be undefined or empty' if content_type.nil? || content_type.empty? + raise ArgumentError, "expected a #{get_valid_content_type} content type" unless get_valid_content_type.casecmp?(content_type) + + MultipartSerializationWriter.new + end + end +end diff --git a/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/version.rb b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/version.rb new file mode 100644 index 0000000..db29a40 --- /dev/null +++ b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module MicrosoftKiotaSerializationMultipart + VERSION = '0.24.1' # x-release-please-version +end diff --git a/components/serialization/multipart/microsoft_kiota_serialization_multipart.gemspec b/components/serialization/multipart/microsoft_kiota_serialization_multipart.gemspec new file mode 100644 index 0000000..5fff006 --- /dev/null +++ b/components/serialization/multipart/microsoft_kiota_serialization_multipart.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative 'lib/microsoft_kiota_serialization_multipart/version' + +Gem::Specification.new do |spec| + spec.name = 'microsoft_kiota_serialization_multipart' + spec.version = MicrosoftKiotaSerializationMultipart::VERSION + spec.authors = 'Microsoft Corporation' + spec.email = 'graphsdkpub+ruby@microsoft.com' + spec.description = 'Implementation of Kiota Serialization interfaces for multipart form data' + spec.summary = 'Microsoft Kiota Serialization - Ruby serialization for building library agnostic http client' + spec.homepage = 'https://learn.microsoft.com/openapi/kiota/' + spec.license = 'MIT' + spec.metadata = { + 'bug_tracker_uri' => 'https://github.com/microsoft/kiota-ruby/issues', + 'changelog_uri' => 'https://github.com/microsoft/kiota-ruby/blob/main/CHANGELOG.md', + 'homepage_uri' => spec.homepage, + 'source_code_uri' => 'https://github.com/microsoft/kiota-ruby/tree/main/components/serialization/multipart', + 'github_repo' => 'ssh://github.com/microsoft/kiota-ruby', + 'rubygems_mfa_required' => 'true' + } + spec.required_ruby_version = '>= 3.3.0' + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } + end + spec.bindir = 'bin' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + spec.add_dependency 'microsoft_kiota_abstractions', MicrosoftKiotaSerializationMultipart::VERSION +end diff --git a/components/serialization/multipart/spec/files/test_entity.rb b/components/serialization/multipart/spec/files/test_entity.rb new file mode 100644 index 0000000..b3a40ad --- /dev/null +++ b/components/serialization/multipart/spec/files/test_entity.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'microsoft_kiota_abstractions' + +module Files + class TestEntity + include MicrosoftKiotaAbstractions::Parsable + include MicrosoftKiotaAbstractions::AdditionalDataHolder + + attr_accessor :id, :display_name, :birthday, :work_duration, :additional_data + + def initialize + @additional_data = {} + end + + def get_field_deserializers + {} + end + + def serialize(writer) + writer.write_guid_value('id', @id) + writer.write_string_value('displayName', @display_name) + writer.write_date_value('birthDay', @birthday) + writer.write_duration_value('workDuration', @work_duration) + writer.write_additional_data(@additional_data) + end + end +end diff --git a/components/serialization/multipart/spec/multipart_request_body_spec.rb b/components/serialization/multipart/spec/multipart_request_body_spec.rb new file mode 100644 index 0000000..669d28a --- /dev/null +++ b/components/serialization/multipart/spec/multipart_request_body_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'uuidtools' +require_relative 'spec_helper' +require_relative 'files/test_entity' +require 'microsoft_kiota_serialization_json' + +RSpec.describe 'a multipart request body' do + let(:registry) do + MicrosoftKiotaAbstractions::SerializationWriterFactoryRegistry.new.tap do |r| + r.content_type_associated_factories['multipart/form-data'] = + MicrosoftKiotaSerializationMultipart::MultipartSerializationWriterFactory.new + r.content_type_associated_factories['application/json'] = + MicrosoftKiotaSerializationJson::JsonSerializationWriterFactory.new + end + end + let(:adapter) { double('request adapter', get_serialization_writer_factory: registry) } + let(:user) do + Files::TestEntity.new.tap { |u| u.id = UUIDTools::UUID.parse('eac79bd3-fd08-4abf-9df2-2565cf3a3845') } + end + + it 'is written with the factory registered for each part and sent with its boundary' do + body = MicrosoftKiotaAbstractions::MultipartBody.new + body.add_or_replace_part('test user', 'application/json', user) + body.add_or_replace_part('message', 'text/plain', 'Hello world') + request_info = MicrosoftKiotaAbstractions::RequestInformation.new + request_info.set_content_from_parsable(adapter, 'multipart/form-data', body) + + expect(request_info.headers.get('Content-Type')).to eq(["multipart/form-data; boundary=#{body.boundary}"]) + expect(request_info.content).to eq( + "--#{body.boundary}\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name=\"test user\"" \ + "\r\n\r\n{\"id\":\"eac79bd3-fd08-4abf-9df2-2565cf3a3845\"}\r\n--#{body.boundary}\r\nContent-Type: text/plain" \ + "\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\nHello world\r\n--#{body.boundary}--\r\n" + ) + end +end diff --git a/components/serialization/multipart/spec/multipart_serialization_writer_factory_spec.rb b/components/serialization/multipart/spec/multipart_serialization_writer_factory_spec.rb new file mode 100644 index 0000000..c64a936 --- /dev/null +++ b/components/serialization/multipart/spec/multipart_serialization_writer_factory_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +RSpec.describe MicrosoftKiotaSerializationMultipart::MultipartSerializationWriterFactory do + subject(:factory) { described_class.new } + + it 'creates a multipart writer' do + expect(factory.get_serialization_writer('multipart/form-data')) + .to be_a(MicrosoftKiotaSerializationMultipart::MultipartSerializationWriter) + end + + it 'matches the content type ignoring case' do + expect(factory.get_serialization_writer('Multipart/Form-Data')) + .to be_a(MicrosoftKiotaSerializationMultipart::MultipartSerializationWriter) + end + + it 'rejects a missing content type' do + expect { factory.get_serialization_writer('') }.to raise_error(ArgumentError, 'content type cannot be undefined or empty') + expect { factory.get_serialization_writer(nil) }.to raise_error(ArgumentError, 'content type cannot be undefined or empty') + end + + it 'rejects an unsupported content type' do + expect { factory.get_serialization_writer('application/xml') } + .to raise_error(ArgumentError, 'expected a multipart/form-data content type') + end + + it 'gives the content type it writes' do + expect(factory.get_valid_content_type).to eq('multipart/form-data') + end +end diff --git a/components/serialization/multipart/spec/multipart_serialization_writer_spec.rb b/components/serialization/multipart/spec/multipart_serialization_writer_spec.rb new file mode 100644 index 0000000..102f44c --- /dev/null +++ b/components/serialization/multipart/spec/multipart_serialization_writer_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'date' +require 'uuidtools' +require_relative 'spec_helper' +require_relative 'files/test_entity' +require 'microsoft_kiota_serialization_json' + +RSpec.describe MicrosoftKiotaSerializationMultipart::MultipartSerializationWriter do + subject(:writer) { described_class.new } + + let(:json_factory) { MicrosoftKiotaSerializationJson::JsonSerializationWriterFactory.new } + let(:request_adapter) { double('request adapter', get_serialization_writer_factory: json_factory) } + let(:body) do + MicrosoftKiotaAbstractions::MultipartBody.new.tap { |b| b.request_adapter = request_adapter } + end + let(:user) do + Files::TestEntity.new.tap do |u| + u.id = UUIDTools::UUID.parse('eac79bd3-fd08-4abf-9df2-2565cf3a3845') + u.birthday = Date.new(2017, 9, 4) + u.work_duration = MicrosoftKiotaAbstractions::ISODuration.new('PT2H') + u.additional_data = { 'businessPhones' => ['+1 412 555 0109'], 'mobilePhone' => nil, 'jobTitle' => 'Auditor' } + end + end + let(:user_json) do + '{"id":"eac79bd3-fd08-4abf-9df2-2565cf3a3845","birthDay":"2017-09-04","workDuration":"PT2H",' \ + '"businessPhones":["+1 412 555 0109"],"mobilePhone":null,"jobTitle":"Auditor"}' + end + + def part(name, content_type, content, filename: nil) + disposition = %(form-data; name="#{name}") + disposition += %(; filename="#{filename}") if filename + "--#{body.boundary}\r\nContent-Type: #{content_type}\r\nContent-Disposition: #{disposition}\r\n\r\n#{content}\r\n" + end + + def closing + "--#{body.boundary}--\r\n" + end + + it 'does not implement the other writers' do + expect { writer.write_boolean_value('isActive', false) }.to raise_error(NotImplementedError) + expect { writer.write_number_value('count', 0) }.to raise_error(NotImplementedError) + expect { writer.write_float_value('gpa', 0.0) }.to raise_error(NotImplementedError) + expect { writer.write_guid_value('id', UUIDTools::UUID.random_create) }.to raise_error(NotImplementedError) + expect { writer.write_date_value('birthday', Date.new(2000, 9, 4)) }.to raise_error(NotImplementedError) + expect { writer.write_time_value('time', Time.now) }.to raise_error(NotImplementedError) + expect { writer.write_date_time_value('updatedAt', DateTime.now) }.to raise_error(NotImplementedError) + expect { writer.write_duration_value('diff', MicrosoftKiotaAbstractions::ISODuration.new('PT2H')) } + .to raise_error(NotImplementedError) + expect { writer.write_enum_value('number', :four) }.to raise_error(NotImplementedError) + expect { writer.write_collection_of_primitive_values('numbers', [1, 2.0, '3']) }.to raise_error(NotImplementedError) + expect { writer.write_collection_of_object_values('users', [user]) }.to raise_error(NotImplementedError) + expect { writer.write_collection_of_enum_values('numbers', %i[four eight]) }.to raise_error(NotImplementedError) + expect { writer.write_null_value('nothing') }.to raise_error(NotImplementedError) + expect { writer.write_additional_data({ 'a' => 1 }) }.to raise_error(NotImplementedError) + expect { writer.write_any_value('a', 1) }.to raise_error(NotImplementedError) + end + + it 'writes a string value' do + writer.write_string_value('message', 'Hello world') + expect(writer.get_serialized_content).to eq("message: Hello world\r\n") + end + + it 'writes a line with only a key or only a value' do + writer.write_string_value('', '--boundary') + writer.write_string_value('key', nil) + writer.write_string_value('', '') + expect(writer.get_serialized_content).to eq("--boundary\r\nkey\r\n\r\n") + end + + it 'writes bytes as they are' do + writer.write_byte_array_value('', 'Hello world') + writer.write_byte_array_value('', nil) + expect(writer.get_serialized_content).to eq('Hello world') + end + + it 'raises for a model that is not a multipart body' do + expect { writer.write_object_value('user', user) } + .to raise_error(ArgumentError, /Expected a MultipartBody instance but got Files::TestEntity/) + end + + it 'writes a model part and a binary part' do + body.add_or_replace_part('test user', 'application/json', user) + body.add_or_replace_part('img', 'application/octet-stream', 'Hello world'.b) + writer.write_object_value('', body, nil) + expect(writer.get_serialized_content) + .to eq(part('test user', 'application/json', user_json) + part('img', 'application/octet-stream', 'Hello world') + closing) + end + + it 'writes the parts in the order they were added' do + body.add_or_replace_part('img', 'application/octet-stream', 'Hello world'.b) + body.add_or_replace_part('test user', 'application/json', user) + writer.write_object_value('', body, nil) + expect(writer.get_serialized_content) + .to eq(part('img', 'application/octet-stream', 'Hello world') + part('test user', 'application/json', user_json) + closing) + end + + it 'writes the file name of a part' do + body.add_or_replace_part('test user', 'application/json', user) + body.add_or_replace_part('file', 'application/octet-stream', 'Hello world'.b, 'file.txt') + writer.write_object_value('', body, nil) + expect(writer.get_serialized_content).to eq( + part('test user', 'application/json', user_json) + + part('file', 'application/octet-stream', 'Hello world', filename: 'file.txt') + closing + ) + end + + it 'writes a string part' do + body.add_or_replace_part('message', 'text/plain', 'Hello world') + writer.write_object_value('', body, nil) + expect(writer.get_serialized_content).to eq( + "--#{body.boundary}\r\nContent-Type: text/plain\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n" \ + "Hello world\r\n--#{body.boundary}--\r\n" + ) + end + + it 'writes several string parts' do + body.add_or_replace_part('message1', 'text/plain', 'Hello world') + body.add_or_replace_part('message2', 'text/plain', 'Goodbye world') + writer.write_object_value('', body, nil) + expect(writer.get_serialized_content) + .to eq(part('message1', 'text/plain', 'Hello world') + part('message2', 'text/plain', 'Goodbye world') + closing) + end + + it 'writes a stream part' do + body.add_or_replace_part('file', 'application/octet-stream', StringIO.new('Hello world')) + writer.write_object_value('', body) + expect(writer.get_serialized_content).to eq(part('file', 'application/octet-stream', 'Hello world') + closing) + end + + it 'mixes UTF-8 text with raw bytes' do + user.display_name = 'Zoë ☃' + binary = "\xFF\xD8\xFF\x00".b + body.add_or_replace_part('test user', 'application/json', user) + body.add_or_replace_part('note', 'text/plain', 'héllo') + body.add_or_replace_part('img', 'image/jpeg', binary) + writer.write_object_value('', body) + content = writer.get_serialized_content + expect(content.encoding).to eq(Encoding::BINARY) + expect(content).to eq( + part('test user', 'application/json', user_json.sub('"birthDay"', '"displayName":"Zoë ☃","birthDay"')).b + + part('note', 'text/plain', 'héllo').b + part('img', 'image/jpeg', binary) + closing + ) + end + + it 'calls the serialization hooks around the body' do + calls = [] + writer.on_before_object_serialization = ->(value) { calls << [:before, value] } + writer.on_start_object_serialization = ->(value, w) { calls << [:start, value, w] } + writer.on_after_object_serialization = ->(value) { calls << [:after, value] } + body.add_or_replace_part('message', 'text/plain', 'Hello world') + writer.write_object_value('', body) + expect(calls).to eq([[:before, body], [:start, body, writer], [:after, body]]) + end + + it 'calls the after hook when writing the body raises' do + calls = [] + writer.on_after_object_serialization = ->(value) { calls << value } + expect { writer.write_object_value('', body) }.to raise_error(ArgumentError, 'No parts to serialize') + expect(calls).to eq([body]) + end +end diff --git a/components/serialization/multipart/spec/spec_helper.rb b/components/serialization/multipart/spec/spec_helper.rb new file mode 100644 index 0000000..5e1b016 --- /dev/null +++ b/components/serialization/multipart/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'microsoft_kiota_abstractions' + +Dir["#{File.dirname(__FILE__)}/./../lib/*.rb"].each { |f| load(f) } + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end From afe43cddd96a7a03993f2428697c9a8ff6165805 Mon Sep 17 00:00:00 2001 From: andreatp Date: Mon, 12 Oct 2026 11:35:23 +0100 Subject: [PATCH 3/9] ci: add the multipart serialization gem to the build and release --- .github/dependabot.yml | 7 +++++++ .github/workflows/release.yml | 1 + .github/workflows/ruby.yml | 1 + Gemfile | 1 + README.md | 3 +++ Rakefile | 1 + release-please-config.json | 1 + 7 files changed, 15 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cca8e99..da90a7b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,6 +39,13 @@ updates: open-pull-requests-limit: 10 cooldown: default-days: 7 +- package-ecosystem: bundler + directory: /components/serialization/multipart + schedule: + interval: daily + open-pull-requests-limit: 10 + cooldown: + default-days: 7 - package-ecosystem: bundler directory: /components/http schedule: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87b63ca..76a86b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,7 @@ jobs: - { name: serialization-json, dir: components/serialization/json } - { name: serialization-form, dir: components/serialization/form } - { name: serialization-text, dir: components/serialization/text } + - { name: serialization-multipart, dir: components/serialization/multipart } - { name: http, dir: components/http } - { name: authentication-oauth, dir: components/authentication/oauth } steps: diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 01b1ef8..5fe314d 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -30,6 +30,7 @@ jobs: - { name: serialization-json, dir: components/serialization/json } - { name: serialization-form, dir: components/serialization/form } - { name: serialization-text, dir: components/serialization/text } + - { name: serialization-multipart, dir: components/serialization/multipart } - { name: http, dir: components/http } - { name: authentication-oauth, dir: components/authentication/oauth } runs-on: ${{ matrix.os }} diff --git a/Gemfile b/Gemfile index 6066d58..69360b7 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem 'microsoft_kiota_authentication_oauth', path: 'components/authentication/oau gem 'microsoft_kiota_faraday', path: 'components/http' gem 'microsoft_kiota_serialization_form', path: 'components/serialization/form' gem 'microsoft_kiota_serialization_json', path: 'components/serialization/json' +gem 'microsoft_kiota_serialization_multipart', path: 'components/serialization/multipart' gem 'microsoft_kiota_serialization_text', path: 'components/serialization/text' gem 'rake', '~> 13.0' diff --git a/README.md b/README.md index 48749fe..bdf8e61 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Ruby 3.3 or newer is required. | [microsoft_kiota_serialization_json](components/serialization/json/) | JSON serialization implementation | [![Gem Version](https://badge.fury.io/rb/microsoft_kiota_serialization_json.svg)](https://rubygems.org/gems/microsoft_kiota_serialization_json) | | [microsoft_kiota_serialization_form](components/serialization/form/) | Form serialization implementation | [![Gem Version](https://badge.fury.io/rb/microsoft_kiota_serialization_form.svg)](https://rubygems.org/gems/microsoft_kiota_serialization_form) | | [microsoft_kiota_serialization_text](components/serialization/text/) | Text serialization implementation | [![Gem Version](https://badge.fury.io/rb/microsoft_kiota_serialization_text.svg)](https://rubygems.org/gems/microsoft_kiota_serialization_text) | +| [microsoft_kiota_serialization_multipart](components/serialization/multipart/) | Multipart serialization implementation | [![Gem Version](https://badge.fury.io/rb/microsoft_kiota_serialization_multipart.svg)](https://rubygems.org/gems/microsoft_kiota_serialization_multipart) | | [microsoft_kiota_faraday](components/http/) | HTTP client implementation with Faraday | [![Gem Version](https://badge.fury.io/rb/microsoft_kiota_faraday.svg)](https://rubygems.org/gems/microsoft_kiota_faraday) | | [microsoft_kiota_authentication_oauth](components/authentication/oauth/) | OAuth authentication provider | [![Gem Version](https://badge.fury.io/rb/microsoft_kiota_authentication_oauth.svg)](https://rubygems.org/gems/microsoft_kiota_authentication_oauth) | @@ -28,6 +29,7 @@ gem 'microsoft_kiota_abstractions' gem 'microsoft_kiota_serialization_json' gem 'microsoft_kiota_serialization_form' gem 'microsoft_kiota_serialization_text' +gem 'microsoft_kiota_serialization_multipart' gem 'microsoft_kiota_faraday' gem 'microsoft_kiota_authentication_oauth' ``` @@ -51,6 +53,7 @@ rake abstractions:spec rake serialization-json:spec rake serialization-form:spec rake serialization-text:spec +rake serialization-multipart:spec rake http:spec rake authentication-oauth:spec ``` diff --git a/Rakefile b/Rakefile index 6a286cf..043101b 100644 --- a/Rakefile +++ b/Rakefile @@ -5,6 +5,7 @@ COMPONENTS = { 'serialization-json' => 'components/serialization/json', 'serialization-form' => 'components/serialization/form', 'serialization-text' => 'components/serialization/text', + 'serialization-multipart' => 'components/serialization/multipart', 'http' => 'components/http', 'authentication-oauth' => 'components/authentication/oauth' }.freeze diff --git a/release-please-config.json b/release-please-config.json index 530a344..9019c60 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,6 +12,7 @@ "components/serialization/json/lib/microsoft_kiota_serialization_json/version.rb", "components/serialization/form/lib/microsoft_kiota_serialization_form/version.rb", "components/serialization/text/lib/microsoft_kiota_serialization_text/version.rb", + "components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/version.rb", "components/http/lib/microsoft_kiota_faraday/version.rb", "components/authentication/oauth/lib/microsoft_kiota_authentication_oauth/version.rb" ] From f5bf02c11fda12dd74c2497371baec3a8c2ea3aa Mon Sep 17 00:00:00 2001 From: andreatp Date: Tue, 13 Oct 2026 15:43:32 +0100 Subject: [PATCH 4/9] refactor: keep the multipart content type to one valid header - reject line breaks in a part content type - replace a Content-Type set beforehand so it carries the boundary --- .../lib/microsoft_kiota_abstractions/multipart_body.rb | 1 + .../request_information.rb | 1 + components/abstractions/spec/multipart_body_spec.rb | 5 +++++ .../abstractions/spec/request_information_spec.rb | 10 ++++++++++ 4 files changed, 17 insertions(+) diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb b/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb index 06158c5..d2e8ddc 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb @@ -21,6 +21,7 @@ def initialize def add_or_replace_part(part_name, content_type, part_value, filename = nil) raise ArgumentError, 'Part name cannot be null' if blank?(part_name) raise ArgumentError, 'Content type cannot be null' if blank?(content_type) + raise ArgumentError, 'Content type cannot contain line breaks' if content_type.to_s.match?(/[\r\n]/) raise ArgumentError, 'Part value cannot be null' if part_value.nil? @parts[normalize(part_name)] = Part.new(part_name, content_type, part_value, filename) diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb index aad0e2e..101c434 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb @@ -117,6 +117,7 @@ def set_content_from_parsable(request_adapter, content_type, values) if values.is_a?(MultipartBody) content_type = "#{content_type}; boundary=#{values.boundary}" values.request_adapter = request_adapter + @headers.remove(@@content_type_header) end @headers.try_add(@@content_type_header, content_type) if !values.nil? && values.is_a?(Array) diff --git a/components/abstractions/spec/multipart_body_spec.rb b/components/abstractions/spec/multipart_body_spec.rb index 3a383e0..3e63a7b 100644 --- a/components/abstractions/spec/multipart_body_spec.rb +++ b/components/abstractions/spec/multipart_body_spec.rb @@ -171,6 +171,11 @@ def closing .to raise_error(ArgumentError, 'Part value cannot be null') end + it 'rejects a content type with a line break, which would add headers to the part' do + expect { body.add_or_replace_part('file', "text/plain\r\nX-Injected: 1", 'data') } + .to raise_error(ArgumentError, 'Content type cannot contain line breaks') + end + it 'rejects an empty part name or content type' do expect { body.add_or_replace_part('', 'text/plain', 'Hello') }.to raise_error(ArgumentError, 'Part name cannot be null') expect { body.add_or_replace_part('text', '', 'Hello') }.to raise_error(ArgumentError, 'Content type cannot be null') diff --git a/components/abstractions/spec/request_information_spec.rb b/components/abstractions/spec/request_information_spec.rb index da91bdb..4a56587 100644 --- a/components/abstractions/spec/request_information_spec.rb +++ b/components/abstractions/spec/request_information_spec.rb @@ -56,6 +56,16 @@ expect(writer).to have_received(:write_object_value).with(nil, body) end + it 'replaces a content type set beforehand with one carrying the boundary' do + writer = double('writer', write_object_value: nil, get_serialized_content: 'content') + adapter = double('adapter', get_serialization_writer_factory: double('factory', get_serialization_writer: writer)) + body = MicrosoftKiotaAbstractions::MultipartBody.new + request_info = MicrosoftKiotaAbstractions::RequestInformation.new + request_info.headers.add('Content-Type', 'multipart/form-data') + request_info.set_content_from_parsable(adapter, 'multipart/form-data', body) + expect(request_info.headers.get('Content-Type')).to eq(["multipart/form-data; boundary=#{body.boundary}"]) + end + it 'leaves the content type of any other body alone' do writer = double('writer', write_object_value: nil, get_serialized_content: '{}') adapter = double('adapter', get_serialization_writer_factory: double('factory', get_serialization_writer: writer)) From fa6eeb031e4c82be93f4e532150e723d328b902c Mon Sep 17 00:00:00 2001 From: andreatp Date: Tue, 13 Oct 2026 16:04:16 +0100 Subject: [PATCH 5/9] refactor: require the multipart body where the request information checks for it --- .../lib/microsoft_kiota_abstractions/request_information.rb | 1 + components/abstractions/spec/request_information_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb index 101c434..f8300bd 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb @@ -5,6 +5,7 @@ require 'uri' require 'stduritemplate' require_relative 'http_method' +require_relative 'multipart_body' require_relative 'request_headers' module MicrosoftKiotaAbstractions diff --git a/components/abstractions/spec/request_information_spec.rb b/components/abstractions/spec/request_information_spec.rb index 4a56587..167c5b0 100644 --- a/components/abstractions/spec/request_information_spec.rb +++ b/components/abstractions/spec/request_information_spec.rb @@ -66,6 +66,12 @@ expect(request_info.headers.get('Content-Type')).to eq(["multipart/form-data; boundary=#{body.boundary}"]) end + it 'loads the multipart body it checks for when required on its own' do + file = File.expand_path('../lib/microsoft_kiota_abstractions/request_information', __dir__) + out = IO.popen([RbConfig.ruby, '-e', "require '#{file}'; print defined?(MicrosoftKiotaAbstractions::MultipartBody)"], &:read) + expect(out).to eq('constant') + end + it 'leaves the content type of any other body alone' do writer = double('writer', write_object_value: nil, get_serialized_content: '{}') adapter = double('adapter', get_serialization_writer_factory: double('factory', get_serialization_writer: writer)) From 29a40e80fb24c159f527bd40f00c9f2e13d8165f Mon Sep 17 00:00:00 2001 From: andreatp Date: Tue, 13 Oct 2026 17:02:04 +0100 Subject: [PATCH 6/9] refactor: replace a boundary already in the multipart content type --- .../microsoft_kiota_abstractions/request_information.rb | 2 +- components/abstractions/spec/request_information_spec.rb | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb index f8300bd..c162d38 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/request_information.rb @@ -116,7 +116,7 @@ def set_content_from_scalar(request_adapter, content_type, values) def set_content_from_parsable(request_adapter, content_type, values) writer = request_adapter.get_serialization_writer_factory.get_serialization_writer(content_type) if values.is_a?(MultipartBody) - content_type = "#{content_type}; boundary=#{values.boundary}" + content_type = "#{content_type.gsub(/;\s*boundary=[^;]*/i, '')}; boundary=#{values.boundary}" values.request_adapter = request_adapter @headers.remove(@@content_type_header) end diff --git a/components/abstractions/spec/request_information_spec.rb b/components/abstractions/spec/request_information_spec.rb index 167c5b0..caaa082 100644 --- a/components/abstractions/spec/request_information_spec.rb +++ b/components/abstractions/spec/request_information_spec.rb @@ -66,6 +66,15 @@ expect(request_info.headers.get('Content-Type')).to eq(["multipart/form-data; boundary=#{body.boundary}"]) end + it 'replaces a boundary given in the content type with the body boundary' do + writer = double('writer', write_object_value: nil, get_serialized_content: 'content') + adapter = double('adapter', get_serialization_writer_factory: double('factory', get_serialization_writer: writer)) + body = MicrosoftKiotaAbstractions::MultipartBody.new + request_info = MicrosoftKiotaAbstractions::RequestInformation.new + request_info.set_content_from_parsable(adapter, 'multipart/form-data; Boundary="old"; charset=utf-8', body) + expect(request_info.headers.get('Content-Type')).to eq(["multipart/form-data; charset=utf-8; boundary=#{body.boundary}"]) + end + it 'loads the multipart body it checks for when required on its own' do file = File.expand_path('../lib/microsoft_kiota_abstractions/request_information', __dir__) out = IO.popen([RbConfig.ruby, '-e', "require '#{file}'; print defined?(MicrosoftKiotaAbstractions::MultipartBody)"], &:read) From 173dc26547057bc3bea9768f12db142eaaae0a80 Mon Sep 17 00:00:00 2001 From: andreatp Date: Wed, 14 Oct 2026 12:29:05 +0100 Subject: [PATCH 7/9] refactor: read a stream part that cannot be rewound on any Ruby --- .../lib/microsoft_kiota_abstractions/multipart_body.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb b/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb index d2e8ddc..7ecd453 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/multipart_body.rb @@ -98,7 +98,7 @@ def write_part_value(writer, part) # read from the start each time the body is written, as .NET does for a seekable stream def rewind(stream) stream.rewind if stream.respond_to?(:rewind) - rescue Errno::ESPIPE + rescue SystemCallError nil end From 6cc8f2bbcdca8dd82781f493608b95f2e99e93ef Mon Sep 17 00:00:00 2001 From: andreatp Date: Fri, 16 Oct 2026 11:17:22 +0100 Subject: [PATCH 8/9] refactor: use the serialization hooks the abstractions now declare --- .../multipart_serialization_writer.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb index ade1494..7c7ae5a 100644 --- a/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb +++ b/components/serialization/multipart/lib/microsoft_kiota_serialization_multipart/multipart_serialization_writer.rb @@ -6,8 +6,6 @@ module MicrosoftKiotaSerializationMultipart class MultipartSerializationWriter include MicrosoftKiotaAbstractions::SerializationWriter - attr_accessor :on_before_object_serialization, :on_after_object_serialization, :on_start_object_serialization - def initialize @content = String.new(encoding: Encoding::BINARY) end @@ -31,10 +29,6 @@ def write_object_value(_key, value, *_additional_values_to_merge) serialize_body(value) end - def write_null_value(_key) - raise NotImplementedError - end - def get_serialized_content @content end From b1dc4323352706daf3018b863d7aee029fac1d82 Mon Sep 17 00:00:00 2001 From: andreatp Date: Mon, 19 Oct 2026 18:06:06 +0100 Subject: [PATCH 9/9] refactor: mark the models of a multipart body clean only once the whole body is written --- ...tore_serialization_writer_proxy_factory.rb | 22 ++++- .../spec/multipart_backing_store_spec.rb | 80 +++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 components/serialization/multipart/spec/multipart_backing_store_spec.rb diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/store/backing_store_serialization_writer_proxy_factory.rb b/components/abstractions/lib/microsoft_kiota_abstractions/store/backing_store_serialization_writer_proxy_factory.rb index e28e3c0..944e59b 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/store/backing_store_serialization_writer_proxy_factory.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/store/backing_store_serialization_writer_proxy_factory.rb @@ -43,8 +43,11 @@ def initialize end def before(value) - # an exception already being handled around the write is not a failure of the write - @outer_error = $ERROR_INFO if @depth.zero? + if @depth.zero? + # an exception already being handled around the write is not a failure of the write + @outer_error = $ERROR_INFO + open_documents.push(self) + end @depth += 1 store = store_of(value) return unless store @@ -61,19 +64,32 @@ def start(value, writer) # runs even when writing the model raised: the store reads every value again, and keeps its changes def after(value) @depth -= 1 + open_documents.pop if @depth.zero? && open_documents.last.equal?(self) @failed ||= !$ERROR_INFO.nil? && !$ERROR_INFO.equal?(@outer_error) store = store_of(value) store.return_only_changed_values = @filtering.delete(store) if store && @filtering.key?(store) end + # a document produced while another is written, such as a multipart part, leaves the models to that one def finish - @written.each_key { |store| store.initialization_completed = true } unless @failed + unless @failed + enclosing = open_documents.last + enclosing ? enclosing.adopt(@written) : @written.each_key { |store| store.initialization_completed = true } + end @written.clear @failed = false end + def adopt(written) + @written.merge!(written) + end + private + def open_documents + Thread.current[:microsoft_kiota_open_documents] ||= [] + end + def store_of(value) value.backing_store if value.is_a?(BackedModel) end diff --git a/components/serialization/multipart/spec/multipart_backing_store_spec.rb b/components/serialization/multipart/spec/multipart_backing_store_spec.rb new file mode 100644 index 0000000..bde5bde --- /dev/null +++ b/components/serialization/multipart/spec/multipart_backing_store_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'microsoft_kiota_serialization_json' + +module BackedMultipartModels + class User + include MicrosoftKiotaAbstractions::Parsable + include MicrosoftKiotaAbstractions::BackedModel + + def initialize + @backing_store = MicrosoftKiotaAbstractions::BackingStoreFactorySingleton.instance.create_backing_store + end + + %w[id name].each do |property| + define_method(property) { @backing_store.get(property) } + define_method("#{property}=") { |value| @backing_store.set(property, value) } + end + + def get_field_deserializers + { 'id' => ->(n) { self.id = n.get_string_value }, 'name' => ->(n) { self.name = n.get_string_value } } + end + + def serialize(writer) + writer.write_string_value('id', id) + writer.write_string_value('name', name) + end + end +end + +RSpec.describe 'a multipart body with the backing store' do + let(:registry) do + MicrosoftKiotaAbstractions::SerializationWriterFactoryRegistry.new.tap do |registry| + factories = registry.content_type_associated_factories + factories['application/json'] = proxy(MicrosoftKiotaSerializationJson::JsonSerializationWriterFactory.new) + factories['multipart/form-data'] = proxy(MicrosoftKiotaSerializationMultipart::MultipartSerializationWriterFactory.new) + end + end + let(:adapter) { double('adapter', get_serialization_writer_factory: registry) } + let(:user) do + MicrosoftKiotaAbstractions::BackingStoreParseNodeFactory.new(MicrosoftKiotaSerializationJson::JsonParseNodeFactory.new) + .get_parse_node('application/json', '{"id":"u1","name":"Ann"}') + .get_object_value(->(_) { BackedMultipartModels::User.new }) + end + + def proxy(factory) = MicrosoftKiotaAbstractions::BackingStoreSerializationWriterProxyFactory.new(factory) + + def json_parts(body) + request_info = MicrosoftKiotaAbstractions::RequestInformation.new + request_info.set_content_from_parsable(adapter, 'multipart/form-data', body) + request_info.content.scan(/\r\n\r\n(\{.*?\})\r\n/).flatten + end + + def body_with(parts) + MicrosoftKiotaAbstractions::MultipartBody.new.tap do |body| + parts.each { |name, content_type, value| body.add_or_replace_part(name, content_type, value) } + end + end + + it 'writes the changes of a model in every part it appears in' do + user.name = 'Bea' + body = body_with([['first', 'application/json', user], ['second', 'application/json', user]]) + expect(json_parts(body)).to eq(['{"name":"Bea"}', '{"name":"Bea"}']) + end + + it 'keeps the changes of a model when a later part fails' do + user.name = 'Bea' + body = body_with([['first', 'application/json', user], ['broken', 'text/plain', 42]]) + expect { json_parts(body) }.to raise_error(StandardError) + body.remove_part('broken') + expect(json_parts(body)).to eq(['{"name":"Bea"}']) + end + + it 'marks the models clean once the whole body is written' do + user.name = 'Bea' + body = body_with([['first', 'application/json', user]]) + json_parts(body) + expect(json_parts(body)).to eq(['{}']) + end +end