This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 1c3e7cf558a4a5734afca80b33771ccc3e90caa2
Merge: dc78d5d1ab 51c7ed2535
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Fri May 29 23:26:35 2026 +0200

    Merge (with refactoring) branch 'feat/CustomHostS3' into geoapi-4.0.
    https://github.com/apache/sis/pull/40

 .../org.apache.sis.cloud.aws/main/module-info.java |   5 +-
 .../apache/sis/cloud/aws/s3/ClientFileSystem.java  |  75 +++--
 .../org/apache/sis/cloud/aws/s3/FileService.java   | 311 +++++++++++++++------
 .../main/org/apache/sis/cloud/aws/s3/KeyPath.java  |  48 +++-
 .../org/apache/sis/cloud/aws/s3/Resources.java     |  22 +-
 .../apache/sis/cloud/aws/s3/Resources.properties   |   6 +-
 .../sis/cloud/aws/s3/Resources_fr.properties       |   8 +-
 .../main/org/apache/sis/cloud/aws/s3/Server.java   | 230 +++++++++++++++
 .../org/apache/sis/cloud/aws/s3/package-info.java  |   7 +-
 .../sis/cloud/aws/s3/ClientFileSystemTest.java     |  35 ++-
 .../apache/sis/cloud/aws/s3/FileServiceTest.java   | 159 +++++++++++
 .../sis/cloud/aws/s3/KeyPathMatcherTest.java       |  10 +-
 .../org/apache/sis/cloud/aws/s3/KeyPathTest.java   | 126 +++++++--
 13 files changed, 882 insertions(+), 160 deletions(-)

diff --cc endorsed/src/org.apache.sis.cloud.aws/main/module-info.java
index 5ab60c1c7a,7fc1f857a0..5c152d2a12
--- a/endorsed/src/org.apache.sis.cloud.aws/main/module-info.java
+++ b/endorsed/src/org.apache.sis.cloud.aws/main/module-info.java
@@@ -16,10 -16,11 +16,11 @@@
   */
  
  /**
-- * Java NIO wrappers for Amazon Simple Storage Service (S3).
++ * Java <abbr>NIO</abbr> wrappers for Amazon Simple Storage Service (S3).
   *
   * @author  Martin Desruisseaux (Geomatys)
-  * @version 1.4
+  * @author  Quentin Bialota (Geomatys)
+  * @version 1.7
   * @since   1.2
   */
  module org.apache.sis.cloud.aws {
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/ClientFileSystem.java
index b8aa19d3eb,73d0468c40..ea90b1d19b
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/ClientFileSystem.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/ClientFileSystem.java
@@@ -20,6 -22,6 +20,7 @@@ import java.util.Set
  import java.util.Collections;
  import java.util.regex.PatternSyntaxException;
  import java.io.IOException;
++import java.net.URISyntaxException;
  import java.nio.file.FileStore;
  import java.nio.file.FileSystem;
  import java.nio.file.Path;
@@@ -54,19 -58,35 +57,18 @@@ final class ClientFileSystem extends Fi
      static final String DEFAULT_SEPARATOR = "/";
  
      /**
--     * The AWS S3 access key, or {@code null} if none.
--     * Also used as key of this file system in the {@link 
FileService#fileSystems} map.
 -     */
 -    final String accessKey;
 -
 -    /**
 -     * The S3 host (if not stored on Amazon Infrastructure), or {@code null} 
if none.
 -     */
 -    final String host;
 -
 -    /**
 -     * The S3 port (if not stored on Amazon Infrastructure), or {@code -1} if 
none.
 -     */
 -    final int port;
 -
 -    /**
 -     * Whether the S3 HTTP Protocol is secure (if this information is not 
stored on Amazon Infrastructure).
 -     * Default is {@code true}.
++     * The provider of this file system.
       */
-     final String accessKey;
 -    final boolean isHttps;
++    private final FileService provider;
  
      /**
--     * The provider of this file system.
++     * Information about the server: protocol, host, port, access key.
       */
--    private final FileService provider;
++    final Server server;
  
      /**
       * The service client for accessing Amazon S3, or {@code null} if this 
file system is closed.
--     * Note that AWS SDK objects are thread-safe.
++     * Note that <abbr>AWS</abbr> <abbr>SDK</abbr> objects are thread-safe.
       */
      private volatile S3Client client;
  
@@@ -82,12 -102,15 +84,14 @@@
      final String duplicatedSeparator;
  
      /**
 -     * Creates a file system with default hostname and default separator.
 +     * Creates a file system with default credential and default separator.
++     * This constructor assumes that the given {@code server} argument has no 
host and no port.
++     * That argument should have been created with {@link 
Server#Server(String)} constructor.
       */
-     ClientFileSystem(final FileService provider, final S3Client client) {
 -    ClientFileSystem(final FileService provider, final S3Client client, 
String accessKey) {
++    ClientFileSystem(final FileService provider, final Server server) {
          this.provider  = provider;
--        this.client    = client;
-         this.accessKey = null;
 -        this.accessKey = accessKey;
 -        this.host      = null;
 -        this.port      = -1;
 -        this.isHttps   = true;
++        this.server    = server;
++        this.client    = S3Client.create();
          this.separator = DEFAULT_SEPARATOR;
          duplicatedSeparator = DEFAULT_SEPARATOR + DEFAULT_SEPARATOR;
      }
@@@ -95,29 -118,44 +99,52 @@@
      /**
       * Creates a new file system with the specified credential.
       *
--     * @param provider    the provider creating this file system.
--     * @param region      the AWS region, or {@code null} for default.
 -     * @param host        the host or {@code null} for AWS request.
 -     * @param port        the port or {@code -1} for AWS request.
 -     * @param isHttps     the protocol is secure or not or {@code null} for 
AWS request.
--     * @param accessKey   the AWS S3 access key for this file system.
--     * @param secret      the password.
--     * @param separator   the separator in paths, or {@code null} for the 
default value.
++     * @param  provider   the provider creating this file system.
++     * @param  region     the AWS region, or {@code null} for default.
++     * @param  server     the host, port, access key and whether the protocol 
is secure.
++     * @param  secret     the password, or {@code null} if none.
++     * @param  separator  the separator in paths, or {@code null} for the 
default value.
++     * @throws URISyntaxException if the server is self-hosted by the 
<abbr>URI</abbr> is invalid.
       */
-     ClientFileSystem(final FileService provider, final Region region, final 
String accessKey, final String secret,
-                      String separator)
 -    ClientFileSystem(final FileService provider, final Region region, final 
String host, final int port,
 -                     final boolean isHttps, final String accessKey, final 
String secret,
 -                     String separator) throws URISyntaxException
++    ClientFileSystem(final FileService provider, final Region region, final 
Server server,
++                     final String secret, String separator) throws 
URISyntaxException
      {
++        // Verify argument validity before to start building the S3 client.
++        final boolean selfHosted = (server.host != null);
++        if (selfHosted != server.port >= 0) {
++            final short  msg;
++            final Object arg;
++            if (selfHosted) {
++                msg = Resources.Keys.MissingPortNumber_1;
++                arg = server.host;
++            } else {
++                msg = Resources.Keys.MissingHostName_1;
++                arg = server.port;
++            }
++            throw new IllegalStateException(Resources.format(msg, arg));
++        }
          if (separator == null) {
              separator = DEFAULT_SEPARATOR;
++        } else {
++            ArgumentChecks.ensureNonEmpty("separator", separator);
          }
--        ArgumentChecks.ensureNonEmpty("separator", separator);
          this.provider  = provider;
--        this.accessKey = accessKey;
-         S3ClientBuilder builder = S3Client.builder().credentialsProvider(
-                 
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, 
secret)));
++        this.server    = server;
++        this.separator = separator;
++        duplicatedSeparator = separator.concat(separator);
+         S3ClientBuilder builder = S3Client.builder();
+         if (secret != null) {
 -            builder = 
builder.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey,
 secret)));
++            AwsBasicCredentials credential = 
AwsBasicCredentials.create(server.accessKey, secret);
++            builder = 
builder.credentialsProvider(StaticCredentialsProvider.create(credential));
+         }
          if (region != null) {
              builder = builder.region(region);
          }
 -        this.host = host;
 -        this.port = port;
 -        this.isHttps = isHttps;
 -        if (host != null) {
 -            String hostname = (port < 0 ? host + ':' + port : host);
 -            String protocol = (this.isHttps ? "https" : "http");
 -            builder = builder.endpointOverride(new URI(protocol + "://" + 
hostname))
 -                    
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build());
++        if (selfHosted) {
++            S3Configuration.Builder config = 
S3Configuration.builder().pathStyleAccessEnabled(true);
++            builder = 
builder.endpointOverride(server.toURI()).serviceConfiguration(config.build());
+         }
          client = builder.build();
--        this.separator = separator;
--        duplicatedSeparator = separator.concat(separator);
      }
  
      /**
@@@ -148,7 -186,7 +175,7 @@@
          final S3Client c = client;
          client = null;
          if (c != null) try {
-             provider.dispose(accessKey);
 -            provider.dispose(new ClientFileSystemKey(accessKey, host, port, 
isHttps));
++            provider.dispose(server);
              c.close();
          } catch (SdkException e) {
              throw new IOException(e);
@@@ -265,6 -303,6 +292,6 @@@
       */
      @Override
      public String toString() {
--        return Strings.toString(getClass(), "accessKey", accessKey);
++        return Strings.toString(getClass(), "server", server);
      }
  }
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/FileService.java
index 635dc54555,126abcb048..a31734f1e3
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/FileService.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/FileService.java
@@@ -22,6 -23,6 +22,8 @@@ import java.util.function.Function
  import java.util.concurrent.ConcurrentMap;
  import java.util.concurrent.ConcurrentHashMap;
  import java.net.URI;
++import java.net.URISyntaxException;
++import java.net.MalformedURLException;
  import java.io.IOException;
  import java.io.InputStream;
  import java.nio.file.Path;
@@@ -47,7 -48,7 +49,6 @@@ import java.nio.channels.SeekableByteCh
  import software.amazon.awssdk.regions.Region;
  import software.amazon.awssdk.core.ResponseInputStream;
  import software.amazon.awssdk.core.exception.SdkException;
--import software.amazon.awssdk.services.s3.S3Client;
  import software.amazon.awssdk.services.s3.model.Bucket;
  import software.amazon.awssdk.services.s3.model.S3Object;
  import software.amazon.awssdk.services.s3.model.GetObjectRequest;
@@@ -57,6 -58,6 +58,7 @@@ import org.apache.sis.util.CharSequence
  import org.apache.sis.util.ArgumentChecks;
  import org.apache.sis.util.resources.Errors;
  import org.apache.sis.util.collection.Containers;
++import org.apache.sis.util.internal.shared.Strings;
  
  
  /**
@@@ -80,48 -84,112 +85,73 @@@
   */
  public class FileService extends FileSystemProvider {
      /**
--     * An arbitrary string used as key in the {@link #fileSystems} map
--     * when the user did not specify explicitly an access key.
--     * In such case, the default mechanism documented in AWS SDK is used.
--     * In preference order:
--     *
--     * <ul>
--     *   <li>{@code aws.accessKeyId} and {@code aws.secretAccessKey} Java 
system properties.</li>
--     *   <li>{@code AWS_ACCESS_KEY_ID} and {@code AWS_SECRET_ACCESS_KEY} 
environment variables.</li>
--     *   <li>{@code ~/.aws/credentials} or {@code ~/.aws/config} files.</li>
--     * </ul>
++     * The default port number for the <abbr>HTTP</abbr> protocol.
+      */
 -    private static final String DEFAULT_ACCESS_KEY = "";
++    static final int HTTP_PORT = 80;
+ 
+     /**
 -     * An arbitrary string used as part of the key in the {@link 
#fileSystems} map
 -     * when the user did not specified explicitly a host.
 -     * In such case, the default host is the Amazon host and is defined with 
the region.
 -     *
 -     * Host can also be defined with:
 -     * <ul>
 -     *   <li>{@code #S3_HOST_URL} Java system properties.</li>
 -     * </ul>
++     * The default port number for the <abbr>HTTPS</abbr> protocol.
       */
-     private static final String DEFAULT_ACCESS_KEY = "";
 -    private static final String DEFAULT_HOST_KEY = null;
++    static final int HTTPS_PORT = 443;
  
      /**
 -     * An arbitrary string used as part of the key in the {@link 
#fileSystems} map
 -     * when the user did not specified explicitly a port.
 -     * In such case, no port is assigned, the default port is used
 +     * The property for the secret access key (password).
 +     * Values shall be instances of {@link String}.
 +     * If not specified, the AWS SDK default mechanism searches for the first 
of the following:
       *
 -     * Port can also be defined with :
       * <ul>
 -     *   <li>{@code #S3_PORT} Java system properties.</li>
 +     *   <li>{@code AWS_SECRET_ACCESS_KEY} environment variable.</li>
 +     *   <li>{@code ~/.aws/credentials} and {@code ~/.aws/config} files.</li>
       * </ul>
 -     */
 -    private static final int DEFAULT_PORT_KEY = -1;
 -
 -    /**
 -     * A boolean used as part of the key in the {@link #fileSystems} map
 -     * when the user did not specified explicitly a protocol.
 -     * In such case, the default protocol is HTTPS
       *
 -     * Port can also be defined with :
 -     * <ul>
 -     *   <li>{@code #S3_IS_HTTPS} Java system properties.</li>
 -     * </ul>
 +     * @see #newFileSystem(URI, Map)
       */
 -    private static final boolean DEFAULT_IS_HTTPS = true;
 +    public static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey";
  
      /**
--     * The property for the secret access key (password).
 -     * Values shall be instances of {@link String}.
 -     * If not specified, the AWS SDK default mechanism searches for the first 
of the following:
++     * The property for the region.
 +     * Values shall should be instances of {@link Region} or
 +     * strings {@linkplain Region#of(String) convertible} to region.
-      * If not specified, the AWS SDK default mechanism searches for the first 
of the following:
++     * If not specified, the <abbr>AWS</abbr> <abbr>SDK</abbr> default
++     * mechanism searches for the first of the following:
       *
       * <ul>
 -     *   <li>{@code AWS_SECRET_ACCESS_KEY} environment variable.</li>
 +     *   <li>{@code AWS_REGION} environment variable.</li>
       *   <li>{@code ~/.aws/credentials} and {@code ~/.aws/config} files.</li>
       * </ul>
       *
       * @see #newFileSystem(URI, Map)
       */
 -    public static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey";
 +    public static final String AWS_REGION = "aws.region";
  
+     /**
 -     * The property for the host (mandatory if not using AWS S3).
++     * The property for the host (mandatory if not using <abbr>AWS</abbr> S3).
+      * Values shall be instances of {@link String}.
+      *
+      * @see #newFileSystem(URI, Map)
+      * @since 1.7
+      */
 -    public static final String S3_HOST_URL = "org.apache.sis.s3.hostURL";
++    public static final String HOST_URL = "hostURL";
+ 
+     /**
 -     * The property for the port (mandatory if not using AWS S3).
++     * The property for the port (mandatory if not using <abbr>AWS</abbr> S3).
+      * Values shall be instances of {@link Integer}.
+      *
+      * @see #newFileSystem(URI, Map)
+      * @since 1.7
+      */
 -    public static final String S3_PORT = "org.apache.sis.s3.port";
++    public static final String PORT = "port";
+ 
+     /**
+      * The property for the protocol (optional).
+      * Values shall be instances of {@link Boolean}.
 -     * Default value : True (HTTPS)
++     * The default value is {@code true} (<abbr>HTTPS</abbr>).
+      *
+      * @see #newFileSystem(URI, Map)
+      * @since 1.7
+      */
 -    public static final String S3_IS_HTTPS = "org.apache.sis.s3.isHttps";
 -
 -    /**
 -     * The property for the secret access key (password).
 -     * Values shall should be instances of {@link Region} or
 -     * strings {@linkplain Region#of(String) convertible} to region.
 -     * If not specified, the AWS SDK default mechanism searches for the first 
of the following:
 -     *
 -     * <ul>
 -     *   <li>{@code AWS_REGION} environment variable.</li>
 -     *   <li>{@code ~/.aws/credentials} and {@code ~/.aws/config} files.</li>
 -     * </ul>
 -     *
 -     * @see #newFileSystem(URI, Map)
 -     */
 -    public static final String AWS_REGION = "aws.region";
++    public static final String IS_HTTPS = "isHttps";
+ 
      /**
       * The property for the name-separator characters.
       * The default value is "/" for simulating Unix paths.
@@@ -132,9 -200,9 +162,9 @@@
      public static final String SEPARATOR = "separator";
  
      /**
--     * All file systems created by this provider. Keys are AWS S3 access keys.
++     * All file systems created by this provider. Keys are <abbr>AWS</abbr> 
S3 access keys.
       */
-     private final ConcurrentMap<String, ClientFileSystem> fileSystems;
 -    private final ConcurrentMap<ClientFileSystemKey, ClientFileSystem> 
fileSystems;
++    private final ConcurrentMap<Server, ClientFileSystem> fileSystems;
  
      /**
       * Creates a new provider of file systems for Amazon S3.
@@@ -144,9 -212,9 +174,9 @@@
      }
  
      /**
--     * Returns the URI scheme that identifies this provider, which is {@code 
"S3"}.
++     * Returns the <abbr>URI</abbr> scheme that identifies this provider, 
which is {@code "S3"}.
       *
--     * @return the {@code "S3"} URI scheme.
++     * @return the {@code "S3"} <abbr>URI</abbr> scheme.
       */
      @Override
      public String getScheme() {
@@@ -191,12 -259,13 +221,19 @@@
       *   <li>{@value #AWS_SECRET_ACCESS_KEY} with {@link String} value.</li>
       *   <li>{@value #AWS_REGION} with {@link Region} value or a string
       *     {@linkplain Region#of(String) convertible} to region.</li>
++     *   <li>{@value #HOST_URL} with {@link String} value.</li>
++     *   <li>{@value #PORT} with {@link Integer} value.</li>
++     *   <li>{@value #IS_HTTPS} with {@link Boolean} value.</li>
       * </ul>
       *
-      * @param  uri         a <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"}.
 -     * @param  uri         a <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"}
 -     *                     or {@code s3://accessKey@host:port/bucket/file} 
(in this second case, host AND port are mandatory).
++     * <p>The <abbr>URI</abbr> can be of the form {@code 
"s3://accessKey@bucket/file"} (<abbr>AWS</abbr> S3)
++     * or {@code "s3://accessKey@host:port/bucket/file"} (self-hosted S3).
++     * In the latter case, the host <em>and</em> the port are mandatory.</p>
++     *
++     * @param  uri an <abbr>URI</abbr> of the form {@code 
"s3://accessKey@[host:port/]bucket/file"}.
       * @param  properties  properties to configure the file system, or {@code 
null} if none.
       * @return the new file system.
--     * @throws IllegalArgumentException if the URI or the map contains 
invalid values.
++     * @throws IllegalArgumentException if the <abbr>URI</abbr> or the map 
contains invalid values.
       * @throws IOException if an I/O error occurs while creating the file 
system.
       * @throws FileSystemAlreadyExistsException if a file system has already 
been created
       *         for the given URI and has not yet been closed.
@@@ -208,72 -279,205 +245,182 @@@
          if (accessKey == null || (secret = Containers.property(properties, 
AWS_SECRET_ACCESS_KEY, String.class)) == null) {
              throw new 
IllegalArgumentException(Resources.format(Resources.Keys.MissingAccessKey_2, 
(accessKey == null) ? 0 : 1, uri));
          }
--        final String separator = Containers.property(properties, SEPARATOR, 
String.class);
--        final Region region;
--        Object value = properties.get(AWS_REGION);
--        if (value instanceof String) {
--            region = Region.of((String) value);
--        } else {
--            region = Containers.property(properties, AWS_REGION, 
Region.class);
--        }
-         final class Creator implements Function<String, ClientFileSystem> {
 -
 -        final class Creator implements Function<ClientFileSystemKey, 
ClientFileSystem> {
--            /** Identifies if a new file system is created. */ boolean 
created;
 -            /** Store URI Syntax exception. */ URISyntaxException exception;
++        final String  separator = Containers.property(properties, SEPARATOR, 
String.class);
++        final Region  region    = property(properties, AWS_REGION, 
Region.class, null, Region::of);
++        /*
++         * Host and port number specified in the property map have precedence 
over the URI.
++         * If these information were not found anywhere, the AWS SDK will 
fetch them itself.
++         */
++        final var server = new Server(accessKey,
++                Containers.property(properties, HOST_URL, String.class),
++                property(properties, PORT, Integer.class, Server.NO_PORT, 
Integer::valueOf),
++                property(properties, IS_HTTPS, Boolean.class, 
Server.DEFAULT_IS_HTTPS, Strings::parseBoolean),
++                uri);
++        /*
++         * The following class is for checking if a `ClientFileSystem` exists 
before to create one,
++         * in one atomic concurrent hash map operation. The standard 
`Map.computeIfAbsent(…)` method
++         * does not tell us whether the returned value was the existing one 
or a new one.
++         * We need a flag for differentiating the two cases.
++         */
++        final class Creator implements Function<Server, ClientFileSystem> {
++            /** Whether this function has been invoked.  */ boolean invoked;
++            /** If the operation failed, the reason why. */ 
URISyntaxException exception;
  
-             /** Invoked if the map does not already contains the file system. 
*/
-             @Override public ClientFileSystem apply(final String key) {
-                 created = true;
-                 return new ClientFileSystem(FileService.this, region, key, 
secret, separator);
+             @Override
 -            public ClientFileSystem apply(final ClientFileSystemKey key) {
 -                created = true;
++            public ClientFileSystem apply(final Server key) {
++                invoked = true;
+                 try {
 -                    return new ClientFileSystem(FileService.this, region, 
key.host, key.port, key.isHttps, key.accessKey, secret, separator);
 -                } catch (URISyntaxException ex) {
 -                    created = false;
 -                    exception = ex;
 -                    return null; // Nothing added to the map
++                    return new ClientFileSystem(FileService.this, region, 
key, secret, separator);
++                } catch (URISyntaxException e) {
++                    exception = e;
++                    return null;
+                 }
              }
          }
 -
 -        Boolean isHttps;
 -        if ((isHttps = Containers.property(properties, S3_IS_HTTPS, 
Boolean.class)) == null) {
 -            isHttps = DEFAULT_IS_HTTPS;
 -        }
 -
 -        /*
 -         * In case of Self-Hosted S3, if host and port are not found in the 
URI
 -         * We check in java properties
 -         * Else we use Default values (=> use AWS S3)
 -         */
 -        if (port < 0) {
 -            if ((host = Containers.property(properties, S3_HOST_URL, 
String.class)) != null) {
 -                Integer portProp = Containers.property(properties, S3_PORT, 
Integer.class);
 -                //  In case of Self-Hosted S3, if port is not found in the 
URI, but a host is defined
 -                if (portProp == null || portProp < 0) {
 -                    if (isHttps) {
 -                        port = 443; // Default HTTPS port
 -                    } else {
 -                        port = 80; // Default HTTP port
 -                    }
 -                } else {
 -                    port = portProp;
 -                }
 -
 -            } else {
 -                host = DEFAULT_HOST_KEY;
 -                port = DEFAULT_PORT_KEY;
 -            }
 -        }
 -
--        final Creator c = new Creator();
-         final ClientFileSystem fs = fileSystems.computeIfAbsent(accessKey, c);
 -        final ClientFileSystem fs = fileSystems.computeIfAbsent(new 
ClientFileSystemKey(accessKey, host, port, isHttps), c);
--        if (c.created) {
++        final var c = new Creator();
++        final ClientFileSystem fs = fileSystems.computeIfAbsent(server, c);
++        if (c.exception != null) {
++            final var e = new 
MalformedURLException(Resources.format(Resources.Keys.CannotConnectTo_1, 
server));
++            e.initCause(c.exception);
++            throw e;
++        } else if (c.invoked) {
              return fs;
 -        } else if (c.exception != null) {
 -            throw new IllegalArgumentException("Invalid URI: " + uri, 
c.exception);
          }
          throw new 
FileSystemAlreadyExistsException(Resources.format(Resources.Keys.FileSystemInitialized_2,
 1, accessKey));
      }
  
      /**
--     * Removes the given file system from the cache.
--     * This method is invoked after the file system has been closed.
 -     */
 -    final void dispose(ClientFileSystemKey identifier) {
 -        if (identifier == null) {
 -            identifier = new ClientFileSystemKey(DEFAULT_ACCESS_KEY, 
DEFAULT_HOST_KEY, DEFAULT_PORT_KEY, DEFAULT_IS_HTTPS);
 -        }
 -        fileSystems.remove(identifier);
 -    }
 -
 -    /**
 -     * Returns the file system associated to the {@link #DEFAULT_ACCESS_KEY}, 
{@link #DEFAULT_HOST_KEY}, {@link #DEFAULT_PORT_KEY} and {@link 
#DEFAULT_IS_HTTPS}.
 -     *
 -     * @throws SdkException if the file system cannot be created.
 -     */
 -    private ClientFileSystem getDefaultFileSystem() {
 -        return fileSystems.computeIfAbsent(new 
ClientFileSystemKey(DEFAULT_ACCESS_KEY, DEFAULT_HOST_KEY, DEFAULT_PORT_KEY, 
DEFAULT_IS_HTTPS), (key) ->
 -                new ClientFileSystem(this, S3Client.create(), null));
 -    }
 -
 -    /**
 -     * Returns the file system associated to the {@link #DEFAULT_HOST_KEY} 
and {@link #DEFAULT_PORT_KEY}.
 -     *
 -     * @param accessKey     the access key
 -     * @throws SdkException if the file system cannot be created.
 -     */
 -    private ClientFileSystem getDefaultFileSystem(String accessKey) {
 -        return fileSystems.computeIfAbsent(
 -                new ClientFileSystemKey(accessKey, DEFAULT_HOST_KEY, 
DEFAULT_PORT_KEY, DEFAULT_IS_HTTPS),
 -                (key) ->
 -                        new ClientFileSystem(this, S3Client.create(), 
key.accessKey));
 -    }
 -
 -    /**
 -     * Returns the file system associated to the {@link #DEFAULT_ACCESS_KEY}.
++     * Returns a value from the map specified in the constructor.
++     * The value can be parsed from a string representation.
+      *
 -     * @param host          the host
 -     * @param port          the port
 -     * @throws SdkException if the file system cannot be created.
 -     * @throws URISyntaxException if the URI is not valid.
++     * @param  <T>           compile-time value of the {@code type} argument.
++     * @param  properties    map from which to get a property value.
++     * @param  key           key of the property to get.
++     * @param  type          type of the property to get.
++     * @param  defaultValue  default value if the key is not associated to a 
non-null value.
++     * @param  parser        function to invoke for converting a text to a 
value.
++     * @return the property value for the given key cast to the given type, 
or {@code defaultValue} if none.
++     * @throws IllegalArgumentException if the value is not of the expected 
type.
       */
-     final void dispose(String identifier) {
-         if (identifier == null) {
-             identifier = DEFAULT_ACCESS_KEY;
 -    private ClientFileSystem getDefaultFileSystem(String host, Integer port) 
throws URISyntaxException {
 -        ClientFileSystemKey key = new ClientFileSystemKey(DEFAULT_ACCESS_KEY, 
host, port, DEFAULT_IS_HTTPS);
 -
 -        synchronized (fileSystems) {
 -            ClientFileSystem fs = fileSystems.get(key);
 -            if (fs != null) return fs;
 -            fs = new ClientFileSystem(this, null, key.host, key.port, 
key.isHttps, key.accessKey, null, null);
 -            fileSystems.put(key, fs);
 -            return fs;
++    private static <T> T property(final Map<String,?> properties, final 
String key,
++                                  final Class<T> type, final T defaultValue,
++                                  final Function<String, T> parser)
++    {
++        final Object value = properties.get(key);
++        if (value == null) {
++            return defaultValue;
++        } else if (value instanceof CharSequence) {
++            T c = parser.apply((String) value);
++            if (c != null) return c;
++        }
++        try {
++            return type.cast(value);
++        } catch (ClassCastException e) {
++            throw new 
IllegalArgumentException(Errors.forProperties(properties)
++                    .getString(Errors.Keys.IllegalPropertyValueClass_3, key, 
type, value.getClass()), e);
          }
-         fileSystems.remove(identifier);
      }
  
      /**
--     * Returns the file system associated to the {@link #DEFAULT_ACCESS_KEY}.
--     *
 -     * @param host          the host
 -     * @param port          the port
 -     * @param isHttps       the protocol
--     * @throws SdkException if the file system cannot be created.
 -     * @throws URISyntaxException if the URI is not valid.
++     * Removes the given file system from the cache.
++     * This method is invoked after the file system has been closed.
       */
-     private ClientFileSystem getDefaultFileSystem() {
-         return fileSystems.computeIfAbsent(DEFAULT_ACCESS_KEY, (key) -> new 
ClientFileSystem(this, S3Client.create()));
 -    private ClientFileSystem getDefaultFileSystem(String host, Integer port, 
boolean isHttps) throws URISyntaxException {
 -        ClientFileSystemKey key = new ClientFileSystemKey(DEFAULT_ACCESS_KEY, 
host, port, isHttps);
 -
 -        // 1. Try to get the existing one first (no locking)
 -        synchronized (fileSystems) {
 -            ClientFileSystem fs = fileSystems.get(key);
 -            if (fs != null) return fs;
 -            fs = new ClientFileSystem(this, null, key.host, key.port, 
key.isHttps, key.accessKey, null, null);
 -            fileSystems.put(key, fs);
 -            return fs;
 -        }
++    final void dispose(final Server server) {
++        fileSystems.remove(server);
      }
  
      /**
       * Returns a reference to a file system that was created by the {@link 
#newFileSystem(URI, Map)} method.
       * If the file system has not been created or has been closed,
       * then this method throws {@link FileSystemNotFoundException}.
 -     * If the given URI contains a port number, HTTP(S) protocol is 
determined by the port number (port 80 means HTTP, port 443 mean HTTPS).
 -     * If another port is used we use the default protocol (HTTPS).
 -     * A first attempt is made with the protocol determined from the port 
number, then a second attempt is made with the opposite protocol.
       *
-      * @param  uri  a <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"}.
 -     * @param  uri  a <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"} or {@code "s3://accessKey@host:port/bucket/key"}.
++     * <p>The <abbr>URI</abbr> scheme should be the value returned by {@link 
#getScheme()}, which is usually {@code "S3"}.
++     * The <abbr>AWS</abbr> S3 service may be implemented on top of 
<abbr>HTTP</abbr> or <abbr>HTTPS</abbr> protocol.
++     * This method detects automatically which protocol was specified in the 
call to {@link #newFileSystem(URI, Map)}.
++     * If the two protocols have been used for the same host, then this 
method uses the port number for disambiguation:
++     * port 80 is mapped to <abbr>HTTP</abbr> and port 443 is mapped to 
<abbr>HTTPS</abbr>.</p>
++     *
++     * @param  uri  an <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"} or {@code "s3://accessKey@host:port/bucket/key"}.
       * @return the file system previously created by {@link 
#newFileSystem(URI, Map)}.
--     * @throws IllegalArgumentException if the URI is not supported by this 
provider.
++     * @throws IllegalArgumentException if the <abbr>URI</abbr> is not 
supported by this provider.
       * @throws FileSystemNotFoundException if the file system does not exist 
or has been closed.
       */
      @Override
      public FileSystem getFileSystem(final URI uri) {
 -        final String accessKey = getAccessKey(uri);
 -        final String host = uri.getHost();
 -        final int port = uri.getPort();
++        return getFileSystem(uri, false);
++    }
+ 
++    /**
++     * Implementation of {@link #getFileSystem(URI)} with the option of 
creating the file system instead
++     * of throwing an exception.
++     *
++     * @param  uri     an <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"} or {@code "s3://accessKey@host:port/bucket/key"}.
++     * @param  create  {@code true} for creating the file system if it does 
not already exist.
++     * @return the file system previously created by {@link 
#newFileSystem(URI, Map)}.
++     * @throws FileSystemNotFoundException if the file system does not exist 
and {@code create} is {@code false}.
++     */
++    private ClientFileSystem getFileSystem(final URI uri, final boolean 
create) {
 +        final String accessKey = getAccessKey(uri);
-         if (accessKey == null) {
-             return getDefaultFileSystem();
++        boolean isHttps = Server.DEFAULT_IS_HTTPS;
++        int port = uri.getPort();
++        final String host = (port >= 0) ? uri.getHost() : null;
++        switch (port) {
++            case HTTP_PORT:  isHttps = false; break;
++            case HTTPS_PORT: isHttps = true;  break;
 +        }
-         final ClientFileSystem fs = fileSystems.get(accessKey);
-         if (fs != null) {
-             return fs;
+         /*
 -         * HTTPS or HTTP is not defined in the URI. By default, we use 
default value,
 -         * except if the port is 80 or 443, in which case we use HTTP or 
HTTPS.
++         * Try the following combinations, in order
++         * (the logic is to give precedence to explicit parameters, then to 
security):
++         *
++         *   - specified port (may be -1),   isHttps
++         *   - specified port (may be -1),  !isHttps
++         *   - default port for  `isHttps`,  isHttps
++         *   - default port for `!isHttps`, !isHttps
++         *   - default port for `!isHttps`,  isHttps  (unusual, possibly a 
user's error.
++         *   - default port for  `isHttps`, !isHttps  (unusual, possibly a 
user's error.
+          */
 -        boolean isHttps = DEFAULT_IS_HTTPS;
 -        if (port == 80) {
 -            isHttps = false;
 -        } else if (port == 443) {
 -            isHttps = true;
 -        }
 -
 -        if (accessKey == null && port > -1) {
 -            // No access key, but host and port are defined => Self Hosted
 -            try {
 -                return getDefaultFileSystem(host, port, isHttps);
 -            } catch (URISyntaxException ex) {
 -                throw new IllegalArgumentException("Invalid URI: " + uri, ex);
++        boolean tryDefaultPorts  = false;
++        boolean tryOppositePorts = false;
++        Server first = null;
++        for (;;) {
++            if (tryDefaultPorts) {
++                port = (tryOppositePorts ^ isHttps) ? HTTPS_PORT : HTTP_PORT;
++            }
++            final Server server = new Server(accessKey, host, port, isHttps, 
uri);
++            final ClientFileSystem fs = fileSystems.get(server);
++            if (fs != null) {
++                return fs;
++            }
++            if (first == null) {
++                first = server;     // Will be used for the error message if 
we cannot find a file system.
++            }
++            isHttps = !isHttps;
++            if (isHttps == first.isHttps) {
++                if (!tryDefaultPorts) {
++                    if (port >= 0) break;       // Do not try default ports 
if a port was explicitly specified.
++                    tryDefaultPorts = true;
++                } else if (!tryOppositePorts) {
++                    tryOppositePorts = true;
++                } else {
++                    break;      // We tried all combinations.
++                }
+             }
 -        } else if (accessKey == null && port < 0) {
 -            // No access key, no host, no port => AWS S3
 -            return getDefaultFileSystem();
 -        } else if (accessKey != null && port < 0) {
 -            // Access key, no host, no port => AWS S3
 -            return getDefaultFileSystem(accessKey);
+         }
 -
+         /*
 -         * Try to get the file system with the protocol determined from the 
port number, then try with the opposite protocol.
++         * No existing file system found. Create if we are allowed to do so.
+          */
 -        ClientFileSystem fs = fileSystems.get(new 
ClientFileSystemKey(accessKey, host, port, isHttps));
 -        if (fs != null) {
 -            return fs;
 -        }
 -        fs = fileSystems.get(new ClientFileSystemKey(accessKey, host, port, 
!isHttps));
 -        if (fs != null) {
 -            return fs;
++        if (create) {
++            return fileSystems.computeIfAbsent(first, (key) -> {
++                try {
++                    return new ClientFileSystem(this, null, key, null, null);
++                } catch (URISyntaxException e) {
++                    throw new 
IllegalArgumentException(Resources.format(Resources.Keys.CannotConnectTo_1, 
key), e);
++                }
++            });
++        } else {
++            throw new FileSystemNotFoundException(Resources.format(
++                    Resources.Keys.FileSystemInitialized_2, 0, first));
          }
--        throw new 
FileSystemNotFoundException(Resources.format(Resources.Keys.FileSystemInitialized_2,
 0, accessKey));
      }
  
      /**
@@@ -281,34 -485,93 +428,42 @@@
       * The resulting {@code Path} is associated with a {@link FileSystem}
       * that already exists or is constructed automatically.
       *
-      * @param  uri  a <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"}.
 -     * @param  uri a <abbr>URI</abbr> of the form {@code 
"s3://accessKey@bucket/file"} (AWS S3)
 -     *             or {@code s3://accessKey@host:port/bucket/file} 
(Self-hosted S3) (in this second case, host AND port are mandatory).
++     * <p>The <abbr>URI</abbr> can be of the form {@code 
"s3://accessKey@bucket/file"} (<abbr>AWS</abbr> S3)
++     * or {@code "s3://accessKey@host:port/bucket/file"} (self-hosted S3).
++     * In the latter case, the host <em>and</em> the port are mandatory.</p>
++     *
++     * @param  uri an <abbr>URI</abbr> of the form {@code 
"s3://accessKey@[host:port/]bucket/file"}.
       * @return the resulting {@code Path}.
       * @throws IllegalArgumentException if the URI is not supported by this 
provider.
       * @throws FileSystemNotFoundException if the file system does not exist 
and cannot be created automatically.
       */
      @Override
      public Path getPath(final URI uri) {
--        final String accessKey = getAccessKey(uri);
-         final ClientFileSystem fs;
 -        String host = uri.getHost();
 -        final int port = uri.getPort();
 -
 -        if (host == null) {
 -            /*
 -             * The host is null if the authority contains characters that are 
invalid for a host name.
 -             * For example if the host contains underscore character ('_'), 
then it is considered invalid.
 -             * We could use the authority instead, but that authority may 
contain a user name, port number, etc.
 -             * Current version do not try to parse that string.
 -             */
 -            host = uri.getAuthority();
 -            if (host == null) host = uri.toString();
 -            throw new 
IllegalArgumentException(Resources.format(Resources.Keys.InvalidBucketName_1, 
host));
 -        }
 -
 -        ClientFileSystem fs;
--        if (accessKey == null) {
-             fs = getDefaultFileSystem();
 -            if (port < 0) {
 -                fs = getDefaultFileSystem();
 -            } else {
 -                try {
 -                    fs = getDefaultFileSystem(host, port);
 -                } catch (URISyntaxException ex) {
 -                    throw new IllegalArgumentException("Invalid URI: " + uri, 
ex);
 -                }
 -            }
--        } else {
--            // TODO: we may need a way to get password here.
-             fs = fileSystems.computeIfAbsent(accessKey, (key) -> new 
ClientFileSystem(FileService.this, null, key, null, null));
 -            // TODO: we may need a way to get SSL status here (is HTTPS or 
not)
 -            ClientFileSystemKey fsKey;
 -
 -            if (port < 0) {
 -                fsKey = new ClientFileSystemKey(accessKey, DEFAULT_HOST_KEY, 
DEFAULT_PORT_KEY, DEFAULT_IS_HTTPS);
 -            } else {
 -                fsKey = new ClientFileSystemKey(accessKey, host, port, 
DEFAULT_IS_HTTPS);
 -            }
 -
 -            // Compute if absent
 -            try {
 -                synchronized (fileSystems) {
 -                    fs = fileSystems.get(fsKey);
 -                    if (fs == null) {
 -                        fs = new ClientFileSystem(this, null, fsKey.host, 
fsKey.port, fsKey.isHttps, fsKey.accessKey, null, null);
 -                        fileSystems.put(fsKey, fs);
 -                    }
 -                }
 -            } catch (URISyntaxException ex) {
 -                throw new IllegalArgumentException("Invalid URI: " + uri, ex);
 -            }
--        }
 -
 -        String path = uri.getPath();
 -
+         /*
 -         * In case of custom host, bucket name will be the first element of 
the "uri.getPath()"
 -         * We need to get the first element of this path (this will be the 
bucket), and the second part will be
++         * In case of custom host, bucket name will be the first element of 
the "uri.getPath()".
++         * We want:
++         *
++         *   - `host` as the S3 bucket name.
++         *   - `path` as the path in above bucket.
+          */
 -        if (fs.host != null) {
 -            if (!(fs.host.equalsIgnoreCase(DEFAULT_HOST_KEY))) {
 -                if (path.startsWith("/")) {
 -                    path = path.substring(1);
 -                }
 -                String[] parts = path.split("/", 2);
 -                if (parts.length >= 2) {
 -                    // Bucket + Path specified (=> /bucket/path/to/folder)
 -                    host = parts[0];
 -                    path = "/" + parts[1];
 -                } else if (parts.length == 1) {
 -                    // Bucket specified (no path) (=> /bucket)
 -                    host = parts[0];
 -                    path = null;
 -                }
++        String path = uri.getPath();
 +        String host = uri.getHost();
-         if (host == null) {
-             /*
-              * The host is null if the authority contains characters that are 
invalid for a host name.
-              * For example if the host contains underscore character ('_'), 
then it is considered invalid.
-              * We could use the authority instead, but that authority may 
contain a user name, port number, etc.
-              * Current version do not try to parse that string.
-              */
-             host = uri.getAuthority();
-             if (host == null) host = uri.toString();
-             throw new 
IllegalArgumentException(Resources.format(Resources.Keys.InvalidBucketName_1, 
host));
++        if (host != null) {
++            if (path.startsWith("/")) {
++                path = path.substring(1);
++            }
++            String[] parts = path.split("/", 2);
++            if (parts.length >= 2) {
++                // Bucket + Path specified. Example: "/bucket/path/to/folder"
++                host = parts[0];
++                path = "/" + parts[1];
++            } else if (parts.length == 1) {
++                // Bucket specified without path. Example: "/bucket"
++                host = parts[0];
++                path = null;
+             }
          }
-         final String path = uri.getPath();
 -        /*
 -         * - "host" in this part is the S3 bucket name
 -         * - "path" is the path in this bucket
 -         */
++        final ClientFileSystem fs = getFileSystem(uri, true);
          return new KeyPath(fs, host, (path != null) ? new String[] {path} : 
CharSequences.EMPTY_ARRAY, true);
      }
  
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/KeyPath.java
index 50171eb101,4b564ac99d..5ce7070213
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/KeyPath.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/KeyPath.java
@@@ -47,6 -48,6 +47,7 @@@ import org.apache.sis.util.resources.Er
   * The interpretation of {@link ClientFileSystem#separator} as a path 
separator is done by this class.</p>
   *
   * @author  Martin Desruisseaux (Geomatys)
++ * @author  Quentin Bialota (Geomatys)
   */
  final class KeyPath implements Path {
      /**
@@@ -57,7 -58,7 +58,7 @@@
      /**
       * Separator between {@value #SCHEME} and the bucket name.
       */
--    private static final String SCHEME_SEPARATOR = "://";
++    static final String SCHEME_SEPARATOR = "://";
  
      /**
       * Length of the {@code "S3://"} header.
@@@ -676,7 -677,7 +677,7 @@@ search:     if (key != null) 
      @Override
      public Path relativize(final Path other) {
          if (other instanceof KeyPath) {
--            final KeyPath kp = (KeyPath) other;
++            final var kp = (KeyPath) other;
              if (kp.startsWith(this) && key != null) {
                  final String suffix = kp.key.substring(key.length() + 
fs.separator.length());
                  if (!suffix.isEmpty()) {
@@@ -688,7 -689,7 +689,8 @@@
      }
  
      /**
--     * Returns an URI with the {@value #SCHEME} scheme if the path is 
absolute, or a relative URI otherwise.
++     * Returns an <abbr>URI</abbr> with the {@value #SCHEME} scheme if the 
path is absolute,
++     * or a relative <abbr>URI</abbr> otherwise.
       *
       * <p>Note: {@link Path#toUri()} specification mandate an absolute URI.
       * But we cannot provide an absolute URI if this path is not already 
absolute.</p>
@@@ -699,12 -700,30 +701,39 @@@
      public URI toUri() {
          String path = key;
          if (path != null) {
--            final StringBuilder sb = new StringBuilder(path.length() + 
2).append('/').append(path);
++            final var sb = new StringBuilder(path.length() + 
2).append('/').append(path);
              if (isDirectory) sb.append('/');
              path = sb.toString();
++        } else {
++            path = "";
++        }
++        if (fs == null) {
++            throw new 
IllegalStateException(Resources.format(Resources.Keys.MissingFileSystem_1, 
path));
++        }
++        /*
++         * We can address two different URI formats,
++         * depending on whether the file system is self-hosted or not:
++         *
++         *   - Self-Hosted path:  s3://accessKey@host:port/bucket/key
++         *   - AWS path:          s3://accessKey@bucket/key
++         *
++         * The `ClientFileSytem` constructor verified that either `host`
++         * and `port` are both specified, or neither of them is specifed.
++         * We also verify bucket presence to allow relative paths URIs.
++         */
++        final Server server = fs.server;
++        final String host;
++        final int port;
++        if (server.host != null && bucket != null) {
++            host = server.host;
++            port = server.port;
++            path = "/" + bucket + path;
++        } else {
++            host = bucket;
++            port = Server.NO_PORT;
          }
          try {
-             return new URI(SCHEME, fs.accessKey, bucket, -1, path, null, 
null);
 -            if (fs != null) {
 -                /*
 -                 * We can address two different URI formats, depending on 
whether the file system is self-hosted or not:
 -                 * - Self-Hosted path : s3://accessKey@host:port/bucket/key
 -                 * - AWS path : s3://accessKey@bucket/key
 -                 * We also verify bucket presence to allow relative paths 
URIs.
 -                 */
 -                if (fs.host != null && fs.port < 0) {
 -                    throw new IllegalStateException("Self-hosted file system 
shall have a port number.");
 -                } else if (fs.host == null && fs.port >= 0) {
 -                    throw new IllegalStateException("Port number specified, 
but no host name. Incompatible with self-hosted and AWS file system.");
 -                }
 -                boolean selfHosted = fs.host != null && fs.port >= 0;
 -                String host = (selfHosted && bucket != null) ? fs.host : 
bucket;
 -                int port = (selfHosted && bucket != null) ? fs.port : -1;
 -                String uriPath = (selfHosted && bucket != null) ? "/" + 
bucket + (path != null ? path : "") : (path != null ? path : "");
 -                return new URI(SCHEME, fs.accessKey, host, port, uriPath, 
null, null);
 -            }
 -            throw new IllegalStateException("No filesystem associated with 
this path.");
++            return new URI(SCHEME, server.accessKey, host, port, path, null, 
null);
          } catch (URISyntaxException e) {
              throw new IllegalStateException(e.getMessage(), e);
          }
@@@ -718,14 -737,27 +747,7 @@@
          if (bucket == null && !isDirectory) {
              return key;
          }
 -        /*
 -         * We can address two different URI formats, depending on whether the 
file system is self-hosted or not:
 -         * - Self-Hosted path : s3://accessKey@host:port/bucket/key
 -         * - AWS path : s3://accessKey@bucket/key
 -         */
 -        if (fs.host != null && fs.port < 0) {
 -            throw new IllegalStateException("Self-hosted file system shall 
have a port number.");
 -        } else if (fs.host == null && fs.port >= 0) {
 -            throw new IllegalStateException("Port number specified, but no 
host name. Incompatible with self-hosted and AWS file system.");
 -        }
--        final StringBuilder sb = new StringBuilder();
--        if (bucket != null) {
--            sb.append(SCHEME).append(SCHEME_SEPARATOR);
--            if (fs.accessKey != null) {
--                sb.append(fs.accessKey).append('@');
 -            }
 -            if (fs.host != null && fs.port >= 0) {
 -                sb.append(fs.host).append(':').append(fs.port).append('/');
--            }
--            sb.append(bucket);
--        }
++        final var sb = fs.server.toString(bucket);
          if (key != null) {
              if (bucket != null) {
                  sb.append(fs.separator);
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources.java
index cf5e2608c1,d7afb279a9..b77165c5ef
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources.java
@@@ -70,13 -56,13 +70,18 @@@ class Resources extends IndexedResource
           */
          public static final short CanNotChangeToAbsolutePath = 1;
  
++        /**
++         * Cannot connect to “{0}”.
++         */
++        public static final short CannotConnectTo_1 = 12;
++
          /**
           * Empty path.
           */
          public static final short EmptyPath = 3;
  
          /**
--         * File system {0,choice,0#not|1#already} initialized for the “{1}” 
access key.
++         * File system {0,choice,0#not|1#already} initialized for the “{1}” 
server or access key.
           */
          public static final short FileSystemInitialized_2 = 4;
  
@@@ -90,6 -76,6 +95,21 @@@
           */
          public static final short MissingAccessKey_2 = 5;
  
++        /**
++         * No file system associated with the “{0}” path.
++         */
++        public static final short MissingFileSystem_1 = 9;
++
++        /**
++         * Port number {0} specified, but no host name.
++         */
++        public static final short MissingHostName_1 = 10;
++
++        /**
++         * Self-hosted file system “{0}” shall have a port number.
++         */
++        public static final short MissingPortNumber_1 = 11;
++
          /**
           * Specified path must be an absolute S3 path.
           */
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources.properties
index ffd2c45e96,ffd2c45e96..3330b6ed46
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources.properties
@@@ -20,10 -20,10 +20,14 @@@
  # For resources shared by all modules in the Apache SIS project, see 
"org.apache.sis.util.resources" package.
  #
  CanNotChangeToAbsolutePath        = Cannot change a relative path to an 
absolute path.
++CannotConnectTo_1                 = Cannot connect to \u201c{0}\u201d.
  EmptyPath                         = Empty path.
--FileSystemInitialized_2           = File system {0,choice,0#not|1#already} 
initialized for the \u201c{1}\u201d access key.
++FileSystemInitialized_2           = File system {0,choice,0#not|1#already} 
initialized for the \u201c{1}\u201d server or access key.
  InvalidBucketName_1               = Invalid bucket name in \u201c{0}\u201d.
  MissingAccessKey_2                = Missing {0,choice,0#public|1#secret} 
access key in \u201c{1}\u201d URI.
++MissingFileSystem_1               = No file system associated with the 
\u201c{0}\u201d path.
++MissingHostName_1                 = Port number {0} specified, but no host 
name.
++MissingPortNumber_1               = Self-hosted file system \u201c{0}\u201d 
shall have a port number.
  MustBeAbsolutePath                = Specified path must be an absolute S3 
path.
  MustHaveKeyComponent              = Specified path cannot be the root.
  UnexpectedProtocol_1              = Unexpected \u201c{0}\u201d protocol.
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources_fr.properties
index c92a992f2a,c92a992f2a..876b7a850a
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Resources_fr.properties
@@@ -25,10 -25,10 +25,14 @@@
  #   U+00A0 NO-BREAK SPACE         before  :
  #
  CanNotChangeToAbsolutePath        = Ne peut pas changer un chemin relatif en 
chemin absolu.
++CannotConnectTo_1                 = Ne peut pas se connecter \u00e0 
\u00ab\u202f{0}\u202f\u00bb.
  EmptyPath                         = Le chemin est vide.
--FileSystemInitialized_2           = Le syst\u00e8me de fichier 
{0,choice,0#n\u2019a pas \u00e9t\u00e9|1#est d\u00e9j\u00e0} initialis\u00e9 
pour la cl\u00e9 d\u2019acc\u00e8s \u00ab\u202f{1}\u202f\u00bb.
++FileSystemInitialized_2           = Le syst\u00e8me de fichier 
{0,choice,0#n\u2019a pas \u00e9t\u00e9|1#est d\u00e9j\u00e0} initialis\u00e9 
pour le serveur ou la cl\u00e9 d\u2019acc\u00e8s \u00ab\u202f{1}\u202f\u00bb.
  InvalidBucketName_1               = Le nom du compartiment dans 
\u00ab\u202f{0}\u202f\u00bb est invalide.
--MissingAccessKey_2                = Il manque la cl\u00e9 d'acc\u00e8s 
{0,choice,0#publique|1#secr\u00e8te} dans l'URI \u00ab\u202f{1}\u202f\u00bb.
++MissingAccessKey_2                = Il manque la cl\u00e9 d\u2019acc\u00e8s 
{0,choice,0#publique|1#secr\u00e8te} dans l\u2019URI 
\u00ab\u202f{1}\u202f\u00bb.
++MissingFileSystem_1               = Aucun syst\u00e8me de fichier n\u2019a 
\u00e9t\u00e9 associ\u00e9 au chemin \u00ab\u202f{0}\u202f\u00bb.
++MissingHostName_1                 = Un num\u00e9ro de port {0} a 
\u00e9t\u00e9 sp\u00e9cifi\u00e9, mais sans nom d\u2019h\u00f4te.
++MissingPortNumber_1               = L\u2019h\u00f4te 
\u00ab\u202f{0}\u202f\u00bb doit \u00eatre associ\u00e9 \u00e0 un num\u00e9ro 
de port.
  MustBeAbsolutePath                = Le chemin sp\u00e9cifi\u00e9 doit 
\u00eatre un chemin S3 absolu.
  MustHaveKeyComponent              = Le chemin sp\u00e9cifi\u00e9 ne peut pas 
\u00eatre la racine.
  UnexpectedProtocol_1              = Le protocole \u00ab\u202f{0}\u202f\u00bb 
est inattendu.
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Server.java
index 0000000000,0000000000..ede83bb87a
new file mode 100644
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/Server.java
@@@ -1,0 -1,0 +1,230 @@@
++/*
++ * Licensed to the Apache Software Foundation (ASF) under one or more
++ * contributor license agreements.  See the NOTICE file distributed with
++ * this work for additional information regarding copyright ownership.
++ * The ASF licenses this file to You under the Apache License, Version 2.0
++ * (the "License"); you may not use this file 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.
++ */
++package org.apache.sis.cloud.aws.s3;
++
++import java.net.URI;
++import java.net.URISyntaxException;
++import java.util.Objects;
++
++
++/**
++ * Information about the server to connect to.
++ * This is used as a key for identifying the file systems stored in {@link 
FileService#fileSystems}.
++ *
++ * @author  Quentin Bialota (Geomatys)
++ * @author  Martin Desruisseaux (Geomatys)
++ */
++final class Server {
++    /**
++     * Default value when user did not specified explicitly a protocol.
++     * In such case, the default protocol is <abbr>HTTPS</abbr>.
++     *
++     * <p>Protocol can also be defined with:</p>
++     * <ul>
++     *   <li>{@value FileService#IS_HTTPS} configuration properties.</li>
++     * </ul>
++     */
++    static final boolean DEFAULT_IS_HTTPS = true;
++
++    /**
++     * An arbitrary value when the user did not specified explicitly a port.
++     * In such case, no port is assigned and the default is chosen by Java.
++     * Note that "no port" is not the same as "default port" for this class,
++     * since the default ports for <abbr>HTTP</abbr> and <abbr>HTTPS</abbr> 
are hard-coded
++     * to {@value FileService#HTTP_PORT} and {@value FileService#HTTPS_PORT} 
respectively.
++     *
++     * <p>Port can also be defined with:</p>
++     * <ul>
++     *   <li>{@value FileService#PORT} configuration properties.</li>
++     * </ul>
++     */
++    static final int NO_PORT = -1;
++
++    /**
++     * The <abbr>AWS</abbr> S3 access key, or {@code null} if none.
++     * In the latter case, the default mechanism documented in 
<abbr>AWS</abbr> <abbr>SDK</abbr> is used.
++     * In preference order:
++     *
++     * <ul>
++     *   <li>{@code aws.accessKeyId} and {@code aws.secretAccessKey} Java 
system properties.</li>
++     *   <li>{@code AWS_ACCESS_KEY_ID} and {@code AWS_SECRET_ACCESS_KEY} 
environment variables.</li>
++     *   <li>{@code ~/.aws/credentials} or {@code ~/.aws/config} files.</li>
++     * </ul>
++     */
++    final String accessKey;
++
++    /**
++     * The S3 host (if not stored on Amazon <abbr>AWS</abbr> Infrastructure).
++     * May be {@code null} if unspecified.
++     * If and only if non-null, the {@link #port} shall be specified.
++     */
++    final String host;
++
++    /**
++     * The S3 port (if not stored on Amazon <abbr>AWS</abbr> Infrastructure).
++     * May be {@link #NO_PORT} if unspecified.
++     * The port shall be specified if and only if {@link #host} is non-null.
++     */
++    final int port;
++
++    /**
++     * Whether the S3 <abbr>HTTP</abbr> Protocol is secure.
++     * Default is {@code true}.
++     */
++    final boolean isHttps;
++
++    /**
++     * Creates a new server for the given access key.
++     *
++     * @param accessKey  the S3 access key or {@code null} for using 
<abbr>AWS</abbr> configuration.
++     */
++    Server(final String accessKey) {
++        this.accessKey = accessKey;
++        this.host      = null;
++        this.port      = NO_PORT;
++        this.isHttps   = DEFAULT_IS_HTTPS;
++    }
++
++    /**
++     * Creates a new server for the given access key, host, port and protocol 
(secure or not secure).
++     *
++     * @param accessKey   the S3 access key or {@code null} for using 
<abbr>AWS</abbr> configuration.
++     * @param host        the host or {@code null} for using <abbr>AWS</abbr> 
configuration.
++     * @param port        the port or {@link #NO_PORT} for using 
<abbr>AWS</abbr> configuration.
++     * @param isHttps     whether the protocol is secure.
++     * @param uri         <abbr>URI</abbr> to use for completing missing 
information, or {@code null} if none.
++     */
++    Server(final String accessKey, String host, int port, final boolean 
isHttps, final URI uri) {
++        if (uri != null) {
++            if (uri.getHost() == null) {
++                /*
++                 * The host is null if the authority contains characters that 
are invalid for a host name.
++                 * For example if the host contains underscore character 
('_'), then it is considered invalid.
++                 * We could use the authority instead, but that authority may 
contain a user name, port number, etc.
++                 * Current version does not try to parse that string.
++                 */
++                String bucket = uri.getAuthority();
++                if (bucket == null) bucket = uri.toString();
++                throw new 
IllegalArgumentException(Resources.format(Resources.Keys.InvalidBucketName_1, 
bucket));
++            }
++            /*
++             * Host and port number specified in the property map have 
precedence over the URI.
++             * If these information were not found anywhere, the AWS SDK will 
fetch them itself.
++             */
++            if (port < 0) {
++                port = uri.getPort();
++                if (port < 0) {
++                    if (host != null) {
++                        // Use default value only if the host was specified 
in the properties map.
++                        // If the host and the port were specified by the 
URI, assume that the URI was complete.
++                        port = isHttps ? FileService.HTTPS_PORT : 
FileService.HTTP_PORT;
++                    }
++                } else if (host == null) {
++                    host = uri.getHost();
++                }
++            }
++        }
++        this.accessKey = accessKey;
++        this.host      = host;
++        this.port      = port;
++        this.isHttps   = isHttps;
++    }
++
++    /**
++     * Returns the protocol, host and port number as an <abbr>URI</abbr>.
++     * The access key is ignored.
++     *
++     * @return <abbr>URI</abbr> to the server, ignoring the access key.
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr.
++     */
++    final URI toURI() throws URISyntaxException {
++        return new URI(isHttps ? "https" : "http", null, host, port, "", 
null, null);
++    }
++
++    /**
++     * Indicates whether some other object is "equal to" this one.
++     *
++     * @param  obj  the object with which to compare.
++     * @return {@code true} if this object is equal to {@code obj}, {@code 
false} otherwise.
++     */
++    @Override
++    public boolean equals(final Object obj) {
++        if (this == obj) {
++            return true;
++        }
++        if (obj instanceof Server) {
++            final var that = (Server) obj;
++            return Objects.equals(accessKey, that.accessKey) &&
++                   Objects.equals(host, that.host) &&
++                   port == that.port &&
++                   isHttps == that.isHttps;
++        }
++        return false;
++    }
++
++    /**
++     * Returns a hash code value for the object.
++     *
++     * @return a hash code value for this object.
++     */
++    @Override
++    public int hashCode() {
++        return Objects.hash(accessKey, host, port, isHttps);
++    }
++
++    /**
++     * Returns a string representation of the path to the server.
++     * The string uses the <abbr>URI</abbr> syntax.
++     * This is used for error message.
++     *
++     * @return  path to the server.
++     */
++    @Override
++    public String toString() {
++        if (host == null) {
++            return accessKey;
++        }
++        return toString(null).toString();
++    }
++
++    /**
++     * Returns a string representation of the path to the given bucket on 
this server.
++     *
++     * @param  bucket  the bucket for which to get the path, or {@code null}.
++     * @return path to the given bucket.
++     */
++    final StringBuilder toString(final String bucket) {
++        final var sb = new StringBuilder();
++        if (bucket != null || host != null) {
++            sb.append(KeyPath.SCHEME).append(KeyPath.SCHEME_SEPARATOR);
++            if (accessKey != null) {
++                sb.append(accessKey).append('@');
++            }
++            if (host != null) {
++                sb.append(host);
++                if (port >= 0) {
++                    sb.append(':').append(port);
++                }
++                sb.append('/');
++            }
++            if (bucket != null) {
++                sb.append(bucket);
++            }
++        }
++        return sb;
++    }
++}
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/package-info.java
index caf3fc4632,695d6fe144..e46b5c215a
--- 
a/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/package-info.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/main/org/apache/sis/cloud/aws/s3/package-info.java
@@@ -17,8 -17,8 +17,8 @@@
  
  
  /**
-- * Java NIO wrappers for Amazon Simple Storage Service (S3).
-- * The wrapped framework is AWS SDK version 2.
++ * Java <abbr>NIO</abbr> wrappers for Amazon Simple Storage Service (S3).
++ * The wrapped framework is <abbr>AWS</abbr> <abbr>SDK</abbr> version 2.
   *
   * <h2><abbr>URL</abbr> syntax</h2>
   * The S3 storage mechanism is similar to a {@code java.util.Map}:
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/ClientFileSystemTest.java
index bad2ad252e,8139b8abff..c306599dda
--- 
a/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/ClientFileSystemTest.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/ClientFileSystemTest.java
@@@ -16,6 -16,6 +16,8 @@@
   */
  package org.apache.sis.cloud.aws.s3;
  
++import java.net.URISyntaxException;
++
  // Test dependencies
  import org.junit.jupiter.api.Test;
  import static org.junit.jupiter.api.Assertions.*;
@@@ -26,32 -28,44 +28,39 @@@ import org.apache.sis.test.TestCase
   * Tests {@link ClientFileSystem}.
   *
   * @author  Martin Desruisseaux (Geomatys)
++ * @author  Quentin Bialota (Geomatys)
   */
++@SuppressWarnings("exports")
  public final class ClientFileSystemTest extends TestCase {
      /**
--     * The instance to use for testing purposes.
--     */
--    private final ClientFileSystem fs;
 -
 -    private final ClientFileSystem fsSelfHosted;
--
--    /**
--     * Returns the file system to use for testing purpose.
++     * Creates a new test case.
       */
--    static ClientFileSystem create() {
-         return new ClientFileSystem(new FileService(), null);
 -        return new ClientFileSystem(new FileService(), null, null);
++    public ClientFileSystemTest() {
      }
  
      /**
-      * Creates a new test case.
+      * Returns the file system to use for testing purpose.
 -     * This file system is configured for self-hosted S3 server.
 -     */
 -    static ClientFileSystem createSelfHosted() throws URISyntaxException {
 -        return new ClientFileSystem(new FileService(), null, "testhost", 
8581, true, null, null, null);
 -    }
 -
 -    /**
 -     * Creates a new test case.
++     *
++     * @param  selfHosted  whether the service should be self hosted.
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
-     public ClientFileSystemTest() {
 -    public ClientFileSystemTest() throws URISyntaxException {
--        fs = create();
 -        fsSelfHosted = createSelfHosted();
++    static ClientFileSystem create(final boolean selfHosted) throws 
URISyntaxException {
++        final var fs = new FileService();
++        if (selfHosted) {
++            return new ClientFileSystem(fs, null, new Server(null, 
"testhost", 8581, true, null), null, null);
++        } else {
++            return new ClientFileSystem(fs, new Server(null));
++        }
      }
  
      /**
       * Tests {@link ClientFileSystem#getSeparator()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGetSeparator() {
--        assertEquals("/", fs.getSeparator());
 -        assertEquals("/", fsSelfHosted.getSeparator());
++    public void testGetSeparator() throws URISyntaxException {
++        assertEquals("/", create(false).getSeparator());
++        assertEquals("/", create(true).getSeparator());
      }
  }
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/FileServiceTest.java
index 0000000000,3970a9bd27..ea6ea97e20
mode 000000,100644..100644
--- 
a/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/FileServiceTest.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/FileServiceTest.java
@@@ -1,0 -1,175 +1,159 @@@
+ /*
+  * Licensed to the Apache Software Foundation (ASF) under one or more
+  * contributor license agreements.  See the NOTICE file distributed with
+  * this work for additional information regarding copyright ownership.
+  * The ASF licenses this file to You under the Apache License, Version 2.0
+  * (the "License"); you may not use this file 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.
+  */
+ package org.apache.sis.cloud.aws.s3;
+ 
+ import java.io.IOException;
+ import java.net.URI;
+ import java.net.URISyntaxException;
 -import java.nio.file.FileSystem;
+ import java.nio.file.FileSystemAlreadyExistsException;
+ import java.nio.file.Path;
 -import java.util.HashMap;
+ import java.util.Map;
+ 
+ // Test dependencies
+ import org.junit.jupiter.api.Test;
+ import static org.junit.jupiter.api.Assertions.*;
+ import org.apache.sis.test.TestCase;
+ 
++
+ /**
+  * Tests {@link FileService}.
+  *
+  * @author  Quentin Bialota (Geomatys)
+  */
++@SuppressWarnings("exports")
+ public final class FileServiceTest extends TestCase {
++    /**
++     * The service to test.
++     */
++    private final FileService service;
+ 
+     /**
+      * Creates a new test case.
+      */
+     public FileServiceTest() {
++        service = new FileService();
+     }
+ 
+     /**
 -     * Tests AWS S3 FileSystem creation.
++     * Creates a new file system and performs a few consistency checks.
++     *
++     * @param  uri         an <abbr>URI</abbr> of the form {@code 
"s3://accessKey@[host:port/]bucket/file"}.
++     * @param  complete    whether the <abbr>URI</abbr> is sufficient for 
finding the file system.
++     * @param  properties  properties to configure the file system, or {@code 
null} if none.
++     * @return the new file system.
++     * @throws IOException if the tested method failed.
+      */
 -    @Test
 -    public void testNewFileSystemAws()
 -            throws URISyntaxException, IOException {
 -        final FileService service = new FileService();
 -        final Map<String, Object> properties = new HashMap<>();
 -        properties.put(FileService.AWS_SECRET_ACCESS_KEY, "secret");
 -        
 -        URI uri = new URI("S3://accessKey@bucket/file");
 -        FileSystem fs = service.newFileSystem(uri, properties);
 -        assertNotNull(fs);
 -        assertTrue(fs instanceof ClientFileSystem);
 -        
 -        assertThrows(FileSystemAlreadyExistsException.class, () -> {
++    private ClientFileSystem newFileSystem(final URI uri, final boolean 
complete, final Map<String, ?> properties)
++            throws IOException
++    {
++        ClientFileSystem fs = assertInstanceOf(ClientFileSystem.class, 
service.newFileSystem(uri, properties));
++        var exception = assertThrows(FileSystemAlreadyExistsException.class, 
() -> {
+             service.newFileSystem(uri, properties);
+         });
++        assertNotNull(exception.getMessage());
++        if (complete) {
++            assertSame(fs, service.getFileSystem(uri));
++        }
++        return fs;
++    }
+ 
 -        // Test FileSystem fetch by URI
 -        FileSystem fetchedFs = service.getFileSystem(uri);
 -        assertSame(fs, fetchedFs);
++    /**
++     * Tests AWS S3 FileSystem creation.
++     *
++     * @throws URISyntaxException if the <abbr>URI</abbr> used for this test 
is invalid.
++     * @throws IOException if the tested method failed.
++     */
++    @Test
++    public void testNewFileSystemAws() throws URISyntaxException, IOException 
{
++        newFileSystem(new URI("S3://accessKey@bucket/file"), true,
++                Map.of(FileService.AWS_SECRET_ACCESS_KEY, "secret"));
+     }
+ 
+     /**
+      * Tests Self-Hosted S3 FileSystem creation.
++     *
++     * @throws URISyntaxException if the <abbr>URI</abbr> used for this test 
is invalid.
++     * @throws IOException if the tested method failed.
+      */
+     @Test
 -    public void testNewFileSystemSelfHosted()
 -            throws URISyntaxException, IOException {
 -        final FileService service = new FileService();
 -        final Map<String, Object> properties = new HashMap<>();
 -        properties.put(FileService.AWS_SECRET_ACCESS_KEY, "secret");
 -        
 -        URI uri = new URI("S3://accessKey@localhost:8080/bucket/file");
 -        FileSystem fs = service.newFileSystem(uri, properties);
 -        assertNotNull(fs);
 -        assertTrue(fs instanceof ClientFileSystem);
 -        
 -        assertThrows(FileSystemAlreadyExistsException.class, () -> {
 -            service.newFileSystem(uri, properties);
 -        });
 -
 -        // Test FileSystem fetch by URI
 -        FileSystem fetchedFs = service.getFileSystem(uri);
 -        assertSame(fs, fetchedFs);
++    public void testNewFileSystemSelfHosted() throws URISyntaxException, 
IOException {
++        newFileSystem(new URI("S3://accessKey@localhost:8080/bucket/file"), 
true,
++                Map.of(FileService.AWS_SECRET_ACCESS_KEY, "secret"));
+     }
+ 
+     /**
+      * Tests Self-Hosted S3 FileSystem creation with properties.
++     *
++     * @throws URISyntaxException if the <abbr>URI</abbr> used for this test 
is invalid.
++     * @throws IOException if the tested method failed.
+      */
+     @Test
 -    public void testNewFileSystemSelfHostedWithPropertiesNoPort()
 -            throws URISyntaxException, IOException {
 -        final FileService service = new FileService();
 -        final Map<String, Object> properties = new HashMap<>();
 -        properties.put(FileService.AWS_SECRET_ACCESS_KEY, "secret");
 -        properties.put(FileService.S3_HOST_URL, "localhost");
 -        properties.put(FileService.S3_IS_HTTPS, false);
 -
 -        URI uri = new URI("S3://accessKey@bucket/file");
 -        FileSystem fs = service.newFileSystem(uri, properties);
 -        assertNotNull(fs);
 -        assertTrue(fs instanceof ClientFileSystem);
 -
 -        assertThrows(FileSystemAlreadyExistsException.class, () -> {
 -            service.newFileSystem(uri, properties);
 -        });
 -
 -        assertEquals(80, ((ClientFileSystem) fs).port);
++    public void testNewFileSystemSelfHostedWithPropertiesNoPort() throws 
URISyntaxException, IOException {
++        final var uri = new URI("S3://accessKey@bucket/file");
++        final ClientFileSystem fs = newFileSystem(uri, false, Map.of(
++                FileService.AWS_SECRET_ACCESS_KEY, "secret",
++                FileService.HOST_URL,              "localhost",
++                FileService.IS_HTTPS,              Boolean.FALSE));
++
++        assertEquals(FileService.HTTP_PORT, fs.server.port);
+         Path basePath = fs.getPath("/bucket/file");
+         URI generatedURI = basePath.toUri();
+         assertEquals("S3://accessKey@localhost:80/bucket/file", 
generatedURI.toString());
+ 
 -        // Test FileSystem fetch by URI
 -        FileSystem fetchedFs = service.getFileSystem(uri);
 -        assertNotSame(fs, fetchedFs);
 -        fetchedFs = service.getFileSystem(generatedURI);
 -        assertSame(fs, fetchedFs);
++        // Test FileSystem fetch by URI.
++        assertSame(fs, service.getFileSystem(generatedURI));
+     }
+ 
+     /**
+      * Tests Self-Hosted S3 FileSystem creation with properties.
++     *
++     * @throws URISyntaxException if the <abbr>URI</abbr> used for this test 
is invalid.
++     * @throws IOException if the tested method failed.
+      */
+     @Test
 -    public void testNewFileSystemSelfHostedWithProperties()
 -            throws URISyntaxException, IOException {
 -        final FileService service = new FileService();
 -        final Map<String, Object> properties = new HashMap<>();
 -        properties.put(FileService.AWS_SECRET_ACCESS_KEY, "secret");
 -        properties.put(FileService.S3_HOST_URL, "localhost");
 -        properties.put(FileService.S3_PORT, 8454);
 -        properties.put(FileService.S3_IS_HTTPS, false);
 -
 -        URI uri = new URI("S3://accessKey@bucket/file");
 -        FileSystem fs = service.newFileSystem(uri, properties);
 -        assertNotNull(fs);
 -        assertTrue(fs instanceof ClientFileSystem);
 -
 -        assertThrows(FileSystemAlreadyExistsException.class, () -> {
 -            service.newFileSystem(uri, properties);
 -        });
 -
 -        assertEquals(8454, ((ClientFileSystem) fs).port);
++    public void testNewFileSystemSelfHostedWithProperties() throws 
URISyntaxException, IOException {
++        final URI uri = new URI("S3://accessKey@bucket/file");
++        final ClientFileSystem fs = newFileSystem(uri, false, Map.of(
++                FileService.AWS_SECRET_ACCESS_KEY, "secret",
++                FileService.HOST_URL,              "localhost",
++                FileService.PORT,                  8454,
++                FileService.IS_HTTPS,              Boolean.FALSE));
++
++        assertEquals(8454, fs.server.port);
+         Path basePath = fs.getPath("/bucket/file");
+         URI generatedURI = basePath.toUri();
+         assertEquals("S3://accessKey@localhost:8454/bucket/file", 
generatedURI.toString());
+ 
 -        // Test FileSystem fetch by URI
 -        FileSystem fetchedFs = service.getFileSystem(uri);
 -        assertNotSame(fs, fetchedFs);
 -        fetchedFs = service.getFileSystem(generatedURI);
 -        assertSame(fs, fetchedFs);
++        // Test FileSystem fetch by URI.
++        assertSame(fs, service.getFileSystem(generatedURI));
+     }
+ 
+     /**
+      * Tests FileSystem creation with missing secret key.
++     *
++     * @throws URISyntaxException if the <abbr>URI</abbr> used for this test 
is invalid.
+      */
+     @Test
 -    public void testMissingSecretKey()
 -            throws URISyntaxException {
 -        final FileService service = new FileService();
 -        final Map<String, Object> properties = new HashMap<>();
 -        
 -        URI uri = new URI("S3://accessKey@bucket/file");
 -        assertThrows(IllegalArgumentException.class, () -> {
++    public void testMissingSecretKey() throws URISyntaxException {
++        final Map<String, Object> properties = Map.of();
++        final var uri = new URI("S3://accessKey@bucket/file");
++        var exception = assertThrows(IllegalArgumentException.class, () -> {
+             service.newFileSystem(uri, properties);
+         });
++        assertNotNull(exception.getMessage());
+     }
+ }
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/KeyPathMatcherTest.java
index afcefb3975,afcefb3975..28b2f8617a
--- 
a/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/KeyPathMatcherTest.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/KeyPathMatcherTest.java
@@@ -17,6 -17,6 +17,7 @@@
  package org.apache.sis.cloud.aws.s3;
  
  import java.util.Locale;
++import java.net.URISyntaxException;
  
  // Test dependencies
  import org.junit.jupiter.api.Test;
@@@ -29,6 -29,6 +30,7 @@@ import org.apache.sis.test.TestCase
   *
   * @author  Martin Desruisseaux (Geomatys)
   */
++@SuppressWarnings("exports")
  public final class KeyPathMatcherTest extends TestCase {
      /**
       * Creates a new test case.
@@@ -38,11 -38,11 +40,13 @@@
  
      /**
       * Tests a pattern using "glob" syntax.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGlob() {
--        final KeyPathMatcher matcher = new 
KeyPathMatcher("glob:bar*foo/fuu/**/f?i", ClientFileSystem.DEFAULT_SEPARATOR);
--        final ClientFileSystem fs = ClientFileSystemTest.create();
++    public void testGlob() throws URISyntaxException {
++        final var matcher = new KeyPathMatcher("glob:bar*foo/fuu/**/f?i", 
ClientFileSystem.DEFAULT_SEPARATOR);
++        final ClientFileSystem fs = ClientFileSystemTest.create(false);
          assertTrue (matcher.matches(new KeyPath(fs, 
"bar_skip_foo/fuu/d1/d2/d3/f_i", false)));
          assertFalse(matcher.matches(new KeyPath(fs, 
"bar_sk/p_foo/fuu/d1/d2/d3/f_i", false)));
      }
diff --cc 
endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/KeyPathTest.java
index f3ce186b69,389b6e2bb8..d7b719d8fa
--- 
a/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/KeyPathTest.java
+++ 
b/endorsed/src/org.apache.sis.cloud.aws/test/org/apache/sis/cloud/aws/s3/KeyPathTest.java
@@@ -30,23 -31,33 +31,34 @@@ import org.apache.sis.test.TestCase
   * Tests {@link KeyPath}.
   *
   * @author  Martin Desruisseaux (Geomatys)
++ * @author  Quentin Bialota (Geomatys)
   */
++@SuppressWarnings("exports")
  public final class KeyPathTest extends TestCase {
      /**
       * The file system used in for the test paths.
       */
--    private final ClientFileSystem fs;
 -
 -    /**
 -     * The file system used to test self-hosted S3 paths.
 -     */
 -    private final ClientFileSystem fsSelfHosted;
++    private ClientFileSystem fs;
  
      /**
       * The path to test.
       */
--    private final KeyPath absolute, relative;
++    private KeyPath absolute, relative;
  
      /**
 -     * The path to test with self-hosted S3 file system.
 +     * Creates a new test case.
       */
 -    private final KeyPath absoluteSelfHosted, relativeSelfHosted;
 +    public KeyPathTest() {
-         fs = ClientFileSystemTest.create();
++    }
+ 
+     /**
 -     * Creates a new test case.
++     * Creates the file system and the paths to use for testing purpose.
++     *
++     * @param  selfHosted  whether the service should be self hosted.
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
+      */
 -    public KeyPathTest() throws URISyntaxException {
 -        fs = ClientFileSystemTest.create();
++    private void createPaths(final boolean selfHosted) throws 
URISyntaxException {
++        fs = ClientFileSystemTest.create(selfHosted);
          final KeyPath root = new KeyPath(fs, 
Bucket.builder().name("the-bucket").build());
          absolute = new KeyPath(root, "first/second/third/the-file", false);
          relative = new KeyPath(fs, "second/third/the-file", false);
@@@ -54,9 -69,9 +66,12 @@@
  
      /**
       * Tests the parsing done in the constructor.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testConstructor() {
++    public void testConstructor() throws URISyntaxException {
++        createPaths(false);
          assertEquals(relative, new KeyPath(fs, "second/third/the-file", new 
String[0], false));
          assertEquals(relative, new KeyPath(fs, "second/", new String[] 
{"/third/", "the-file"}, false));
          assertEquals(absolute, new KeyPath(fs, 
"S3://the-bucket/first/second/third/the-file", new String[0], false));
@@@ -66,20 -81,20 +81,26 @@@
  
      /**
       * Tests {@link KeyPath#isAbsolute()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testIsAbsolute() {
++    public void testIsAbsolute() throws URISyntaxException {
++        createPaths(false);
          assertTrue (absolute.isAbsolute());
          assertFalse(relative.isAbsolute());
      }
  
      /**
       * Tests {@link KeyPath#getRoot()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGetRoot() {
++    public void testGetRoot() throws URISyntaxException {
++        createPaths(false);
          assertNull(relative.getRoot());
--        final KeyPath p = (KeyPath) absolute.getRoot();
++        final var p = (KeyPath) absolute.getRoot();
          assertSame(p, p.getRoot());
          assertTrue(p.isDirectory);
          assertNotNull(p.bucket);
@@@ -88,9 -103,9 +109,12 @@@
  
      /**
       * Tests {@link KeyPath#getFileName()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGetFileName() {
++    public void testGetFileName() throws URISyntaxException {
++        createPaths(false);
          KeyPath p = (KeyPath) absolute.getFileName();
          assertSame(p, p.getFileName());
          assertEquals("the-file", p.key);
@@@ -106,9 -121,9 +130,12 @@@
  
      /**
       * Tests {@link KeyPath#getParent()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGetParent() {
++    public void testGetParent() throws URISyntaxException {
++        createPaths(false);
          KeyPath p = (KeyPath) absolute.getParent();
          assertEquals("first/second/third", p.key);
          assertTrue(p.isDirectory);
@@@ -122,9 -137,9 +149,12 @@@
  
      /**
       * Tests {@link KeyPath#getNameCount()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGetNameCount() {
++    public void testGetNameCount() throws URISyntaxException {
++        createPaths(false);
          assertEquals(5, absolute.getNameCount());
          assertEquals(3, relative.getNameCount());
          assertEquals(1, absolute.getRoot().getNameCount());
@@@ -132,9 -147,9 +162,12 @@@
  
      /**
       * Tests {@link KeyPath#getName(int)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testGetName() {
++    public void testGetName() throws URISyntaxException {
++        createPaths(false);
          assertEquals("S3://the-bucket", absolute.getName(0).toString());
          assertEquals("first",           absolute.getName(1).toString());
          assertEquals("second",          absolute.getName(2).toString());
@@@ -149,9 -164,9 +182,12 @@@
  
      /**
       * Tests {@link KeyPath#subpath(int, int)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testSubpath() {
++    public void testSubpath() throws URISyntaxException {
++        createPaths(false);
          assertEquals("S3://the-bucket/first/second", absolute.subpath(0, 
3).toString());
          assertEquals("second/third/the-file",        absolute.subpath(2, 
5).toString());
          assertEquals("second/third",                 absolute.subpath(2, 
4).toString());
@@@ -162,9 -177,9 +198,12 @@@
  
      /**
       * Tests {@link KeyPath#startsWith(Path)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testStartsWith() {
++    public void testStartsWith() throws URISyntaxException {
++        createPaths(false);
          assertFalse(absolute.startsWith(new KeyPath(fs,       "first/second", 
true)));
          assertTrue (absolute.startsWith(new KeyPath(absolute, "first/second", 
true)));
          assertFalse(absolute.startsWith(new KeyPath(absolute, "first/secon",  
true)));
@@@ -177,9 -192,9 +216,12 @@@
  
      /**
       * Tests {@link KeyPath#endsWith(Path)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testEndsWith() {
++    public void testEndsWith() throws URISyntaxException {
++        createPaths(false);
          assertTrue (relative.endsWith(relative));
          assertTrue (absolute.endsWith(absolute));
          assertTrue (absolute.endsWith(relative));
@@@ -191,11 -206,11 +233,14 @@@
  
      /**
       * Tests {@link KeyPath#resolve(Path)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testResolve() {
++    public void testResolve() throws URISyntaxException {
++        createPaths(false);
          assertSame(absolute, relative.resolve(absolute));
--        final KeyPath tip = new KeyPath(fs, "tip", false);
++        final var tip = new KeyPath(fs, "tip", false);
          assertEquals("second/third/the-file/tip", 
relative.resolve(tip).toString());
          assertEquals("S3://the-bucket/first/second/third/the-file/tip", 
absolute.resolve(tip).toString());
          assertEquals(absolute, new KeyPath(absolute, "first", 
true).resolve(relative));
@@@ -203,18 -218,18 +248,24 @@@
  
      /**
       * Tests {@link KeyPath#relativize(Path)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testRelativize() {
--        final KeyPath base = new KeyPath(absolute, "first", true);
++    public void testRelativize() throws URISyntaxException {
++        createPaths(false);
++        final var base = new KeyPath(absolute, "first", true);
          assertEquals(relative, base.relativize(absolute));
      }
  
      /**
       * Tests {@link KeyPath#iterator()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testIterator() {
++    public void testIterator() throws URISyntaxException {
++        createPaths(false);
          verifyIterator(absolute.iterator(), true);
          verifyIterator(relative.iterator(), false);
      }
@@@ -235,13 -250,35 +286,48 @@@
  
      /**
       * Tests {@link KeyPath#compareTo(Path)}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
       */
      @Test
--    public void testCompareTo() {
++    public void testCompareTo() throws URISyntaxException {
++        createPaths(false);
          assertEquals( 0, absolute.compareTo(absolute));
          assertEquals( 0, relative.compareTo(relative));
          assertEquals(-1, absolute.compareTo(relative));
          assertEquals(+1, relative.compareTo(absolute));
          assertTrue(absolute.compareTo(new KeyPath(absolute, "first", true)) > 
0);
      }
+ 
+     /**
+      * Tests {@link KeyPath#toString()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
+      */
+     @Test
 -    public void testToString() {
++    public void testToString() throws URISyntaxException {
++        createPaths(false);
+         assertEquals("S3://the-bucket/first/second/third/the-file", 
absolute.toString());
+         assertEquals("second/third/the-file", relative.toString());
 -        
assertEquals("S3://testhost:8581/the-bucket/first/second/third/the-file", 
absoluteSelfHosted.toString());
 -        assertEquals("second/third/the-file", relativeSelfHosted.toString());
++
++        createPaths(true);
++        
assertEquals("S3://testhost:8581/the-bucket/first/second/third/the-file", 
absolute.toString());
++        assertEquals("second/third/the-file", relative.toString());
+     }
+ 
+     /**
+      * Tests {@link KeyPath#toUri()}.
++     *
++     * @throws URISyntaxException if an error occurred while creating the 
<abbr>URI</abbr> for the self-hosted S3.
+      */
+     @Test
 -    public void testToUri() {
++    public void testToUri() throws URISyntaxException {
++        createPaths(false);
+         assertEquals("S3://the-bucket/first/second/third/the-file", 
absolute.toUri().toString());
+         assertEquals("S3:/second/third/the-file", 
relative.toUri().toString());
 -        
assertEquals("S3://testhost:8581/the-bucket/first/second/third/the-file", 
absoluteSelfHosted.toUri().toString());
 -        assertEquals("S3:/second/third/the-file", 
relativeSelfHosted.toUri().toString());
++
++        createPaths(true);
++        
assertEquals("S3://testhost:8581/the-bucket/first/second/third/the-file", 
absolute.toUri().toString());
++        assertEquals("S3:/second/third/the-file", 
relative.toUri().toString());
+     }
  }


Reply via email to