diff --git a/.vscode/settings.json b/.vscode/settings.json index 9fdc6535..11ff985d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "cSpell.words": [ "Kiota" - ] + ], + "editor.formatOnSave": true, + "dotnet-test-explorer.testProjectPath": "**/*.Tests.csproj" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b08635d..10fdea5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2023-08-01 + +### Added + +- Added support for multipart form data request body serialization. + ## [1.2.1] - 2023-07-03 ### Fixed diff --git a/Microsoft.Kiota.Abstractions.Tests/RequestInformationTests.cs b/Microsoft.Kiota.Abstractions.Tests/RequestInformationTests.cs index 96d6a738..6766359d 100644 --- a/Microsoft.Kiota.Abstractions.Tests/RequestInformationTests.cs +++ b/Microsoft.Kiota.Abstractions.Tests/RequestInformationTests.cs @@ -25,7 +25,7 @@ public void SetUriExtractsQueryParameters() // Assert Assert.Equal("http://localhost/baz/me?foo=bar", testRequest.URI.ToString()); Assert.NotEmpty(testRequest.QueryParameters); - Assert.Equal("foo",testRequest.QueryParameters.First().Key); + Assert.Equal("foo", testRequest.QueryParameters.First().Key); Assert.Equal("bar", testRequest.QueryParameters.First().Value.ToString()); } [Fact] @@ -40,7 +40,7 @@ public void AddsAndRemovesRequestOptions() var testRequestOption = new Mock().Object; Assert.Empty(testRequest.RequestOptions); // Act - testRequest.AddRequestOptions(new IRequestOption[] {testRequestOption}); + testRequest.AddRequestOptions(new IRequestOption[] { testRequestOption }); // Assert Assert.NotEmpty(testRequest.RequestOptions); Assert.Equal(testRequestOption, testRequest.RequestOptions.First()); @@ -68,7 +68,7 @@ public void SetsSelectQueryParameters() // Assert Assert.True(requestInfo.QueryParameters.ContainsKey("%24select")); Assert.False(requestInfo.QueryParameters.ContainsKey("select")); - Assert.Equal("%24select",requestInfo.QueryParameters.First().Key); + Assert.Equal("%24select", requestInfo.QueryParameters.First().Key); } [Fact] public void DoesNotSetEmptyStringQueryParameters() @@ -180,7 +180,7 @@ public void BuildsUrlOnProvidedBaseUrl() }; // Act - requestInfo.PathParameters = new Dictionary() + requestInfo.PathParameters = new Dictionary() { { "baseurl","http://localhost" } }; @@ -237,7 +237,8 @@ public void GetsAndSetsResponseHandlerByType() Assert.NotNull(requestInfo.GetRequestOption()); } [Fact] - public void SetsObjectContent() { + public void SetsObjectContent() + { var requestAdapterMock = new Mock(); var serializationWriterMock = new Mock(); var serializationWriterFactoryMock = new Mock(); @@ -256,7 +257,8 @@ public void SetsObjectContent() { serializationWriterMock.Verify(x => x.WriteCollectionOfObjectValues(It.IsAny(), It.IsAny>()), Times.Never); } [Fact] - public void SetsObjectCollectionContentSingleElement() { + public void SetsObjectCollectionContentSingleElement() + { var requestAdapterMock = new Mock(); var serializationWriterMock = new Mock(); var serializationWriterFactoryMock = new Mock(); @@ -268,14 +270,15 @@ public void SetsObjectCollectionContentSingleElement() { UrlTemplate = "{+baseurl}/users{?%24count}" }; - requestInfo.SetContentFromParsable(requestAdapterMock.Object, "application/json", new [] {new TestEntity()}); + requestInfo.SetContentFromParsable(requestAdapterMock.Object, "application/json", new[] { new TestEntity() }); // Assert we now have an option serializationWriterMock.Verify(x => x.WriteObjectValue(It.IsAny(), It.IsAny()), Times.Never); serializationWriterMock.Verify(x => x.WriteCollectionOfObjectValues(It.IsAny(), It.IsAny>()), Times.Once); } [Fact] - public void SetsScalarContent() { + public void SetsScalarContent() + { var requestAdapterMock = new Mock(); var serializationWriterMock = new Mock(); var serializationWriterFactoryMock = new Mock(); @@ -294,7 +297,8 @@ public void SetsScalarContent() { serializationWriterMock.Verify(x => x.WriteCollectionOfPrimitiveValues(It.IsAny(), It.IsAny>()), Times.Never); } [Fact] - public void SetsScalarCollectionContent() { + public void SetsScalarCollectionContent() + { var requestAdapterMock = new Mock(); var serializationWriterMock = new Mock(); var serializationWriterFactoryMock = new Mock(); @@ -306,7 +310,7 @@ public void SetsScalarCollectionContent() { UrlTemplate = "{+baseurl}/users{?%24count}" }; - requestInfo.SetContentFromScalarCollection(requestAdapterMock.Object, "application/json", new[] {"foo"}); + requestInfo.SetContentFromScalarCollection(requestAdapterMock.Object, "application/json", new[] { "foo" }); // Assert we now have an option serializationWriterMock.Verify(x => x.WriteStringValue(It.IsAny(), It.IsAny()), Times.Never); @@ -328,6 +332,35 @@ public void GetUriResolvesParametersCaseInsensitive() // Assert Assert.Equal("http://localhost/UriTemplate/ParameterMapping?IsCaseSensitive=false", testRequest.URI.ToString()); } + [Fact] + public void SetsBoundaryOnMultipartBody() + { + // Arrange + var testRequest = new RequestInformation() + { + HttpMethod = Method.POST, + UrlTemplate = "http://localhost/{URITemplate}/ParameterMapping?IsCaseSensitive={IsCaseSensitive}" + }; + var requestAdapterMock = new Mock(); + var serializationWriterFactoryMock = new Mock(); + var serializationWriterMock = new Mock(); + serializationWriterFactoryMock.Setup(x => x.GetSerializationWriter(It.IsAny())).Returns(serializationWriterMock.Object); + requestAdapterMock.SetupGet(x => x.SerializationWriterFactory).Returns(serializationWriterFactoryMock.Object); + // Given + var multipartBody = new MultipartBody + { + RequestAdapter = requestAdapterMock.Object + }; + + // When + testRequest.SetContentFromParsable(requestAdapterMock.Object, "multipart/form-data", multipartBody); + + // Then + Assert.NotNull(multipartBody.Boundary); + Assert.True(testRequest.Headers.TryGetValue("Content-Type", out var contentType)); + Assert.Single(contentType); + Assert.Equal("multipart/form-data; boundary=" + multipartBody.Boundary, contentType.First()); + } } /// The messages in a mailbox or folder. Read-only. Nullable. diff --git a/Microsoft.Kiota.Abstractions.Tests/Store/InMemoryBackingStoreTests.cs b/Microsoft.Kiota.Abstractions.Tests/Store/InMemoryBackingStoreTests.cs index 5dff26f0..271456dd 100644 --- a/Microsoft.Kiota.Abstractions.Tests/Store/InMemoryBackingStoreTests.cs +++ b/Microsoft.Kiota.Abstractions.Tests/Store/InMemoryBackingStoreTests.cs @@ -409,7 +409,7 @@ public void TestsBackingStoreEmbeddedInModelWithByUpdatingNestedIBackedModelColl Assert.True(changedNestedValues.ContainsKey("id")); Assert.True(changedNestedValues.ContainsKey("businessPhones")); var businessPhones = ((Tuple)changedNestedValues["businessPhones"]).Item1; - Assert.Equal(1, businessPhones.Count); + Assert.Single(businessPhones); } } } diff --git a/src/Microsoft.Kiota.Abstractions.csproj b/src/Microsoft.Kiota.Abstractions.csproj index e89b9592..62c87bc2 100644 --- a/src/Microsoft.Kiota.Abstractions.csproj +++ b/src/Microsoft.Kiota.Abstractions.csproj @@ -14,7 +14,7 @@ https://aka.ms/kiota/docs true true - 1.2.1 + 1.3.0 true false diff --git a/src/MultipartBody.cs b/src/MultipartBody.cs new file mode 100644 index 00000000..7303cb84 --- /dev/null +++ b/src/MultipartBody.cs @@ -0,0 +1,166 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace Microsoft.Kiota.Abstractions; + +/// +/// Represents a multipart body for a request or a response. +/// +public class MultipartBody : IParsable +{ + private readonly Lazy _boundary = new Lazy(() => Guid.NewGuid().ToString("N")); + /// + /// The boundary to use for the multipart body. + /// + public string Boundary => _boundary.Value; + /// + /// The request adapter to use for serialization. + /// + public IRequestAdapter? RequestAdapter { get; set; } + /// + /// Adds or replaces a part to the multipart body. + /// + /// The type of the part value. + /// The name of the part. + /// The content type of the part. + /// The value of the part. + public void AddOrReplacePart(string partName, string contentType, T partValue) + { + if(string.IsNullOrEmpty(partName)) + { + throw new ArgumentNullException(nameof(partName)); + } + if(string.IsNullOrEmpty(contentType)) + { + throw new ArgumentNullException(nameof(contentType)); + } + if(partValue == null) + { + throw new ArgumentNullException(nameof(partValue)); + } + var value = new Tuple(contentType, partValue); + if(!_parts.TryAdd(partName, value)) + { + _parts[partName] = value; + } + } + /// + /// Gets the value of a part from the multipart body. + /// + /// The type of the part value. + /// The name of the part. + /// The value of the part. + public T? GetPartValue(string partName) + { + if(string.IsNullOrEmpty(partName)) + { + throw new ArgumentNullException(nameof(partName)); + } + if(_parts.TryGetValue(partName, out var value)) + { + return (T)value.Item2; + } + return default; + } + /// + /// Removes a part from the multipart body. + /// + /// The name of the part. + /// True if the part was removed, false otherwise. + public bool RemovePart(string partName) + { + if(string.IsNullOrEmpty(partName)) + { + throw new ArgumentNullException(nameof(partName)); + } + return _parts.Remove(partName); + } + private readonly Dictionary> _parts = new Dictionary>(StringComparer.OrdinalIgnoreCase); + /// + public IDictionary> GetFieldDeserializers() => throw new NotImplementedException(); + /// + public void Serialize(ISerializationWriter writer) + { + if(writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + if(RequestAdapter?.SerializationWriterFactory == null) + { + throw new InvalidOperationException(nameof(RequestAdapter.SerializationWriterFactory)); + } + if(!_parts.Any()) + { + throw new InvalidOperationException("No parts to serialize"); + } + var first = true; + foreach(var part in _parts) + { + try + { + if(first) + first = false; + else + AddNewLine(writer); + + writer.WriteStringValue(string.Empty, $"--{Boundary}"); + writer.WriteStringValue("Content-Type", $"{part.Value.Item1}"); + writer.WriteStringValue("Content-Disposition", $"form-data; name=\"{part.Key}\""); + AddNewLine(writer); + if(part.Value.Item2 is IParsable parsable) + { + using var partWriter = RequestAdapter.SerializationWriterFactory.GetSerializationWriter(part.Value.Item1); + partWriter.WriteObjectValue(string.Empty, parsable); + using var partContent = partWriter.GetSerializedContent(); + if(partContent.CanSeek) + partContent.Seek(0, SeekOrigin.Begin); + using var ms = new MemoryStream(); + partContent.CopyTo(ms); + writer.WriteByteArrayValue(string.Empty, ms.ToArray()); + } + else if(part.Value.Item2 is string currentString) + { + writer.WriteStringValue(string.Empty, currentString); + } + else if(part.Value.Item2 is MemoryStream originalMemoryStream) + { + writer.WriteByteArrayValue(string.Empty, originalMemoryStream.ToArray()); + } + else if(part.Value.Item2 is Stream currentStream) + { + if(currentStream.CanSeek) + currentStream.Seek(0, SeekOrigin.Begin); + using var ms = new MemoryStream(); + currentStream.CopyTo(ms); + writer.WriteByteArrayValue(string.Empty, ms.ToArray()); + } + else if(part.Value.Item2 is byte[] currentBinary) + { + writer.WriteByteArrayValue(string.Empty, currentBinary); + } + else + { + throw new InvalidOperationException($"Unsupported type {part.Value.Item2.GetType().Name} for part {part.Key}"); + } + } + catch(InvalidOperationException) when(part.Value.Item2 is byte[] currentBinary) + { // binary payload + writer.WriteByteArrayValue(part.Key, currentBinary); + } + } + AddNewLine(writer); + writer.WriteStringValue(string.Empty, $"--{Boundary}--"); + } + private void AddNewLine(ISerializationWriter writer) + { + writer.WriteStringValue(string.Empty, string.Empty); + } +} diff --git a/src/RequestInformation.cs b/src/RequestInformation.cs index 9d3ea5e2..a41ba723 100644 --- a/src/RequestInformation.cs +++ b/src/RequestInformation.cs @@ -24,19 +24,23 @@ public class RequestInformation /// /// The URI of the request. /// - public Uri URI { - set { + public Uri URI + { + set + { if(value == null) throw new ArgumentNullException(nameof(value)); QueryParameters.Clear(); PathParameters.Clear(); _rawUri = value; } - get { + get + { if(_rawUri != null) return _rawUri; else if(PathParameters.TryGetValue(RawUrlKey, out var rawUrl) && - rawUrl is string rawUrlString) { + rawUrl is string rawUrlString) + { URI = new Uri(rawUrlString); return _rawUri!; } @@ -109,7 +113,7 @@ public void AddQueryParameters(object source) Value: x.GetValue(source) ) ) - .Where(x => x.Value != null && + .Where(x => x.Value != null && !QueryParameters.ContainsKey(x.Name!) && !string.IsNullOrEmpty(x.Value.ToString()) && // no need to add an empty string value (x.Value is not ICollection collection || collection.Count > 0))) // no need to add empty collection @@ -120,11 +124,12 @@ public void AddQueryParameters(object source) /// /// The Request Headers. /// - public RequestHeaders Headers { get; private set; } = new (); + public RequestHeaders Headers { get; private set; } = new(); /// /// Vanity method to add the headers to the request headers dictionary. /// - public void AddHeaders(RequestHeaders headers) { + public void AddHeaders(RequestHeaders headers) + { if(headers == null) return; Headers.AddAll(headers); } @@ -220,14 +225,19 @@ public void SetContentFromParsable(IRequestAdapter requestAdapter, string con using var activity = _activitySource?.StartActivity(nameof(SetContentFromParsable)); using var writer = GetSerializationWriter(requestAdapter, contentType, item); SetRequestType(item, activity); + if(item is MultipartBody mpBody) + { + contentType += "; boundary=" + mpBody.Boundary; + mpBody.RequestAdapter = requestAdapter; + } writer.WriteObjectValue(null, item); Headers.Add(ContentTypeHeader, contentType); Content = writer.GetSerializedContent(); } private static void SetRequestType(object? result, Activity? activity) { - if (activity == null) return; - if (result == null) return; + if(activity == null) return; + if(result == null) return; activity.SetTag("com.microsoft.kiota.request.type", result.GetType().FullName); } private static ISerializationWriter GetSerializationWriter(IRequestAdapter requestAdapter, string contentType, T item)