diff --git a/build.gradle b/build.gradle index 85220bc..3423174 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,10 @@ subprojects { dependencies { testCompile "org.testng:testng:6.14.3" + testCompile "com.google.jimfs:jimfs:1.1" + testCompile "org.apache.commons:commons-lang3:3.7" + + compile 'commons-io:commons-io:2.5' } test { diff --git a/core/- b/core/- new file mode 100644 index 0000000..9dfbf8b --- /dev/null +++ b/core/- @@ -0,0 +1 @@ +some stuff \ No newline at end of file diff --git a/core/src/main/java/org/htsjdk/core/api/io/IOResource.java b/core/src/main/java/org/htsjdk/core/api/io/IOResource.java new file mode 100644 index 0000000..02f8fdc --- /dev/null +++ b/core/src/main/java/org/htsjdk/core/api/io/IOResource.java @@ -0,0 +1,90 @@ +package org.htsjdk.core.api.io; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Interface representing htsjdk-next input/output resources. + */ +public interface IOResource { + + /** + * Determine if this resource has a scheme that has an installed NIO file system provider. This does not + * guarantee the resource can be converted into a {@code java.nio.file.Path}, since the resource can be + * syntactically valid, and specify a valid file system provider, but still fail to be semantically meaningful. + * @return true if this URI has a scheme that has an installed NIO file system provider. + */ + boolean isNIO(); + + /** + * Return true if this {code IOResource} can be resolved to an NIO Path. If true, {@code #toPath()} can be + * safely called. + * + * There are cases where a valid URI with a valid scheme backed by an installed NIO File System + * still can't be turned into a {@code java.nio.file.Path}, i.e., the following specifies an invalid + * authority "namenode": + * + * file://namenode/to/file + * + * @return {@code true} if this {@code IOResource} can be resolved to an NIO Path. + */ + boolean isPath(); + + /** + * Get a {@code java.net.URI} object for this {@code IOResource}. Will not be null. + * @return The {@code URI} object for this IOResource. + */ + URI getURI(); + + /** + * Returns the String representation of the {{@code UR} backing this {@code IOResource} URI. This string + * may differ from the normalized string returned from a Path that has been object resolved from this + * IOResource. + * + * @return String from which this URI as originally created. Will not be null, and will always + * include a URI scheme. + */ + default String getURIString() { return getURI().toString(); } + + /** + * Return the raw (source) input used to create this {@code IOResource} as a String. + */ + String getRawInputString(); + + /** + * Resolve this {@code IOResource} to an NIO Path. Can be safely called only if {@link #isPath()} returns true. + */ + Path toPath(); + + /** + * Return a string message describing why this IOResource cannot be converted to a {@code java.nio.file.Path} + * ({@code #isPath()} returns false). + * + * @return Optional message explaining toPath failure reason, since it can fail for various reasons. + */ + Optional getToPathFailureReason(); + + /** + * Return the scheme for this IOResource. For file resources (URIs that have no explicit scheme), this + * will return the scheme "file". + * @return the scheme String for the URI backing this {@code IOResource}, if any. Will not be null. + */ + default String getScheme() { + return getURI().getScheme(); + } + + /** + * Get a {@code InputStream} for this resource. + * @return {@code InputStream} for this resource. + */ + InputStream getInputStream(); + + /** + * Get an {@code OutputStream} for this resource. + * @return {@code OutputStream} for this URI. + */ + OutputStream getOutputStream(); +} diff --git a/core/src/main/java/org/htsjdk/core/exception/HtsjdkIOException.java b/core/src/main/java/org/htsjdk/core/exception/HtsjdkIOException.java new file mode 100644 index 0000000..0ef0d19 --- /dev/null +++ b/core/src/main/java/org/htsjdk/core/exception/HtsjdkIOException.java @@ -0,0 +1,32 @@ +package org.htsjdk.core.exception; + +public class HtsjdkIOException extends HtsjdkException { + + /** + * Constructs an HtsjdkIOException exception. + * + * @param message detailed message. + */ + public HtsjdkIOException(String message) { + super(message); + } + + /** + * Constructs an HtsjdkIOException exception with a specified cause. + * + * @param message detailed message. + * @param cause cause of the exception. + */ + public HtsjdkIOException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs an HtsjdkIOException exception with a message constructed from the cause. + * + * @param cause cause of the exception. + */ + public HtsjdkIOException(Throwable cause) { + super(cause); + } +} diff --git a/core/src/main/java/org/htsjdk/core/utils/IOUtils.java b/core/src/main/java/org/htsjdk/core/utils/IOUtils.java new file mode 100644 index 0000000..5055830 --- /dev/null +++ b/core/src/main/java/org/htsjdk/core/utils/IOUtils.java @@ -0,0 +1,22 @@ +package org.htsjdk.core.utils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +public class IOUtils { + + /** + * Create a temporary file using a given name prefix and name suffix and return a {@link java.nio.file.Path}. + * @param prefix + * @param suffix + * @return temp File that will be deleted on exit + * @throws IOException + */ + public static Path createTempPath(final String prefix, final String suffix) throws IOException { + final File tempFile = File.createTempFile(prefix, suffix); + tempFile.deleteOnExit(); + return tempFile.toPath(); + } + +} diff --git a/core/src/main/java/org/htsjdk/core/utils/PathSpecifier.java b/core/src/main/java/org/htsjdk/core/utils/PathSpecifier.java new file mode 100644 index 0000000..070e63f --- /dev/null +++ b/core/src/main/java/org/htsjdk/core/utils/PathSpecifier.java @@ -0,0 +1,258 @@ +package org.htsjdk.core.utils; + +import org.htsjdk.core.api.io.IOResource; +import org.htsjdk.core.exception.HtsjdkException; +import org.htsjdk.core.exception.HtsjdkIOException; + +import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.*; +import java.nio.file.spi.FileSystemProvider; +import java.util.Optional; + +/** + * Default implementation for IOResource. + * + * This class takes a raw string that is to be interpreted as a path specifier, and converts it internally to a + * URI and/or Path object from which a stream can be obtained. If no scheme is provided as part of the raw string + * used in the constructor(s), the input is assumed to represent a file on the local file system, and will be + * backed by a URI with a "file:/" scheme and a path part that is automatically encoded/escaped to ensure it is + * a valid URI. If the raw string contains a scheme, it will be backed by a URI formed from the raw string as + * presented, with no further encoding/escaping. + * + * For example, a URI that contains a scheme and has an embedded "#" in the path will be treated as a URI + * having a fragment delimiter. If the URI contains an scheme, the "#" will be escaped and the encoded "#" + * will be interpreted as part of the URI path. + * + * There are 3 succeeding levels of input validation/conversion: + * + * 1) PathSpecifier constructor: requires a syntactically valid URI, possibly containing a scheme (if no scheme + * is present the path part will be escaped/encoded), or a valid local file reference + * 2) isNio: true if the input string is an identifier that is syntactically valid, and is backed by + * an installed NIO provider that matches the URI scheme + * 3) isPath: syntactically valid URI that can be resolved to a java.io.Path by the associated provider + * + * Definitions: + * + * "absolute" URI - specifies a scheme + * "relative" URI - does not specify a scheme + * "opaque" URI - an "absolute" URI whose scheme-specific part does not begin with a slash character + * "hierarchical" URI - either an "absolute" URI whose scheme-specific part begins with a slash character, + * or a "relative" URI (no scheme) + * + * URIs that do not make use of the slash "/" character for separating hierarchical components are + * considered "opaque" by the generic URI parser. + * + * General syntax for an "absolute" URI: + * + * : + * + * Many "hierarchical" URI schemes use this syntax: + * + * ://? + * + * More specifically: + * + * absoluteURI = scheme ":" ( hier_part | opaque_part ) + * hier_part = ( net_path | abs_path ) [ "?" query ] + * net_path = "//" authority [ abs_path ] + * abs_path = "/" path_segments + * opaque_part = uric_no_slash *uric + * uric_no_slash = unreserved | escaped | ";" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | "," + */ +public class PathSpecifier implements IOResource, Serializable { + private static final long serialVersionUID = 1L; + + private final String rawInputString; // raw input string provided by th user; may or may not have a scheme + private final URI uri; // working URI; always has a scheme (assume "file" if not provided) + private transient Path cachedPath; // cache the Path associated with this URI if its "Path-able" + private Optional pathFailureReason = Optional.empty(); // cache the reason for "toPath" conversion failure + + /** + * If the raw input string already contains a scheme (including a "file" scheme), assume its already + * properly escape/encoded. If no scheme component is present, assume it referencess a raw path on the + * local file system, so try to get a Path first, and then retrieve the URI from the resulting Path. + * This ensures that input strings that are local file references without a scheme component and contain + * embedded characters are valid in file names, but which would otherwise be interpreted as excluded + * URI characters (such as the URI fragment delimiter "#") are properly escape/encoded. + * @param rawInputString + */ + public PathSpecifier(final String rawInputString) { + ParamUtils.nonNull(rawInputString); + this.rawInputString = rawInputString; + + URI tempURI; + try { + tempURI = new URI(rawInputString); + if (!tempURI.isAbsolute()) { + // if the URI has no scheme, assume its a local (non-URI) file reference, and resolve + // it to a Path and retrieve the URI from the Path to ensure proper escape/encoding + setCachedPath(Paths.get(rawInputString)); + tempURI = getCachedPath().toUri(); + } + } catch (URISyntaxException uriException) { + // the input string isn't a valid URI; assume its a local (non-URI) file reference, and + // use the URI resulting from the corresponding Path + try { + setCachedPath(Paths.get(rawInputString)); + tempURI = getCachedPath().toUri(); + } catch (InvalidPathException | UnsupportedOperationException | SecurityException pathException) { + // we have two exceptions, each of which might be relevant since we can't tell whether + // the user intended to provide a local file reference or a URI, so preserve both messages + final String errorMessage = String.format( + "%s can't be interpreted as a local file (%s) or as a URI (%s).", + rawInputString, + pathException.getMessage(), + uriException.getMessage()); + throw new IllegalArgumentException(errorMessage, pathException); + } + } + if (!tempURI.isAbsolute()) { + // assert the invariant that every URI we create has a scheme, even if the raw input string does not + throw new HtsjdkIOException("URI has no scheme"); + } + + uri = tempURI; + } + + @Override + public boolean isNIO() { + // try to find a provider; assume that our URI always has a scheme + for (FileSystemProvider provider: FileSystemProvider.installedProviders()) { + if (provider.getScheme().equalsIgnoreCase(uri.getScheme())) { + return true; + } + } + return false; + } + + @Override + public boolean isPath() { + try { + return toPath() != null; + } catch (ProviderNotFoundException | + FileSystemNotFoundException | + IllegalArgumentException | + HtsjdkException | + // thrown byjimfs + AssertionError e) { + // jimfs throws an AssertionError that wraps a URISyntaxException when trying to create path where + // the scheme-specific part is missing or incorrect + pathFailureReason = Optional.of(e.getMessage()); + return false; + } + } + + @Override + public URI getURI() { + return uri; + } + + @Override + public String getURIString() { + return getURI().toString(); + } + + /** + * Return the raw input string as provided to the constructor. + */ + @Override + public String getRawInputString() { return rawInputString; } + + /** + * Resolve the URI to a {@link Path} object. + * + * @return the resulting {@code Path} + */ + @Override + public Path toPath() { + if (getCachedPath() != null) { + return getCachedPath(); + } else { + final Path tmpPath = Paths.get(getURI()); + setCachedPath(tmpPath); + return tmpPath; + } + } + + @Override + public Optional getToPathFailureReason() { + if (!pathFailureReason.isPresent()) { + try { + toPath(); + return Optional.empty(); + } catch (ProviderNotFoundException e) { + pathFailureReason = Optional.of(String.format("ProviderNotFoundException: %s", e.getMessage())); + } catch (FileSystemNotFoundException e) { + pathFailureReason = Optional.of(String.format("FileSystemNotFoundException: %s", e.getMessage())); + } catch (IllegalArgumentException e) { + pathFailureReason = Optional.of(String.format("IllegalArgumentException: %s", e.getMessage())); + } catch (HtsjdkException e) { + pathFailureReason = Optional.of(String.format("HtsjdkException: %s", e.getMessage())); + } + } + return pathFailureReason; + } + + @Override + public InputStream getInputStream() { + if (!isPath()) { + throw new HtsjdkIOException(getToPathFailureReason().get()); + } + + final Path resourcePath = toPath(); + try { + return Files.newInputStream(resourcePath); + } catch (IOException e) { + throw new HtsjdkIOException( + String.format("Could not create open input stream for %s (as URI %s)", getRawInputString(), getURIString()), e); + } + } + + @Override + public OutputStream getOutputStream() { + if (!isPath()) { + throw new HtsjdkIOException(getToPathFailureReason().get()); + } + + final Path resourcePath = toPath(); + try { + return Files.newOutputStream(resourcePath); + } catch (IOException e) { + throw new HtsjdkIOException(String.format("Could not open output stream for %s (as URI %s)", getRawInputString(), getURIString()), e); + } + } + + // get the cached path associated with this URI if its already been created + protected Path getCachedPath() { return cachedPath; } + + protected void setCachedPath(Path path) { + this.cachedPath = path; + } + + @Override + public String toString() { + return rawInputString; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PathSpecifier)) return false; + + PathSpecifier that = (PathSpecifier) o; + + if (!getURIString().equals(that.getURIString())) return false; + if (!getURI().equals(that.getURI())) return false; + return true; + } + + @Override + public int hashCode() { + int result = getURIString().hashCode(); + result = 31 * result + getURI().hashCode(); + return result; + } + +} diff --git a/core/src/test/java/org/htsjdk/core/utils/PathSpecifierUnitTest.java b/core/src/test/java/org/htsjdk/core/utils/PathSpecifierUnitTest.java new file mode 100644 index 0000000..4905d97 --- /dev/null +++ b/core/src/test/java/org/htsjdk/core/utils/PathSpecifierUnitTest.java @@ -0,0 +1,358 @@ +package org.htsjdk.core.utils; + +//TODO: NAMING: is there any useful distinction between Unit/Integration tests in this repo ? + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import org.apache.commons.lang3.SystemUtils; +import org.htsjdk.core.api.io.IOResource; +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.io.*; +import java.nio.file.*; + +public class PathSpecifierUnitTest { + + final static String FS_SEPARATOR = FileSystems.getDefault().getSeparator(); + + @DataProvider + public Object[][] validPathSpecifiers() { + return new Object[][] { + // Paths specifiers that are syntactically valid as either a relative or absolute local file + // name, or as a URI, but which may fail isNIO or isPath + + // input String, expected resulting URI String, expected isNIO, expected isPath + + //******************************** + // Local (non-URI) file references + //******************************** + + {"localFile.bam", "file://" + getCWDAsURIPathString() + "localFile.bam", true, true}, + // absolute reference to a file in the root of the current file system (Windows accepts the "/" as root) + {"/localFile.bam", "file://" + getRootDirectoryAsURIPathString() + "localFile.bam", true, true}, + // absolute reference to a file in the root of the current file system, where root is specified using the + // default FS separator + {FS_SEPARATOR + "localFile.bam", "file://" + getRootDirectoryAsURIPathString() + "localFile.bam", true, true}, + // absolute reference to a file + {FS_SEPARATOR + joinWithFSSeparator("path", "to", "localFile.bam"), + "file://" + getRootDirectoryAsURIPathString() + "path/to/localFile.bam", true, true}, + // absolute reference to a file that contains a URI "excluded" character in the path ("#"), which without + // encoding will be treated as a fragment delimiter + {FS_SEPARATOR + joinWithFSSeparator("project", "gvcf-pcr", "23232_1#1", "1.g.vcf.gz"), + "file://" + getRootDirectoryAsURIPathString() + "project/gvcf-pcr/23232_1%231/1.g.vcf.gz", true, true}, + // relative reference to a file on the local file system + {joinWithFSSeparator("path", "to", "localFile.bam"), + "file://" + getCWDAsURIPathString() + "path/to/localFile.bam", true, true}, + // Windows also accepts "/" as a valid root specifier + {"/", "file://" + getRootDirectoryAsURIPathString(), true, true}, + {".", "file://" + getCWDAsURIPathString() + "./", true, true}, + {"../.", "file://" + getCWDAsURIPathString() + ".././", true, true}, + // an empty path is equivalent to accessing the current directory of the default file system + {"", "file://" + getCWDAsURIPathString(), true, true}, + + //*********************************************************** + // Local file references using a URI with a "file://" scheme. + //*********************************************************** + + {"file:localFile.bam", "file:localFile.bam", true, false}, // absolute, opaque (not hierarchical) + {"file:/localFile.bam", "file:/localFile.bam", true, true}, // absolute, hierarchical + {"file://localFile.bam", "file://localFile.bam", true, false}, // file URLs can't have an authority ("localFile.bam") + {"file:///localFile.bam", "file:///localFile.bam", true, true}, // empty authority + {"file:path/to/localFile.bam", "file:path/to/localFile.bam", true, false}, + {"file:/path/to/localFile.bam", "file:/path/to/localFile.bam", true, true}, + // "path" appears to be an authority, and will be accepted on Windows since this URI will be + // interpreted as a UNC path containing an authority + {"file://path/to/localFile.bam", "file://path/to/localFile.bam", true, SystemUtils.IS_OS_WINDOWS}, + // "localhost" is accepted as a special case authority for "file://" Paths on Windows; but not Linux/Mac + {"file://localhost/to/localFile.bam","file://localhost/to/localFile.bam", true, SystemUtils.IS_OS_WINDOWS}, + {"file:///path/to/localFile.bam", "file:///path/to/localFile.bam", true, true}, // empty authority + + //***************************************************************************** + // Valid URIs which are NOT valid NIO paths (no installed file system provider) + //***************************************************************************** + + {"gs://file.bam", "gs://file.bam", false, false}, + {"gs://bucket/file.bam", "gs://bucket/file.bam", false, false}, + {"gs:///bucket/file.bam", "gs:///bucket/file.bam", false, false}, + {"gs://auth/bucket/file.bam", "gs://auth/bucket/file.bam", false, false}, + {"gs://hellbender/test/resources/", "gs://hellbender/test/resources/", false, false}, + {"gcs://abucket/bucket", "gcs://abucket/bucket", false, false}, + {"gendb://somegdb", "gendb://somegdb", false, false}, + {"chr1:1-100", "chr1:1-100", false, false}, + + //***************************************************************************************** + // Valid URIs which are backed by an installed NIO file system provider), but are which not + // actually resolvable as paths because the scheme-specific part is not valid for one reason + // or another. + //********************************************************************************************** + + // uri must have a path: jimfs:file.bam + {"jimfs:file.bam", "jimfs:file.bam", true, false}, + // java.lang.AssertionError: java.net.URISyntaxException: Expected scheme-specific part at index 6: jimfs: + {"jimfs:/file.bam", "jimfs:/file.bam", true, false}, + // java.lang.AssertionError: uri must have a path: jimfs://file.bam + {"jimfs://file.bam", "jimfs://file.bam", true, false}, + // java.lang.AssertionError: java.net.URISyntaxException: Expected scheme-specific part at index 6: jimfs: + {"jimfs:///file.bam", "jimfs:///file.bam", true, false}, + // java.nio.file.FileSystemNotFoundException: jimfs://root + {"jimfs://root/file.bam","jimfs://root/file.bam", true, false}, + + //*********************************************************************************************** + // References that contain characters that require URI-encoding. If the input string is presented + // without no scheme, it will be be automatically encoded by PathSpecifier, otherwise it + // must already be URI-encoded. + //*********************************************************************************************** + + // relative (non-URI) reference to a file on the local file system that contains a URI fragment delimiter + // is automatically URI-encoded + {joinWithFSSeparator("project", "gvcf-pcr", "23232_1#1", "1.g.vcf.gz"), + "file://" + getCWDAsURIPathString() + "project/gvcf-pcr/23232_1%231/1.g.vcf.gz", true, true}, + // URI reference with fragment delimiter is not automatically URI-encoded + {"file:project/gvcf-pcr/23232_1#1/1.g.vcf.gz", "file:project/gvcf-pcr/23232_1#1/1.g.vcf.gz", true, false}, + {"file:/project/gvcf-pcr/23232_1#1/1.g.vcf.gz", "file:/project/gvcf-pcr/23232_1#1/1.g.vcf.gz", true, false}, + {"file:///project/gvcf-pcr/23232_1%231/1.g.vcf.g", "file:///project/gvcf-pcr/23232_1%231/1.g.vcf.g", true, true}, + }; + } + + @Test(dataProvider = "validPathSpecifiers") + public void testPathSpecifier(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) { + final IOResource ioResource = new PathSpecifier(referenceString); + Assert.assertNotNull(ioResource); + Assert.assertEquals(ioResource.getURI().toString(), expectedURIString); + } + + @Test(dataProvider = "validPathSpecifiers") + public void testIsNIO(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) { + final IOResource pathURI = new PathSpecifier(referenceString); + Assert.assertEquals(pathURI.isNIO(), isNIO); + } + + @Test(dataProvider = "validPathSpecifiers") + public void testIsPath(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) { + final IOResource pathURI = new PathSpecifier(referenceString); + if (isPath) { + Assert.assertEquals(pathURI.isPath(), isPath, pathURI.getToPathFailureReason().orElse("no failure")); + } else { + Assert.assertEquals(pathURI.isPath(), isPath); + } + } + + @Test(dataProvider = "validPathSpecifiers") + public void testToPath(final String referenceString, final String expectedURIString, final boolean isNIO, final boolean isPath) { + final IOResource pathURI = new PathSpecifier(referenceString); + if (isPath) { + final Path path = pathURI.toPath(); + Assert.assertEquals(path != null, isPath, pathURI.getToPathFailureReason().orElse("no failure")); + } else { + Assert.assertEquals(pathURI.isPath(), isPath); + } + } + + @DataProvider + public Object[][] invalidPathSpecifiers() { + return new Object[][] { + // the nul character is rejected on all of the supported platforms in both local + // filenames and URIs, so use it to test PathSpecifier constructor failure on all platforms + {"\0"}, + }; + } + + @Test(dataProvider = "invalidPathSpecifiers", expectedExceptions = {IllegalArgumentException.class}) + public void testPathSpecifierInvalid(final String referenceString) { + new PathSpecifier(referenceString); + } + + @DataProvider + public Object[][] invalidPath() { + return new Object[][] { + // valid references that are not valid as a path + + {"file:/project/gvcf-pcr/23232_1#1/1.g.vcf.gz"}, // not encoded + {"file:project/gvcf-pcr/23232_1#1/1.g.vcf.gz"}, // scheme-specific part is not hierarchical + + // The hadoop file system provider explicitly throws an NPE if no host is specified and HDFS is not + // the default file system + //{"hdfs://nonexistent_authority/path/to/file.bam"}, // unknown authority "nonexistent_authority" + {"hdfs://userinfo@host:80/path/to/file.bam"}, // UnknownHostException "host" + + {"unknownscheme://foobar"}, + {"gendb://adb"}, + {"gcs://abucket/bucket"}, + + // URIs with schemes that are backed by an valid NIO provider, but for which the + // scheme-specific part is not valid. + {"file://nonexistent_authority/path/to/file.bam"}, // unknown authority "nonexistent_authority" + }; + } + + @Test(dataProvider = "invalidPath") + public void testIsPathInvalid(final String invalidPathString) { + final IOResource htsURI = new PathSpecifier(invalidPathString); + Assert.assertFalse(htsURI.isPath()); + } + + @Test(dataProvider = "invalidPath", expectedExceptions = {IllegalArgumentException.class, FileSystemNotFoundException.class}) + public void testToPathInvalid(final String invalidPathString) { + final IOResource htsURI = new PathSpecifier(invalidPathString); + htsURI.toPath(); + } + + @Test + public void testInstalledNonDefaultFileSystem() throws IOException { + // create a jimfs file system and round trip through PathSpecifier/stream + try (FileSystem jimfs = Jimfs.newFileSystem(Configuration.unix())) { + final Path outputPath = jimfs.getPath("alternateFileSystemTest.txt"); + doStreamRoundTrip(outputPath.toUri().toString()); + } + } + + @DataProvider + public Object[][] inputStreamSpecifiers() throws IOException { + return new Object[][]{ + // references that can be resolved to an actual test file that can be read + + // relative (file) reference to a local file + {joinWithFSSeparator("..", "data", "utils", "testTextFile.txt"), "Test file."}, + + // absolute reference to a local file + {getCWDAsFileReference() + FS_SEPARATOR + joinWithFSSeparator("..", "data", "utils", "testTextFile.txt"), "Test file."}, + + // URI reference to a local file, where the path is absolute + {"file://" + getCWDAsURIPathString() + "../data/utils/testTextFile.txt", "Test file."}, + + // reference to a local file with an embedded fragment delimiter ("#") in the name; if the file + // scheme is included, the rest of the path must already be encoded; if no file scheme is + // included, the path is encoded by the PathSpecifier class + {joinWithFSSeparator("..", "data", "utils", "testDirWith#InName", "testTextFile.txt"), "Test file."}, + {"file://" + getCWDAsURIPathString() + "../data/utils/testDirWith%23InName/testTextFile.txt", "Test file."}, + }; + } + + @Test(dataProvider = "inputStreamSpecifiers") + public void testGetInputStream(final String referenceString, final String expectedFileContents) throws IOException { + final IOResource htsURI = new PathSpecifier(referenceString); + + try (final InputStream is = htsURI.getInputStream(); + final DataInputStream dis = new DataInputStream(is)) { + final byte[] actualFileContents = new byte[expectedFileContents.length()]; + dis.readFully(actualFileContents); + + Assert.assertEquals(new String(actualFileContents), expectedFileContents); + } + } + + @DataProvider + public Object[][] outputStreamSpecifiers() throws IOException { + return new Object[][]{ + // output URIs that can be resolved to an actual test file + {IOUtils.createTempPath("testOutputStream", ".txt").toString()}, + {"file://" + getLocalFileAsURIPathString(IOUtils.createTempPath("testOutputStream", ".txt"))}, + }; + } + + @Test(dataProvider = "outputStreamSpecifiers") + public void testGetOutputStream(final String referenceString) throws IOException { + doStreamRoundTrip(referenceString); + } + + @Test + public void testStdIn() throws IOException { + final IOResource htsURI = new PathSpecifier( + SystemUtils.IS_OS_WINDOWS ? + "-" : + "/dev/stdin"); + try (final InputStream is = htsURI.getInputStream(); + final DataInputStream dis = new DataInputStream(is)) { + final byte[] actualFileContents = new byte[0]; + dis.readFully(actualFileContents); + + Assert.assertEquals(new String(actualFileContents), ""); + } + } + + @Test + public void testStdOut() throws IOException { + final IOResource pathURI = new PathSpecifier( + SystemUtils.IS_OS_WINDOWS ? + "-" : + "/dev/stdout"); + try (final OutputStream os = pathURI.getOutputStream(); + final DataOutputStream dos = new DataOutputStream(os)) { + dos.write("some stuff".getBytes()); + } + } + + /** + * Return the string resulting from joining the individual components using the local default + * file system separator. + * + * This is used to create test inputs that are local file references, as would be presented by a + * user on the platform on which these tests are running. + */ + private String joinWithFSSeparator(String... parts) { + return String.join(FileSystems.getDefault().getSeparator(), parts); + } + + private void doStreamRoundTrip(final String referenceString) throws IOException { + final String expectedFileContents = "Test contents"; + + final IOResource pathURI = new PathSpecifier(referenceString); + try (final OutputStream os = pathURI.getOutputStream(); + final DataOutputStream dos = new DataOutputStream(os)) { + dos.write(expectedFileContents.getBytes()); + } + + // read it back in and make sure it matches expected contents + try (final InputStream is = pathURI.getInputStream(); + final DataInputStream dis = new DataInputStream(is)) { + final byte[] actualFileContents = new byte[expectedFileContents.length()]; + dis.readFully(actualFileContents); + + Assert.assertEquals(new String(actualFileContents), expectedFileContents); + } + } + + /** + * Get an absolute reference to the current working directory using local file system syntax and + * the local file system separator. Used to construct valid local, absolute file references as test inputs. + * + * Returns a string of the form '/some/path/.` or `d:\some\path\.` on Windows + */ + private static String getCWDAsFileReference() { + return new File(".").getAbsolutePath(); + } + + /** + * Get the current working directory as a locally valid, hierarchical URI string. Used to + * construct expected URI string values for test inputs that are local file references. + * + * Returns '/some/path/` or `/d:/some/path/` on Windows + */ + private String getCWDAsURIPathString() { + return getLocalFileAsURIPathString(Paths.get(".")); + } + + /** + * Get just the path part of the URI representing the current working directory. Used + * to construct expected URI string values for test inputs that specify a file in the + * root of the local file system. + * + * Returns a string of the form "/" or "/d:/" on Windows. + */ + private String getRootDirectoryAsURIPathString() { + return getLocalFileAsURIPathString(Paths.get(FS_SEPARATOR)); + } + + /** + * Get just the path part of the URI representing a file on the local file system as a normalized + * String. + * + * Returns a string of the form `/some/path' or '/d:/some/path/` on Windows. + */ + private String getLocalFileAsURIPathString(final Path localPath) { + return localPath.toUri().normalize().getPath(); + } + +} diff --git a/data/utils/testDirWith#InName/testTextFile.txt b/data/utils/testDirWith#InName/testTextFile.txt new file mode 100644 index 0000000..f5d299f --- /dev/null +++ b/data/utils/testDirWith#InName/testTextFile.txt @@ -0,0 +1,2 @@ +Test file. + diff --git a/data/utils/testTextFile.txt b/data/utils/testTextFile.txt new file mode 100644 index 0000000..f5d299f --- /dev/null +++ b/data/utils/testTextFile.txt @@ -0,0 +1,2 @@ +Test file. +