From 73b8243fb1006a15cc48df5171b410d9736da054 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 24 Jul 2023 16:36:17 -0400 Subject: [PATCH 01/11] - draft implementation of multipart body --- .../com/microsoft/kiota/MultipartBody.java | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java new file mode 100644 index 000000000..a56022d9c --- /dev/null +++ b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java @@ -0,0 +1,163 @@ +package com.microsoft.kiota; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.microsoft.kiota.serialization.Parsable; +import com.microsoft.kiota.serialization.ParseNode; +import com.microsoft.kiota.serialization.SerializationWriter; +import com.microsoft.kiota.serialization.SerializationWriterFactory; + +/** + * Represents a multipart body for a request or a response. + */ +public class MultipartBody implements Parsable { + @Nonnull + private final String boundary = UUID.randomUUID().toString().replace("-", ""); + /** @return the boundary string for the multipart body. */ + @Nonnull + public String getBoundary() { + return boundary; + } + /** + * The request adapter to use for the multipart body serialization. + */ + @Nullable + public RequestAdapter requestAdapter; + /** + * Adds or replaces a part in the multipart body. + * @param the type of the part to add or replace. + * @param name the name of the part to add or replace. + * @param contentType the content type of the part to add or replace. + * @param value the value of the part to add or replace. + */ + public void addOrReplacePart(@Nonnull final String name, @Nonnull final String contentType, @Nonnull final T value) { + Objects.requireNonNull(name); + Objects.requireNonNull(contentType); + Objects.requireNonNull(value); + if (contentType.isBlank() || contentType.isEmpty()) + throw new IllegalArgumentException("contentType cannot be blank or empty"); + if (name.isBlank() || name.isEmpty()) + throw new IllegalArgumentException("name cannot be blank or empty"); + + final String normalizedName = normalizePartName(name); + originalNames.put(name, normalizedName); + parts.put(normalizedName, Map.entry(contentType, value)); + } + private final Map> parts = new HashMap<>(); + private final Map originalNames = new HashMap<>(); + private String normalizePartName(@Nonnull final String original) + { + return original.toLowerCase(Locale.ROOT); + } + /** + * Gets the content type of the part with the specified name. + * @param partName the name of the part to get. + * @return the content type of the part with the specified name. + */ + @Nullable + public Object getPartValue(@Nonnull final String partName) + { + Objects.requireNonNull(partName); + if (partName.isBlank() || partName.isEmpty()) + throw new IllegalArgumentException("partName cannot be blank or empty"); + final String normalizedName = normalizePartName(partName); + final Object candidate = parts.get(normalizedName); + if(candidate == null) + return null; + + return candidate; + } + /** + * Gets the content type of the part with the specified name. + * @param partName the name of the part to get. + * @return the content type of the part with the specified name. + */ + public boolean removePart(@Nonnull final String partName) + { + Objects.requireNonNull(partName); + if (partName.isBlank() || partName.isEmpty()) + throw new IllegalArgumentException("partName cannot be blank or empty"); + final String normalizedName = normalizePartName(partName); + final Object candidate = parts.remove(normalizedName); + if(candidate == null) + return false; + + originalNames.remove(partName); + return true; + } + + /** {@inheritDoc} */ + @Override + @Nonnull + public Map> getFieldDeserializers() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getFieldDeserializers'"); + } + + /** {@inheritDoc} */ + @Override + public void serialize(@Nonnull final SerializationWriter writer) { + Objects.requireNonNull(writer); + final RequestAdapter ra = requestAdapter; + if (ra == null) + throw new IllegalStateException("requestAdapter cannot be null"); + if (parts.isEmpty()) + throw new IllegalStateException("multipart body cannot be empty"); + final SerializationWriterFactory serializationFactory = ra.getSerializationWriterFactory(); + boolean isFirst = true; + for (final Map.Entry> partEntry : parts.entrySet()) { + try { + if (isFirst) + isFirst = false; + else + writer.writeStringValue("", ""); + writer.writeStringValue("", getBoundary()); + final String partContentType = partEntry.getValue().getKey(); + writer.writeStringValue("Content-Type", partContentType); + writer.writeStringValue("Content-Disposition", "form-data; name=\"" + originalNames.get(partEntry.getKey()) + "\""); + writer.writeStringValue("", ""); + final Object objectValue = partEntry.getValue().getValue(); + if (objectValue instanceof Parsable) { + try (final SerializationWriter partWriter = serializationFactory.getSerializationWriter(partContentType)) { + partWriter.writeObjectValue("", ((Parsable)objectValue)); + try (final InputStream partContent = partWriter.getSerializedContent()) { + if (partContent.markSupported()) + partContent.reset(); + final byte[] bytes = partContent.readAllBytes(); + if (bytes != null) + writer.writeByteArrayValue("", bytes); + } + } + } else if (objectValue instanceof String) { + writer.writeStringValue("", (String)objectValue); + } else if (objectValue instanceof InputStream) { + final InputStream inputStream = (InputStream)objectValue; + if (inputStream.markSupported()) + inputStream.reset(); + final byte[] bytes = inputStream.readAllBytes(); + if (bytes != null) + writer.writeByteArrayValue("", bytes); + } else if (objectValue instanceof byte[]) { + writer.writeByteArrayValue("", (byte[])objectValue); + } else { + throw new IllegalStateException("Unsupported part type" + objectValue.getClass().getName()); + } + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + writer.writeStringValue("", ""); + writer.writeStringValue("", "--" + boundary + "--"); + } + } + +} From 5ea75f4842d59b9615978d65a1ae9e668896c329 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 25 Jul 2023 10:14:20 -0400 Subject: [PATCH 02/11] - adds setting the boundary in the request headers Signed-off-by: Vincent Biret --- .../microsoft/kiota/RequestInformation.java | 8 +++- .../kiota/RequestInformationTest.java | 43 +++++++++++++------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/RequestInformation.java b/components/abstractions/src/main/java/com/microsoft/kiota/RequestInformation.java index da31f1d53..730af0c85 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/RequestInformation.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/RequestInformation.java @@ -240,7 +240,13 @@ public void setContentFromParsable(@Nonnull final RequestAd final Span span = GlobalOpenTelemetry.getTracer(OBSERVABILITY_TRACER_NAME).spanBuilder(SPAN_NAME).startSpan(); try (final Scope scope = span.makeCurrent()) { try(final SerializationWriter writer = getSerializationWriter(requestAdapter, contentType, value)) { - headers.add(CONTENT_TYPE_HEADER, contentType); + String effectiveContentType = contentType; + if (value instanceof MultipartBody) { + final MultipartBody multipartBody = (MultipartBody)value; + effectiveContentType += "; boundary=" + multipartBody.getBoundary(); + multipartBody.requestAdapter = requestAdapter; + } + headers.add(CONTENT_TYPE_HEADER, effectiveContentType); setRequestType(value, span); writer.writeObjectValue(null, value); this.content = writer.getSerializedContent(); diff --git a/components/abstractions/src/test/java/com/microsoft/kiota/RequestInformationTest.java b/components/abstractions/src/test/java/com/microsoft/kiota/RequestInformationTest.java index de963c85f..1112e5c6f 100644 --- a/components/abstractions/src/test/java/com/microsoft/kiota/RequestInformationTest.java +++ b/components/abstractions/src/test/java/com/microsoft/kiota/RequestInformationTest.java @@ -18,9 +18,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -public class RequestInformationTest { +class RequestInformationTest { @Test - public void ThrowsInvalidOperationExceptionWhenBaseUrlNotSet() + void ThrowsInvalidOperationExceptionWhenBaseUrlNotSet() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); @@ -33,7 +33,7 @@ public void ThrowsInvalidOperationExceptionWhenBaseUrlNotSet() } @Test - public void BuildsUrlOnProvidedBaseUrl() + void BuildsUrlOnProvidedBaseUrl() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); @@ -49,7 +49,7 @@ public void BuildsUrlOnProvidedBaseUrl() } @Test - public void SetsPathParametersOfDateTimeOffsetType() + void SetsPathParametersOfDateTimeOffsetType() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); @@ -69,7 +69,7 @@ public void SetsPathParametersOfDateTimeOffsetType() } @Test - public void ExpandQueryParametersAfterPathParams() + void ExpandQueryParametersAfterPathParams() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); @@ -89,7 +89,7 @@ public void ExpandQueryParametersAfterPathParams() } @Test - public void DoesNotSetQueryParametersParametersIfEmptyString() + void DoesNotSetQueryParametersParametersIfEmptyString() { // Arrange as the request builders would @@ -111,7 +111,7 @@ public void DoesNotSetQueryParametersParametersIfEmptyString() } @Test - public void DoesNotSetQueryParametersParametersIfEmptyCollection() + void DoesNotSetQueryParametersParametersIfEmptyCollection() { // Arrange as the request builders would @@ -131,7 +131,7 @@ public void DoesNotSetQueryParametersParametersIfEmptyCollection() } @Test - public void SetsPathParametersOfBooleanType() + void SetsPathParametersOfBooleanType() { // Arrange as the request builders would @@ -148,7 +148,7 @@ public void SetsPathParametersOfBooleanType() assertTrue(uriResult.toString().contains("count=true")); } @Test - public void SetsParsableContent() { + void SetsParsableContent() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); requestInfo.httpMethod= HttpMethod.POST; @@ -164,7 +164,7 @@ public void SetsParsableContent() { verify(writerMock, never()).writeCollectionOfObjectValues(anyString(), any(ArrayList.class)); } @Test - public void SetsParsableContentCollection() { + void SetsParsableContentCollection() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); requestInfo.httpMethod= HttpMethod.POST; @@ -180,7 +180,7 @@ public void SetsParsableContentCollection() { verify(writerMock, times(1)).writeCollectionOfObjectValues(any(), any(Iterable.class)); } @Test - public void SetsScalarContentCollection() { + void SetsScalarContentCollection() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); requestInfo.httpMethod= HttpMethod.POST; @@ -196,7 +196,7 @@ public void SetsScalarContentCollection() { verify(writerMock, times(1)).writeCollectionOfPrimitiveValues(any(), any(Iterable.class)); } @Test - public void SetsScalarContent() { + void SetsScalarContent() { // Arrange as the request builders would final RequestInformation requestInfo = new RequestInformation(); requestInfo.httpMethod= HttpMethod.POST; @@ -211,6 +211,25 @@ public void SetsScalarContent() { verify(writerMock, times(1)).writeStringValue(any(), anyString()); verify(writerMock, never()).writeCollectionOfPrimitiveValues(any(), any(Iterable.class)); } + @Test + void SetsBoundaryOnMultipartBody() { + final RequestInformation requestInfo = new RequestInformation(); + requestInfo.httpMethod= HttpMethod.POST; + requestInfo.urlTemplate = "http://localhost/{URITemplate}/ParameterMapping?IsCaseSensitive={IsCaseSensitive}"; + final SerializationWriter writerMock = mock(SerializationWriter.class); + final SerializationWriterFactory factoryMock = mock(SerializationWriterFactory.class); + when(factoryMock.getSerializationWriter(anyString())).thenReturn(writerMock); + final RequestAdapter requestAdapterMock = mock(RequestAdapter.class); + when(requestAdapterMock.getSerializationWriterFactory()).thenReturn(factoryMock); + + final MultipartBody multipartBody = new MultipartBody(); + multipartBody.requestAdapter = requestAdapterMock; + + requestInfo.setContentFromParsable(requestAdapterMock, "multipart/form-data", multipartBody); + assertNotNull(multipartBody.getBoundary()); + assertFalse(multipartBody.getBoundary().isEmpty()); + assertEquals("multipart/form-data; boundary=" + multipartBody.getBoundary(), requestInfo.headers.get("Content-Type").toArray()[0]); + } } From c56ba19364711f13836a3553c460006e6f584189 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 25 Jul 2023 10:41:35 -0400 Subject: [PATCH 03/11] - scaffolds the multipart component --- .github/dependabot.yml | 10 + .github/workflows/release.yml | 5 + README.md | 43 +-- components/serialization/multipart/.gitignore | 10 + .../multipart/android/.gitignore | 2 + .../multipart/android/AndroidManifest.xml | 23 ++ .../multipart/android/build.gradle | 73 +++++ .../multipart/android/gradle.properties | 1 + .../serialization/multipart/build.gradle | 239 +++++++++++++++ .../serialization/multipart/gradle.properties | 1 + .../multipart/gradle/dependencies.gradle | 12 + .../multipart/spotBugsExcludeFilter.xml | 15 + .../MultipartSerializationWriter.java | 276 ++++++++++++++++++ .../MultipartSerializationWriterFactory.java | 28 ++ .../MultipartSerializationWriterTests.java | 85 ++++++ .../SerializationWriterFactoryTests.java | 24 ++ .../serialization/mocks/SecondTestEntity.java | 70 +++++ .../kiota/serialization/mocks/TestEntity.java | 144 +++++++++ settings.gradle | 1 + 19 files changed, 1044 insertions(+), 18 deletions(-) create mode 100644 components/serialization/multipart/.gitignore create mode 100644 components/serialization/multipart/android/.gitignore create mode 100644 components/serialization/multipart/android/AndroidManifest.xml create mode 100644 components/serialization/multipart/android/build.gradle create mode 100644 components/serialization/multipart/android/gradle.properties create mode 100644 components/serialization/multipart/build.gradle create mode 100644 components/serialization/multipart/gradle.properties create mode 100644 components/serialization/multipart/gradle/dependencies.gradle create mode 100644 components/serialization/multipart/spotBugsExcludeFilter.xml create mode 100644 components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java create mode 100644 components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriterFactory.java create mode 100644 components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/MultipartSerializationWriterTests.java create mode 100644 components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/SerializationWriterFactoryTests.java create mode 100644 components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/SecondTestEntity.java create mode 100644 components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/TestEntity.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 420409ac3..23229986f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,6 +20,11 @@ updates: schedule: interval: daily open-pull-requests-limit: 10 +- package-ecosystem: gradle + directory: "/components/serialization/multipart" + schedule: + interval: daily + open-pull-requests-limit: 10 - package-ecosystem: gradle directory: "/components/authentication/azure" schedule: @@ -50,6 +55,11 @@ updates: schedule: interval: daily open-pull-requests-limit: 10 +- package-ecosystem: gradle + directory: "/components/serialization/multipart/android" + schedule: + interval: daily + open-pull-requests-limit: 10 - package-ecosystem: gradle directory: "/components/authentication/azure/android" schedule: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acdc91366..4a444d6c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ jobs: Copy-Item secring.gpg components/serialization/form/ -Verbose Copy-Item secring.gpg components/serialization/text/ -Verbose Copy-Item secring.gpg components/serialization/json/ -Verbose + Copy-Item secring.gpg components/serialization/multipart/ -Verbose Copy-Item secring.gpg components/http/okHttp/ -Verbose shell: pwsh working-directory: ./ @@ -90,6 +91,7 @@ jobs: Copy-Item secring.gpg components/serialization/form/ -Verbose Copy-Item secring.gpg components/serialization/text/ -Verbose Copy-Item secring.gpg components/serialization/json/ -Verbose + Copy-Item secring.gpg components/serialization/multipart/ -Verbose Copy-Item secring.gpg components/http/okHttp/ -Verbose shell: pwsh working-directory: ./ @@ -108,6 +110,9 @@ jobs: - name: Publish Release serialization text run: ./gradlew --no-daemon :components:serialization:text:$PUBLISH_TASK -PmavenCentralSnapshotArtifactSuffix="" working-directory: ./ + - name: Publish Release serialization multipart + run: ./gradlew --no-daemon :components:serialization:multipart:$PUBLISH_TASK -PmavenCentralSnapshotArtifactSuffix="" + working-directory: ./ - name: Publish Release authentication azure run: ./gradlew --no-daemon :components:authentication:azure:$PUBLISH_TASK -PmavenCentralSnapshotArtifactSuffix="" working-directory: ./ diff --git a/README.md b/README.md index 2215ce983..1cc77ead1 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ The Kiota Java Libraries for Java are: - - [abstractions] Defining the basic constructs Kiota projects need once an SDK has been generated from an OpenAPI definition - - [authentication/azure] Implementing Azure authentication mechanisms - - [http/okHttp] Implementing a default OkHttp client - - [serialization/form] Implementing default serialization for forms - - [serialization/json] Implementing default serialization for json - - [serialization/text] Implementing default serialization for text +- [abstractions] Defining the basic constructs Kiota projects need once an SDK has been generated from an OpenAPI definition +- [authentication/azure] Implementing Azure authentication mechanisms +- [http/okHttp] Implementing a default OkHttp client +- [serialization/form] Implementing default serialization for forms +- [serialization/json] Implementing default serialization for json +- [serialization/text] Implementing default serialization for text +- [serialization/multipart] Implementing default serialization for multipart Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README.md). @@ -20,12 +21,13 @@ Read more about Kiota [here](https://github.com/microsoft/kiota/blob/main/README In `build.gradle` in the `dependencies` section: ```Groovy -implementation 'com.microsoft.kiota:microsoft-kiota-abstractions:0.4.0' -implementation 'com.microsoft.kiota:microsoft-kiota-authentication-azure:0.4.0' -implementation 'com.microsoft.kiota:microsoft-kiota-http-okHttp:0.4.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-json:0.4.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-text:0.4.0' -implementation 'com.microsoft.kiota:microsoft-kiota-serialization-form:0.4.0' +implementation 'com.microsoft.kiota:microsoft-kiota-abstractions:0.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-authentication-azure:0.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-http-okHttp:0.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-json:0.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-text:0.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-form:0.5.0' +implementation 'com.microsoft.kiota:microsoft-kiota-serialization-multipart:0.5.0' ``` ### With Maven: @@ -36,32 +38,37 @@ In `pom.xml` in the `dependencies` section: com.microsoft.kiota microsoft-kiota-abstractions - 0.4.0 + 0.5.0 com.microsoft.kiota microsoft-kiota-authentication-azure - 0.4.0 + 0.5.0 com.microsoft.kiota microsoft-kiota-http-okHttp - 0.4.0 + 0.5.0 com.microsoft.kiota microsoft-kiota-serialization-json - 0.4.0 + 0.5.0 com.microsoft.kiota microsoft-kiota-serialization-text - 0.4.0 + 0.5.0 com.microsoft.kiota microsoft-kiota-serialization-form - 0.4.0 + 0.5.0 + + + com.microsoft.kiota + microsoft-kiota-serialization-multipart + 0.5.0 ``` diff --git a/components/serialization/multipart/.gitignore b/components/serialization/multipart/.gitignore new file mode 100644 index 000000000..20a548b24 --- /dev/null +++ b/components/serialization/multipart/.gitignore @@ -0,0 +1,10 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + + +.settings +.project +.classpath \ No newline at end of file diff --git a/components/serialization/multipart/android/.gitignore b/components/serialization/multipart/android/.gitignore new file mode 100644 index 000000000..f06dfad69 --- /dev/null +++ b/components/serialization/multipart/android/.gitignore @@ -0,0 +1,2 @@ +.gradle +build \ No newline at end of file diff --git a/components/serialization/multipart/android/AndroidManifest.xml b/components/serialization/multipart/android/AndroidManifest.xml new file mode 100644 index 000000000..2932f8879 --- /dev/null +++ b/components/serialization/multipart/android/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/components/serialization/multipart/android/build.gradle b/components/serialization/multipart/android/build.gradle new file mode 100644 index 000000000..1b1c84099 --- /dev/null +++ b/components/serialization/multipart/android/build.gradle @@ -0,0 +1,73 @@ +buildscript { + repositories { + google() + gradlePluginPortal() + maven { + url "https://plugins.gradle.org/m2/" + } + maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } + } + + dependencies { + classpath "com.gradle:gradle-enterprise-gradle-plugin:3.14" + classpath "com.android.tools.build:gradle:8.0.2" + classpath "com.github.ben-manes:gradle-versions-plugin:0.47.0" + } +} + +repositories { + google() + gradlePluginPortal() +} + +apply plugin: "com.android.library" +apply plugin: "com.github.ben-manes.versions" + +android { + namespace "com.microsoft.kiota.serialization" + compileSdkVersion 34 + + defaultConfig { + versionCode 1 + versionName "1.0" + minSdkVersion 26 + targetSdkVersion 34 + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + textOutput "stdout" + checkAllWarnings true + warningsAsErrors true + disable "UnusedResources" // Unused will be removed on release + disable "IconExpectedSize" // Using the material icons provided from Google + disable "GoogleAppIndexingApiWarning" // We might want to index our app later + disable "InvalidPackage" // Butterknife, Okio and Realm + disable "ResourceType" // Annotation binding + disable "GradleDependency" + disable "NewerVersionAvailable" + disable "DuplicatePlatformClasses" // xpp3 added by azure-identity + } + sourceSets { + main { + java.srcDirs = ['../src/main/java'] + res.srcDirs = ['../src/main/java'] + manifest.srcFile 'AndroidManifest.xml' + } + androidTest { + setRoot '../src/test' + } + } +} + +apply from: "../gradle/dependencies.gradle" diff --git a/components/serialization/multipart/android/gradle.properties b/components/serialization/multipart/android/gradle.properties new file mode 100644 index 000000000..c4b9317c4 --- /dev/null +++ b/components/serialization/multipart/android/gradle.properties @@ -0,0 +1 @@ +mavenArtifactId = microsoft-kiota-serialization-multipart diff --git a/components/serialization/multipart/build.gradle b/components/serialization/multipart/build.gradle new file mode 100644 index 000000000..7eeb6a51a --- /dev/null +++ b/components/serialization/multipart/build.gradle @@ -0,0 +1,239 @@ +plugins { + // Apply the java-library plugin to add support for Java Library + id 'java-library' + id 'java' + id 'eclipse' + id 'maven-publish' + id 'signing' + id 'jacoco' + id 'com.github.spotbugs' version '5.0.14' + id "org.sonarqube" version "4.3.0.3225" +} + +java { + modularity.inferModulePath = true + withSourcesJar() + withJavadocJar() +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport // report is always generated after tests run +} + +jacocoTestReport { + dependsOn test // tests are required to run before generating the report +} + +jacoco { + toolVersion = "0.8.10" +} + +spotbugsMain { + excludeFilter = file("spotBugsExcludeFilter.xml") + reports { + html { + required + outputLocation = file("$buildDir/reports/spotbugs/main/spotbugs.html") + stylesheet = 'fancy-hist.xsl' + } + } +} + +spotbugsTest { + excludeFilter = file("spotBugsExcludeFilter.xml") + reports { + html { + required + outputLocation = file("$buildDir/reports/spotbugs/test/spotbugs.html") + stylesheet = 'fancy-hist.xsl' + } + } +} + +jacocoTestReport { + reports { + xml.required + } +} + +sourceSets { + main { + java { + exclude 'pom.xml' + } + } +} + +// In this section you declare where to find the dependencies of your project +repositories { + // You can declare any Maven/Ivy/file repository here. + mavenCentral() +} + +apply from: "gradle/dependencies.gradle" + +def pomConfig = { + licenses { + license([:]) { + name "MIT License" + url "http://opensource.org/licenses/MIT" + distribution "repo" + } + } +} + +sonarqube { + properties { + property "sonar.projectKey", "microsoft_kiota-java" + property "sonar.organization", "microsoft" + property "sonar.host.url", "https://sonarcloud.io" + } +} + +//Publishing tasks- +//Maven Central Snapshot: publishMavenPublicationToMavenRepository +//Maven Central Release: publishmavenPublicationToMaven2Repository + +tasks.jar { + manifest { + attributes('Automatic-Module-Name': project.property('mavenGroupId')) + } +} + +publishing { + + publications { + maven(MavenPublication) { + customizePom(pom) + groupId project.property('mavenGroupId') + artifactId project.property('mavenArtifactId') + version "${mavenMajorVersion}.${mavenMinorVersion}.${mavenPatchVersion}${mavenCentralSnapshotArtifactSuffix}" + from components.java + pom.withXml { + def pomFile = file("${project.buildDir}/generated-pom.xml") + writeTo(pomFile) + } + } + } + repositories { + maven { + url = 'https://oss.sonatype.org/content/repositories/snapshots' + name = 'sonatypeSnapshot' + + credentials { + if (project.rootProject.file('local.properties').exists()) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + username = properties.getProperty('sonatypeUsername') + password = properties.getProperty('sonatypePassword') + } + } + } + + maven { + url = 'https://oss.sonatype.org/service/local/staging/deploy/maven2' + name = 'sonatype' + + credentials { + if (project.rootProject.file('local.properties').exists()) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + username = properties.getProperty('sonatypeUsername') + password = properties.getProperty('sonatypePassword') + } + } + } + } +} + +signing { + sign publishing.publications.maven +} +tasks.withType(Sign)*.enabled = mavenCentralPublishingEnabled.toBoolean() + +def fixAscNames = { name -> + if(name.contains('pom')) { + "${project.property('mavenArtifactId')}-${mavenMajorVersion}.${mavenMinorVersion}.${mavenPatchVersion}.pom.asc" + } else { + name.replace('microsoft-kiota-java-serialization-multipart', "${project.property('mavenArtifactId')}-${mavenMajorVersion}.${mavenMinorVersion}.${mavenPatchVersion}") + } +} + +compileJava { + options.compilerArgs << "-parameters" + sourceCompatibility = '1.8' + targetCompatibility = '1.8' +} + +def getVersionCode() { + return mavenMajorVersion.toInteger() * 10000 + mavenMinorVersion.toInteger() * 100 + mavenPatchVersion.toInteger() +} + +def getVersionName() { + return "${mavenMajorVersion}.${mavenMinorVersion}.${mavenPatchVersion}${mavenArtifactSuffix}" +} + +artifacts { + archives jar +} + +def customizePom(pom) { + pom.withXml { + def root = asNode() + + root.dependencies.removeAll { dep -> + dep.scope == "test" + } + + root.children().last() + { + resolveStrategy = Closure.DELEGATE_FIRST + + description 'Microsoft Kiota-Serialization for Multipart' + name 'Microsoft Kiota-Java Serialization-Multipart' + url 'https://github.com/microsoft/kiota-java' + organization { + name 'Microsoft' + url 'https://github.com/microsoft/kiota-java' + } + issueManagement { + system 'GitHub' + url 'https://github.com/microsoft/kiota-java/issues' + } + licenses { + license { + name "MIT License" + url "http://opensource.org/licenses/MIT" + distribution "repo" + } + } + scm { + url 'https://github.com/microsoft/kiota-java' + connection 'scm:git:git://github.com/microsoft/kiota-java.git' + developerConnection 'scm:git:ssh://git@github.com:microsoft/kiota-java.git' + } + developers { + developer { + name 'Microsoft' + } + } + } + } +} + +gradle.taskGraph.whenReady { taskGraph -> + if (project.rootProject.file('local.properties').exists()) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + tasks.withType(Sign)*.enabled = (properties.containsKey('enableSigning')) ? properties.getProperty('enableSigning').toBoolean() : false + allprojects { ext."signing.keyId" = properties.getProperty('signing.keyId') } + allprojects { ext."signing.secretKeyRingFile" = properties.getProperty('signing.secretKeyRingFile') } + allprojects { ext."signing.password" = properties.getProperty('signing.password') } + } +} + +model { + tasks.generatePomFileForMavenPublication { + destination = file("${project.buildDir}/generated-pom.xml") + } +} \ No newline at end of file diff --git a/components/serialization/multipart/gradle.properties b/components/serialization/multipart/gradle.properties new file mode 100644 index 000000000..c4b9317c4 --- /dev/null +++ b/components/serialization/multipart/gradle.properties @@ -0,0 +1 @@ +mavenArtifactId = microsoft-kiota-serialization-multipart diff --git a/components/serialization/multipart/gradle/dependencies.gradle b/components/serialization/multipart/gradle/dependencies.gradle new file mode 100644 index 000000000..33d93b0a9 --- /dev/null +++ b/components/serialization/multipart/gradle/dependencies.gradle @@ -0,0 +1,12 @@ +dependencies { + // Use JUnit Jupiter API for testing. + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' + + // Use JUnit Jupiter Engine for testing. + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + + // This dependency is used internally, and not exposed to consumers on their own compile classpath. + implementation 'com.google.guava:guava:32.1.1-jre' + + api project(':components:abstractions') +} diff --git a/components/serialization/multipart/spotBugsExcludeFilter.xml b/components/serialization/multipart/spotBugsExcludeFilter.xml new file mode 100644 index 000000000..d9952e56b --- /dev/null +++ b/components/serialization/multipart/spotBugsExcludeFilter.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java b/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java new file mode 100644 index 000000000..3811c4602 --- /dev/null +++ b/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java @@ -0,0 +1,276 @@ +package com.microsoft.kiota.serialization; + +import com.microsoft.kiota.PeriodAndDuration; + +import java.lang.Enum; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.UnsupportedEncodingException; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.function.BiConsumer; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Serialization writer implementation for Multipart encoded payloads */ +public class MultipartSerializationWriter implements SerializationWriter { + private final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + private final OutputStreamWriter writer; + private final String encoding = StandardCharsets.UTF_8.name(); + private boolean written; + private int depth = 0; + /** Instantiates a new MultipartSerializationWriter. */ + public MultipartSerializationWriter() { + try { + this.writer = new OutputStreamWriter(this.stream, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("could not create writer", e); + } + } + public void writeStringValue(@Nullable final String key, @Nullable final String value) { + if(value == null || key == null || key.isEmpty()) + return; + try { + if(written) + writer.write("&"); + else + written = true; + writer.write(URLEncoder.encode(key, encoding) + "=" + URLEncoder.encode(value, encoding)); + } catch (IOException ex) { + throw new RuntimeException("could not serialize value", ex); + } + } + public void writeBooleanValue(@Nullable final String key, @Nullable final Boolean value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeShortValue(@Nullable final String key, @Nullable final Short value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeByteValue(@Nullable final String key, @Nullable final Byte value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeBigDecimalValue(@Nullable final String key, @Nullable final BigDecimal value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeIntegerValue(@Nullable final String key, @Nullable final Integer value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeFloatValue(@Nullable final String key, @Nullable final Float value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeDoubleValue(@Nullable final String key, @Nullable final Double value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeLongValue(@Nullable final String key, @Nullable final Long value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeUUIDValue(@Nullable final String key, @Nullable final UUID value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeOffsetDateTimeValue(@Nullable final String key, @Nullable final OffsetDateTime value) { + if(value != null) + writeStringValue(key, value.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); + } + public void writeLocalDateValue(@Nullable final String key, @Nullable final LocalDate value) { + if(value != null) + writeStringValue(key, value.format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + public void writeLocalTimeValue(@Nullable final String key, @Nullable final LocalTime value) { + if(value != null) + writeStringValue(key, value.format(DateTimeFormatter.ISO_LOCAL_TIME)); + } + public void writePeriodAndDurationValue(@Nullable final String key, @Nullable final PeriodAndDuration value) { + if(value != null) + writeStringValue(key, value.toString()); + } + public void writeCollectionOfPrimitiveValues(@Nullable final String key, @Nullable final Iterable values) { + if(values != null) { + for (final T t : values) { + this.writeAnyValue(key, t); + } + } + } + public void writeCollectionOfObjectValues(@Nullable final String key, @Nullable final Iterable values) { + throw new RuntimeException("collections serialization is not supported with form encoding"); + } + public > void writeCollectionOfEnumValues(@Nullable final String key, @Nullable final Iterable values) { + if(values != null) { //empty array is meaningful + final StringBuffer buffer = new StringBuffer(); + int writtenValuesCount = -1; + for (final T t : values) { + if(++writtenValuesCount > 0) + buffer.append(","); + buffer.append(getStringValueFromValuedEnum(t)); + } + writeStringValue(key, buffer.toString()); + } + } + public void writeObjectValue(@Nullable final String key, @Nullable final T value, @Nonnull final Parsable ...additionalValuesToMerge) { + Objects.requireNonNull(additionalValuesToMerge); + if (depth > 0) + throw new RuntimeException("serialization of complex properties is not supported with form encoding"); + depth++; + final List nonNullAdditionalValuesToMerge = Stream.of(additionalValuesToMerge).filter(Objects::nonNull).collect(Collectors.toList()); + if(value != null || nonNullAdditionalValuesToMerge.size() > 0) { + if(onBeforeObjectSerialization != null && value != null) { + onBeforeObjectSerialization.accept(value); + } + if(value != null) { + if(onStartObjectSerialization != null) { + onStartObjectSerialization.accept(value, this); + } + value.serialize(this); + } + for(final Parsable additionalValueToMerge : nonNullAdditionalValuesToMerge) { + if(onBeforeObjectSerialization != null) { + onBeforeObjectSerialization.accept(additionalValueToMerge); + } + if(onStartObjectSerialization != null) { + onStartObjectSerialization.accept(additionalValueToMerge, this); + } + additionalValueToMerge.serialize(this); + if(onAfterObjectSerialization != null) { + onAfterObjectSerialization.accept(additionalValueToMerge); + } + } + if(onAfterObjectSerialization != null && value != null) { + onAfterObjectSerialization.accept(value); + } + } + } + public > void writeEnumSetValue(@Nullable final String key, @Nullable final EnumSet values) { + if(values != null && !values.isEmpty()) { + final Optional concatenatedValue = values.stream().map(v -> this.getStringValueFromValuedEnum(v)).reduce((x, y) -> { return x + "," + y; }); + if(concatenatedValue.isPresent()) { + this.writeStringValue(key, concatenatedValue.get()); + } + } + } + public > void writeEnumValue(@Nullable final String key, @Nullable final T value) { + if(value != null) { + this.writeStringValue(key, getStringValueFromValuedEnum(value)); + } + } + public void writeNullValue(@Nullable final String key) { + writeStringValue(key, "null"); + } + private > String getStringValueFromValuedEnum(final T value) { + if(value instanceof ValuedEnum) { + final ValuedEnum valued = (ValuedEnum)value; + return valued.getValue(); + } else return null; + } + @Nonnull + public InputStream getSerializedContent() { + try { + this.writer.flush(); + return new ByteArrayInputStream(this.stream.toByteArray()); + //This copies the whole array in memory could result in memory pressure for large objects, we might want to replace by some kind of piping in the future + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + public void close() throws IOException { + this.writer.close(); + this.stream.close(); + } + public void writeAdditionalData(@Nonnull final Map value) { + if(value == null) return; + for(final Map.Entry dataValue : value.entrySet()) { + this.writeAnyValue(dataValue.getKey(), dataValue.getValue()); + } + } + private void writeAnyValue(@Nullable final String key, @Nullable final Object value) { + if(value == null) { + this.writeNullValue(key); + } else { + final Class valueClass = value.getClass(); + if(valueClass.equals(String.class)) + this.writeStringValue(key, (String)value); + else if(valueClass.equals(Boolean.class)) + this.writeBooleanValue(key, (Boolean)value); + else if(valueClass.equals(Byte.class)) + this.writeByteValue(key, (Byte)value); + else if(valueClass.equals(Short.class)) + this.writeShortValue(key, (Short)value); + else if(valueClass.equals(BigDecimal.class)) + this.writeBigDecimalValue(key, (BigDecimal)value); + else if(valueClass.equals(Float.class)) + this.writeFloatValue(key, (Float)value); + else if(valueClass.equals(Long.class)) + this.writeLongValue(key, (Long)value); + else if(valueClass.equals(Integer.class)) + this.writeIntegerValue(key, (Integer)value); + else if(valueClass.equals(UUID.class)) + this.writeUUIDValue(key, (UUID)value); + else if(valueClass.equals(OffsetDateTime.class)) + this.writeOffsetDateTimeValue(key, (OffsetDateTime)value); + else if(valueClass.equals(LocalDate.class)) + this.writeLocalDateValue(key, (LocalDate)value); + else if(valueClass.equals(LocalTime.class)) + this.writeLocalTimeValue(key, (LocalTime)value); + else if(valueClass.equals(PeriodAndDuration.class)) + this.writePeriodAndDurationValue(key, (PeriodAndDuration)value); + else if(value instanceof Iterable) + this.writeCollectionOfPrimitiveValues(key, (Iterable)value); + else + throw new RuntimeException("unknown type to serialize " + valueClass.getName()); + } + } + @Nullable + public Consumer getOnBeforeObjectSerialization() { + return this.onBeforeObjectSerialization; + } + @Nullable + public Consumer getOnAfterObjectSerialization() { + return this.onAfterObjectSerialization; + } + @Nullable + public BiConsumer getOnStartObjectSerialization() { + return this.onStartObjectSerialization; + } + private Consumer onBeforeObjectSerialization; + public void setOnBeforeObjectSerialization(@Nullable final Consumer value) { + this.onBeforeObjectSerialization = value; + } + private Consumer onAfterObjectSerialization; + public void setOnAfterObjectSerialization(@Nullable final Consumer value) { + this.onAfterObjectSerialization = value; + } + private BiConsumer onStartObjectSerialization; + public void setOnStartObjectSerialization(@Nullable final BiConsumer value) { + this.onStartObjectSerialization = value; + } + public void writeByteArrayValue(@Nullable final String key, @Nullable @Nonnull final byte[] value) { + if(value != null) + this.writeStringValue(key, Base64.getEncoder().encodeToString(value)); + } +} diff --git a/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriterFactory.java b/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriterFactory.java new file mode 100644 index 000000000..6411d1fcc --- /dev/null +++ b/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriterFactory.java @@ -0,0 +1,28 @@ +package com.microsoft.kiota.serialization; + +import java.util.Objects; + +import javax.annotation.Nonnull; + +/** Creates instances of Multipart Serialization Writers */ +public class MultipartSerializationWriterFactory implements SerializationWriterFactory { + /** Instantiates a new factory */ + public MultipartSerializationWriterFactory() { + } + @Nonnull + public String getValidContentType() { + return validContentType; + } + private static final String validContentType = "multipart/form-data"; + @Override + @Nonnull + public SerializationWriter getSerializationWriter(@Nonnull final String contentType) { + Objects.requireNonNull(contentType, "parameter contentType cannot be null"); + if(contentType.isEmpty()) { + throw new NullPointerException("contentType cannot be empty"); + } else if (!contentType.equals(validContentType)) { + throw new IllegalArgumentException("expected a " + validContentType + " content type"); + } + return new MultipartSerializationWriter(); + } +} diff --git a/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/MultipartSerializationWriterTests.java b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/MultipartSerializationWriterTests.java new file mode 100644 index 000000000..af4cf83a7 --- /dev/null +++ b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/MultipartSerializationWriterTests.java @@ -0,0 +1,85 @@ +package com.microsoft.kiota.serialization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.stream.Collectors; + +import com.microsoft.kiota.PeriodAndDuration; +import org.junit.jupiter.api.Test; + +import com.microsoft.kiota.serialization.mocks.TestEntity; + +public class MultipartSerializationWriterTests { + @Test + public void writesSampleObjectValue() throws IOException, UnsupportedEncodingException { + final var testEntity = new TestEntity() {{ + setId("48d31887-5fad-4d73-a9f5-3c356e68a038"); + setWorkDuration(PeriodAndDuration.parse("P1M")); + setStartWorkTime(LocalTime.of(8, 0, 0)); + setBirthDay(LocalDate.of(2017, 9, 4)); + setDeviceNames(Arrays.asList("device1","device2")); + }}; + testEntity.getAdditionalData().put("mobilePhone", null); + testEntity.getAdditionalData().put("jobTitle", "Author"); + testEntity.getAdditionalData().put("accountEnabled", false); + testEntity.getAdditionalData().put("createdDateTime", OffsetDateTime.MIN); + testEntity.getAdditionalData().put("otherPhones", Arrays.asList(Arrays.asList("device1","device2"))); + try (final var serializationWriter = new MultipartSerializationWriter()) { + serializationWriter.writeObjectValue(null, testEntity); + try(final var content = serializationWriter.getSerializedContent()) { + try(final var contentReader = new BufferedReader(new InputStreamReader(content, "UTF-8"))) { + String result = contentReader.lines().collect(Collectors.joining("\n")); + final var expectedString = "id=48d31887-5fad-4d73-a9f5-3c356e68a038&" + + "birthDay=2017-09-04&" + // Serializes dates + "workDuration=P1M&"+ // Serializes timespans + "startWorkTime=08%3A00%3A00&" + //Serializes times + "deviceNames=device1&deviceNames=device2&"+ + "mobilePhone=null&" + // Serializes null values + "jobTitle=Author&" + + "createdDateTime=-999999999-01-01T00%3A00%3A00%2B18%3A00&" + + "otherPhones=device1&otherPhones=device2&" + + "accountEnabled=false"; + assertEquals(expectedString, result); + } + } + } + } + @Test + public void writesSampleCollectionOfObjectValues() throws IOException { + final var testEntity = new TestEntity() {{ + setId("48d31887-5fad-4d73-a9f5-3c356e68a038"); + setWorkDuration(PeriodAndDuration.parse("P1M")); + setStartWorkTime(LocalTime.of(8, 0, 0)); + setBirthDay(LocalDate.of(2017, 9, 4)); + }}; + final var entityList = new ArrayList() {{ add(testEntity); }}; + try (final var serializationWriter = new MultipartSerializationWriter()) { + assertThrows(RuntimeException.class, () -> serializationWriter.writeCollectionOfObjectValues(null, entityList)); + } + } + @Test + public void writesNestedObjectValuesInAdditionalData() throws IOException { + final var testEntity = new TestEntity() {{ + setId("48d31887-5fad-4d73-a9f5-3c356e68a038"); + setWorkDuration(PeriodAndDuration.parse("P1M")); + setStartWorkTime(LocalTime.of(8, 0, 0)); + setBirthDay(LocalDate.of(2017, 9, 4)); + }}; + testEntity.getAdditionalData().put("nestedEntity", new TestEntity() {{ + setId("foo"); + }}); + try (final var serializationWriter = new MultipartSerializationWriter()) { + assertThrows(RuntimeException.class, () -> serializationWriter.writeObjectValue(null, testEntity)); + } + } +} diff --git a/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/SerializationWriterFactoryTests.java b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/SerializationWriterFactoryTests.java new file mode 100644 index 000000000..73e918f0f --- /dev/null +++ b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/SerializationWriterFactoryTests.java @@ -0,0 +1,24 @@ +package com.microsoft.kiota.serialization; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +public class SerializationWriterFactoryTests { + private static final MultipartSerializationWriterFactory _serializationWriterFactory = new MultipartSerializationWriterFactory(); + private static final String contentType = "multipart/form-data"; + @Test + public void getsWriterForMultipartContentType() { + final var serializationWriter = _serializationWriterFactory.getSerializationWriter(contentType); + assertNotNull(serializationWriter); + } + @Test + public void throwsArgumentOutOfRangeExceptionForInvalidContentType() { + assertThrows(IllegalArgumentException.class, () -> _serializationWriterFactory.getSerializationWriter("application/json")); + } + @Test + public void throwsArgumentNullExceptionForNoContentType() { + assertThrows(NullPointerException.class, () -> _serializationWriterFactory.getSerializationWriter("")); + } +} diff --git a/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/SecondTestEntity.java b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/SecondTestEntity.java new file mode 100644 index 000000000..71793aa62 --- /dev/null +++ b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/SecondTestEntity.java @@ -0,0 +1,70 @@ +package com.microsoft.kiota.serialization.mocks; + +import com.microsoft.kiota.serialization.ParseNode; +import com.microsoft.kiota.serialization.SerializationWriter; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +import com.microsoft.kiota.serialization.Parsable; +import com.microsoft.kiota.serialization.AdditionalDataHolder; + +public class SecondTestEntity implements Parsable, AdditionalDataHolder { + private String _displayName; + private Integer _id; + private Long _failureRate; + + @Override + public Map> getFieldDeserializers() { + return new HashMap<>(){{ + put("displayName", (n) -> setDisplayName(n.getStringValue())); + put("id", (n) -> setId(n.getIntegerValue())); + put("failureRate", (n) -> setFailureRate(n.getLongValue())); + }}; + } + + @Override + public void serialize(SerializationWriter writer) { + Objects.requireNonNull(writer); + + writer.writeStringValue("displayName", getDisplayName()); + writer.writeIntegerValue("id", getId()); + writer.writeLongValue("failureRate", getFailureRate()); + writer.writeAdditionalData(getAdditionalData()); + } + private final Map _additionalData = new HashMap<>(); + @Override + public Map getAdditionalData() { + return _additionalData; + } + + public String getDisplayName() { + return _displayName; + } + + public void setDisplayName(String value) { + this._displayName = value; + } + + public Integer getId() { + return _id; + } + + public void setId(Integer value) { + this._id = value; + } + + public Long getFailureRate() { + return _failureRate; + } + + public void setFailureRate(Long value) { + this._failureRate = value; + } + @javax.annotation.Nonnull + public static SecondTestEntity createFromDiscriminatorValue(@javax.annotation.Nonnull final ParseNode parseNode) { + return new SecondTestEntity(); + } +} diff --git a/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/TestEntity.java b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/TestEntity.java new file mode 100644 index 000000000..e2eefbc07 --- /dev/null +++ b/components/serialization/multipart/src/test/java/com/microsoft/kiota/serialization/mocks/TestEntity.java @@ -0,0 +1,144 @@ +package com.microsoft.kiota.serialization.mocks; + +import com.microsoft.kiota.PeriodAndDuration; +import com.microsoft.kiota.serialization.ParseNode; +import com.microsoft.kiota.serialization.SerializationWriter; + +import java.util.*; +import java.util.function.Consumer; +import java.time.OffsetDateTime; +import java.time.LocalDate; +import java.time.LocalTime; + +import com.microsoft.kiota.serialization.Parsable; +import com.microsoft.kiota.serialization.AdditionalDataHolder; + +public class TestEntity implements Parsable, AdditionalDataHolder { + private String _id; + public String getId() { + return _id; + } + + public void setId(String _id) { + this._id = _id; + } + + private String _officeLocation; + public String getOfficeLocation() { + return _officeLocation; + } + + public void setOfficeLocation(String _officeLocation) { + this._officeLocation = _officeLocation; + } + + private LocalDate _birthDay; + public LocalDate getBirthDay() { + return _birthDay; + } + + public void setBirthDay(LocalDate value) { + this._birthDay = value; + } + + private List _deviceNames; + public List getDeviceNames() { + return _deviceNames; + } + + public void setDeviceNames(List value) { + this._deviceNames = new ArrayList(value); + } + private PeriodAndDuration _workDuration; + public PeriodAndDuration getWorkDuration() { + return _workDuration; + } + + public void setWorkDuration(PeriodAndDuration value) { + this._workDuration = value; + } + + private LocalTime _startWorkTime; + public LocalTime getStartWorkTime() { + return _startWorkTime; + } + + public void setStartWorkTime(LocalTime value) { + this._startWorkTime = value; + } + + private LocalTime _endWorkTime; + public LocalTime getEndWorkTime() { + return _endWorkTime; + } + + public void setEndWorkTime(LocalTime value) { + this._endWorkTime = value; + } + + //TODO enum + private OffsetDateTime _createdDateTime; + + public OffsetDateTime getCreatedDateTime() { + return _createdDateTime; + } + + public void setCreatedDateTime(OffsetDateTime value) { + this._createdDateTime = value; + } + + @Override + public Map> getFieldDeserializers() { + return new HashMap<>() {{ + put("id", (n) -> { + setId(n.getStringValue()); + }); + put("officeLocation", (n) -> { + setOfficeLocation(n.getStringValue()); + }); + put("birthDay", (n) -> { + setBirthDay(n.getLocalDateValue()); + }); + put("workDuration", (n) -> { + setWorkDuration(n.getPeriodAndDurationValue()); + }); + put("startWorkTime", (n) -> { + setStartWorkTime(n.getLocalTimeValue()); + }); + put("endWorkTime", (n) -> { + setEndWorkTime(n.getLocalTimeValue()); + }); + put("createdDateTime", (n) -> { + setCreatedDateTime(n.getOffsetDateTimeValue()); + }); + put("deviceNames", (n) -> { + setDeviceNames(n.getCollectionOfPrimitiveValues(String.class)); + }); + }}; + } + + @Override + public void serialize(SerializationWriter writer) { + Objects.requireNonNull(writer); + writer.writeStringValue("id", getId()); + writer.writeStringValue("officeLocation", getOfficeLocation()); + writer.writeLocalDateValue("birthDay", getBirthDay()); + writer.writePeriodAndDurationValue("workDuration", getWorkDuration()); + writer.writeLocalTimeValue("startWorkTime", getStartWorkTime()); + writer.writeLocalTimeValue("endWorkTime", getEndWorkTime()); + writer.writeOffsetDateTimeValue("createdDateTime", getCreatedDateTime()); + writer.writeCollectionOfPrimitiveValues("deviceNames", getDeviceNames()); + writer.writeAdditionalData(getAdditionalData()); + } + + private final Map _additionalData = new HashMap<>(); + + @Override + public Map getAdditionalData() { + return _additionalData; + } + @javax.annotation.Nonnull + public static TestEntity createFromDiscriminatorValue(@javax.annotation.Nonnull final ParseNode parseNode) { + return new TestEntity(); + } +} diff --git a/settings.gradle b/settings.gradle index f9579ac0d..bff5bcbbc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,5 +10,6 @@ include ':components:abstractions' + suffix include ':components:serialization:form' + suffix include ':components:serialization:json' + suffix include ':components:serialization:text' + suffix +include ':components:serialization:multipart' + suffix include ':components:authentication:azure' + suffix include ':components:http:okHttp' + suffix From 284ce50d21bd45f794f0879e5805b3e9905fa65e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 25 Jul 2023 16:27:25 -0400 Subject: [PATCH 04/11] - adds multipart serialization writer implementation --- .../com/microsoft/kiota/MultipartBody.java | 11 +- .../multipart/gradle/dependencies.gradle | 2 + .../MultipartSerializationWriter.java | 186 ++++-------------- .../MultipartSerializationWriterTests.java | 94 ++++----- 4 files changed, 98 insertions(+), 195 deletions(-) diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java index a56022d9c..40f2838de 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java @@ -50,7 +50,7 @@ public void addOrReplacePart(@Nonnull final String name, @Nonnull final Stri throw new IllegalArgumentException("name cannot be blank or empty"); final String normalizedName = normalizePartName(name); - originalNames.put(name, normalizedName); + originalNames.put(normalizedName, name); parts.put(normalizedName, Map.entry(contentType, value)); } private final Map> parts = new HashMap<>(); @@ -92,7 +92,7 @@ public boolean removePart(@Nonnull final String partName) if(candidate == null) return false; - originalNames.remove(partName); + originalNames.remove(normalizedName); return true; } @@ -100,7 +100,6 @@ public boolean removePart(@Nonnull final String partName) @Override @Nonnull public Map> getFieldDeserializers() { - // TODO Auto-generated method stub throw new UnsupportedOperationException("Unimplemented method 'getFieldDeserializers'"); } @@ -121,7 +120,7 @@ public void serialize(@Nonnull final SerializationWriter writer) { isFirst = false; else writer.writeStringValue("", ""); - writer.writeStringValue("", getBoundary()); + writer.writeStringValue("", "--" + getBoundary()); final String partContentType = partEntry.getValue().getKey(); writer.writeStringValue("Content-Type", partContentType); writer.writeStringValue("Content-Disposition", "form-data; name=\"" + originalNames.get(partEntry.getKey()) + "\""); @@ -155,9 +154,9 @@ public void serialize(@Nonnull final SerializationWriter writer) { } catch (final IOException ex) { throw new RuntimeException(ex); } - writer.writeStringValue("", ""); - writer.writeStringValue("", "--" + boundary + "--"); } + writer.writeStringValue("", ""); + writer.writeStringValue("", "--" + boundary + "--"); } } diff --git a/components/serialization/multipart/gradle/dependencies.gradle b/components/serialization/multipart/gradle/dependencies.gradle index 33d93b0a9..e685e4533 100644 --- a/components/serialization/multipart/gradle/dependencies.gradle +++ b/components/serialization/multipart/gradle/dependencies.gradle @@ -1,6 +1,7 @@ dependencies { // Use JUnit Jupiter API for testing. testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' + testImplementation 'org.mockito:mockito-inline:5.2.0' // Use JUnit Jupiter Engine for testing. testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' @@ -9,4 +10,5 @@ dependencies { implementation 'com.google.guava:guava:32.1.1-jre' api project(':components:abstractions') + testImplementation project(':components:serialization:json') } diff --git a/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java b/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java index 3811c4602..29e40e96f 100644 --- a/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java +++ b/components/serialization/multipart/src/main/java/com/microsoft/kiota/serialization/MultipartSerializationWriter.java @@ -1,11 +1,9 @@ package com.microsoft.kiota.serialization; import com.microsoft.kiota.PeriodAndDuration; +import com.microsoft.kiota.MultipartBody; -import java.lang.Enum; import java.math.BigDecimal; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -15,16 +13,12 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.EnumSet; -import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.UUID; import java.util.function.Consumer; -import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.function.BiConsumer; @@ -35,9 +29,6 @@ public class MultipartSerializationWriter implements SerializationWriter { private final ByteArrayOutputStream stream = new ByteArrayOutputStream(); private final OutputStreamWriter writer; - private final String encoding = StandardCharsets.UTF_8.name(); - private boolean written; - private int depth = 0; /** Instantiates a new MultipartSerializationWriter. */ public MultipartSerializationWriter() { try { @@ -47,203 +38,106 @@ public MultipartSerializationWriter() { } } public void writeStringValue(@Nullable final String key, @Nullable final String value) { - if(value == null || key == null || key.isEmpty()) - return; try { - if(written) - writer.write("&"); - else - written = true; - writer.write(URLEncoder.encode(key, encoding) + "=" + URLEncoder.encode(value, encoding)); + if (key != null && !key.isEmpty()) + writer.write(key); + if (value != null && !value.isEmpty()) { + if (key != null && !key.isEmpty()) + writer.write(": "); + writer.write(value); + } + writer.write("\r\n"); + writer.flush(); } catch (IOException ex) { throw new RuntimeException("could not serialize value", ex); } } public void writeBooleanValue(@Nullable final String key, @Nullable final Boolean value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeShortValue(@Nullable final String key, @Nullable final Short value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeByteValue(@Nullable final String key, @Nullable final Byte value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeBigDecimalValue(@Nullable final String key, @Nullable final BigDecimal value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeIntegerValue(@Nullable final String key, @Nullable final Integer value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeFloatValue(@Nullable final String key, @Nullable final Float value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeDoubleValue(@Nullable final String key, @Nullable final Double value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeLongValue(@Nullable final String key, @Nullable final Long value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeUUIDValue(@Nullable final String key, @Nullable final UUID value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeOffsetDateTimeValue(@Nullable final String key, @Nullable final OffsetDateTime value) { - if(value != null) - writeStringValue(key, value.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); + throw new UnsupportedOperationException(); } public void writeLocalDateValue(@Nullable final String key, @Nullable final LocalDate value) { - if(value != null) - writeStringValue(key, value.format(DateTimeFormatter.ISO_LOCAL_DATE)); + throw new UnsupportedOperationException(); } public void writeLocalTimeValue(@Nullable final String key, @Nullable final LocalTime value) { - if(value != null) - writeStringValue(key, value.format(DateTimeFormatter.ISO_LOCAL_TIME)); + throw new UnsupportedOperationException(); } public void writePeriodAndDurationValue(@Nullable final String key, @Nullable final PeriodAndDuration value) { - if(value != null) - writeStringValue(key, value.toString()); + throw new UnsupportedOperationException(); } public void writeCollectionOfPrimitiveValues(@Nullable final String key, @Nullable final Iterable values) { - if(values != null) { - for (final T t : values) { - this.writeAnyValue(key, t); - } - } + throw new UnsupportedOperationException(); } public void writeCollectionOfObjectValues(@Nullable final String key, @Nullable final Iterable values) { - throw new RuntimeException("collections serialization is not supported with form encoding"); + throw new UnsupportedOperationException(); } public > void writeCollectionOfEnumValues(@Nullable final String key, @Nullable final Iterable values) { - if(values != null) { //empty array is meaningful - final StringBuffer buffer = new StringBuffer(); - int writtenValuesCount = -1; - for (final T t : values) { - if(++writtenValuesCount > 0) - buffer.append(","); - buffer.append(getStringValueFromValuedEnum(t)); - } - writeStringValue(key, buffer.toString()); - } + throw new UnsupportedOperationException(); } public void writeObjectValue(@Nullable final String key, @Nullable final T value, @Nonnull final Parsable ...additionalValuesToMerge) { Objects.requireNonNull(additionalValuesToMerge); - if (depth > 0) - throw new RuntimeException("serialization of complex properties is not supported with form encoding"); - depth++; - final List nonNullAdditionalValuesToMerge = Stream.of(additionalValuesToMerge).filter(Objects::nonNull).collect(Collectors.toList()); - if(value != null || nonNullAdditionalValuesToMerge.size() > 0) { - if(onBeforeObjectSerialization != null && value != null) { + if(value != null) { + if(onBeforeObjectSerialization != null) { onBeforeObjectSerialization.accept(value); } - if(value != null) { + if(value instanceof MultipartBody) { if(onStartObjectSerialization != null) { onStartObjectSerialization.accept(value, this); } value.serialize(this); + } else { + throw new RuntimeException("expected MultipartBody instance but got " + value.getClass().getName()); } - for(final Parsable additionalValueToMerge : nonNullAdditionalValuesToMerge) { - if(onBeforeObjectSerialization != null) { - onBeforeObjectSerialization.accept(additionalValueToMerge); - } - if(onStartObjectSerialization != null) { - onStartObjectSerialization.accept(additionalValueToMerge, this); - } - additionalValueToMerge.serialize(this); - if(onAfterObjectSerialization != null) { - onAfterObjectSerialization.accept(additionalValueToMerge); - } - } - if(onAfterObjectSerialization != null && value != null) { + if(onAfterObjectSerialization != null) { onAfterObjectSerialization.accept(value); } } } public > void writeEnumSetValue(@Nullable final String key, @Nullable final EnumSet values) { - if(values != null && !values.isEmpty()) { - final Optional concatenatedValue = values.stream().map(v -> this.getStringValueFromValuedEnum(v)).reduce((x, y) -> { return x + "," + y; }); - if(concatenatedValue.isPresent()) { - this.writeStringValue(key, concatenatedValue.get()); - } - } + throw new UnsupportedOperationException(); } public > void writeEnumValue(@Nullable final String key, @Nullable final T value) { - if(value != null) { - this.writeStringValue(key, getStringValueFromValuedEnum(value)); - } + throw new UnsupportedOperationException(); } public void writeNullValue(@Nullable final String key) { - writeStringValue(key, "null"); - } - private > String getStringValueFromValuedEnum(final T value) { - if(value instanceof ValuedEnum) { - final ValuedEnum valued = (ValuedEnum)value; - return valued.getValue(); - } else return null; + throw new UnsupportedOperationException(); } @Nonnull public InputStream getSerializedContent() { - try { - this.writer.flush(); - return new ByteArrayInputStream(this.stream.toByteArray()); - //This copies the whole array in memory could result in memory pressure for large objects, we might want to replace by some kind of piping in the future - } catch (IOException ex) { - throw new RuntimeException(ex); - } + return new ByteArrayInputStream(this.stream.toByteArray()); } public void close() throws IOException { this.writer.close(); this.stream.close(); } public void writeAdditionalData(@Nonnull final Map value) { - if(value == null) return; - for(final Map.Entry dataValue : value.entrySet()) { - this.writeAnyValue(dataValue.getKey(), dataValue.getValue()); - } - } - private void writeAnyValue(@Nullable final String key, @Nullable final Object value) { - if(value == null) { - this.writeNullValue(key); - } else { - final Class valueClass = value.getClass(); - if(valueClass.equals(String.class)) - this.writeStringValue(key, (String)value); - else if(valueClass.equals(Boolean.class)) - this.writeBooleanValue(key, (Boolean)value); - else if(valueClass.equals(Byte.class)) - this.writeByteValue(key, (Byte)value); - else if(valueClass.equals(Short.class)) - this.writeShortValue(key, (Short)value); - else if(valueClass.equals(BigDecimal.class)) - this.writeBigDecimalValue(key, (BigDecimal)value); - else if(valueClass.equals(Float.class)) - this.writeFloatValue(key, (Float)value); - else if(valueClass.equals(Long.class)) - this.writeLongValue(key, (Long)value); - else if(valueClass.equals(Integer.class)) - this.writeIntegerValue(key, (Integer)value); - else if(valueClass.equals(UUID.class)) - this.writeUUIDValue(key, (UUID)value); - else if(valueClass.equals(OffsetDateTime.class)) - this.writeOffsetDateTimeValue(key, (OffsetDateTime)value); - else if(valueClass.equals(LocalDate.class)) - this.writeLocalDateValue(key, (LocalDate)value); - else if(valueClass.equals(LocalTime.class)) - this.writeLocalTimeValue(key, (LocalTime)value); - else if(valueClass.equals(PeriodAndDuration.class)) - this.writePeriodAndDurationValue(key, (PeriodAndDuration)value); - else if(value instanceof Iterable) - this.writeCollectionOfPrimitiveValues(key, (Iterable)value); - else - throw new RuntimeException("unknown type to serialize " + valueClass.getName()); - } + throw new UnsupportedOperationException(); } @Nullable public Consumer getOnBeforeObjectSerialization() { @@ -271,6 +165,10 @@ public void setOnStartObjectSerialization(@Nullable final BiConsumer serializationWriter.writeObjectValue(null, testEntity)); } } + private final byte[] byteForTest = new byte[] { 0x01, 0x02, 0x03 }; @Test - public void writesSampleCollectionOfObjectValues() throws IOException { - final var testEntity = new TestEntity() {{ - setId("48d31887-5fad-4d73-a9f5-3c356e68a038"); - setWorkDuration(PeriodAndDuration.parse("P1M")); - setStartWorkTime(LocalTime.of(8, 0, 0)); - setBirthDay(LocalDate.of(2017, 9, 4)); - }}; - final var entityList = new ArrayList() {{ add(testEntity); }}; + public void writesBytArrayValue() throws IOException { try (final var serializationWriter = new MultipartSerializationWriter()) { - assertThrows(RuntimeException.class, () -> serializationWriter.writeCollectionOfObjectValues(null, entityList)); + serializationWriter.writeByteArrayValue("key", byteForTest); + try(final var result = serializationWriter.getSerializedContent()) { + try(final var reader = new BufferedReader(new InputStreamReader(result, "UTF-8"))) { + final String strResult = reader.lines().collect(Collectors.joining("\n")); + assertEquals("\u0001\u0002\u0003", strResult); + } + } } } @Test - public void writesNestedObjectValuesInAdditionalData() throws IOException { - final var testEntity = new TestEntity() {{ - setId("48d31887-5fad-4d73-a9f5-3c356e68a038"); - setWorkDuration(PeriodAndDuration.parse("P1M")); - setStartWorkTime(LocalTime.of(8, 0, 0)); - setBirthDay(LocalDate.of(2017, 9, 4)); - }}; - testEntity.getAdditionalData().put("nestedEntity", new TestEntity() {{ - setId("foo"); - }}); + public void writesAStructuredObject() throws IOException { + final TestEntity testEntity = new TestEntity(); + testEntity.setId("48d31887-5fad-4d73-a9f5-3c356e68a038"); + testEntity.setWorkDuration(PeriodAndDuration.parse("P1M")); + testEntity.setStartWorkTime(LocalTime.of(8, 0, 0)); + testEntity.setBirthDay(LocalDate.of(2017, 9, 4)); + testEntity.setDeviceNames(Arrays.asList("device1","device2")); + testEntity.getAdditionalData().put("mobilePhone", null); + testEntity.getAdditionalData().put("jobTitle", "Author"); + testEntity.getAdditionalData().put("accountEnabled", false); + testEntity.getAdditionalData().put("createdDateTime", OffsetDateTime.MIN); + testEntity.getAdditionalData().put("otherPhones", Arrays.asList(Arrays.asList("device1","device2"))); + final RequestAdapter requestAdapter = mock(RequestAdapter.class); + when(requestAdapter.getSerializationWriterFactory()).thenReturn(new JsonSerializationWriterFactory()); try (final var serializationWriter = new MultipartSerializationWriter()) { - assertThrows(RuntimeException.class, () -> serializationWriter.writeObjectValue(null, testEntity)); + final MultipartBody multipartBody = new MultipartBody(); + multipartBody.requestAdapter = requestAdapter; + multipartBody.addOrReplacePart("testEntity", "application/json", testEntity); + multipartBody.addOrReplacePart("image", "application/octet-stream", byteForTest); + serializationWriter.writeObjectValue(null, multipartBody); + + try(final var result = serializationWriter.getSerializedContent()) { + try(final var reader = new BufferedReader(new InputStreamReader(result, "UTF-8"))) { + final String strResult = reader.lines().collect(Collectors.joining("\r\n")); + assertEquals("--" + multipartBody.getBoundary() + "\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name=\"image\"\r\n\r\n"+new String(byteForTest, "UTF-8")+"\r\n--" + multipartBody.getBoundary() + "\r\nContent-Type: application/json\r\nContent-Disposition: form-data; name=\"testEntity\"\r\n\r\n{\"id\":\"48d31887-5fad-4d73-a9f5-3c356e68a038\",\"birthDay\":\"2017-09-04\",\"workDuration\":\"P1M\",\"startWorkTime\":\"08:00:00\",\"deviceNames\":[\"device1\",\"device2\"],\"mobilePhone\":null,\"jobTitle\":\"Author\",\"createdDateTime\":\"-999999999-01-01T00:00:00+18:00\",\"otherPhones\":[[\"device1\",\"device2\"]],\"accountEnabled\":false}\r\n--" + multipartBody.getBoundary() + "--", strResult); + } + } } } } From 23b112c61687bc027b6669e64ab97589bf777962 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 26 Jul 2023 09:18:13 -0400 Subject: [PATCH 05/11] - aligns android plugin version --- components/serialization/multipart/android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/serialization/multipart/android/build.gradle b/components/serialization/multipart/android/build.gradle index 1b1c84099..29f8f6f8c 100644 --- a/components/serialization/multipart/android/build.gradle +++ b/components/serialization/multipart/android/build.gradle @@ -10,7 +10,7 @@ buildscript { dependencies { classpath "com.gradle:gradle-enterprise-gradle-plugin:3.14" - classpath "com.android.tools.build:gradle:8.0.2" + classpath "com.android.tools.build:gradle:8.1.0" classpath "com.github.ben-manes:gradle-versions-plugin:0.47.0" } } From 496755179034e269e45c950bbd992a792958a07b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 26 Jul 2023 09:48:52 -0400 Subject: [PATCH 06/11] - fixes android compatibility --- .../com/microsoft/kiota/MultipartBody.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java index 40f2838de..32ccc6cdf 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java @@ -1,5 +1,8 @@ package com.microsoft.kiota; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; @@ -132,9 +135,7 @@ public void serialize(@Nonnull final SerializationWriter writer) { try (final InputStream partContent = partWriter.getSerializedContent()) { if (partContent.markSupported()) partContent.reset(); - final byte[] bytes = partContent.readAllBytes(); - if (bytes != null) - writer.writeByteArrayValue("", bytes); + writer.writeByteArrayValue("", readAllBytes(partContent)); } } } else if (objectValue instanceof String) { @@ -143,9 +144,7 @@ public void serialize(@Nonnull final SerializationWriter writer) { final InputStream inputStream = (InputStream)objectValue; if (inputStream.markSupported()) inputStream.reset(); - final byte[] bytes = inputStream.readAllBytes(); - if (bytes != null) - writer.writeByteArrayValue("", bytes); + writer.writeByteArrayValue("", readAllBytes(inputStream)); } else if (objectValue instanceof byte[]) { writer.writeByteArrayValue("", (byte[])objectValue); } else { @@ -158,5 +157,16 @@ public void serialize(@Nonnull final SerializationWriter writer) { writer.writeStringValue("", ""); writer.writeStringValue("", "--" + boundary + "--"); } - + @Nonnull + private byte[] readAllBytes(@Nonnull final InputStream inputStream) throws IOException { + // InputStream.readAllBytes() is only available to Android API level 33+ + final int bufLen = 1024; + byte[] buf = new byte[bufLen]; + int readLen; + try(final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + while ((readLen = inputStream.read(buf, 0, bufLen)) != -1) + outputStream.write(buf, 0, readLen); + return outputStream.toByteArray(); + } + } } From 2bf20ca8a228d92d90102caea85d491993232e1f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 26 Jul 2023 10:34:01 -0400 Subject: [PATCH 07/11] - adds tests for multipart body Signed-off-by: Vincent Biret --- .../com/microsoft/kiota/MultipartBody.java | 8 +-- .../microsoft/kiota/MultiPartBodyTest.java | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java index 32ccc6cdf..2629a4460 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java @@ -68,17 +68,13 @@ private String normalizePartName(@Nonnull final String original) * @return the content type of the part with the specified name. */ @Nullable - public Object getPartValue(@Nonnull final String partName) + public Map.Entry getPartValue(@Nonnull final String partName) { Objects.requireNonNull(partName); if (partName.isBlank() || partName.isEmpty()) throw new IllegalArgumentException("partName cannot be blank or empty"); final String normalizedName = normalizePartName(partName); - final Object candidate = parts.get(normalizedName); - if(candidate == null) - return null; - - return candidate; + return parts.get(normalizedName); } /** * Gets the content type of the part with the specified name. diff --git a/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java b/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java new file mode 100644 index 000000000..c6d0537fb --- /dev/null +++ b/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java @@ -0,0 +1,62 @@ +package com.microsoft.kiota; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.microsoft.kiota.serialization.SerializationWriter; + +class MultiPartBodyTest { + @Test + void defensive() { + final MultipartBody multipartBody = new MultipartBody(); + assertThrows(NullPointerException.class, () -> multipartBody.addOrReplacePart(null, "foo", "bar")); + assertThrows(NullPointerException.class, () -> multipartBody.addOrReplacePart("foo", null, "bar")); + assertThrows(NullPointerException.class, () -> multipartBody.addOrReplacePart("foo", "bar", null)); + assertThrows(NullPointerException.class, () -> multipartBody.getPartValue(null)); + assertThrows(NullPointerException.class, () -> multipartBody.removePart(null)); + assertThrows(NullPointerException.class, () -> multipartBody.serialize(null)); + assertThrows(UnsupportedOperationException.class, () -> multipartBody.getFieldDeserializers()); + } + @Test + void requiresRequestAdapter(){ + final MultipartBody multipartBody = new MultipartBody(); + final SerializationWriter writer = mock(SerializationWriter.class); + assertThrows(IllegalStateException.class, () -> multipartBody.serialize(writer)); + } + @Test + void requiresPartsForSerialization() { + final MultipartBody multipartBody = new MultipartBody(); + final SerializationWriter writer = mock(SerializationWriter.class); + final RequestAdapter requestAdapter = mock(RequestAdapter.class); + multipartBody.requestAdapter = requestAdapter; + assertThrows(IllegalStateException.class, () -> multipartBody.serialize(writer)); + } + @Test + void addsPart() { + final MultipartBody multipartBody = new MultipartBody(); + final RequestAdapter requestAdapter = mock(RequestAdapter.class); + multipartBody.requestAdapter = requestAdapter; + multipartBody.addOrReplacePart("foo", "bar", "baz"); + final Map.Entry result = multipartBody.getPartValue("foo"); + assertNotNull(result); + assertTrue(result.getValue() instanceof String); + } + @Test + void removesPart() { + final MultipartBody multipartBody = new MultipartBody(); + final RequestAdapter requestAdapter = mock(RequestAdapter.class); + multipartBody.requestAdapter = requestAdapter; + multipartBody.addOrReplacePart("foo", "bar", "baz"); + multipartBody.removePart("FOO"); + final Map.Entry result = multipartBody.getPartValue("foo"); + assertNull(result); + } + // serialize method is being tested in the serialization library +} From 118a8aba7e4d9bc52588253c6552983d80732a90 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 26 Jul 2023 10:37:01 -0400 Subject: [PATCH 08/11] - bumps version for multipart body Signed-off-by: Vincent Biret --- CHANGELOG.md | 6 ++++++ .../http/middleware/options/UserAgentHandlerOption.java | 2 +- gradle.properties | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1672993..d2071ecd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +## [0.5.0] - 2023-07-26 + +### Added + +- Added support for multipart form data request bodies. + ## [0.4.7] - 2023-07-21 ### Added diff --git a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java index 3c85b0924..9f0163ea9 100644 --- a/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java +++ b/components/http/okHttp/src/main/java/com/microsoft/kiota/http/middleware/options/UserAgentHandlerOption.java @@ -14,7 +14,7 @@ public UserAgentHandlerOption() { } @Nonnull private String productName = "kiota-java"; @Nonnull - private String productVersion = "0.2.0"; + private String productVersion = "0.5.0"; /** * Gets the product name to be used in the user agent header * @return the product name diff --git a/gradle.properties b/gradle.properties index 6a1346055..be6ba7aef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,8 +25,8 @@ org.gradle.caching=true mavenGroupId = com.microsoft.kiota mavenMajorVersion = 0 -mavenMinorVersion = 4 -mavenPatchVersion = 7 +mavenMinorVersion = 5 +mavenPatchVersion = 0 mavenArtifactSuffix = #These values are used to run functional tests From 54a4e2f1bb18a7eea9433ea76e13b8cb44652995 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 26 Jul 2023 10:47:05 -0400 Subject: [PATCH 09/11] - fixes get part implementation --- .../src/main/java/com/microsoft/kiota/MultipartBody.java | 7 +++++-- .../test/java/com/microsoft/kiota/MultiPartBodyTest.java | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java index 2629a4460..99e7b85ce 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java @@ -68,13 +68,16 @@ private String normalizePartName(@Nonnull final String original) * @return the content type of the part with the specified name. */ @Nullable - public Map.Entry getPartValue(@Nonnull final String partName) + public Object getPartValue(@Nonnull final String partName) { Objects.requireNonNull(partName); if (partName.isBlank() || partName.isEmpty()) throw new IllegalArgumentException("partName cannot be blank or empty"); final String normalizedName = normalizePartName(partName); - return parts.get(normalizedName); + final Map.Entry candidate = parts.get(normalizedName); + if(candidate == null) + return null; + return candidate.getValue(); } /** * Gets the content type of the part with the specified name. diff --git a/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java b/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java index c6d0537fb..325c3fe40 100644 --- a/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java +++ b/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java @@ -44,9 +44,9 @@ void addsPart() { final RequestAdapter requestAdapter = mock(RequestAdapter.class); multipartBody.requestAdapter = requestAdapter; multipartBody.addOrReplacePart("foo", "bar", "baz"); - final Map.Entry result = multipartBody.getPartValue("foo"); + final Object result = multipartBody.getPartValue("foo"); assertNotNull(result); - assertTrue(result.getValue() instanceof String); + assertTrue(result instanceof String); } @Test void removesPart() { @@ -55,7 +55,7 @@ void removesPart() { multipartBody.requestAdapter = requestAdapter; multipartBody.addOrReplacePart("foo", "bar", "baz"); multipartBody.removePart("FOO"); - final Map.Entry result = multipartBody.getPartValue("foo"); + final Object result = multipartBody.getPartValue("foo"); assertNull(result); } // serialize method is being tested in the serialization library From 87a00d4c0452638194716d421477dc97d234575e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Jul 2023 08:35:32 -0400 Subject: [PATCH 10/11] - uses string is null or empty for defensive programing in multipart body Signed-off-by: Vincent Biret --- .../java/com/microsoft/kiota/MultipartBody.java | 13 +++++-------- .../java/com/microsoft/kiota/MultiPartBodyTest.java | 10 ++++------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java index 99e7b85ce..984c8eaae 100644 --- a/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java +++ b/components/abstractions/src/main/java/com/microsoft/kiota/MultipartBody.java @@ -15,6 +15,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import com.google.common.base.Strings; import com.microsoft.kiota.serialization.Parsable; import com.microsoft.kiota.serialization.ParseNode; import com.microsoft.kiota.serialization.SerializationWriter; @@ -44,12 +45,10 @@ public String getBoundary() { * @param value the value of the part to add or replace. */ public void addOrReplacePart(@Nonnull final String name, @Nonnull final String contentType, @Nonnull final T value) { - Objects.requireNonNull(name); - Objects.requireNonNull(contentType); Objects.requireNonNull(value); - if (contentType.isBlank() || contentType.isEmpty()) + if (Strings.isNullOrEmpty(contentType) || contentType.isBlank()) throw new IllegalArgumentException("contentType cannot be blank or empty"); - if (name.isBlank() || name.isEmpty()) + if (Strings.isNullOrEmpty(name) || name.isBlank()) throw new IllegalArgumentException("name cannot be blank or empty"); final String normalizedName = normalizePartName(name); @@ -70,8 +69,7 @@ private String normalizePartName(@Nonnull final String original) @Nullable public Object getPartValue(@Nonnull final String partName) { - Objects.requireNonNull(partName); - if (partName.isBlank() || partName.isEmpty()) + if (Strings.isNullOrEmpty(partName) || partName.isBlank()) throw new IllegalArgumentException("partName cannot be blank or empty"); final String normalizedName = normalizePartName(partName); final Map.Entry candidate = parts.get(normalizedName); @@ -86,8 +84,7 @@ public Object getPartValue(@Nonnull final String partName) */ public boolean removePart(@Nonnull final String partName) { - Objects.requireNonNull(partName); - if (partName.isBlank() || partName.isEmpty()) + if (Strings.isNullOrEmpty(partName) || partName.isBlank()) throw new IllegalArgumentException("partName cannot be blank or empty"); final String normalizedName = normalizePartName(partName); final Object candidate = parts.remove(normalizedName); diff --git a/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java b/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java index 325c3fe40..5679cb2b9 100644 --- a/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java +++ b/components/abstractions/src/test/java/com/microsoft/kiota/MultiPartBodyTest.java @@ -6,8 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; -import java.util.Map; - import org.junit.jupiter.api.Test; import com.microsoft.kiota.serialization.SerializationWriter; @@ -16,11 +14,11 @@ class MultiPartBodyTest { @Test void defensive() { final MultipartBody multipartBody = new MultipartBody(); - assertThrows(NullPointerException.class, () -> multipartBody.addOrReplacePart(null, "foo", "bar")); - assertThrows(NullPointerException.class, () -> multipartBody.addOrReplacePart("foo", null, "bar")); + assertThrows(IllegalArgumentException.class, () -> multipartBody.addOrReplacePart(null, "foo", "bar")); + assertThrows(IllegalArgumentException.class, () -> multipartBody.addOrReplacePart("foo", null, "bar")); assertThrows(NullPointerException.class, () -> multipartBody.addOrReplacePart("foo", "bar", null)); - assertThrows(NullPointerException.class, () -> multipartBody.getPartValue(null)); - assertThrows(NullPointerException.class, () -> multipartBody.removePart(null)); + assertThrows(IllegalArgumentException.class, () -> multipartBody.getPartValue(null)); + assertThrows(IllegalArgumentException.class, () -> multipartBody.removePart(null)); assertThrows(NullPointerException.class, () -> multipartBody.serialize(null)); assertThrows(UnsupportedOperationException.class, () -> multipartBody.getFieldDeserializers()); } From b14438ea2aa51503173a87ecbeb463a7cee4264d Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 1 Aug 2023 11:31:54 -0400 Subject: [PATCH 11/11] - bumps guava to align with latest main changes --- components/serialization/multipart/gradle/dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/serialization/multipart/gradle/dependencies.gradle b/components/serialization/multipart/gradle/dependencies.gradle index e685e4533..6ef8cf374 100644 --- a/components/serialization/multipart/gradle/dependencies.gradle +++ b/components/serialization/multipart/gradle/dependencies.gradle @@ -7,7 +7,7 @@ dependencies { testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' // This dependency is used internally, and not exposed to consumers on their own compile classpath. - implementation 'com.google.guava:guava:32.1.1-jre' + implementation 'com.google.guava:guava:32.1.2-jre' api project(':components:abstractions') testImplementation project(':components:serialization:json')