diff --git a/clients/sellingpartner-api-documents-helper-java/.gitignore b/clients/sellingpartner-api-documents-helper-java/.gitignore new file mode 100644 index 0000000..e3800fd --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/.gitignore @@ -0,0 +1,22 @@ +.idea +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# exclude jar for gradle wrapper +!gradle/wrapper/*.jar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# build files +**/target +target +.gradle +build \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/README.md b/clients/sellingpartner-api-documents-helper-java/README.md new file mode 100644 index 0000000..2637fc6 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/README.md @@ -0,0 +1,207 @@ +# Selling Partner API Documents Helper + +This library provides helper classes that you can use to work with encrypted documents. Specifically, they help with: +* Encrypting and uploading a document +* Downloading an encrypted document and reading its decrypted contents + +Note: It’s the developer’s responsibility to always maintain encryption at rest. Unencrypted document content should never be stored on disk, even temporarily, because documents can contain sensitive information. +The helper classes provided in this library are built to assist with this. + +## Example usage + +### Upload + +```java +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; + +import com.amazon.spapi.documents.UploadHelper; +import com.amazon.spapi.documents.UploadSpecification; +import com.amazon.spapi.documents.exception.CryptoException; +import com.amazon.spapi.documents.exception.HttpResponseException; +import com.amazon.spapi.documents.impl.AESCryptoStreamFactory; + +/* We want to maintain encryption at rest, so do not write unencrypted data to disk. This is bad: + InputStream source = new FileInputStream(new File("/path/to/data.txt")); + + Instead, if your data can fit in memory, you can create an InputStream from a String (see encryptAndUpload_fromString()). + Otherwise, you can pipe data into an InputStream using Piped streams (see encryptAndUpload_fromPipedInputStream()). +*/ +public class UploadExample { + private final UploadHelper uploadHelper = new UploadHelper.Builder().build(); + + public void encryptAndUpload_fromString(String key, String initializationVector, String url) { + AESCryptoStreamFactory aesCryptoStreamFactory = + new AESCryptoStreamFactory.Builder(key, initializationVector) + .build(); + + String contentType = String.format("text/plain; charset=%s", StandardCharsets.UTF_8); + + // The character set must be the same one that is specified in contentType. + try (InputStream source = new ByteArrayInputStream("my document contents".getBytes(StandardCharsets.UTF_8))) { + UploadSpecification uploadSpec = + new UploadSpecification.Builder(contentType, aesCryptoStreamFactory, source, url) + .build(); + + uploadHelper.upload(uploadSpec); + } catch (CryptoException | HttpResponseException | IOException e) { + // Handle exception. + } + } + public void encryptAndUpload_fromPipedInputStream(String key, String initializationVector, String url) { + AESCryptoStreamFactory aesCryptoStreamFactory = + new AESCryptoStreamFactory.Builder(key, initializationVector) + .build(); + + String contentType = String.format("text/plain; charset=%s", StandardCharsets.UTF_8); + + try (PipedInputStream source = new PipedInputStream()) { + new Thread ( + new Runnable() { + public void run() { + try (PipedOutputStream documentContents = new PipedOutputStream(source)) { + // The character set must be the same one that is specified in contentType. + documentContents.write("my document contents\n".getBytes(StandardCharsets.UTF_8)); + documentContents.write("more document contents".getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + // Handle exception. + } + } + } + ).start(); + + UploadSpecification uploadSpec = + new UploadSpecification.Builder(contentType, aesCryptoStreamFactory, source, url) + .build(); + + uploadHelper.upload(uploadSpec); + } catch (CryptoException | HttpResponseException | IOException e) { + // Handle exception. + } + } +} +``` + +### Download + +```java +import java.io.BufferedReader; +import java.io.IOException; + +import com.amazon.spapi.documents.CompressionAlgorithm; +import com.amazon.spapi.documents.DownloadBundle; +import com.amazon.spapi.documents.DownloadHelper; +import com.amazon.spapi.documents.DownloadSpecification; +import com.amazon.spapi.documents.exception.CryptoException; +import com.amazon.spapi.documents.exception.HttpResponseException; +import com.amazon.spapi.documents.exception.MissingCharsetException; +import com.amazon.spapi.documents.impl.AESCryptoStreamFactory; + +public class DownloadExample { + final DownloadHelper downloadHelper = new DownloadHelper.Builder().build(); + + public void downloadAndDecrypt(String key, String initializationVector, String url, String compressionAlgorithm) { + AESCryptoStreamFactory aesCryptoStreamFactory = + new AESCryptoStreamFactory.Builder(key, initializationVector).build(); + + DownloadSpecification downloadSpec = new DownloadSpecification.Builder(aesCryptoStreamFactory, url) + .withCompressionAlgorithm(CompressionAlgorithm.fromEquivalent(compressionAlgorithm)) + .build(); + + try (DownloadBundle downloadBundle = downloadHelper.download(downloadSpec)) { + // This example assumes that the downloaded document has a charset in the content type, e.g. + // text/plain; charset=UTF-8 + try (BufferedReader reader = downloadBundle.newBufferedReader()) { + String line; + do { + line = reader.readLine(); + // Process the decrypted line. + } while (line != null); + } + } catch (CryptoException | HttpResponseException | IOException | MissingCharsetException e) { + // Handle exception here. + } + } +} +``` + +## Requirements + +Building the Selling Partner API Documents Helper requires: +1. Java 1.8+ +2. Maven/Gradle + + +## Installation + +To install the Selling Partner API Documents Helper to your local Maven repository, simply execute: + +```shell +mvn clean install +``` + +To deploy it to a remote Maven repository instead, configure the settings of the repository and execute: + +```shell +mvn clean deploy +``` + +Refer to the [OSSRH Guide](http://central.sonatype.org/pages/ossrh-guide.html) for more information. + +### Maven users + +Add this dependency to your project's POM: + +```xml + + com.amazon.sellingpartnerapi + sellingpartner-api-documents-helper-java + 1.0.0 + +``` + +### Gradle users + +Add this dependency to your project's build file: + +```groovy +implementation "com.amazon.sellingpartnerapi:sellingpartner-api-documents-helper-java:1.0.0" +``` + +### Others + +At first generate the JAR by executing: + +```shell +mvn clean package +``` + +Then manually install the following JARs: + +* `target/sellingpartner-api-documents-helper-java.jar` +* `target/lib/*.jar` + +## License +Swagger Codegen templates are subject to the [Swagger Codegen License](https://github.com/swagger-api/swagger-codegen#license). + +All other work licensed as follows: + +Copyright Amazon.com Inc. or its affiliates. + +All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this library except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/clients/sellingpartner-api-documents-helper-java/build.gradle b/clients/sellingpartner-api-documents-helper-java/build.gradle new file mode 100644 index 0000000..4f341f9 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/build.gradle @@ -0,0 +1,73 @@ +plugins { + id 'idea' + id 'eclipse' + id 'java' +} + +group = 'com.amazon.sellingpartnerapi' +version = '1.0.0' + + +repositories { + jcenter() + mavenCentral() +} + +sourceCompatibility = JavaVersion.VERSION_1_8 +targetCompatibility = JavaVersion.VERSION_1_8 + + +/*sourceSets { + main { + java { + srcDirs = ['src/main/java'] + } + resources { + srcDirs = ['src/main/resources'] + } + } + + test { + java { + srcDirs = ['src/test/java'] + } + resources { + srcDirs = ['src/test/resources'] + } + } +}*/ + +task execute(type: JavaExec) { + main = System.getProperty('mainClass') + classpath = sourceSets.main.runtimeClasspath +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} + +dependencies { + testImplementation(platform('org.junit:junit-bom:5.7.0')) + testImplementation('org.junit.jupiter:junit-jupiter') + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.7.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.7.0") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.7.0") + + implementation 'com.squareup.okhttp:okhttp:2.7.5' + implementation 'com.google.guava:guava:28.2-jre' + + // https://mvnrepository.com/artifact/org.threeten/threetenbp + implementation group: 'org.threeten', name: 'threetenbp', version: '1.3.5' + + // https://mvnrepository.com/artifact/junit/junit + implementation 'org.junit.jupiter:junit-jupiter-migrationsupport:5.5.1' + + implementation 'org.mockito:mockito-core:3.0.0' + implementation 'org.mockito:mockito-inline:3.0.0' + + implementation 'org.apache.directory.studio:org.apache.commons.io:2.4' + +} diff --git a/clients/sellingpartner-api-documents-helper-java/gradle/wrapper/gradle-wrapper.jar b/clients/sellingpartner-api-documents-helper-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/clients/sellingpartner-api-documents-helper-java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/clients/sellingpartner-api-documents-helper-java/gradle/wrapper/gradle-wrapper.properties b/clients/sellingpartner-api-documents-helper-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3ac035e --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Sep 23 13:37:56 CST 2020 +distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/clients/sellingpartner-api-documents-helper-java/pom.xml b/clients/sellingpartner-api-documents-helper-java/pom.xml new file mode 100644 index 0000000..1904804 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + com.amazon.sellingpartnerapi + sellingpartner-api-documents-helper-java + 1.0.0 + + + com.squareup.okhttp + okhttp + 2.7.5 + + + com.squareup.okhttp + logging-interceptor + 2.7.5 + + + io.gsonfire + gson-fire + 1.8.0 + + + org.apache.directory.studio + org.apache.commons.io + 2.4 + + + com.google.guava + guava + 28.2-jre + + + org.threeten + threetenbp + 1.3.5 + + + + org.junit.platform + junit-platform-commons + 1.7.0 + + + org.junit.jupiter + junit-jupiter-engine + 5.0.0 + + + org.junit.jupiter + junit-jupiter-params + 5.3.2 + + + org.junit.jupiter + junit-jupiter-migrationsupport + 5.5.1 + + + org.mockito + mockito-core + 3.0.0 + + + org.mockito + mockito-inline + 3.0.0 + + + + + + maven-surefire-plugin + 2.22.2 + + + maven-failsafe-plugin + 2.22.2 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + + + + + 1.8 + ${java.version} + ${java.version} + 1.0.0 + UTF-8 + + diff --git a/clients/sellingpartner-api-documents-helper-java/settings.gradle b/clients/sellingpartner-api-documents-helper-java/settings.gradle new file mode 100644 index 0000000..07ac347 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "sellingpartner-api-documents-helper-java" \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/CompressionAlgorithm.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/CompressionAlgorithm.java new file mode 100644 index 0000000..a107d2d --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/CompressionAlgorithm.java @@ -0,0 +1,36 @@ +package com.amazon.spapi.documents; + +/** + * The compression algorithm. + */ +public enum CompressionAlgorithm { + GZIP; + + /** + * Convert from any equivalent enum value. If the specified enum value is null, return null. + * + * @param val The equivalent enum value to convert + * @return This enum's equivalent to the specified enum value + */ + public static CompressionAlgorithm fromEquivalent(T val) { + if (val != null) { + return CompressionAlgorithm.valueOf(val.toString()); + } + + return null; + } + + /** + * Convert from a string. If the specified string is null, return null. + * + * @param val The value to convert. + * @return This enum's equivalent to the specified string + */ + public static CompressionAlgorithm fromEquivalent(String val) { + if (val != null) { + return CompressionAlgorithm.valueOf(val); + } + + return null; + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/CryptoStreamFactory.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/CryptoStreamFactory.java new file mode 100644 index 0000000..f8ecaca --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/CryptoStreamFactory.java @@ -0,0 +1,29 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.exception.CryptoException; + +import java.io.InputStream; + +/** + * Crypto stream factory interface. + */ +public interface CryptoStreamFactory { + /** + * Create a new {@link InputStream} that decrypts a stream of encrypted data. + * + * @param source The source for encrypted data to decrypt + * @return A new {@link InputStream} from which decrypted data can be read + * @throws CryptoException Crypto exception + */ + InputStream newDecryptStream(InputStream source) throws CryptoException; + + /** + * Create a new {@link InputStream} that encrypts a stream of unencrypted data. + * + * @param source The source for unencrypted data to encrypt + * @return A new {@link InputStream} from which encrypted data can be read + * @throws CryptoException Crypto exception + */ + InputStream newEncryptStream(InputStream source) throws CryptoException; + +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadBundle.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadBundle.java new file mode 100644 index 0000000..028f908 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadBundle.java @@ -0,0 +1,153 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.exception.CryptoException; +import com.amazon.spapi.documents.exception.MissingCharsetException; +import com.squareup.okhttp.MediaType; +import org.apache.commons.io.IOUtils; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.util.zip.GZIPInputStream; + +/** + * Helper that contains and provides access to the downloaded contents of an encrypted document. {@link #close()} must + * be called to delete the temporary file that contains the encrypted document contents. + * + * Multiple independent streams and readers (from which unencrypted data can be read while maintaining encryption at + * rest on the filesystem) can be opened from an instance of {@link DownloadBundle}, but once {@link #close()} is + * called the behavior of any open streams or readers from this instance will be unspecified as the underlying + * temporary file will be deleted. + */ +public class DownloadBundle implements AutoCloseable { + private final CompressionAlgorithm compressionAlgorithm; + private final String contentType; + private final CryptoStreamFactory cryptoStreamFactory; + private final File document; + + DownloadBundle(CompressionAlgorithm compressionAlgorithm, String contentType, + CryptoStreamFactory cryptoStreamFactory, File document) { + this.compressionAlgorithm = compressionAlgorithm; + this.contentType = contentType; + this.cryptoStreamFactory = cryptoStreamFactory; + this.document = document; + } + + /** + * The content type of the unencrypted document contents. + * + * @return The content type + */ + public String getContentType() { + return contentType; + } + + /** + * Open an {@link InputStream} that allows the caller to read the decompressed (if applicable) and decrypted + * contents of the downloaded document. It is the responsibility of the caller to close the returned + * {@link InputStream}. The {@link InputStream}'s operation will be unspecified once this {@link DownloadBundle} is + * closed. + * + * @return An {@link InputStream} that decompresses and decrypts the document's contents + * @throws CryptoException Crypto exception + * @throws IOException IO exception + */ + public InputStream newInputStream() throws CryptoException, IOException { + Closeable closeThis = null; + try { + InputStream inputStream = new FileInputStream(document); + closeThis = inputStream; + + inputStream = cryptoStreamFactory.newDecryptStream(inputStream); + closeThis = inputStream; + + if (compressionAlgorithm != null) { + switch (compressionAlgorithm) { + case GZIP: + inputStream = new GZIPInputStream(inputStream); + closeThis = inputStream; + } + } + + closeThis = null; + return inputStream; + } finally { + IOUtils.closeQuietly(closeThis); + } + } + + /** + * Open a {@link BufferedReader} that allows the caller to read the decompressed (if applicable) and decrypted + * contents of the downloaded document. The character set is parsed from {@link #getContentType()}. If the character + * set could not be parsed, this method will fail with {@link MissingCharsetException}. + * + * It is the responsibility of the caller to close the returned {@link BufferedReader}. This {@link BufferedReader} + * will become invalid once this {@link DownloadBundle} is closed. + * + * @return A {@link BufferedReader} that decompresses and decrypts the document's contents + * @throws CryptoException Crypto exception + * @throws IOException IO exception + * @throws MissingCharsetException The character set could not be parsed from {@link #getContentType()} + */ + public BufferedReader newBufferedReader() throws CryptoException, IOException, MissingCharsetException { + return newBufferedReader(null); + } + + /** + * Open a {@link BufferedReader} that allows the caller to read the decompressed (if applicable) and decrypted + * contents of the downloaded document. The character set is parsed from {@link #getContentType()}. If the character + * set could not be parsed and defaultCharset is specified, this method will attempt to open the reader + * with defaultCharset. Otherwise, this method will fail with {@link MissingCharsetException}. + * + * It is the responsibility of the caller to close the returned {@link BufferedReader}. This {@link BufferedReader} + * will become invalid once this {@link DownloadBundle} is closed. + * + * @param defaultCharset The default charset to use if a charset cannot be parsed from the content type. + * @return A {@link BufferedReader} that decompresses and decrypts the document's contents + * @throws CryptoException Crypto exception + * @throws IOException IO exception + * @throws MissingCharsetException The character set could not be parsed from {@link #getContentType()} and + * defaultCharset was not specified. + */ + public BufferedReader newBufferedReader(Charset defaultCharset) throws CryptoException, IOException, + MissingCharsetException { + String contentType = getContentType(); + + Charset charset = MediaType.parse(contentType).charset(); + if (charset == null) { + charset = defaultCharset; + } + + if (charset == null) { + throw new MissingCharsetException(String.format( + "Could not parse character set from content type '%s' and no default provided", contentType)); + } + + Closeable closeThis = null; + try { + InputStream inputStream = newInputStream(); + closeThis = inputStream; + + InputStreamReader reader = new InputStreamReader(inputStream, charset); + closeThis = reader; + + BufferedReader bufferedReader = new BufferedReader(reader); + closeThis = null; + return bufferedReader; + } finally { + IOUtils.closeQuietly(closeThis); + } + } + + /** + * Closes this {@link DownloadBundle}, deleting the temporary file containing the encrypted document contents. + */ + public void close() { + document.delete(); + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadHelper.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadHelper.java new file mode 100644 index 0000000..aaa1436 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadHelper.java @@ -0,0 +1,128 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.exception.HttpResponseException; +import com.amazon.spapi.documents.impl.OkHttpTransferClient; + +import java.io.File; +import java.io.IOException; + +/** + * Helper for downloading encrypted documents. + */ +public class DownloadHelper { + private final HttpTransferClient httpTransferClient; + private final String tmpFilePrefix; + private final String tmpFileSuffix; + private final File tmpFileDirectory; + + private DownloadHelper(HttpTransferClient httpTransferClient, String tmpFilePrefix, String tmpFileSuffix, + File tmpFileDirectory) { + this.httpTransferClient = httpTransferClient; + this.tmpFilePrefix = tmpFilePrefix; + this.tmpFileSuffix = tmpFileSuffix; + this.tmpFileDirectory = tmpFileDirectory; + } + + /** + * Download the specified document's encrypted contents to a temporary file on disk. It is the responsibility of the + * caller to call close on the returned {@link AutoCloseable} {@link DownloadBundle}. + * + * Common reasons for receiving a 403 response include: + *
  • The signed URL has expired + * + * @param spec The specification for the download + * @return The closeable {@link DownloadBundle} + * @throws HttpResponseException On failure HTTP response + * @throws IOException IO Exception + */ + public DownloadBundle download(DownloadSpecification spec) throws HttpResponseException, IOException { + + File tmpFile = File.createTempFile(tmpFilePrefix, tmpFileSuffix, tmpFileDirectory); + + try { + tmpFile.deleteOnExit(); + + String contentType = httpTransferClient.download(spec.getUrl(), tmpFile); + + return new DownloadBundle( + spec.getCompressionAlgorithm(), contentType, spec.getCryptoStreamFactory(), tmpFile); + } catch (Exception e) { + tmpFile.delete(); + throw e; + } + } + + /** + * Use this to create an instance of a {@link DownloadHelper}. + */ + public static class Builder { + private HttpTransferClient httpTransferClient = null; + private String tmpFilePrefix = "SPAPI"; + private String tmpFileSuffix = null; + private File tmpFileDirectory = null; + + /** + * The HTTP transfer client. + * + * @param httpTransferClient The HTTP transfer client + * @return this + */ + public Builder withHttpTransferClient(HttpTransferClient httpTransferClient) { + this.httpTransferClient = httpTransferClient; + return this; + } + + /** + * The tmp file prefix. If not specified, defaults to "SPAPI". + * + * @param tmpFilePrefix The prefix string to be used in generating the tmp file's name; must be at least three + * characters long + * @return this + */ + public Builder withTmpFilePrefix(String tmpFilePrefix) { + if (tmpFilePrefix.length() < 3) { + throw new IllegalArgumentException("Prefix string too short"); + } + + this.tmpFilePrefix = tmpFilePrefix; + return this; + } + + /** + * The tmp file suffix. If not specified, defaults to null. + * + * @param tmpFileSuffix The suffix string to be used in generating the file's name; may be null, in + * which case the suffix ".tmp" will be used + * @return this + */ + public Builder withTmpFileSuffix(String tmpFileSuffix) { + this.tmpFileSuffix = tmpFileSuffix; + return this; + } + + /** + * The tmp file directory. If not specified, defaults to null. + * + * @param tmpFileDirectory The directory in which the file is to be created, or null if the default + * temporary-file directory is to be used + * @return this + */ + public Builder withTmpFileDirectory(File tmpFileDirectory) { + this.tmpFileDirectory = tmpFileDirectory; + return this; + } + + /** + * Create the helper. + * + * @return The helper + */ + public DownloadHelper build() { + if (httpTransferClient == null) { + httpTransferClient = new OkHttpTransferClient.Builder().build(); + } + + return new DownloadHelper(httpTransferClient, tmpFilePrefix, tmpFileSuffix, tmpFileDirectory); + } + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadSpecification.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadSpecification.java new file mode 100644 index 0000000..6b03869 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/DownloadSpecification.java @@ -0,0 +1,76 @@ +package com.amazon.spapi.documents; + +import com.google.common.base.Preconditions; + +/** + * Specification for {@link DownloadHelper#download(DownloadSpecification)}. + */ +public class DownloadSpecification { + private final CompressionAlgorithm compressionAlgorithm; + private final CryptoStreamFactory cryptoStreamFactory; + private final String url; + + private DownloadSpecification(CompressionAlgorithm compressionAlgorithm, CryptoStreamFactory cryptoStreamFactory, + String url) { + this.compressionAlgorithm = compressionAlgorithm; + this.cryptoStreamFactory = cryptoStreamFactory; + this.url = url; + } + + CompressionAlgorithm getCompressionAlgorithm() { + return compressionAlgorithm; + } + + CryptoStreamFactory getCryptoStreamFactory() { + return cryptoStreamFactory; + } + + String getUrl() { + return url; + } + + /** + * Use this to create an instance of a {@link DownloadSpecification}. + */ + public static class Builder { + private final CryptoStreamFactory cryptoStreamFactory; + private final String url; + + private CompressionAlgorithm compressionAlgorithm = null; + + /** + * Create the builder. + * + * @param cryptoStreamFactory The crypto stream factory + * @param url The url to download the encrypted document from + */ + public Builder(CryptoStreamFactory cryptoStreamFactory, String url) { + Preconditions.checkArgument(cryptoStreamFactory != null, "cryptoStreamFactory is required"); + Preconditions.checkArgument(url != null, "url is required"); + + this.cryptoStreamFactory = cryptoStreamFactory; + this.url = url; + } + + /** + * The compression algorithm. + * + * @param compressionAlgorithm The compression algorithm + * @return this + */ + public Builder withCompressionAlgorithm(CompressionAlgorithm compressionAlgorithm) { + this.compressionAlgorithm = compressionAlgorithm; + return this; + } + + /** + * Create the specification. + * + * @return The specification + */ + public DownloadSpecification build() { + return new DownloadSpecification(compressionAlgorithm, cryptoStreamFactory, url); + } + + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/HttpTransferClient.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/HttpTransferClient.java new file mode 100644 index 0000000..ec2dc80 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/HttpTransferClient.java @@ -0,0 +1,34 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.exception.HttpResponseException; + +import java.io.File; +import java.io.IOException; + +/** + * HTTP transfer client. Implementations of this interface must be thread-safe and reusable for multiple requests. + */ +public interface HttpTransferClient { + /** + * Perform an HTTP GET on the specified url, storing the response body to destination. + * + * @param url The url to perform an HTTP GET on + * @param destination The file to write the HTTP GET response body to + * @return The Content-Type header value extracted from the response to the HTTP GET + * @throws HttpResponseException On failure HTTP response + * @throws IOException IO exception + */ + String download(String url, File destination) throws HttpResponseException, IOException; + + /** + * Perform an HTTP PUT on the specified url, uploading the contents of source to the body + * of the request. + * + * @param url The url to perform an HTTP PUT on + * @param contentType The Content-Type header to be used for the HTTP PUT request + * @param source The file to read the HTTP PUT body from + * @throws HttpResponseException On failure HTTP response + * @throws IOException IO exception + */ + void upload(String url, String contentType, File source) throws HttpResponseException, IOException; +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/UploadHelper.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/UploadHelper.java new file mode 100644 index 0000000..553f122 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/UploadHelper.java @@ -0,0 +1,131 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.exception.CryptoException; +import com.amazon.spapi.documents.exception.HttpResponseException; +import com.amazon.spapi.documents.impl.OkHttpTransferClient; +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * Helper class for encrypting and uploading documents. + */ +public class UploadHelper { + private final HttpTransferClient httpTransferClient; + private final String tmpFilePrefix; + private final String tmpFileSuffix; + private final File tmpFileDirectory; + + private UploadHelper(HttpTransferClient httpTransferClient, String tmpFilePrefix, String tmpFileSuffix, + File tmpFileDirectory) { + this.httpTransferClient = httpTransferClient; + this.tmpFilePrefix = tmpFilePrefix; + this.tmpFileSuffix = tmpFileSuffix; + this.tmpFileDirectory = tmpFileDirectory; + } + + /** + * Perform the specified upload. This method will buffer the encrypted contents of the document in a temporary file + * before uploading to the specified url. + * + * Common reasons for receiving a 403 response include: + *
  • The signed URL has expired + *
  • {@link UploadSpecification#getContentType()} does not match the content type the URL was signed with + * + * @param spec The specification for the upload + * @throws CryptoException Crypto exception + * @throws HttpResponseException On failure HTTP response + * @throws IOException IO exception + */ + public void upload(UploadSpecification spec) throws CryptoException, HttpResponseException, IOException { + File tmpFile = File.createTempFile(tmpFilePrefix, tmpFileSuffix, tmpFileDirectory); + + try { + tmpFile.deleteOnExit(); + + try (InputStream inputStream = spec.getCryptoStreamFactory().newEncryptStream(spec.getSource())) { + FileUtils.copyInputStreamToFile(inputStream, tmpFile); + } + + httpTransferClient.upload(spec.getUrl(), spec.getContentType(), tmpFile); + } finally { + tmpFile.delete(); + } + } + + /** + * Use this to create an instance of an {@link UploadHelper}. + */ + public static class Builder { + private HttpTransferClient httpTransferClient = null; + private String tmpFilePrefix = "SPAPI"; + private String tmpFileSuffix = null; + private File tmpFileDirectory = null; + + /** + * The HTTP transfer client. + * + * @param httpTransferClient The HTTP transfer client. + * @return this + */ + public Builder withHttpTransferClient(HttpTransferClient httpTransferClient) { + this.httpTransferClient = httpTransferClient; + return this; + } + + /** + * The tmp file prefix. If not specified, defaults to "SPAPI". + * + * @param tmpFilePrefix The prefix string to be used in generating the tmp file's name; must be at least three + * characters long + * @return this + */ + public Builder withTmpFilePrefix(String tmpFilePrefix) { + if (tmpFilePrefix.length() < 3) { + throw new IllegalArgumentException("Prefix string too short"); + } + + this.tmpFilePrefix = tmpFilePrefix; + return this; + } + + /** + * The tmp file suffix. If not specified, defaults to null. + * + * @param tmpFileSuffix The suffix string to be used in generating the file's name; may be null, in + * which case the suffix ".tmp" will be used + * @return this + */ + public Builder withTmpFileSuffix(String tmpFileSuffix) { + this.tmpFileSuffix = tmpFileSuffix; + return this; + } + + /** + * The tmp file directory. If not specified, defaults to null. + * + * @param tmpFileDirectory The directory in which the file is to be created, or null if the default + * temporary-file directory is to be used + * @return this + */ + public Builder withTmpFileDirectory(File tmpFileDirectory) { + this.tmpFileDirectory = tmpFileDirectory; + return this; + } + + /** + * Create the helper. + * + * @return The helper. + */ + public UploadHelper build() { + if (httpTransferClient == null) { + httpTransferClient = new OkHttpTransferClient.Builder().build(); + } + + return new UploadHelper(httpTransferClient, tmpFilePrefix, tmpFileSuffix, tmpFileDirectory); + } + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/UploadSpecification.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/UploadSpecification.java new file mode 100644 index 0000000..eb93d1f --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/UploadSpecification.java @@ -0,0 +1,79 @@ +package com.amazon.spapi.documents; + +import com.google.common.base.Preconditions; + +import java.io.InputStream; + +/** + * Specification for {@link UploadHelper#upload(UploadSpecification)}. + */ +public class UploadSpecification { + private final String contentType; + private final CryptoStreamFactory cryptoStreamFactory; + private final InputStream source; + private final String url; + + private UploadSpecification(String contentType, CryptoStreamFactory cryptoStreamFactory, InputStream source, + String url) { + this.contentType = contentType; + this.cryptoStreamFactory = cryptoStreamFactory; + this.source = source; + this.url = url; + } + + String getContentType() { + return contentType; + } + + CryptoStreamFactory getCryptoStreamFactory() { + return cryptoStreamFactory; + } + + InputStream getSource() { + return source; + } + + String getUrl() { + return url; + } + + /** + * Use this to create an instance of an {@link UploadSpecification}. + */ + public static class Builder { + private final String contentType; + private final CryptoStreamFactory cryptoStreamFactory; + private final InputStream source; + private final String url; + + /** + * Create the builder. + * + * @param contentType The content type of the document to upload + * @param cryptoStreamFactory The crypto stream factory + * @param source The source of the unencrypted data to upload + * @param url The url + */ + public Builder(String contentType, CryptoStreamFactory cryptoStreamFactory, InputStream source, String url) { + Preconditions.checkArgument(contentType != null, "contentType is required"); + Preconditions.checkArgument(cryptoStreamFactory != null, "cryptoStreamFactory is required"); + Preconditions.checkArgument(source != null, "source is required"); + Preconditions.checkArgument(url != null, "url is required"); + + this.contentType = contentType; + this.cryptoStreamFactory = cryptoStreamFactory; + this.source = source; + this.url = url; + } + + /** + * Create the specification. + * + * @return The specification + */ + public UploadSpecification build() { + return new UploadSpecification(contentType, cryptoStreamFactory, source, url); + } + + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/CryptoException.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/CryptoException.java new file mode 100644 index 0000000..1e36f72 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/CryptoException.java @@ -0,0 +1,13 @@ +package com.amazon.spapi.documents.exception; + +/** + * Crypto exception. + */ +public class CryptoException extends Exception { + /** + * {@inheritDoc} + */ + public CryptoException(Throwable cause) { + super(cause); + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/HttpResponseException.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/HttpResponseException.java new file mode 100644 index 0000000..c749c2c --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/HttpResponseException.java @@ -0,0 +1,63 @@ +package com.amazon.spapi.documents.exception; + +/** + * The details of an HTTP response that indicates failure. + */ +public class HttpResponseException extends Exception { + private final String body; + private final int code; + + /** + * {@inheritDoc} + * + * @param message The {@link Exception} message + * @param body The body + * @param code The HTTP status code + */ + public HttpResponseException(String message, String body, int code) { + super(message); + this.body = body; + this.code = code; + } + + /** + * {@inheritDoc} + * + * @param message The {@link Exception} message + * @param cause The {@link Exception} cause + * @param body The body + * @param code The HTTP status code + */ + public HttpResponseException(String message, Throwable cause, String body, int code) { + super(message, cause); + this.body = body; + this.code = code; + } + + /** + * The body. To ensure that a remote server cannot overwhelm heap memory, the body may have been truncated. + * + * @return The body + */ + public String getBody() { + return body; + } + + /** + * The HTTP status code + * + * @return The HTTP status code + */ + public int getCode() { + return code; + } + + @Override + public String toString() { + return super.toString() + " {code=" + + getCode() + + ", body=" + + getBody() + + '}'; + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/MissingCharsetException.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/MissingCharsetException.java new file mode 100644 index 0000000..693431a --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/exception/MissingCharsetException.java @@ -0,0 +1,13 @@ +package com.amazon.spapi.documents.exception; + +/** + * Missing charset exception. + */ +public class MissingCharsetException extends Exception { + /** + * {@inheritDoc} + */ + public MissingCharsetException(String message) { + super(message); + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/impl/AESCryptoStreamFactory.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/impl/AESCryptoStreamFactory.java new file mode 100644 index 0000000..d6b0fc5 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/impl/AESCryptoStreamFactory.java @@ -0,0 +1,96 @@ +package com.amazon.spapi.documents.impl; + +import com.amazon.spapi.documents.CryptoStreamFactory; +import com.amazon.spapi.documents.exception.CryptoException; +import com.google.common.base.Preconditions; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * A crypto stream factory implementing AES encryption. + */ +public class AESCryptoStreamFactory implements CryptoStreamFactory { + private static final String ENCRYPTION_ALGORITHM = "AES/CBC/PKCS5Padding"; + + private final Key key; + private final byte[] initializationVector; + + private AESCryptoStreamFactory(Key key, byte[] initializationVector) { + this.key = key; + this.initializationVector = initializationVector; + } + + private Cipher createInitializedCipher(int mode) throws CryptoException { + try { + Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM); + cipher.init(mode, key, new IvParameterSpec(initializationVector)); + return cipher; + } catch (InvalidKeyException | InvalidAlgorithmParameterException | NoSuchAlgorithmException | + NoSuchPaddingException e) { + throw new CryptoException(e); + } + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream newDecryptStream(InputStream source) throws CryptoException { + return new CipherInputStream(source, createInitializedCipher(Cipher.DECRYPT_MODE)); + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream newEncryptStream(InputStream source) throws CryptoException { + return new CipherInputStream(source, createInitializedCipher(Cipher.ENCRYPT_MODE)); + } + + /** + * Use this to create an instance of an {@link AESCryptoStreamFactory}. + */ + public static class Builder { + private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder(); + private static final String REQUIRED_KEY_ALGORITHM = "AES"; + + private final String key; + private final String initializationVector; + + /** + * Create the builder. + * + * @param key The key + * @param initializationVector The initialization vector + */ + public Builder(String key, String initializationVector) { + Preconditions.checkArgument(key != null, "key is required"); + Preconditions.checkArgument(initializationVector != null, "initializationVector is required"); + + this.key = key; + this.initializationVector = initializationVector; + } + + /** + * Create the crypto stream factory. + * + * @return the crypto stream factory + */ + public AESCryptoStreamFactory build() { + Key convertedKey = new SecretKeySpec(BASE64_DECODER.decode(key), REQUIRED_KEY_ALGORITHM); + byte[] convertedInitializationVector = BASE64_DECODER.decode(initializationVector); + + return new AESCryptoStreamFactory(convertedKey, convertedInitializationVector); + } + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/impl/OkHttpTransferClient.java b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/impl/OkHttpTransferClient.java new file mode 100644 index 0000000..6d682bd --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/main/java/com/amazon/spapi/documents/impl/OkHttpTransferClient.java @@ -0,0 +1,136 @@ +package com.amazon.spapi.documents.impl; + +import com.amazon.spapi.documents.HttpTransferClient; +import com.amazon.spapi.documents.exception.HttpResponseException; +import com.squareup.okhttp.MediaType; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.RequestBody; +import com.squareup.okhttp.Response; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; + +import java.io.File; +import java.io.IOException; +import java.io.Reader; + +/** + * HTTP transfer client utilizing OkHttp. + */ +public class OkHttpTransferClient implements HttpTransferClient { + private static final String CONTENT_TYPE_HEADER = "Content-Type"; + + private final OkHttpClient client; + private final int maxErrorBodyLen; + + private OkHttpTransferClient(OkHttpClient client, int maxErrorBodyLen) { + this.client = client; + this.maxErrorBodyLen = maxErrorBodyLen; + } + + private HttpResponseException createResponseException(Response response) { + String body = ""; + if (maxErrorBodyLen > 0) { + try (Reader bodyReader = response.body().charStream()) { + char[] buf = new char[maxErrorBodyLen]; + int charsRead = IOUtils.read(bodyReader, buf); + if (charsRead > 0) { + body = new String(buf, 0, charsRead); + } + } catch (Exception e) { + // Ignore any failures reading the body so that the original failure is not lost + } + } + + return new HttpResponseException(response.message(), body, response.code()); + } + + /** + * {@inheritDoc} + */ + @Override + public String download(String url, File destination) throws HttpResponseException, IOException { + Request request = new Request.Builder() + .url(url) + .get() + .build(); + + Response response = client.newCall(request).execute(); + try { + if (!response.isSuccessful()) { + throw createResponseException(response); + } + + FileUtils.copyInputStreamToFile(response.body().byteStream(), destination); + } finally { + IOUtils.closeQuietly(response.body()); + } + + return response.header(CONTENT_TYPE_HEADER); + } + + /** + * {@inheritDoc} + */ + @Override + public void upload(String url, String contentType, File source) throws HttpResponseException, IOException { + Request request = new Request.Builder() + .url(url) + .put(RequestBody.create(MediaType.parse(contentType), source)) + .build(); + + Response response = client.newCall(request).execute(); + try { + if (!response.isSuccessful()) { + throw createResponseException(response); + } + } finally { + IOUtils.closeQuietly(response.body()); + } + } + + /** + * Use this to create an instance of an {@link OkHttpTransferClient}. + */ + public static class Builder { + private OkHttpClient client = null; + private int maxErrorBodyLen = 4096; + + /** + * The {@link OkHttpClient}. If not specified, a new instance of {@link OkHttpClient} will be created using the + * no-arg constructor. + * + * @param client The client + * @return this + */ + public Builder withClient(OkHttpClient client) { + this.client = client; + return this; + } + + /** + * When an HTTP response indicates failure, the maximum number of characters for + * {@link HttpResponseException#getBody()}. Default 4096. + * + * @param maxErrorBodyLen The maximum number of characters to extract from the response body on failure + * @return this + */ + public Builder withMaxErrorBodyLen(int maxErrorBodyLen) { + this.maxErrorBodyLen = maxErrorBodyLen; + return this; + } + + /** + * Create the client. + * + * @return The client + */ + public OkHttpTransferClient build() { + if (client == null) { + client = new OkHttpClient(); + } + + return new OkHttpTransferClient(client, maxErrorBodyLen); + } + } +} diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/CompressionAlgorithmTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/CompressionAlgorithmTest.java new file mode 100644 index 0000000..511581b --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/CompressionAlgorithmTest.java @@ -0,0 +1,46 @@ +package com.amazon.spapi.documents; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CompressionAlgorithmTest { + public enum MyEnum { + GZIP, NOT_GZIP + } + + @Test + public void testFromEquivalent() { + assertEquals(CompressionAlgorithm.GZIP, CompressionAlgorithm.fromEquivalent(MyEnum.GZIP)); + } + + @Test + public void testFromEquivalentNull() { + MyEnum myEnum = null; + assertNull(CompressionAlgorithm.fromEquivalent(myEnum)); + } + + @Test + public void testNotEquivalent() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> CompressionAlgorithm.fromEquivalent(MyEnum.NOT_GZIP)); + } + + @Test + public void testFromString() { + assertEquals(CompressionAlgorithm.GZIP, CompressionAlgorithm.fromEquivalent("GZIP")); + } + + @Test + public void testFromStringNull() { + String val = null; + assertNull(CompressionAlgorithm.fromEquivalent(val)); + } + + @Test + public void testFromStringUnsupportedValue() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> CompressionAlgorithm.fromEquivalent("NOT_GZIP")); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadBundleTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadBundleTest.java new file mode 100644 index 0000000..793207e --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadBundleTest.java @@ -0,0 +1,36 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.impl.AESCryptoStreamFactory; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileNotFoundException; + +import static org.junit.jupiter.api.Assertions.*; + +class DownloadBundleTest { + private static String KEY = "sxx/wImF6BFndqSAz56O6vfiAh8iD9P297DHfFgujec="; + private static String VECTOR = "7S2tn363v0wfCfo1IX2Q1A=="; + + private DownloadBundle createBadFileBundle() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + + File file = File.createTempFile("foo", null, null); + file.delete(); + + return new DownloadBundle(null, contentType, cryptoStreamFactory, file); + } + + @Test + public void testNewInputStreamBadFile() throws Exception { + DownloadBundle downloadBundle = createBadFileBundle(); + assertThrows(FileNotFoundException.class, () -> downloadBundle.newInputStream()); + } + + @Test + public void testNewReaderBadFile() throws Exception { + DownloadBundle downloadBundle = createBadFileBundle(); + assertThrows(FileNotFoundException.class, () -> downloadBundle.newBufferedReader()); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadHelperTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadHelperTest.java new file mode 100644 index 0000000..b9d46d3 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadHelperTest.java @@ -0,0 +1,403 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.exception.MissingCharsetException; +import com.amazon.spapi.documents.impl.AESCryptoStreamFactory; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class DownloadHelperTest { + private static String KEY = "sxx/wImF6BFndqSAz56O6vfiAh8iD9P297DHfFgujec="; + private static String VECTOR = "7S2tn363v0wfCfo1IX2Q1A=="; + + private Answer getTransferAnswer(String contentType, CryptoStreamFactory cryptoStreamFactory, String fileContents, + boolean isGZipped) { + return new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + File file = invocation.getArgument(1); + + byte[] dataToWrite = fileContents.getBytes(StandardCharsets.UTF_8); + + if (isGZipped) { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(dataToWrite.length); + GZIPOutputStream zipStream = new GZIPOutputStream(byteStream); + zipStream.write(dataToWrite); + zipStream.close(); + dataToWrite = byteStream.toByteArray(); + } + + // Write encrypted contents to file to mimic downloading an encrypted file + InputStream source = new ByteArrayInputStream(dataToWrite); + try (InputStream inputStream = cryptoStreamFactory.newEncryptStream(source)) { + FileUtils.copyInputStreamToFile(inputStream, file); + } + + return contentType; + } + }; + } + + @Test + public void testSuccess() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "SPAPI.*\\.tmp$"; + String expectContents = "Hello World"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + ArgumentCaptor fileArg = ArgumentCaptor.forClass(File.class); + Mockito.doReturn(contentType).when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + Mockito.doAnswer(getTransferAnswer(contentType, cryptoStreamFactory, expectContents, false)) + .when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + DownloadHelper helper = new DownloadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url).build(); + + File file; + try (DownloadBundle bundle = helper.download(spec)) { + assertEquals(contentType, bundle.getContentType()); + + file = fileArg.getValue(); + assertTrue(file.exists()); + + assertTrue(file.getName().matches(expectedFileRegex)); + + try (InputStream contents = bundle.newInputStream()) { + assertTrue(IOUtils.contentEquals(contents, new ByteArrayInputStream(expectContents.getBytes( + StandardCharsets.UTF_8)))); + } + } + + assertFalse(file.exists()); + + Mockito.verify(httpTransferClient).download(Mockito.eq(url), Mockito.any()); + } + + @Test + public void testSuccessWithCompression() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "SPAPI.*\\.tmp$"; + String expectContents = "Hello World"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + ArgumentCaptor fileArg = ArgumentCaptor.forClass(File.class); + Mockito.doReturn(contentType).when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + Mockito.doAnswer(getTransferAnswer(contentType, cryptoStreamFactory, expectContents, true)) + .when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + DownloadHelper helper = new DownloadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url) + .withCompressionAlgorithm(CompressionAlgorithm.GZIP) + .build(); + + File file; + try (DownloadBundle bundle = helper.download(spec)) { + assertEquals(contentType, bundle.getContentType()); + + file = fileArg.getValue(); + assertTrue(file.exists()); + + assertTrue(file.getName().matches(expectedFileRegex)); + + try (InputStream contents = bundle.newInputStream()) { + assertTrue(IOUtils.contentEquals(contents, new ByteArrayInputStream(expectContents.getBytes( + StandardCharsets.UTF_8)))); + } + + try (BufferedReader contents = bundle.newBufferedReader()) { + assertTrue(IOUtils.contentEquals(contents, new StringReader(expectContents))); + } + + assertTrue(file.exists()); + } + + assertFalse(file.exists()); + + Mockito.verify(httpTransferClient).download(Mockito.eq(url), Mockito.any()); + } + + @Test + public void testSuccessWithCustomTempFileParams() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "NOTSPAPI.*\\.NOTtmp$"; + String expectContents = "Hello World"; + String expectedFilePath = "spapitmp"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + ArgumentCaptor fileArg = ArgumentCaptor.forClass(File.class); + Mockito.doReturn(contentType).when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + Mockito.doAnswer(getTransferAnswer(contentType, cryptoStreamFactory, expectContents, false)) + .when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + File tmpDir = new File(Paths.get(System.getProperty("java.io.tmpdir"), expectedFilePath) + .toAbsolutePath().toString()); + tmpDir.mkdirs(); + tmpDir.deleteOnExit(); + + DownloadHelper helper = new DownloadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .withTmpFilePrefix("NOTSPAPI") + .withTmpFileSuffix(".NOTtmp") + .withTmpFileDirectory(tmpDir) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url).build(); + + File file; + try (DownloadBundle bundle = helper.download(spec)) { + assertEquals(contentType, bundle.getContentType()); + + file = fileArg.getValue(); + assertTrue(file.exists()); + assertEquals(expectedFilePath, file.getParentFile().getName()); + + assertTrue(file.getName().matches(expectedFileRegex)); + + try (BufferedReader contents = bundle.newBufferedReader()) { + assertTrue(IOUtils.contentEquals(contents, new StringReader(expectContents))); + } + + try (InputStream contents = bundle.newInputStream()) { + assertTrue(IOUtils.contentEquals(contents, new ByteArrayInputStream(expectContents.getBytes( + StandardCharsets.UTF_8)))); + } + + assertTrue(file.exists()); + } + + assertFalse(file.exists()); + + Mockito.verify(httpTransferClient).download(Mockito.eq(url), Mockito.any()); + } + + @Test + public void testReaderBadCharset() throws Exception { + String contentType = "text/xml; charset=NOTSUPPORTED"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "NOTSPAPI.*\\.NOTtmp$"; + String expectContents = "Hello World"; + String expectedFilePath = "spapitmp"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + ArgumentCaptor fileArg = ArgumentCaptor.forClass(File.class); + Mockito.doReturn(contentType).when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + Mockito.doAnswer(getTransferAnswer(contentType, cryptoStreamFactory, expectContents, false)) + .when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + File tmpDir = new File(Paths.get(System.getProperty("java.io.tmpdir"), expectedFilePath) + .toAbsolutePath().toString()); + tmpDir.mkdirs(); + tmpDir.deleteOnExit(); + + DownloadHelper helper = new DownloadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .withTmpFilePrefix("NOTSPAPI") + .withTmpFileSuffix(".NOTtmp") + .withTmpFileDirectory(tmpDir) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url).build(); + + File file; + try (DownloadBundle bundle = helper.download(spec)) { + assertEquals(contentType, bundle.getContentType()); + + file = fileArg.getValue(); + assertTrue(file.exists()); + assertEquals(expectedFilePath, file.getParentFile().getName()); + + assertTrue(file.getName().matches(expectedFileRegex)); + + assertThrows(UnsupportedCharsetException.class, () -> bundle.newBufferedReader()); + + try (InputStream contents = bundle.newInputStream()) { + assertTrue(IOUtils.contentEquals(contents, new ByteArrayInputStream(expectContents.getBytes( + StandardCharsets.UTF_8)))); + } + + assertTrue(file.exists()); + } + + assertFalse(file.exists()); + + Mockito.verify(httpTransferClient).download(Mockito.eq(url), Mockito.any()); + } + + @Test + public void testMissingCharsetNoDefault() throws Exception { + String contentType = "text/xml"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "NOTSPAPI.*\\.NOTtmp$"; + String expectContents = "Hello World"; + String expectedFilePath = "spapitmp"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + ArgumentCaptor fileArg = ArgumentCaptor.forClass(File.class); + Mockito.doReturn(contentType).when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + Mockito.doAnswer(getTransferAnswer(contentType, cryptoStreamFactory, expectContents, false)) + .when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + File tmpDir = new File(Paths.get(System.getProperty("java.io.tmpdir"), expectedFilePath) + .toAbsolutePath().toString()); + tmpDir.mkdirs(); + tmpDir.deleteOnExit(); + + DownloadHelper helper = new DownloadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .withTmpFilePrefix("NOTSPAPI") + .withTmpFileSuffix(".NOTtmp") + .withTmpFileDirectory(tmpDir) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url).build(); + + File file; + try (DownloadBundle bundle = helper.download(spec)) { + assertEquals(contentType, bundle.getContentType()); + + file = fileArg.getValue(); + assertTrue(file.exists()); + assertEquals(expectedFilePath, file.getParentFile().getName()); + + assertTrue(file.getName().matches(expectedFileRegex)); + + assertThrows(MissingCharsetException.class, () -> bundle.newBufferedReader()); + + try (InputStream contents = bundle.newInputStream()) { + assertTrue(IOUtils.contentEquals(contents, new ByteArrayInputStream(expectContents.getBytes( + StandardCharsets.UTF_8)))); + } + + assertTrue(file.exists()); + } + + assertFalse(file.exists()); + + Mockito.verify(httpTransferClient).download(Mockito.eq(url), Mockito.any()); + } + + @Test + public void testSuccessMissingBadCharsetDefaultProvided() throws Exception { + String contentType = "text/xml"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "NOTSPAPI.*\\.NOTtmp$"; + String expectContents = "Hello World"; + String expectedFilePath = "spapitmp"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + ArgumentCaptor fileArg = ArgumentCaptor.forClass(File.class); + Mockito.doReturn(contentType).when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + Mockito.doAnswer(getTransferAnswer(contentType, cryptoStreamFactory, expectContents, false)) + .when(httpTransferClient).download(Mockito.eq(url), fileArg.capture()); + + File tmpDir = new File(Paths.get(System.getProperty("java.io.tmpdir"), expectedFilePath) + .toAbsolutePath().toString()); + tmpDir.mkdirs(); + tmpDir.deleteOnExit(); + + DownloadHelper helper = new DownloadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .withTmpFilePrefix("NOTSPAPI") + .withTmpFileSuffix(".NOTtmp") + .withTmpFileDirectory(tmpDir) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url).build(); + + File file; + try (DownloadBundle bundle = helper.download(spec)) { + assertEquals(contentType, bundle.getContentType()); + + file = fileArg.getValue(); + assertTrue(file.exists()); + assertEquals(expectedFilePath, file.getParentFile().getName()); + + assertTrue(file.getName().matches(expectedFileRegex)); + + try (InputStream contents = bundle.newInputStream()) { + assertTrue(IOUtils.contentEquals(contents, new ByteArrayInputStream(expectContents.getBytes( + StandardCharsets.UTF_8)))); + } + + try (BufferedReader contents = bundle.newBufferedReader(StandardCharsets.UTF_8)) { + assertTrue(IOUtils.contentEquals(contents, new StringReader(expectContents))); + } + + assertTrue(file.exists()); + } + + assertFalse(file.exists()); + + Mockito.verify(httpTransferClient).download(Mockito.eq(url), Mockito.any()); + } + + @Test + public void testBadUrlDownload() throws Exception { + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + String url = "sdfkajsdfiefi"; + + Path path = Files.createTempDirectory("SPAPI"); + + DownloadHelper helper = new DownloadHelper.Builder() + .withTmpFileDirectory(path.toFile()) + .build(); + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url) + .build(); + + try { + assertThrows(IllegalArgumentException.class, () -> helper.download(spec)); + } finally { + // This will throw if the directory is not empty, indicating that the temp file was not removed + Files.delete(path); + } + } + + @Test + public void testInvalidTmpFilePrefix() { + DownloadHelper.Builder builder = new DownloadHelper.Builder(); + + assertThrows(IllegalArgumentException.class, () -> builder + .withTmpFilePrefix("A")); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadSpecificationTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadSpecificationTest.java new file mode 100644 index 0000000..b9cac73 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/DownloadSpecificationTest.java @@ -0,0 +1,53 @@ +package com.amazon.spapi.documents; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.*; + +class DownloadSpecificationTest { + @Test + public void testBuilderConstructorMissingCryptoStreamFactory() throws Exception { + CryptoStreamFactory cryptoStreamFactory = null; + String url = "https://www.amazon.com"; + + assertThrows(IllegalArgumentException.class, () -> + new DownloadSpecification.Builder(cryptoStreamFactory, url)); + } + + @Test + public void testBuilderConstructorMissingUrl() throws Exception { + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + String url = null; + + assertThrows(IllegalArgumentException.class, () -> + new DownloadSpecification.Builder(cryptoStreamFactory, url)); + } + + @Test + public void testSuccess() throws Exception { + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + String url = "http://abc.com/123"; + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url).build(); + + assertNull(spec.getCompressionAlgorithm()); + assertSame(cryptoStreamFactory, spec.getCryptoStreamFactory()); + assertEquals(url, spec.getUrl()); + } + + @Test + public void testCompressionAlgorithm() throws Exception { + CompressionAlgorithm compressionAlgorithm = CompressionAlgorithm.GZIP; + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + String url = "http://abc.com/123"; + + DownloadSpecification spec = new DownloadSpecification.Builder(cryptoStreamFactory, url) + .withCompressionAlgorithm(compressionAlgorithm) + .build(); + + assertEquals(compressionAlgorithm, spec.getCompressionAlgorithm()); + assertSame(cryptoStreamFactory, spec.getCryptoStreamFactory()); + assertEquals(url, spec.getUrl()); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/UploadHelperTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/UploadHelperTest.java new file mode 100644 index 0000000..ae925b1 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/UploadHelperTest.java @@ -0,0 +1,171 @@ +package com.amazon.spapi.documents; + +import com.amazon.spapi.documents.impl.AESCryptoStreamFactory; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +class UploadHelperTest { + private static String KEY = "sxx/wImF6BFndqSAz56O6vfiAh8iD9P297DHfFgujec="; + private static String VECTOR = "7S2tn363v0wfCfo1IX2Q1A=="; + + class FileCapture { + volatile boolean existed = false; + volatile File file = null; + } + + private Answer getAnswer(FileCapture fileCapture, Exception e) { + return new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + File tmpFile = (File)invocation.getArgument(2); + fileCapture.existed = tmpFile.exists(); + fileCapture.file = tmpFile; + + if (e != null) { + throw e; + } + + return null; + } + }; + } + + private Answer getAnswer(FileCapture fileCapture) { + return getAnswer(fileCapture, null); + } + + @Test + public void testSuccess() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "SPAPI.*\\.tmp$"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + + FileCapture fileCapture = new FileCapture(); + Mockito.doAnswer(getAnswer(fileCapture)).when(httpTransferClient) + .upload(Mockito.eq(url), Mockito.eq(contentType), Mockito.any()); + + UploadHelper helper = new UploadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .build(); + + UploadSpecification spec = new UploadSpecification.Builder(contentType, cryptoStreamFactory, source, url) + .build(); + helper.upload(spec); + + assertTrue(fileCapture.file.getName().matches(expectedFileRegex)); + assertTrue(fileCapture.existed); + assertFalse(fileCapture.file.exists()); + + Mockito.verify(httpTransferClient).upload(Mockito.eq(url), Mockito.eq(contentType), Mockito.any()); + } + + @Test + public void testSuccessWithCustomTmpFileParams() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "http://www.abc.com/123"; + String expectedFileRegex = "NOTSPAPI.*\\.NOTtmp$"; + String expectedFilePath = "spapitmp"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + + FileCapture fileCapture = new FileCapture(); + Mockito.doAnswer(getAnswer(fileCapture)).when(httpTransferClient) + .upload(Mockito.eq(url), Mockito.eq(contentType), Mockito.any()); + + File tmpDir = new File(Paths.get(System.getProperty("java.io.tmpdir"), expectedFilePath) + .toAbsolutePath().toString()); + tmpDir.mkdirs(); + + UploadHelper helper = new UploadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .withTmpFilePrefix("NOTSPAPI") + .withTmpFileSuffix(".NOTtmp") + .withTmpFileDirectory(tmpDir) + .build(); + + UploadSpecification spec = new UploadSpecification.Builder(contentType, cryptoStreamFactory, source, url) + .build(); + helper.upload(spec); + + assertTrue(fileCapture.file.getName().matches(expectedFileRegex)); + assertEquals(expectedFilePath, fileCapture.file.getParentFile().getName()); + assertTrue(fileCapture.existed); + assertFalse(fileCapture.file.exists()); + + Mockito.verify(httpTransferClient).upload(Mockito.eq(url), Mockito.eq(contentType), Mockito.any()); + } + + @Test + public void testFileDeletedOnFailure() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "http://www.abc.com/123"; + + HttpTransferClient httpTransferClient = Mockito.mock(HttpTransferClient.class); + + FileCapture fileCapture = new FileCapture(); + Mockito.doAnswer(getAnswer(fileCapture, new IllegalArgumentException())).when(httpTransferClient) + .upload(Mockito.eq(url), Mockito.eq(contentType), Mockito.any()); + + UploadHelper helper = new UploadHelper.Builder() + .withHttpTransferClient(httpTransferClient) + .build(); + + UploadSpecification spec = new UploadSpecification.Builder(contentType, cryptoStreamFactory, source, url) + .build(); + assertThrows(IllegalArgumentException.class, () -> helper.upload(spec)); + + assertTrue(fileCapture.existed); + assertFalse(fileCapture.file.exists()); + + Mockito.verify(httpTransferClient).upload(Mockito.eq(url), Mockito.eq(contentType), Mockito.any()); + } + + @Test + public void testBadUrlFailedUpload() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "sdfkajsdfiefi"; + + Path path = Files.createTempDirectory("SPAPI"); + try { + UploadHelper helper = new UploadHelper.Builder() + .withTmpFileDirectory(path.toFile()) + .build(); + + UploadSpecification spec = new UploadSpecification.Builder(contentType, cryptoStreamFactory, source, url) + .build(); + assertThrows(IllegalArgumentException.class, () -> helper.upload(spec)); + } finally { + // This will throw if the directory is not empty, indicating that the temp file was not removed + Files.delete(path); + } + } + + @Test + public void testInvalidTmpFilePrefix() { + UploadHelper.Builder builder = new UploadHelper.Builder(); + + assertThrows(IllegalArgumentException.class, () -> builder + .withTmpFilePrefix("A")); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/UploadSpecificationTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/UploadSpecificationTest.java new file mode 100644 index 0000000..ff16734 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/UploadSpecificationTest.java @@ -0,0 +1,71 @@ +package com.amazon.spapi.documents; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class UploadSpecificationTest { + @Test + public void testBuilderConstructorMissingContentType() throws Exception { + String contentType = null; + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "https://www.amazon.com"; + + assertThrows(IllegalArgumentException.class, () -> new UploadSpecification.Builder( + contentType, cryptoStreamFactory, source, url)); + } + + @Test + public void testBuilderConstructorMissingCryptoStreamFactory() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = null; + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "https://www.amazon.com"; + + assertThrows(IllegalArgumentException.class, () -> new UploadSpecification.Builder( + contentType, cryptoStreamFactory, source, url)); + } + + @Test + public void testBuilderConstructorMissingSource() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + InputStream source = null; + String url = "https://www.amazon.com"; + + assertThrows(IllegalArgumentException.class, () -> new UploadSpecification.Builder( + contentType, cryptoStreamFactory, source, url)); + } + + @Test + public void testBuilderConstructorMissingUrl() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = null; + + assertThrows(IllegalArgumentException.class, () -> new UploadSpecification.Builder( + contentType, cryptoStreamFactory, source, url)); + } + + @Test + public void testSuccess() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + CryptoStreamFactory cryptoStreamFactory = Mockito.mock(CryptoStreamFactory.class); + InputStream source = new ByteArrayInputStream(new byte[0]); + String url = "http://abc.com/123"; + + UploadSpecification spec = new UploadSpecification.Builder(contentType, cryptoStreamFactory, source, url) + .build(); + + assertEquals(contentType, spec.getContentType()); + assertSame(cryptoStreamFactory, spec.getCryptoStreamFactory()); + assertSame(source, spec.getSource()); + assertEquals(url, spec.getUrl()); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/CryptoExceptionTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/CryptoExceptionTest.java new file mode 100644 index 0000000..dc35992 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/CryptoExceptionTest.java @@ -0,0 +1,15 @@ +package com.amazon.spapi.documents.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CryptoExceptionTest { + @Test + public void testConstructor() { + Throwable throwable = new RuntimeException(); + CryptoException exception = new CryptoException(throwable); + + assertSame(throwable, exception.getCause()); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/HttpResponseExceptionTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/HttpResponseExceptionTest.java new file mode 100644 index 0000000..2dbd666 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/HttpResponseExceptionTest.java @@ -0,0 +1,54 @@ +package com.amazon.spapi.documents.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpResponseExceptionTest { + @Test + public void testConstructor() { + String message = "This is the message"; + String body = "This is the body"; + int code = 403; + String expectToString = "com.amazon.spapi.documents.exception.HttpResponseException: " + + "This is the message {code=403, body=This is the body}"; + + HttpResponseException exception = new HttpResponseException(message, body, code); + + assertEquals(message, exception.getMessage()); + assertEquals(body, exception.getBody()); + assertEquals(code, exception.getCode()); + assertEquals(expectToString, exception.toString()); + } + + @Test + public void testConstructorNullBody() { + String message = "This is the message"; + String body = null; + int code = 403; + String expectToString = "com.amazon.spapi.documents.exception.HttpResponseException: " + + "This is the message {code=403, body=null}"; + + HttpResponseException exception = new HttpResponseException(message, body, code); + + assertEquals(message, exception.getMessage()); + assertEquals(body, exception.getBody()); + assertEquals(code, exception.getCode()); + assertEquals(expectToString, exception.toString()); + } + + @Test + public void testConstructorCause() { + String message = "This is the message"; + Throwable cause = new RuntimeException(); + String body = "This is the body"; + int code = 403; + + HttpResponseException exception = new HttpResponseException(message, cause, body, code); + + assertEquals(message, exception.getMessage()); + assertSame(cause, exception.getCause()); + assertEquals(body, exception.getBody()); + assertEquals(code, exception.getCode()); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/MissingCharsetExceptionTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/MissingCharsetExceptionTest.java new file mode 100644 index 0000000..ccf3a53 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/exception/MissingCharsetExceptionTest.java @@ -0,0 +1,15 @@ +package com.amazon.spapi.documents.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MissingCharsetExceptionTest { + @Test + public void testConstructor() { + String message = "This is the message"; + MissingCharsetException exception = new MissingCharsetException(message); + + assertEquals(message, exception.getMessage()); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/impl/AESCryptoStreamFactoryTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/impl/AESCryptoStreamFactoryTest.java new file mode 100644 index 0000000..0601230 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/impl/AESCryptoStreamFactoryTest.java @@ -0,0 +1,69 @@ +package com.amazon.spapi.documents.impl; + +import com.amazon.spapi.documents.exception.CryptoException; +import com.google.common.io.ByteStreams; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +class AESCryptoStreamFactoryTest { + private static String KEY = "sxx/wImF6BFndqSAz56O6vfiAh8iD9P297DHfFgujec="; + private static String VECTOR = "7S2tn363v0wfCfo1IX2Q1A=="; + + @Test + public void testBuilderConstructorMissingInitializationVector() { + String key = "DEF"; + String initializationVector = null; + + assertThrows(IllegalArgumentException.class, () -> + new AESCryptoStreamFactory.Builder(key, initializationVector)); + } + + @Test + public void testBuilderConstructorMissingKey() { + String key = null; + String initializationVector = "ABC"; + + assertThrows(IllegalArgumentException.class, () -> + new AESCryptoStreamFactory.Builder(key, initializationVector)); + } + + @Test + public void testBadKey() throws Exception { + byte[] decodedKey = Base64.getDecoder().decode(KEY); + String encodedKey = Base64.getEncoder().encodeToString( + Arrays.copyOfRange(decodedKey, 2, decodedKey.length-1)); + + AESCryptoStreamFactory aesCryptoStreamFactory = new AESCryptoStreamFactory.Builder(encodedKey, VECTOR).build(); + try (InputStream inputStream = new ByteArrayInputStream("Hello World!".getBytes(StandardCharsets.UTF_8))) { + assertThrows(CryptoException.class, () -> aesCryptoStreamFactory.newDecryptStream(inputStream)); + } + } + + @Test + public void testEncryptDecrypt() throws Exception { + String stringContent = "Hello World!"; + byte[] byteContent = stringContent.getBytes(StandardCharsets.UTF_8); + + AESCryptoStreamFactory aesCryptoStreamFactory = new AESCryptoStreamFactory.Builder(KEY, VECTOR).build(); + + try (InputStream encryptStream = + aesCryptoStreamFactory.newEncryptStream(new ByteArrayInputStream(byteContent))) { + byte[] encryptedContent = ByteStreams.toByteArray(encryptStream); + + try (InputStream decryptStream = + aesCryptoStreamFactory.newDecryptStream(new ByteArrayInputStream(encryptedContent))) { + byte[] decryptedContent = ByteStreams.toByteArray(decryptStream); + + assertArrayEquals(decryptedContent, byteContent); + assertFalse(Arrays.equals(encryptedContent, decryptedContent)); + } + } + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/impl/OkHttpTransferClientTest.java b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/impl/OkHttpTransferClientTest.java new file mode 100644 index 0000000..f18ed82 --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/java/com/amazon/spapi/documents/impl/OkHttpTransferClientTest.java @@ -0,0 +1,331 @@ +package com.amazon.spapi.documents.impl; + +import com.amazon.spapi.documents.exception.HttpResponseException; +import com.squareup.okhttp.Call; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.Response; +import com.squareup.okhttp.ResponseBody; +import okio.Buffer; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.platform.commons.util.ReflectionUtils; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class OkHttpTransferClientTest { + + @Test + public void testDefaultBuilder() throws Exception { + OkHttpTransferClient helper = new OkHttpTransferClient.Builder().build(); + OkHttpClient client = (OkHttpClient)ReflectionUtils.tryToReadFieldValue(OkHttpTransferClient.class, + "client", helper).get(); + } + + @Test + public void testDownloadSuccess() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + String url = "https://www.s3-amazon.com/123"; + String content = "Hello world!"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Response response = Mockito.mock(Response.class); + Mockito.doReturn(response).when(call).execute(); + + Mockito.doReturn(contentType).when(response).header("Content-Type"); + + Mockito.doReturn(true).when(response).isSuccessful(); + + ResponseBody responseBody = Mockito.mock(ResponseBody.class); + Mockito.doReturn(responseBody).when(response).body(); + + Mockito.doReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) + .when(responseBody).byteStream(); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + assertEquals(contentType, helper.download(url, tmpFile)); + assertTrue(IOUtils.contentEquals(new FileInputStream(tmpFile), + new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)))); + } finally { + tmpFile.delete(); + } + + assertEquals(url, requestArg.getValue().urlString()); + assertEquals("GET", requestArg.getValue().method()); + Mockito.verify(responseBody).close(); + } + + @Test + public void testDownloadIOException() throws Exception { + String url = "https://www.s3-amazon.com/123"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Mockito.doThrow(new IOException()).when(call).execute(); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + Assertions.assertThrows(IOException.class, () -> helper.download(url, tmpFile)); + } finally { + tmpFile.delete(); + } + } + + private void failureResponseMocks(Response response, ResponseBody responseBody, + int httpStatusCode, String message, String bodyContent) throws Exception { + Mockito.doReturn(httpStatusCode).when(response).code(); + Mockito.doReturn(message).when(response).message(); + + Mockito.doReturn(new StringReader(bodyContent)).when(responseBody) + .charStream(); + } + + @Test + public void testDownloadFailure() throws Exception { + String url = "https://www.s3-amazon.com/123"; + String responseBodyText = "This is the response body"; + int maxErrorBodyLen = 5; + int httpStatusCode = 403; + String message = "Forbidden"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Response response = Mockito.mock(Response.class); + Mockito.doReturn(response).when(call).execute(); + + ResponseBody responseBody = Mockito.mock(ResponseBody.class); + Mockito.doReturn(responseBody).when(response).body(); + Mockito.doNothing().when(responseBody).close(); + + failureResponseMocks(response, responseBody, httpStatusCode, message, responseBodyText); + + Mockito.doReturn(false).when(response).isSuccessful(); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .withMaxErrorBodyLen(maxErrorBodyLen) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + helper.download(url, tmpFile); + fail("Expected exception"); + } catch(HttpResponseException e) { + assertEquals(httpStatusCode, e.getCode()); + assertEquals(message, e.getMessage()); + assertEquals("This ", e.getBody()); + } finally { + tmpFile.delete(); + } + + Mockito.verify(responseBody).close(); + } + + @Test + public void testFailureInBodyExtraction() throws Exception { + String url = "https://www.s3-amazon.com/123"; + int maxErrorBodyLen = 5; + int httpStatusCode = 403; + String message = "Forbidden"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Response response = Mockito.mock(Response.class); + Mockito.doReturn(response).when(call).execute(); + + ResponseBody responseBody = Mockito.mock(ResponseBody.class); + Mockito.doReturn(responseBody).when(response).body(); + Mockito.doNothing().when(responseBody).close(); + + Mockito.doReturn(httpStatusCode).when(response).code(); + Mockito.doReturn(message).when(response).message(); + + Mockito.doThrow(new IOException()).when(responseBody).charStream(); + + Mockito.doReturn(false).when(response).isSuccessful(); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .withMaxErrorBodyLen(maxErrorBodyLen) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + helper.download(url, tmpFile); + fail("Expected exception"); + } catch(HttpResponseException e) { + assertEquals(httpStatusCode, e.getCode()); + assertEquals(message, e.getMessage()); + assertEquals("", e.getBody()); + } finally { + tmpFile.delete(); + } + + Mockito.verify(responseBody).close(); + } + + @Test + public void testUploadSuccess() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + String url = "https://www.s3-amazon.com/123"; + String content = "Hello world!"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Response response = Mockito.mock(Response.class); + Mockito.doReturn(response).when(call).execute(); + + Mockito.doReturn(true).when(response).isSuccessful(); + + ResponseBody responseBody = Mockito.mock(ResponseBody.class); + Mockito.doReturn(responseBody).when(response).body(); + Mockito.doNothing().when(responseBody).close(); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile)) { + fileOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); + } + + helper.upload(url, contentType, tmpFile); + + Buffer buffer = new Buffer(); + requestArg.getValue().body().writeTo(buffer); + assertEquals(content, buffer.readString(StandardCharsets.UTF_8)); + + assertEquals(url, requestArg.getValue().urlString()); + assertEquals("PUT", requestArg.getValue().method()); + assertEquals(contentType, requestArg.getValue().body().contentType().toString()); + } finally { + tmpFile.delete(); + } + + Mockito.verify(responseBody).close(); + } + + @Test + public void testUploadIOException() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + String url = "https://www.s3-amazon.com/123"; + String content = "Hello world!"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Mockito.doThrow(new IOException()).when(call).execute(); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile)) { + fileOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); + } + + Assertions.assertThrows(IOException.class, () -> helper.upload(url, contentType, tmpFile)); + } finally { + tmpFile.delete(); + } + } + + @Test + public void testUploadFailure() throws Exception { + String contentType = "text/xml; charset=UTF-8"; + String url = "https://www.s3-amazon.com/123"; + String content = "Hello world!"; + + String responseBodyText = "This is the response body"; + int maxErrorBodyLen = 5; + int httpStatusCode = 403; + String message = "Forbidden"; + + OkHttpClient client = Mockito.mock(OkHttpClient.class); + Call call = Mockito.mock(Call.class); + + ArgumentCaptor requestArg = ArgumentCaptor.forClass(Request.class); + Mockito.doReturn(call).when(client).newCall(requestArg.capture()); + + Response response = Mockito.mock(Response.class); + Mockito.doReturn(response).when(call).execute(); + + Mockito.doReturn(false).when(response).isSuccessful(); + + ResponseBody responseBody = Mockito.mock(ResponseBody.class); + Mockito.doReturn(responseBody).when(response).body(); + Mockito.doNothing().when(responseBody).close(); + failureResponseMocks(response, responseBody, httpStatusCode, message, responseBodyText); + + OkHttpTransferClient helper = new OkHttpTransferClient.Builder() + .withClient(client) + .withMaxErrorBodyLen(maxErrorBodyLen) + .build(); + + File tmpFile = File.createTempFile("foo", null); + try { + try (FileOutputStream fileOutputStream = new FileOutputStream(tmpFile)) { + fileOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); + } + + helper.upload(url, contentType, tmpFile); + fail("Expected exception"); + } catch(HttpResponseException e) { + assertEquals(httpStatusCode, e.getCode()); + assertEquals(message, e.getMessage()); + assertEquals("This ", e.getBody()); + } finally { + tmpFile.delete(); + } + + Mockito.verify(responseBody).close(); + } +} \ No newline at end of file diff --git a/clients/sellingpartner-api-documents-helper-java/src/test/resources/mockito-extensions/org.mockito.plugins.Mockmaker b/clients/sellingpartner-api-documents-helper-java/src/test/resources/mockito-extensions/org.mockito.plugins.Mockmaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/clients/sellingpartner-api-documents-helper-java/src/test/resources/mockito-extensions/org.mockito.plugins.Mockmaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file