You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@ant.apache.org by mb...@apache.org on 2022/03/31 14:20:50 UTC

[ant-antlibs-s3] 07/07: total overhaul of SDK building

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

mbenson pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ant-antlibs-s3.git

commit a6252af4eb247f73f229f4fbc61af71ea7538160
Author: Matt Benson <mb...@apache.org>
AuthorDate: Thu Mar 31 09:20:20 2022 -0500

    total overhaul of SDK building
---
 ivy.xml                                            |  10 +-
 src/main/org/apache/ant/s3/Builder.java            | 208 ---------
 src/main/org/apache/ant/s3/Client.java             |  79 +---
 src/main/org/apache/ant/s3/Credentials.java        | 122 -----
 src/main/org/apache/ant/s3/Exceptions.java         |   2 +-
 src/main/org/apache/ant/s3/HttpConfiguration.java  |  57 ---
 src/main/org/apache/ant/s3/InlineProperties.java   |   9 +
 src/main/org/apache/ant/s3/ProjectUtils.java       |   2 +-
 src/main/org/apache/ant/s3/StringConversions.java  | 176 -------
 .../ant/s3/build/AwsStringConversionsProvider.java |  43 ++
 .../org/apache/ant/s3/build/BuildableSupplier.java | 133 ++++++
 src/main/org/apache/ant/s3/build/Buildables.java   |  94 ++++
 src/main/org/apache/ant/s3/build/Builder.java      | 266 +++++++++++
 src/main/org/apache/ant/s3/build/ClassFinder.java  | 138 ++++++
 .../apache/ant/s3/build/ConfigurableSupplier.java  |  77 ++++
 .../ant/s3/build/ConfigurableSupplierFactory.java  |  76 +++
 .../apache/ant/s3/build/ConfiguringSupplier.java   |  39 ++
 .../apache/ant/s3/build/ConfiguringSuppliers.java  |  96 ++++
 .../s3/build/DefaultStringConversionsProvider.java | 116 +++++
 .../org/apache/ant/s3/build/MetaBuilderByType.java | 140 ++++++
 .../org/apache/ant/s3/build/MethodSignature.java   | 126 +++++
 .../ant/s3/build/RootConfiguringSupplier.java      | 116 +++++
 .../org/apache/ant/s3/build/StringConversions.java | 248 ++++++++++
 .../s3/build/spi/ConfiguringSuppliersProvider.java |  76 +++
 .../apache/ant/s3/build/spi/DefaultProvider.java   |  33 ++
 .../s3/build/spi/IntrospectingProviderBase.java    | 183 ++++++++
 .../org/apache/ant/s3/build/spi/Providers.java     |  64 +++
 .../s3/build/spi/StringConversionsProvider.java    |  94 ++++
 .../CredentialsConfiguringSuppliersProvider.java   | 513 +++++++++++++++++++++
 .../http/ClientConfiguringSuppliersProvider.java   |  82 ++++
 src/main/org/apache/ant/s3/strings/ClassNames.java | 234 ++++++++++
 .../org/apache/ant/s3/strings/PackageNames.java    | 214 +++++++++
 src/main/org/apache/ant/s3/strings/Strings.java    | 128 +++++
 src/tests/antunit/s3-test-base.xml                 |   9 +-
 .../apache/ant/s3/build/StringConversionsTest.java | 156 +++++++
 .../org/apache/ant/s3/strings/ClassNamesTest.java  | 345 ++++++++++++++
 .../apache/ant/s3/strings/PackageNamesTest.java    | 286 ++++++++++++
 .../org/apache/ant/s3/strings/StringsTest.java     | 114 +++++
 38 files changed, 4256 insertions(+), 648 deletions(-)

diff --git a/ivy.xml b/ivy.xml
index ceaaed1..e84e2ac 100644
--- a/ivy.xml
+++ b/ivy.xml
@@ -32,7 +32,8 @@
   </info>
   <configurations defaultconfmapping="*->default">
     <conf name="default" description="full antlib with all dependencies" />
-    <conf name="provided" description="Ant must be present at runtime" />
+    <conf name="provided" description="Ant must be present at runtime" visibility="private" />
+    <conf name="sso" description="Optional SSO support" />
     <conf name="test" description="dependencies used for tests of the antlib" visibility="private" />
   </configurations>
   <publications xmlns:e="urn:ant.apache.org:ivy-extras">
@@ -55,15 +56,20 @@
   </publications>
   <dependencies defaultconfmapping="*->default">
     <dependency org="software.amazon.awssdk" name="s3" rev="${aws.sdk.version}" conf="default">
-      <exclude org="software.amazon.awssdk" name="apache-client" />
     </dependency>
     <dependency org="software.amazon.awssdk" name="url-connection-client" rev="${aws.sdk.version}" conf="default" />
+    <dependency org="software.amazon.awssdk" name="sts" rev="${aws.sdk.version}" conf="default" />
     <dependency org="org.apache.commons" name="commons-lang3" rev="3.12.0" conf="default" />
+    <dependency org="org.kohsuke.metainf-services" name="metainf-services" rev="1.8" conf="default" />
     <dependency org="org.apache.ant" name="ant" rev="1.10.12" conf="provided" />
     <dependency org="junit" name="junit" rev="4.13" conf="test" />
     <dependency org="com.adobe.testing" name="s3mock" rev="2.4.7" conf="test" />
     <dependency org="jakarta.servlet.jsp" name="jakarta.servlet.jsp-api" rev="2.3.6" conf="test" />
     <dependency org="org.apache.groovy" name="groovy-ant" rev="4.0.0" transitive="false" conf="test" />
     <dependency org="org.apache.groovy" name="groovy-jsr223" rev="4.0.0" conf="test" />
+    <dependency org="org.assertj" name="assertj-core" rev="3.22.0" conf="test" />
+    <dependency org="software.amazon.awssdk" name="aws-json-protocol" rev="${aws.sdk.version}" conf="sso" transitive="false" />
+    <dependency org="software.amazon.awssdk" name="sso" rev="${aws.sdk.version}" conf="sso" transitive="false" />
+    <exclude org="software.amazon.awssdk" artifact="apache-client" />
   </dependencies>
 </ivy-module>
diff --git a/src/main/org/apache/ant/s3/Builder.java b/src/main/org/apache/ant/s3/Builder.java
deleted file mode 100644
index db68725..0000000
--- a/src/main/org/apache/ant/s3/Builder.java
+++ /dev/null
@@ -1,208 +0,0 @@
-/*
- *  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
- *
- *      https://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.ant.s3;
-
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.lang.reflect.ParameterizedType;
-import java.lang.reflect.Type;
-import java.lang.reflect.TypeVariable;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.function.BiPredicate;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-import org.apache.commons.lang3.ClassUtils;
-import org.apache.commons.lang3.ClassUtils.Interfaces;
-import org.apache.tools.ant.BuildException;
-import org.apache.tools.ant.DynamicAttributeNS;
-import org.apache.tools.ant.DynamicElementNS;
-import org.apache.tools.ant.Project;
-
-import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
-import software.amazon.awssdk.http.SdkHttpClient;
-
-/**
- * Support AWS SDK v2 fluent builder conventions.
- *
- * @param <T>
- */
-public class Builder<T> extends S3DataType implements Consumer<T>, DynamicAttributeNS, DynamicElementNS {
-    private static class Parameter {
-        final Method mutator;
-        final Object value;
-
-        Parameter(Method mutator, Object value) {
-            this.mutator = mutator;
-            this.value = value;
-        }
-    }
-
-    private static final Map<Class<?>, Supplier<?>> SUPPLIERS;
-    private static final Set<BiPredicate<String, String>> NAME_COMPARISONS =
-        Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(String::equals, String::equalsIgnoreCase)));
-
-    static {
-        final Map<Class<?>, Supplier<?>> suppliers = new LinkedHashMap<>();
-        suppliers.put(SdkHttpClient.Builder.class, DefaultSdkHttpClientBuilder::new);
-        SUPPLIERS = Collections.unmodifiableMap(suppliers);
-    }
-
-    private static boolean isEquivalentTo(Class<?> c, Type t) {
-        if (ParameterizedType.class.isInstance(t)) {
-            t = ((ParameterizedType) t).getRawType();
-        }
-        return c.equals(t);
-    }
-
-    private static boolean isFluent(Method m) {
-        final Class<?> declaringClass = m.getDeclaringClass();
-
-        final Type genericReturnType = m.getGenericReturnType();
-
-        if (isEquivalentTo(declaringClass, genericReturnType)) {
-            return true;
-        }
-        if (TypeVariable.class.isInstance(genericReturnType)) {
-            final TypeVariable<?> var = (TypeVariable<?>) genericReturnType;
-            if (var.getGenericDeclaration().equals(declaringClass)) {
-                ;
-            }
-            for (final Type bound : var.getBounds()) {
-                if (isEquivalentTo(declaringClass, bound)) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    private static boolean isFluentSdkMutator(Method m) {
-        return isFluent(m) && !Modifier.isStatic(m.getModifiers()) && m.getParameterTypes().length == 1;
-    }
-
-    private static Parameter parameter(Class<?> c, String name, String value) {
-        return searchMethods(c, name, m -> {
-            try {
-                return Optional.of(new Parameter(m, StringConversions.as(m.getParameterTypes()[0], value)));
-            } catch (IllegalArgumentException e) {
-                return Optional.empty();
-            }
-        });
-    }
-
-    private static Method element(Class<?> c, String name) {
-        return searchMethods(c, name, m -> {
-            return Optional.of(m).filter(o -> {
-                final Class<?> pt = m.getParameterTypes()[0];
-                return Consumer.class.equals(pt) || SUPPLIERS.containsKey(pt);
-            });
-        });
-    }
-
-    private static <R> R searchMethods(Class<?> c, String name, Function<Method, Optional<R>> fn) {
-        for (BiPredicate<String, String> nameComparison : NAME_COMPARISONS) {
-
-            for (Class<?> type : ClassUtils.hierarchy(c, Interfaces.INCLUDE)) {
-                for (Method m : type.getDeclaredMethods()) {
-                    if (nameComparison.test(name, m.getName()) && isFluentSdkMutator(m)) {
-                        final Optional<R> result = fn.apply(m);
-                        if (result.isPresent()) {
-                            return result.get();
-                        }
-                    }
-                }
-            }
-        }
-        throw new IllegalArgumentException(name);
-    }
-
-    private final Class<T> target;
-    private final Set<Parameter> parameters = new LinkedHashSet<>();
-    private final Map<Method, Builder<?>> elements = new LinkedHashMap<>();
-
-    /**
-     * Create a new {@link Builder} instance.
-     *
-     * @param target
-     *            type
-     */
-    public Builder(Class<T> target, Project project) {
-        super(project);
-        this.target = Objects.requireNonNull(target, "target");
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public void accept(T t) {
-        parameters.forEach(p -> {
-            try {
-                p.mutator.invoke(t, p.value);
-            } catch (IllegalAccessException | InvocationTargetException e) {
-                throw new IllegalStateException(e);
-            }
-        });
-        elements.forEach((m, b) -> {
-            final Class<?> pt = m.getParameterTypes()[0];
-            final Object arg;
-            if (Consumer.class.equals(pt)) {
-                arg = b;
-            } else {
-                arg = SUPPLIERS.get(pt).get();
-
-                @SuppressWarnings("unchecked")
-                final Consumer<Object> cmer = (Consumer<Object>) b;
-                cmer.accept(arg);
-            }
-            try {
-                m.invoke(t, arg);
-            } catch (IllegalAccessException | InvocationTargetException e) {
-                throw new IllegalStateException(e);
-            }
-        });
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public void setDynamicAttribute(String uri, String localName, String qName, String value) throws BuildException {
-        parameters.add(parameter(target, localName, value));
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
-        final Method m = element(target, localName);
-        final Builder<?> result = new Builder<>(m.getParameterTypes()[0], getProject());
-        elements.put(m, result);
-        return result;
-    }
-}
diff --git a/src/main/org/apache/ant/s3/Client.java b/src/main/org/apache/ant/s3/Client.java
index 7c550aa..42e3c77 100644
--- a/src/main/org/apache/ant/s3/Client.java
+++ b/src/main/org/apache/ant/s3/Client.java
@@ -16,96 +16,23 @@
  */
 package org.apache.ant.s3;
 
-import java.util.Objects;
-import java.util.Optional;
-import java.util.function.Supplier;
-import java.util.stream.Stream;
-
-import org.apache.tools.ant.BuildException;
+import org.apache.ant.s3.build.RootConfiguringSupplier;
 import org.apache.tools.ant.Project;
 import org.apache.tools.ant.types.DataType;
 
 import software.amazon.awssdk.services.s3.S3Client;
-import software.amazon.awssdk.services.s3.S3ClientBuilder;
 
 /**
  * {@link DataType} providing access to an {@link S3Client} instance.
  */
-public class Client extends S3DataType implements Supplier<S3Client> {
-
-    private Builder<S3ClientBuilder> builder;
-    private Credentials credentials;
-    private HttpConfiguration httpConfiguration;
+public class Client extends RootConfiguringSupplier<S3Client> {
 
     /**
      * Create a new {@link Client}.
      *
-     * @param project
+     * @param project Ant {@link Project}
      */
     public Client(Project project) {
         super(project);
     }
-
-    /**
-     * Create a nested {@code builder} element to allow customization.
-     *
-     * @return {@link AmazonS3ClientBuilder}
-     */
-    public Builder<S3ClientBuilder> createBuilder() {
-        checkChildrenAllowed();
-
-        if (builder != null) {
-            singleElementAllowed("builder");
-        }
-        return builder = new Builder<>(S3ClientBuilder.class, getProject());
-    }
-
-    /**
-     * Create a nested {@link Credentials} element.
-     *
-     * @return {@link Credentials}
-     */
-    public Credentials createCredentials() {
-        checkChildrenAllowed();
-
-        if (credentials != null) {
-            singleElementAllowed("credentials");
-        }
-        return credentials = new Credentials(getProject());
-    }
-
-    /**
-     * Create a nested {@link HttpConfiguration} element.
-     *
-     * @return {@link HttpConfiguration}
-     */
-    public HttpConfiguration createHttp() {
-        checkChildrenAllowed();
-
-        if (httpConfiguration != null) {
-            singleElementAllowed("http");
-        }
-        return httpConfiguration = new HttpConfiguration(getProject());
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public S3Client get() {
-        if (isReference()) {
-            return getRefid().<Client> getReferencedObject().get();
-        }
-        final S3ClientBuilder scb = S3Client.builder();
-        Optional.ofNullable(builder).ifPresent(bb -> bb.accept(scb));
-
-        Stream.of(credentials, httpConfiguration).filter(Objects::nonNull).forEach(c -> c.accept(scb));
-
-        return scb.build();
-    }
-
-    private void singleElementAllowed(final String name) {
-        throw new BuildException(String.format("%s permits a single nested %s element", getDataTypeName(), name),
-            getLocation());
-    }
 }
diff --git a/src/main/org/apache/ant/s3/Credentials.java b/src/main/org/apache/ant/s3/Credentials.java
deleted file mode 100644
index 3efd63b..0000000
--- a/src/main/org/apache/ant/s3/Credentials.java
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- *  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
- *
- *      https://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.ant.s3;
-
-import java.util.function.Consumer;
-
-import org.apache.tools.ant.Project;
-import org.apache.tools.ant.util.StringUtils;
-
-import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
-import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
-import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider;
-import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
-import software.amazon.awssdk.services.s3.S3ClientBuilder;
-
-/**
- * AWS credentials configuration. {@pcode profile} is preferred to
- * {@code accessKey}/{@code secretKey}.
- */
-public class Credentials extends S3DataType implements Consumer<S3ClientBuilder> {
-    private String accessKey;
-    private String secretKey;
-    private String profile;
-
-    /**
-     * Create a new {@link Credentials} instance.
-     *
-     * @param project
-     */
-    public Credentials(final Project project) {
-        super(project);
-    }
-
-    /**
-     * Get the access key.
-     *
-     * @return {@link String}
-     */
-    public String getAccessKey() {
-        return accessKey;
-    }
-
-    /**
-     * Set the access key.
-     *
-     * @param accessKey
-     */
-    public void setAccessKey(final String accessKey) {
-        this.accessKey = StringUtils.trimToNull(accessKey);
-    }
-
-    /**
-     * Get the secret key.
-     *
-     * @return {@link String}
-     */
-    public String getSecretKey() {
-        return secretKey;
-    }
-
-    /**
-     * Set the secret key.
-     *
-     * @param secretKey
-     */
-    public void setSecretKey(final String secretKey) {
-        this.secretKey = StringUtils.trimToNull(secretKey);
-    }
-
-    /**
-     * Get the desired profile.
-     *
-     * @return {@link String}
-     */
-    public String getProfile() {
-        return profile;
-    }
-
-    /**
-     * Set the desired profile.
-     *
-     * @param profile
-     */
-    public void setProfile(final String profile) {
-        this.profile = StringUtils.trimToNull(profile);
-    }
-
-    /**
-     * Apply settings to {@code builder}.
-     *
-     * @param builder
-     */
-    @Override
-    public void accept(S3ClientBuilder builder) {
-        final AwsCredentialsProvider credentialsProvider;
-
-        if (getProfile() == null) {
-            Exceptions.raiseIf(getAccessKey() == null || getSecretKey() == null, buildException(),
-                "%s requires both @accessKey and @secretKey in the absence of @profile", getDataTypeName());
-
-            credentialsProvider =
-                StaticCredentialsProvider.create(AwsBasicCredentials.create(getAccessKey(), getSecretKey()));
-        } else {
-            credentialsProvider = ProfileCredentialsProvider.create(getProfile());
-        }
-        builder.credentialsProvider(credentialsProvider);
-    }
-}
\ No newline at end of file
diff --git a/src/main/org/apache/ant/s3/Exceptions.java b/src/main/org/apache/ant/s3/Exceptions.java
index 5a0a164..bfe8ba9 100644
--- a/src/main/org/apache/ant/s3/Exceptions.java
+++ b/src/main/org/apache/ant/s3/Exceptions.java
@@ -24,7 +24,7 @@ import java.util.stream.Stream;
 /**
  * Utility class for the creation and throwing of {@link Exception}s.
  */
-class Exceptions {
+public class Exceptions {
 
     public static <E extends Exception> E create(final Function<? super String, ? extends E> fn, final String format,
         final Object... args) {
diff --git a/src/main/org/apache/ant/s3/HttpConfiguration.java b/src/main/org/apache/ant/s3/HttpConfiguration.java
deleted file mode 100644
index 4c6144c..0000000
--- a/src/main/org/apache/ant/s3/HttpConfiguration.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- *  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
- *
- *      https://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.ant.s3;
-
-import java.util.Map;
-import java.util.function.Consumer;
-
-import org.apache.tools.ant.Project;
-
-import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
-import software.amazon.awssdk.http.SdkHttpConfigurationOption;
-import software.amazon.awssdk.services.s3.S3ClientBuilder;
-import software.amazon.awssdk.utils.AttributeMap;
-
-/**
- * S3 client Http configuration.
- */
-public class HttpConfiguration extends InlineProperties implements Consumer<S3ClientBuilder> {
-
-    /**
-     * Create a new {@link HttpConfiguration} element.
-     *
-     * @param project
-     */
-    public HttpConfiguration(Project project) {
-        super(project);
-    }
-
-    /**
-     * Apply this {@link HttpConfiguration} to the specified
-     * {@link S3ClientBuilder}.
-     *
-     * @param b
-     */
-    @Override
-    public void accept(S3ClientBuilder b) {
-        @SuppressWarnings({ "unchecked", "rawtypes" })
-        final AttributeMap attributeMap =
-            StringConversions.attributes(SdkHttpConfigurationOption.class, (Map) properties);
-
-        b.httpClient(new DefaultSdkHttpClientBuilder().buildWithDefaults(attributeMap));
-    }
-}
diff --git a/src/main/org/apache/ant/s3/InlineProperties.java b/src/main/org/apache/ant/s3/InlineProperties.java
index 22f4f89..fc199f4 100644
--- a/src/main/org/apache/ant/s3/InlineProperties.java
+++ b/src/main/org/apache/ant/s3/InlineProperties.java
@@ -94,4 +94,13 @@ public class InlineProperties extends S3DataType implements DynamicElementNS {
     public InlineProperty createDynamicElement(String uri, String localName, String qName) {
         return new InlineProperty(localName);
     }
+
+    /**
+     * Get the managed {@link Properties} instance.
+     * 
+     * @return {@link Properties}
+     */
+    public Properties getProperties() {
+        return properties;
+    }
 }
\ No newline at end of file
diff --git a/src/main/org/apache/ant/s3/ProjectUtils.java b/src/main/org/apache/ant/s3/ProjectUtils.java
index e4a739a..d8e3bf2 100644
--- a/src/main/org/apache/ant/s3/ProjectUtils.java
+++ b/src/main/org/apache/ant/s3/ProjectUtils.java
@@ -30,7 +30,7 @@ import org.apache.tools.ant.Project;
 /**
  * Interface providing behavior for Ant {@link Project} components.
  */
-interface ProjectUtils {
+public interface ProjectUtils {
 
     /**
      * Attempt to determine a component name for the specified type.
diff --git a/src/main/org/apache/ant/s3/StringConversions.java b/src/main/org/apache/ant/s3/StringConversions.java
deleted file mode 100644
index 7705e85..0000000
--- a/src/main/org/apache/ant/s3/StringConversions.java
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- *  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
- *
- *      https://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.ant.s3;
-
-import java.lang.reflect.Constructor;
-import java.lang.reflect.Field;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Modifier;
-import java.lang.reflect.Type;
-import java.lang.reflect.TypeVariable;
-import java.time.Duration;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Optional;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import org.apache.commons.lang3.reflect.TypeUtils;
-import org.apache.tools.ant.BuildException;
-import org.apache.tools.ant.Project;
-
-import software.amazon.awssdk.regions.Region;
-import software.amazon.awssdk.utils.AttributeMap;
-
-/**
- * Static utility class for conversions from {@link String} to needed types.
- */
-class StringConversions {
-    private static final TypeVariable<?> ATTRIBUTE_KEY_TYPE = AttributeMap.Key.class.getTypeParameters()[0];
-
-    private static final Map<Class<?>, Function<String, ?>> CONVERTERS;
-
-    private static final Map<Class<?>, Class<?>> PRIMITIVE_TO_WRAPPER =
-        Stream
-            .of(Byte.class, Short.class, Integer.class, Character.class, Long.class, Float.class, Double.class,
-                Boolean.class)
-            .collect(Collectors.collectingAndThen(Collectors.<Class<?>, Class<?>, Class<?>> toMap(t -> {
-                try {
-                    return (Class<?>) t.getDeclaredField("TYPE").get(null);
-                } catch (IllegalAccessException | NoSuchFieldException e) {
-                    throw new IllegalStateException(e);
-                }
-            }, Function.identity()), Collections::unmodifiableMap));
-
-    static {
-        final Map<Class<?>, Function<String, ?>> cnv = new LinkedHashMap<>();
-        cnv.put(Byte.class, Byte::valueOf);
-        cnv.put(Short.class, Short::valueOf);
-        cnv.put(Integer.class, Integer::valueOf);
-        cnv.put(Character.class, s -> s.charAt(0));
-        cnv.put(Long.class, Long::valueOf);
-        cnv.put(Float.class, Float::valueOf);
-        cnv.put(Double.class, Double::valueOf);
-        cnv.put(Boolean.class, Boolean::valueOf);
-        cnv.put(String.class, Function.identity());
-        cnv.put(Region.class, Region::of);
-        cnv.put(Duration.class, Duration::parse);
-        CONVERTERS = Collections.unmodifiableMap(cnv);
-    }
-
-    private static Field keyField(Class<? extends AttributeMap.Key<?>> keyType, String name) {
-        try {
-            final Field result = keyType.getDeclaredField(name.toUpperCase(Locale.US));
-            Exceptions.raiseUnless(Modifier.isStatic(result.getModifiers()) && result.getType().equals(keyType),
-                IllegalArgumentException::new,
-                () -> String.format("Illegal %s key: %s", keyType.getSimpleName(), name));
-            return result;
-        } catch (NoSuchFieldException | SecurityException e) {
-            throw new BuildException(e);
-        }
-    }
-
-    /**
-     * Convert {@code value} to {@code type}.
-     *
-     * @param type
-     * @param value
-     * @return T
-     */
-    static <T> T as(Class<?> type, String value) {
-        if (type.isPrimitive()) {
-            type = PRIMITIVE_TO_WRAPPER.get(type);
-        }
-        final Optional<Object> converted = Optional.of(type).map(CONVERTERS::get).map(fn -> fn.apply(value));
-
-        if (converted.isPresent()) {
-            @SuppressWarnings("unchecked")
-            final T result = (T) converted.get();
-            return result;
-        }
-        if (type.isEnum()) {
-            try {
-                @SuppressWarnings({ "unchecked", "rawtypes" })
-                final T result = (T) Enum.valueOf((Class) type, value);
-                return result;
-            } catch (IllegalArgumentException e) {
-            }
-            @SuppressWarnings({ "unchecked", "rawtypes" })
-            final T result = (T) Enum.valueOf((Class) type, value.toUpperCase(Locale.US));
-            return result;
-        }
-        // Ant conventions
-        Constructor<T> ctor;
-        try {
-            @SuppressWarnings("unchecked")
-            final Constructor<T> _ctor = (Constructor<T>) type.getDeclaredConstructor(Project.class, String.class);
-            ctor = _ctor;
-        } catch (NoSuchMethodException | SecurityException e) {
-            try {
-                @SuppressWarnings("unchecked")
-                final Constructor<T> _ctor = (Constructor<T>) type.getDeclaredConstructor(String.class);
-                ctor = _ctor;
-            } catch (NoSuchMethodException | SecurityException e2) {
-                ctor = null;
-            }
-        }
-        if (ctor != null) {
-            try {
-                return ctor.newInstance(value);
-            } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
-                throw new IllegalStateException(e);
-            }
-        }
-        throw new IllegalArgumentException();
-    }
-
-    /**
-     * Generate an AWS SDK {@link AttributeMap} from the specified
-     * {@link String} {@link Map} for the specified key type.
-     *
-     * @param <K>
-     * @param keyType
-     * @param m
-     * @return {@link AttributeMap}
-     */
-    static <K extends AttributeMap.Key<?>> AttributeMap attributes(Class<K> keyType, Map<String, String> m) {
-        final AttributeMap.Builder b = AttributeMap.builder();
-
-        m.forEach((k, v) -> {
-            final Field keyField = keyField(keyType, k);
-            final AttributeMap.Key<Object> key;
-            try {
-                @SuppressWarnings("unchecked")
-                final AttributeMap.Key<Object> _key = (AttributeMap.Key<Object>) keyField.get(null);
-                key = _key;
-            } catch (IllegalArgumentException | IllegalAccessException e) {
-                throw new BuildException(e);
-            }
-            final Type valueType =
-                TypeUtils.getTypeArguments(keyField.getGenericType(), AttributeMap.Key.class).get(ATTRIBUTE_KEY_TYPE);
-
-            final Object value = as(TypeUtils.getRawType(valueType, null), v);
-
-            b.<Object> put(key, value);
-        });
-
-        return b.build();
-    }
-}
diff --git a/src/main/org/apache/ant/s3/build/AwsStringConversionsProvider.java b/src/main/org/apache/ant/s3/build/AwsStringConversionsProvider.java
new file mode 100644
index 0000000..ba560e2
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/AwsStringConversionsProvider.java
@@ -0,0 +1,43 @@
+/*
+ *  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.ant.s3.build;
+
+import java.util.function.Function;
+
+import org.apache.ant.s3.build.spi.DefaultProvider;
+import org.apache.ant.s3.build.spi.StringConversionsProvider;
+import org.kohsuke.MetaInfServices;
+
+import software.amazon.awssdk.awscore.defaultsmode.DefaultsMode;
+import software.amazon.awssdk.regions.Region;
+
+/**
+ * AWS {@link StringConversionsProvider}.
+ */
+@DefaultProvider
+@MetaInfServices
+public class AwsStringConversionsProvider extends StringConversionsProvider {
+    /**
+     * {@link Region} from {@link String}.
+     */
+    public static final Function<String, Region> regionOf = Region::of;
+
+    /**
+     * {@link DefaultsMode} from {@link String}.
+     */
+    public static final Function<String, DefaultsMode> defaultsModeFromValue = DefaultsMode::fromValue;
+}
diff --git a/src/main/org/apache/ant/s3/build/BuildableSupplier.java b/src/main/org/apache/ant/s3/build/BuildableSupplier.java
new file mode 100644
index 0000000..dc3c612
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/BuildableSupplier.java
@@ -0,0 +1,133 @@
+/*
+ *  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.ant.s3.build;
+
+import static software.amazon.awssdk.utils.FunctionalUtils.safeSupplier;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Proxy;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.function.Supplier;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.commons.lang3.reflect.MethodUtils;
+import org.apache.commons.lang3.reflect.TypeUtils;
+
+import software.amazon.awssdk.utils.builder.Buildable;
+
+/**
+ * Buildable supplier.
+ */
+public class BuildableSupplier<B extends Buildable, T> implements Supplier<B> {
+
+    @SuppressWarnings("rawtypes")
+    static final TypeVariable<Class<BuildableSupplier>> BUILDER = BuildableSupplier.class.getTypeParameters()[0];
+
+    @SuppressWarnings("rawtypes")
+    static final TypeVariable<Class<BuildableSupplier>> BUILT = BuildableSupplier.class.getTypeParameters()[1];
+
+    private static Type resolveAgainst(Class<?> t, TypeVariable<?> v) {
+        return TypeUtils.getTypeArguments(t, (Class<?>) v.getGenericDeclaration()).get(v);
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    static BuildableSupplier of(Method m) {
+        Exceptions.raiseUnless(Modifier.isStatic(m.getModifiers()), IllegalArgumentException::new,
+            "%s is not a static method", m);
+
+        Class<?> buildableType = m.getReturnType();
+
+        Supplier<Object> impl = safeSupplier(() -> m.invoke(null));
+
+        if (!Buildable.class.isAssignableFrom(buildableType)) {
+            Exceptions.raiseUnless(buildableType.isInterface(), IllegalArgumentException::new,
+                "%s returns type that is neither %s nor an interface", m, Buildable.class.getSimpleName());
+
+            Exceptions.raiseUnless(Buildables.quacksLikeABuildable(buildableType), IllegalArgumentException::new,
+                "%s returns type that is not duck-type %s", m, Buildable.class.getSimpleName());
+
+            final Method buildMethod = MethodUtils.getAccessibleMethod(buildableType, Buildables.BUILD_NAME);
+
+            final Class[] interfaces = new Class[] { buildableType, Buildable.class };
+            final ClassLoader ccl = Thread.currentThread().getContextClassLoader();
+
+            final Supplier wrapped = impl;
+            impl = () -> {
+                final Object target = wrapped.get();
+                return Proxy.newProxyInstance(ccl, interfaces, (proxy, method, args) -> {
+                    if (Buildables.BUILD_METHOD.test(method)) {
+                        method = buildMethod;
+                    }
+                    return method.invoke(target, args);
+                });
+            };
+            buildableType = Proxy.getProxyClass(ccl, interfaces);
+        }
+        final Class<?> builtType =
+            MethodUtils.getMatchingAccessibleMethod(buildableType, Buildables.BUILD_NAME).getReturnType();
+
+        return new BuildableSupplier(impl, buildableType, builtType);
+    }
+
+    final Supplier<B> impl;
+    final Class<B> builderType;
+    final Class<T> returnType;
+
+    @SuppressWarnings("unchecked")
+    protected BuildableSupplier(Supplier<B> impl) {
+        this.impl = impl;
+
+        this.builderType = (Class<B>) TypeUtils.getRawType(resolveAgainst(getClass(), Buildables.BUILDER), null)
+            .asSubclass(Buildable.class);
+
+        this.returnType = (Class<T>) TypeUtils.getRawType(resolveAgainst(getClass(), Buildables.BUILT), null);
+    }
+
+    BuildableSupplier(Supplier<B> impl, Class<B> builderType, Class<T> returnType) {
+        this.impl = impl;
+        this.builderType = builderType;
+        this.returnType = returnType;
+    }
+
+    /**
+     * Learn the {@link Buildable} type itself.
+     * 
+     * @return {@link Class} of {@code B}
+     */
+    public Class<B> getBuilderType() {
+        return builderType;
+    }
+
+    /**
+     * Learn the return type.
+     * 
+     * @return {@link Class}
+     */
+    public Class<T> getReturnType() {
+        return returnType;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public B get() {
+        return impl.get();
+    }
+}
\ No newline at end of file
diff --git a/src/main/org/apache/ant/s3/build/Buildables.java b/src/main/org/apache/ant/s3/build/Buildables.java
new file mode 100644
index 0000000..b0f11f6
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/Buildables.java
@@ -0,0 +1,94 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import java.lang.reflect.Modifier;
+import java.lang.reflect.TypeVariable;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import org.apache.commons.lang3.reflect.MethodUtils;
+
+import software.amazon.awssdk.utils.builder.Buildable;
+
+/**
+ * Work with AWS SDK {@link Buildable}s.
+ */
+public class Buildables {
+    private static final String BUILDABLE_SUPPLIER_NAME = "builder";
+
+    static final String BUILD_NAME = "build";
+
+    @SuppressWarnings("rawtypes")
+    static final TypeVariable<Class<BuildableSupplier>> BUILDER = BuildableSupplier.class.getTypeParameters()[0];
+
+    @SuppressWarnings("rawtypes")
+    static final TypeVariable<Class<BuildableSupplier>> BUILT = BuildableSupplier.class.getTypeParameters()[1];
+
+    static final MethodSignature BUILD_METHOD;
+
+    private static final Map<Class<?>, Optional<BuildableSupplier<?, ?>>> BUILDABLE_SUPPLIERS =
+        Collections.synchronizedMap(new HashMap<>());
+
+    static {
+        try {
+            BUILD_METHOD = MethodSignature.of(Buildable.class.getDeclaredMethod(BUILD_NAME));
+        } catch (NoSuchMethodException | SecurityException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * Find a {@link Supplier} of {@link Buildable} for the specified class.
+     * 
+     * @param <T>
+     *            supplied type
+     * @param c
+     *            {@link Class} instance for {@code T}
+     * @return {@link Optional} {@link Buildable} {@link Supplier}
+     */
+    public static <T> Optional<BuildableSupplier<?, T>> findBuildableSupplier(Class<T> c) {
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        final Optional<BuildableSupplier<?, T>> result = (Optional) BUILDABLE_SUPPLIERS.computeIfAbsent(c, k -> {
+            try {
+                return Optional.of(c.getDeclaredMethod(BUILDABLE_SUPPLIER_NAME))
+                    .filter(m -> quacksLikeABuildable(m.getReturnType()) && Modifier.isStatic(m.getModifiers()))
+                    .map(BuildableSupplier::of);
+            } catch (NoSuchMethodException | SecurityException e) {
+                return Optional.empty();
+            }
+        });
+        return result;
+    }
+
+    /**
+     * Learn whether {@code type} implements {@link Buildable#build()} by "duck
+     * typing."
+     * 
+     * @param type
+     *            to test
+     * @return {@code boolean}
+     */
+    static boolean quacksLikeABuildable(Class<?> type) {
+        return Buildable.class.isAssignableFrom(type)
+            || Optional.ofNullable(MethodUtils.getMatchingAccessibleMethod(type, BUILD_NAME))
+                .filter(m -> !Modifier.isStatic(m.getModifiers()) && !Void.TYPE.equals(m.getReturnType())).isPresent();
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/Builder.java b/src/main/org/apache/ant/s3/build/Builder.java
new file mode 100644
index 0000000..808cf54
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/Builder.java
@@ -0,0 +1,266 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BiPredicate;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.S3DataType;
+import org.apache.ant.s3.build.ConfigurableSupplier.DynamicConfiguration;
+import org.apache.commons.lang3.ClassUtils;
+import org.apache.commons.lang3.ClassUtils.Interfaces;
+import org.apache.commons.lang3.reflect.TypeUtils;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.types.DataType;
+import org.apache.tools.ant.types.Reference;
+
+/**
+ * Support AWS SDK v2 fluent builder conventions.
+ *
+ * @param <T>
+ *            type to configure/introspect
+ */
+public class Builder<T> extends S3DataType implements DynamicConfiguration, Consumer<T>, ConfigurableSupplierFactory {
+
+    private abstract class Mutation implements Consumer<T> {
+        final Method mutator;
+
+        Mutation(Method mutator) {
+            this.mutator = mutator;
+        }
+
+        abstract Object getArg();
+
+        @Override
+        public void accept(T t) {
+            try {
+                mutator.invoke(t, getArg());
+            } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+                throw new IllegalStateException(e);
+            }
+        }
+    }
+
+    private class AttributeMutation extends Mutation {
+        final Object arg;
+
+        AttributeMutation(Method mutator, Object arg) {
+            super(mutator);
+            this.arg = arg;
+        }
+
+        @Override
+        Object getArg() {
+            return arg;
+        }
+    }
+
+    private class ElementMutation extends Mutation {
+        final ConfigurableSupplier<?> configurableSupplier;
+
+        ElementMutation(Method mutator, ConfigurableSupplier<?> configurableSupplier) {
+            super(mutator);
+            this.configurableSupplier = configurableSupplier;
+        }
+
+        @Override
+        Object getArg() {
+            return Optional.of(getConfig()).filter(DataType.class::isInstance).map(DataType.class::cast)
+                .filter(DataType::isReference).map(DataType::getRefid).map(Reference::getReferencedObject)
+                .orElseGet(configurableSupplier);
+        }
+
+        DynamicConfiguration getConfig() {
+            return configurableSupplier.getConfiguration();
+        }
+    }
+
+    private static final TypeVariable<?> CONSUMER_ARG = Consumer.class.getTypeParameters()[0];
+
+    private static final Set<BiPredicate<String, String>> NAME_COMPARISONS =
+        Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(String::equals, String::equalsIgnoreCase)));
+
+    private static boolean isEquivalentTo(Class<?> c, Type t) {
+        if (ParameterizedType.class.isInstance(t)) {
+            t = ((ParameterizedType) t).getRawType();
+        }
+        return c.equals(t);
+    }
+
+    private static boolean isFluent(Method m) {
+        final Class<?> declaringClass = m.getDeclaringClass();
+
+        final Type genericReturnType = m.getGenericReturnType();
+
+        if (isEquivalentTo(declaringClass, genericReturnType)) {
+            return true;
+        }
+        if (TypeVariable.class.isInstance(genericReturnType)) {
+            final TypeVariable<?> var = (TypeVariable<?>) genericReturnType;
+            for (final Type bound : var.getBounds()) {
+                if (isEquivalentTo(declaringClass, bound)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean isFluentSdkMutator(Method m) {
+        return isFluent(m) && !Modifier.isStatic(m.getModifiers()) && m.getParameterTypes().length == 1;
+    }
+
+    private static <T> Builder<T>.ElementMutation elementMutation(Builder<T> b, String name) {
+        return Stream.<Function<Method, Optional<Builder<T>
+            .ElementMutation>>> of(b::cmer, b::configurableSupplier, b::fallback).map(fn -> b.searchMethods(name, fn))
+            .filter(Optional::isPresent).findFirst()
+            .orElseThrow(() -> Exceptions.create(b.buildException(), "Unknown element %s", name)).get();
+    }
+
+    protected final Class<T> target;
+    private final Set<Mutation> mutations = new LinkedHashSet<>();
+
+    /**
+     * Create a new {@link Builder} instance.
+     *
+     * @param target
+     *            type
+     * @param project
+     *            Ant {@link Project}
+     */
+    public Builder(Class<T> target, Project project) {
+        super(project);
+        this.target = Objects.requireNonNull(target, "target");
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void accept(T t) {
+        validate();
+        mutations.forEach(cmer -> cmer.accept(t));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void setDynamicAttribute(String uri, String localName, String qName, String value) throws BuildException {
+        final AttributeMutation mutation = searchMethods(localName, m -> {
+            try {
+                final Object convertedValue = StringConversions.as(m.getGenericParameterTypes()[0], value);
+                return Optional.of(new AttributeMutation(m, convertedValue));
+            } catch (IllegalArgumentException e) {
+                return Optional.empty();
+            }
+        }).orElseThrow(() -> Exceptions.create(buildException(), "Unknown attribute %s", localName));
+
+        mutations.add(mutation);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+        final ElementMutation mutation = elementMutation(this, localName);
+        mutations.add(mutation);
+        return mutation.getConfig();
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private Optional<ElementMutation> cmer(Method method) {
+        return Optional.of(method).filter(m -> Consumer.class.equals(m.getParameterTypes()[0])).map(m -> {
+            final Builder<?> builder = new Builder(TypeUtils.getRawType(
+                TypeUtils.getTypeArguments(method.getGenericParameterTypes()[0], Consumer.class).get(CONSUMER_ARG),
+                null), getProject());
+
+            return new ElementMutation(method, new ConfigurableSupplier() {
+
+                @Override
+                public Object get() {
+                    return builder;
+                }
+
+                @Override
+                public DynamicConfiguration getConfiguration() {
+                    return builder;
+                }
+            });
+        });
+    }
+
+    private Optional<ElementMutation> configurableSupplier(Method method) {
+        return configurableSupplier(method.getParameterTypes()[0]).map(cs -> new ElementMutation(method, cs));
+    }
+
+    private Optional<ElementMutation> fallback(Method m) {
+        return Optional.of(new ElementMutation(m, new ConfigurableSupplier<Object>() {
+            final Class<?> pt = m.getParameterTypes()[0];
+            final Builder<?> builder = new Builder<>(pt, getProject());
+
+            @Override
+            public Object get() {
+                throw Exceptions.create(buildExceptionTriggered(), new UnsupportedOperationException(),
+                    "Don't know how to handle argument of type %s; consider @refid for this argument", pt.getName());
+            }
+
+            @Override
+            public DynamicConfiguration getConfiguration() {
+                return builder;
+            }
+        }));
+    }
+
+    private <R> Optional<R> searchMethods(String name, Function<Method, Optional<R>> fn) {
+        for (BiPredicate<String, String> nameComparison : NAME_COMPARISONS) {
+            for (Class<?> type : ClassUtils.hierarchy(target, Interfaces.INCLUDE)) {
+                for (Method m : type.getDeclaredMethods()) {
+                    if (nameComparison.test(name, m.getName()) && isFluentSdkMutator(m)) {
+                        final Optional<R> result = fn.apply(m);
+                        if (result.isPresent()) {
+                            return result;
+                        }
+                    }
+                }
+            }
+        }
+        return Optional.empty();
+    }
+
+    private void validate() {
+        Exceptions.raiseIf(isReference() && !mutations.isEmpty(), buildException(),
+            "Cannot specify @refid in conjunction with configured builder mutations");
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/ClassFinder.java b/src/main/org/apache/ant/s3/build/ClassFinder.java
new file mode 100644
index 0000000..62576b2
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/ClassFinder.java
@@ -0,0 +1,138 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import static software.amazon.awssdk.utils.FunctionalUtils.safeFunction;
+
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.strings.ClassNames;
+import org.apache.ant.s3.strings.PackageNames;
+import org.apache.ant.s3.strings.Strings;
+import org.apache.commons.lang3.ClassUtils;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Entity to find Java classes by naming convention. A "fragment" is "plugged
+ * into" a matrix of packagename prefixes and classname suffixes to yield FQ
+ * classnames. The fragment can be prefixed by a full or partial package name,
+ * and is also interpretable as a FQ classname itself.
+ */
+public class ClassFinder {
+
+    /**
+     * {@link Pattern} to identify the point at which the final dot in a
+     * {@link String} is followed by a lowercase character.
+     */
+    private static final Pattern PACKAGE_TO_CLASS_TRANSITION = Pattern.compile("((?:^|\\.)[a-z])(?=[^\\.]*$)");
+
+    private static String toClassConvention(String fragment) {
+        final String result;
+        final Matcher m = PACKAGE_TO_CLASS_TRANSITION.matcher(fragment);
+        if (m.find()) {
+            final StringBuffer b = new StringBuffer();
+            m.appendReplacement(b, m.group(1).toUpperCase());
+            m.appendTail(b);
+            result = b.toString();
+        } else {
+            result = fragment;
+        }
+        return StringUtils.stripStart(result, ".");
+    }
+
+    private static String toString(Strings strings) {
+        return strings.stream().collect(Collectors.joining(", ", "[", "]"));
+    }
+
+    private final PackageNames packageNames;
+    private final ClassNames suffixes;
+
+    /**
+     * Create a new {@link ClassFinder}.
+     * 
+     * @param packageNames
+     *            relative to which to search
+     * @param suffixes
+     *            to append
+     */
+    public ClassFinder(Iterable<String> packageNames, Iterable<String> suffixes) {
+        this.packageNames = PackageNames.of("").andThen(packageNames).distinct();
+        this.suffixes = ClassNames.of("").andThen(suffixes).distinct();
+    }
+
+    /**
+     * Find a {@link Class} instance plugging the specified shorthand
+     * {@link String} into the specified matrix of package names and classname
+     * suffixes.
+     * 
+     * @param s
+     *            fragment whose first alpha character after any final dot
+     *            ({@code .}) will be converted to uppercase
+     * @return {@link Class}
+     */
+    public Class<?> find(String s) {
+        return find(s, null);
+    }
+
+    /**
+     * Find a {@link Class} instance plugging the specified shorthand
+     * {@link String} into the specified matrix of package names and classname
+     * suffixes.
+     * 
+     * @param <T>
+     *            supertype
+     * @param s
+     *            fragment whose first alpha character after any final dot
+     *            ({@code .}) will be converted to uppercase
+     * @param requiredSupertype
+     *            may be {@code null}
+     * @return {@link Class} extending {@code T}
+     */
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public <T> Class<? extends T> find(String s, Class<T> requiredSupertype) {
+        final String fragment = toClassConvention(s);
+
+        final Predicate<Class<?>> filter;
+        final ClassLoader loader;
+        if (requiredSupertype == null) {
+            filter = c -> true;
+            loader = Thread.currentThread().getContextClassLoader();
+        } else {
+            filter = requiredSupertype::isAssignableFrom;
+            loader = requiredSupertype.getClassLoader();
+        }
+        return (Class) packageNames.stream().map(p -> p.isEmpty() ? fragment : p + '.' + fragment)
+            .flatMap(pf -> suffixes.stream().map(sf -> pf + sf)).map(name -> probeFor(loader, name))
+            .filter(Objects::nonNull).filter(filter).findFirst()
+            .orElseThrow(() -> Exceptions.create(IllegalArgumentException::new,
+                () -> String.format("Cannot find class for '%s' among packages %s X suffixes %s", s,
+                    toString(packageNames), toString(suffixes))));
+    }
+
+    private Class<?> probeFor(ClassLoader loader, String className) {
+        try {
+            return ClassUtils.getClass(loader, className);
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/org/apache/ant/s3/build/ConfigurableSupplier.java b/src/main/org/apache/ant/s3/build/ConfigurableSupplier.java
new file mode 100644
index 0000000..8a90e73
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/ConfigurableSupplier.java
@@ -0,0 +1,77 @@
+/*
+ *  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.ant.s3.build;
+
+import java.util.function.Supplier;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.ProjectUtils;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.DynamicAttributeNS;
+import org.apache.tools.ant.DynamicElementNS;
+
+/**
+ * A configurable {@link Supplier}.
+ *
+ * @param <T>
+ *            supplied type
+ */
+public interface ConfigurableSupplier<T> extends Supplier<T> {
+
+    /**
+     * Dynamic configuration superset interface.
+     */
+    interface DynamicConfiguration extends DynamicAttributeNS, DynamicElementNS, ProjectUtils {
+
+        /**
+         * {@inheritDoc}
+         * 
+         * Default implementation.
+         * 
+         * @throws BuildException
+         *             always
+         */
+        @Override
+        default void setDynamicAttribute(String uri, String localName, String qName, String value)
+            throws BuildException {
+            Exceptions.raise(buildExceptionTriggered(), new UnsupportedOperationException(), "@%s not supported",
+                qName);
+        }
+
+        /**
+         * {@inheritDoc}
+         * 
+         * Default implementation.
+         * 
+         * @throws BuildException
+         *             always
+         */
+        @Override
+        default Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+            throw Exceptions.create(buildExceptionTriggered(), new UnsupportedOperationException(),
+                "nested %s element not supported", qName);
+        }
+    }
+
+    /**
+     * Get the {@link DynamicConfiguration} for this
+     * {@link ConfigurableSupplier}.
+     * 
+     * @return {@link DynamicConfiguration}
+     */
+    DynamicConfiguration getConfiguration();
+}
diff --git a/src/main/org/apache/ant/s3/build/ConfigurableSupplierFactory.java b/src/main/org/apache/ant/s3/build/ConfigurableSupplierFactory.java
new file mode 100644
index 0000000..159ac39
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/ConfigurableSupplierFactory.java
@@ -0,0 +1,76 @@
+/*
+ *  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.ant.s3.build;
+
+import java.util.Optional;
+
+import org.apache.tools.ant.Project;
+
+import software.amazon.awssdk.utils.builder.Buildable;
+
+/**
+ * Mixin style implementation of functionality to create
+ * {@link ConfigurableSupplier}s, permitting creation of subordinate objects
+ * bound to a single Ant {@link Project}.
+ */
+public interface ConfigurableSupplierFactory {
+    /**
+     * Primary functionality provided by this interface. Get a
+     * {@link ConfigurableSupplier} for the specified {@link Class}.
+     * 
+     * @param <T> type param
+     * @param type supplied
+     * @return {@link Optional} {@link ConfigurableSupplier} of {@code T}
+     */
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    default <T> Optional<ConfigurableSupplier<T>> configurableSupplier(Class<T> type) {
+        final Optional<ConfiguringSupplier<T>> configuringSupplier =
+            ConfiguringSuppliers.forProject(getProject()).findConfiguringSupplier(type);
+
+        if (configuringSupplier.isPresent()) {
+            return (Optional) configuringSupplier;
+        }
+        final Optional<BuildableSupplier<?, T>> buildableSupplier = Buildables.findBuildableSupplier(type);
+        if (buildableSupplier.isPresent()) {
+            final Buildable buildable = buildableSupplier.get().get();
+            final Builder builder = new Builder(buildable.getClass(), getProject());
+
+            return Optional.of(new ConfigurableSupplier<T>() {
+
+                @Override
+                public T get() {
+                    builder.accept(buildable);
+                    return (T) buildable.build();
+                }
+
+                @Override
+                public DynamicConfiguration getConfiguration() {
+                    return builder;
+                }
+            });
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * Get the {@link Project} associated with this
+     * {@link ConfigurableSupplierFactory}.
+     * 
+     * @return {@link Project}
+     */
+    Project getProject();
+}
diff --git a/src/main/org/apache/ant/s3/build/ConfiguringSupplier.java b/src/main/org/apache/ant/s3/build/ConfiguringSupplier.java
new file mode 100644
index 0000000..981f920
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/ConfiguringSupplier.java
@@ -0,0 +1,39 @@
+/*
+ *  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.ant.s3.build;
+
+import org.apache.ant.s3.build.ConfigurableSupplier.DynamicConfiguration;
+
+/**
+ * A {@link ConfigurableSupplier} that <em>is</em> its own
+ * {@code configuration}.
+ * 
+ * @param <T>
+ *            supplied type
+ */
+public interface ConfiguringSupplier<T> extends DynamicConfiguration, ConfigurableSupplier<T> {
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@code this}
+     */
+    @Override
+    default DynamicConfiguration getConfiguration() {
+        return this;
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/ConfiguringSuppliers.java b/src/main/org/apache/ant/s3/build/ConfiguringSuppliers.java
new file mode 100644
index 0000000..90d2b57
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/ConfiguringSuppliers.java
@@ -0,0 +1,96 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.ant.s3.build.spi.ConfiguringSuppliersProvider;
+import org.apache.ant.s3.build.spi.Providers;
+import org.apache.tools.ant.Project;
+
+/**
+ * {@link ConfiguringSupplier}s management.
+ */
+public class ConfiguringSuppliers {
+    private static final Map<Class<?>, Optional<Function<Project, ConfiguringSupplier<?>>>> CONFIGURING_SUPPLIERS =
+        Collections.synchronizedMap(new HashMap<>());
+
+    static {
+        Providers.stream(ConfiguringSuppliersProvider.class).map(ConfiguringSuppliersProvider::get)
+            .forEach(ConfiguringSuppliers::registerConfiguringSuppliers);
+    }
+
+    /**
+     * Get the {@link ConfiguringSuppliers} instance for the specified
+     * {@link Project}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link ConfiguringSuppliers}
+     */
+    public static ConfiguringSuppliers forProject(Project project) {
+        ConfiguringSuppliers result = project.getReference(ConfiguringSuppliers.class.getName());
+        if (result == null) {
+            result = new ConfiguringSuppliers(project);
+            project.addReference(ConfiguringSuppliers.class.getName(), result);
+        }
+        return result;
+    }
+
+    /**
+     * Register the {@link ConfiguringSupplier} {@link Supplier}s in the
+     * specified {@link Map}.
+     * 
+     * @param configuringSuppliers
+     *            to register
+     * @return whether any existing {@link ConfiguringSupplier} {@link Supplier}
+     *         was displaced by this process
+     */
+    static boolean registerConfiguringSuppliers(
+        Map<Class<?>, Function<Project, ConfiguringSupplier<?>>> configuringSuppliers) {
+        return configuringSuppliers.entrySet().stream()
+            .map(e -> CONFIGURING_SUPPLIERS.put(e.getKey(), Optional.of(e.getValue()))).filter(Objects::nonNull)
+            .anyMatch(Optional::isPresent);
+    }
+
+    private final Project project;
+
+    private ConfiguringSuppliers(Project project) {
+        this.project = Objects.requireNonNull(project);
+    }
+
+    /**
+     * Find a {@link ConfiguringSupplier} for the specified class.
+     * 
+     * @param <T>
+     *            supplied type
+     * @param c
+     *            type instance
+     * @return {@link Optional} {@link ConfiguringSupplier}
+     */
+    @SuppressWarnings("unchecked")
+    public <T> Optional<ConfiguringSupplier<T>> findConfiguringSupplier(Class<T> c) {
+        return CONFIGURING_SUPPLIERS.computeIfAbsent(c, k -> Optional.empty())
+            .map(fn -> (ConfiguringSupplier<T>) fn.apply(project));
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/DefaultStringConversionsProvider.java b/src/main/org/apache/ant/s3/build/DefaultStringConversionsProvider.java
new file mode 100644
index 0000000..e598b8f
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/DefaultStringConversionsProvider.java
@@ -0,0 +1,116 @@
+/*
+ *  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.ant.s3.build;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.function.Function;
+
+import org.apache.ant.s3.build.spi.DefaultProvider;
+import org.apache.ant.s3.build.spi.StringConversionsProvider;
+import org.kohsuke.MetaInfServices;
+
+/**
+ * Default/baseline {@link StringConversionsProvider}.
+ */
+@DefaultProvider
+@MetaInfServices
+public class DefaultStringConversionsProvider extends StringConversionsProvider {
+    /**
+     * {@link Byte} from {@link String}.
+     */
+    public final Function<String, Byte> toByte = Byte::valueOf;
+
+    /**
+     * {@link Short} from {@link String}.
+     */
+    public final Function<String, Short> toShort = Short::valueOf;
+
+    /**
+     * {@link Integer} from {@link String}.
+     */
+    public final Function<String, Integer> toInt = Integer::valueOf;
+
+    /**
+     * {@link Character} from {@link String}.
+     */
+    public final Function<String, Character> toChar = s -> s.charAt(0);
+
+    /**
+     * {@link Long} from {@link String}.
+     */
+    public final Function<String, Long> toLong = Long::valueOf;
+
+    /**
+     * {@link Float} from {@link String}.
+     */
+    public final Function<String, Float> toFloat = Float::valueOf;
+
+    /**
+     * {@link Double} from {@link String}.
+     */
+    public final Function<String, Double> toDouble = Double::valueOf;
+
+    /**
+     * {@link Boolean} from {@link String}.
+     */
+    public final Function<String, Boolean> toBoolean = Boolean::valueOf;
+
+    /**
+     * Identity.
+     */
+    public final Function<String, String> toString = Function.identity();
+
+    /**
+     * {@link Duration} from {@link String}.
+     */
+    public final Function<String, Duration> toDuration = Duration::parse;
+
+    /**
+     * {@link URI} from {@link String}.
+     */
+    public final Function<String, URI> toUri = URI::create;
+
+    /**
+     * {@link Path} from {@link String}.
+     */
+    public final Function<String, Path> toPath = Paths::get;
+
+    /**
+     * {@link BigDecimal} from {@link String}.
+     */
+    public final Function<String, BigDecimal> toBigDecimal = BigDecimal::new;
+
+    /**
+     * {@link BigInteger} from {@link String}.
+     */
+    public final Function<String, BigInteger> toBigInteger = BigInteger::new;
+
+    /**
+     * {@code byte[]} from {@link String}.
+     */
+    public final Function<String, byte[]> toByteArray = String::getBytes;
+
+    /**
+     * {@code char[]} from {@link String}.
+     */
+    public final Function<String, char[]> toCharArray = String::toCharArray;
+}
diff --git a/src/main/org/apache/ant/s3/build/MetaBuilderByType.java b/src/main/org/apache/ant/s3/build/MetaBuilderByType.java
new file mode 100644
index 0000000..af23ff8
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/MetaBuilderByType.java
@@ -0,0 +1,140 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.ProjectUtils;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.ProjectComponent;
+
+import software.amazon.awssdk.utils.builder.Buildable;
+
+/**
+ * {@link ConfiguringSupplier} by named Java type.
+ */
+public class MetaBuilderByType<T> extends ProjectComponent
+    implements ConfiguringSupplier<T>, ConfigurableSupplierFactory, ProjectUtils {
+
+    private final Class<T> api;
+    private final ClassFinder classFinder;
+    private final Class<? extends T> defaultImpl;
+    private Map<String, String> attributeCache = new LinkedHashMap<>();
+    private BiConsumer<String, String> attributeCmer = attributeCache::put;
+    private Optional<ConfigurableSupplier<? extends T>> configurableSupplier = Optional.empty();
+
+    /**
+     * Create a new {@link MetaBuilderByType}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @param api
+     *            supertype whose child to find
+     * @param classFinder
+     *            to use
+     */
+    public MetaBuilderByType(Project project, Class<T> api, ClassFinder classFinder) {
+        this(project, api, classFinder, null);
+    }
+
+    /**
+     * Create a new {@link MetaBuilderByType}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @param api
+     *            supertype whose child to find
+     * @param classFinder
+     *            to use
+     * @param defaultImpl
+     *            of {@code api}
+     */
+    public MetaBuilderByType(Project project, Class<T> api, ClassFinder classFinder, Class<? extends T> defaultImpl) {
+        this.api = api;
+        this.classFinder = classFinder;
+        this.defaultImpl = defaultImpl;
+        setProject(project);
+    }
+
+    /**
+     * Set the implementation type by name.
+     * 
+     * @param impl
+     *            fragment
+     * @see ClassFinder#find(String, Class)
+     */
+    public void setImpl(String impl) {
+        useImpl(classFinder.find(impl, api));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public T get() {
+        return configurableSupplier().get();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public synchronized void setDynamicAttribute(String uri, String localName, String qName, String value)
+        throws BuildException {
+        attributeCmer.accept(localName, value);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+        return configurableSupplier().getConfiguration().createDynamicElement(uri, localName, qName);
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private void useImpl(Class<? extends T> type) {
+        Exceptions.raiseIf(configurableSupplier.isPresent(), buildExceptionTriggered(), new IllegalStateException(),
+            "Already set %s [for type %s]", ConfigurableSupplier.class.getSimpleName());
+
+        configurableSupplier = (Optional) configurableSupplier(type);
+
+        Exceptions.raiseUnless(configurableSupplier.isPresent(), buildExceptionTriggered(),
+            new IllegalArgumentException(), "Could not find %s for %s", Buildable.class.getSimpleName(), type);
+
+        synchronized (this) {
+            attributeCmer = (k, v) -> configurableSupplier().getConfiguration().setDynamicAttribute(null, k, null, v);
+            attributeCache.forEach(attributeCmer);
+            attributeCache = null;
+        }
+    }
+
+    private ConfigurableSupplier<? extends T> configurableSupplier() {
+        if (!configurableSupplier.isPresent()) {
+            Exceptions.raiseIf(defaultImpl == null, buildExceptionTriggered(), new IllegalStateException(),
+                "subtype has not been set/found and no default impl was configured");
+
+            useImpl(defaultImpl);
+        }
+        return configurableSupplier.get();
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/MethodSignature.java b/src/main/org/apache/ant/s3/build/MethodSignature.java
new file mode 100644
index 0000000..c2add3a
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/MethodSignature.java
@@ -0,0 +1,126 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Modeled method signature.
+ */
+public final class MethodSignature implements Predicate<Method> {
+
+    private static final Set<MethodSignature> INTERNED = new HashSet<>();
+
+    /**
+     * Factory method.
+     * 
+     * @param m
+     *            {@link Method}
+     * @return {@link MethodSignature}
+     */
+    public static MethodSignature of(Method m) {
+        final MethodSignature result = new MethodSignature(m.getName(), m.getParameterTypes());
+
+        final Optional<MethodSignature> interned = INTERNED.stream().filter(Predicate.isEqual(result)).findFirst();
+
+        if (interned.isPresent()) {
+            return interned.get();
+        }
+        INTERNED.add(result);
+        return result;
+    }
+
+    private final String name;
+    private final List<Class<?>> parameterTypes;
+
+    private MethodSignature(String name, Class<?>[] parameterTypes) {
+        this.name = name;
+        this.parameterTypes = Stream.of(parameterTypes)
+            .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));
+    }
+
+    /**
+     * Get the {@code name}.
+     * 
+     * @return {@link String}
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Get the parameter types.
+     * 
+     * @return {@link List} of {@link Class}
+     */
+    public List<Class<?>> getParameterTypes() {
+        return parameterTypes;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, parameterTypes);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof MethodSignature)) {
+            return false;
+        }
+        final MethodSignature other = (MethodSignature) obj;
+        return Objects.equals(name, other.name) && Objects.equals(parameterTypes, other.parameterTypes);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString() {
+        return parameterTypes.stream().map(Class::getName).collect(Collectors.joining(", ", name + "(", ")"));
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @param t
+     *            {@link Method}
+     * @return {@code boolean}
+     */
+    @Override
+    public boolean test(Method t) {
+        return name.equals(t.getName()) && Arrays.asList(t.getParameterTypes()).equals(parameterTypes);
+    }
+}
\ No newline at end of file
diff --git a/src/main/org/apache/ant/s3/build/RootConfiguringSupplier.java b/src/main/org/apache/ant/s3/build/RootConfiguringSupplier.java
new file mode 100644
index 0000000..4326996
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/RootConfiguringSupplier.java
@@ -0,0 +1,116 @@
+/*
+ *  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.ant.s3.build;
+
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.S3DataType;
+import org.apache.commons.lang3.reflect.TypeUtils;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+
+import software.amazon.awssdk.utils.Lazy;
+
+/**
+ * Root {@link ConfiguringSupplier}.
+ *
+ * @param <T>
+ *            supplied type
+ */
+public class RootConfiguringSupplier<T> extends S3DataType
+    implements ConfiguringSupplier<T>, ConfigurableSupplierFactory {
+
+    private static final TypeVariable<?> SUPPLIED_TYPE = Supplier.class.getTypeParameters()[0];
+
+    private final ConfigurableSupplier<T> configurableSupplier;
+    private final Lazy<T> payload;
+
+    /**
+     * Create a new {@link RootConfiguringSupplier}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @param t
+     *            type supplied
+     */
+    @SuppressWarnings("unchecked")
+    public RootConfiguringSupplier(Project project, Class<T> t) {
+        super(project);
+
+        if (t == null) {
+            final Type boundType = TypeUtils.getTypeArguments(getClass(), Supplier.class).get(SUPPLIED_TYPE);
+
+            Exceptions.raiseIf(boundType == null, IllegalStateException::new, "%s does not bind %s", getClass(),
+                SUPPLIED_TYPE);
+
+            t = (Class<T>) TypeUtils.getRawType(boundType, null);
+        }
+        configurableSupplier = configurableSupplier(t).get();
+        payload = new Lazy<>(configurableSupplier::get);
+    }
+
+    /**
+     * Create a new {@link RootConfiguringSupplier} for a bound subtype.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     */
+    protected RootConfiguringSupplier(Project project) {
+        this(project, null);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public T get() {
+        return payload.getValue();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void setDynamicAttribute(String uri, String localName, String qName, String value) throws BuildException {
+        configurableSupplier.getConfiguration().setDynamicAttribute(uri, localName, qName, value);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+        return configurableSupplier.getConfiguration().createDynamicElement(uri, localName, qName);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public <TT> Optional<ConfigurableSupplier<TT>> configurableSupplier(Class<TT> type) {
+        final Optional<ConfigurableSupplier<TT>> result = ConfigurableSupplierFactory.super.configurableSupplier(type);
+
+        Exceptions.raiseUnless(result.isPresent(), buildExceptionTriggered(), new IllegalArgumentException(),
+            "Could not find %s for %s", ConfigurableSupplier.class.getSimpleName(), type);
+
+        return result;
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/StringConversions.java b/src/main/org/apache/ant/s3/build/StringConversions.java
new file mode 100644
index 0000000..c52cb0d
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/StringConversions.java
@@ -0,0 +1,248 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.build;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.build.spi.Providers;
+import org.apache.ant.s3.build.spi.StringConversionsProvider;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.ClassUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.reflect.TypeUtils;
+import org.apache.commons.lang3.reflect.Typed;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+
+import software.amazon.awssdk.utils.AttributeMap;
+
+/**
+ * Static utility class for conversions from {@link String} to needed types.
+ */
+public class StringConversions {
+    private static final TypeVariable<?> ATTRIBUTE_KEY_TYPE = AttributeMap.Key.class.getTypeParameters()[0];
+
+    private static final TypeVariable<?> COLLECTION_ELEMENT = Collection.class.getTypeParameters()[0];
+
+    private static final Map<Class<?>, Function<String, ?>> CONVERTERS = new HashMap<>();
+
+    static {
+        Providers.stream(StringConversionsProvider.class).map(StringConversionsProvider::get)
+            .forEach(StringConversions::register);
+    }
+
+    private static Field keyField(Class<? extends AttributeMap.Key<?>> keyType, String name) {
+        try {
+            final Field result = keyType.getDeclaredField(name.toUpperCase(Locale.US));
+            Exceptions.raiseUnless(Modifier.isStatic(result.getModifiers()) && result.getType().equals(keyType),
+                IllegalArgumentException::new,
+                () -> String.format("Illegal %s key: %s", keyType.getSimpleName(), name));
+            return result;
+        } catch (NoSuchFieldException | SecurityException e) {
+            throw new BuildException(e);
+        }
+    }
+
+    /**
+     * Convert {@code value} to the type represented by {@code type}.
+     *
+     * @param <T>
+     *            target
+     * @param type
+     *            {@link Typed} of {@code T}
+     * @param value
+     *            source
+     * @return {@code T}
+     */
+    public static <T> T as(Typed<T> type, String value) {
+        return as(type.getType(), value);
+    }
+
+    /**
+     * Convert {@code value} to {@code type}.
+     *
+     * @param <T>
+     *            target
+     * @param type
+     *            target type instance
+     * @param value
+     *            source
+     * @return {@code T}
+     */
+    @SuppressWarnings("unchecked")
+    public static <T> T as(Type type, String value) {
+        final Class<?> clazz = ClassUtils.primitiveToWrapper(TypeUtils.getRawType(type, null));
+
+        final Optional<Object> converted = Optional.of(clazz).map(CONVERTERS::get).map(fn -> fn.apply(value));
+
+        if (converted.isPresent()) {
+            return (T) converted.get();
+        }
+        final Optional<Type> componentType = getComponentType(type);
+        if (componentType.isPresent()) {
+            final List<Object> list = Stream.of(StringUtils.split(value, ',')).map(String::trim)
+                .map(v -> as(componentType.get(), v)).collect(Collectors.toList());
+
+            return (T) toCollectionish(type, list);
+        }
+        if (clazz.isEnum()) {
+            try {
+                @SuppressWarnings("rawtypes")
+                final T result = (T) Enum.valueOf((Class) clazz, value);
+                return result;
+            } catch (IllegalArgumentException e) {
+            }
+            @SuppressWarnings("rawtypes")
+            final T result = (T) Enum.valueOf((Class) clazz, value.toUpperCase(Locale.US));
+            return result;
+        }
+        // Ant conventions
+        Constructor<T> ctor;
+        try {
+            final Constructor<T> _ctor = (Constructor<T>) clazz.getDeclaredConstructor(Project.class, String.class);
+            ctor = _ctor;
+        } catch (NoSuchMethodException | SecurityException e) {
+            try {
+                final Constructor<T> _ctor = (Constructor<T>) clazz.getDeclaredConstructor(String.class);
+                ctor = _ctor;
+            } catch (NoSuchMethodException | SecurityException e2) {
+                ctor = null;
+            }
+        }
+        if (ctor != null) {
+            try {
+                return ctor.newInstance(value);
+            } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
+                throw new IllegalStateException(e);
+            }
+        }
+        throw new IllegalArgumentException();
+    }
+
+    /**
+     * Generate an AWS SDK {@link AttributeMap} from the specified
+     * {@link String} {@link Map} for the specified key type.
+     *
+     * @param <K>
+     *            key type
+     * @param keyType
+     *            as {@link Class}
+     * @param m
+     *            source
+     * @return {@link AttributeMap}
+     */
+    public static <K extends AttributeMap.Key<?>> AttributeMap attributes(Class<K> keyType, Map<String, String> m) {
+        final AttributeMap.Builder b = AttributeMap.builder();
+
+        m.forEach((k, v) -> {
+            final Field keyField = keyField(keyType, k);
+            final AttributeMap.Key<Object> key;
+            try {
+                @SuppressWarnings("unchecked")
+                final AttributeMap.Key<Object> _key = (AttributeMap.Key<Object>) keyField.get(null);
+                key = _key;
+            } catch (IllegalArgumentException | IllegalAccessException e) {
+                throw new BuildException(e);
+            }
+            final Type valueType =
+                TypeUtils.getTypeArguments(keyField.getGenericType(), AttributeMap.Key.class).get(ATTRIBUTE_KEY_TYPE);
+
+            final Object value = as(valueType, v);
+
+            b.<Object> put(key, value);
+        });
+
+        return b.build();
+    }
+
+    /**
+     * Register the converters in the specified {@link Map}.
+     * 
+     * @param converters
+     *            to register
+     * @return whether any existing converter was displaced by this process
+     */
+    static boolean register(Map<Class<?>, Function<String, ?>> converters) {
+        return converters.entrySet().stream().map(e -> CONVERTERS.put(e.getKey(), e.getValue()))
+            .anyMatch(Objects::nonNull);
+    }
+
+    private static Optional<Type> getComponentType(Type t) {
+        final Class<?> clazz = TypeUtils.getRawType(t, null);
+        if (clazz.isArray()) {
+            return Optional.of(clazz.getComponentType());
+        }
+        if (Collection.class.isAssignableFrom(clazz)) {
+            return Optional.ofNullable(TypeUtils.getTypeArguments(t, Collection.class))
+                .map(m -> m.get(COLLECTION_ELEMENT));
+        }
+        return Optional.empty();
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private static <T> T toCollectionish(Type t, List<Object> l) {
+        final Class<?> c = TypeUtils.getRawType(t, null);
+        if (Arrays.asList(Collection.class, List.class).contains(c)) {
+            return (T) l;
+        }
+        if (Set.class.equals(c)) {
+            if (Optional.of(COLLECTION_ELEMENT).map(TypeUtils.getTypeArguments(t, Collection.class)::get)
+                .filter(Class.class::isInstance).map(Class.class::cast).filter(Class::isEnum).isPresent()) {
+                return (T) EnumSet.copyOf((List) l);
+            }
+            return (T) new LinkedHashSet<>(l);
+        }
+        if (Collection.class.isAssignableFrom(c)) {
+            try {
+                final Constructor<?> ctor = c.getDeclaredConstructor(Collection.class);
+                return (T) ctor.newInstance(l);
+            } catch (Exception e) {
+            }
+        }
+        if (c.isArray()) {
+            Class<?> componentType = c.getComponentType();
+            final boolean primitive = (componentType.isPrimitive());
+            if (primitive) {
+                componentType = ClassUtils.primitiveToWrapper(componentType);
+            }
+            final Object result = l.toArray((Object[]) Array.newInstance(componentType, l.size()));
+            return (T) (primitive ? ArrayUtils.toPrimitive(result) : result);
+        }
+        throw new IllegalArgumentException();
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/spi/ConfiguringSuppliersProvider.java b/src/main/org/apache/ant/s3/build/spi/ConfiguringSuppliersProvider.java
new file mode 100644
index 0000000..da636cd
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/spi/ConfiguringSuppliersProvider.java
@@ -0,0 +1,76 @@
+/*
+ *  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.ant.s3.build.spi;
+
+import static software.amazon.awssdk.utils.FunctionalUtils.safeFunction;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.ant.s3.build.ConfiguringSupplier;
+import org.apache.commons.lang3.reflect.TypeUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.tools.ant.Project;
+
+/**
+ * {@code abstract} SPI class for {@link ConfiguringSupplier}s provision.
+ * Methods accepting a {@link Project} and returning a
+ * {@link ConfiguringSupplier} are converted to {@link Function}s and mapped to
+ * the supplied type.
+ */
+public abstract class ConfiguringSuppliersProvider
+    extends IntrospectingProviderBase<Map.Entry<Class<?>, Function<Project, ConfiguringSupplier<?>>>>
+    implements Supplier<Map<Class<?>, Function<Project, ConfiguringSupplier<?>>>> {
+
+    private static final TypeVariable<?> SUPPLIED_TYPE = Supplier.class.getTypeParameters()[0];
+
+    /**
+     * Create a new {@link ConfiguringSuppliersProvider}.
+     */
+    public ConfiguringSuppliersProvider() {
+        fieldFilter(filter -> f -> false);
+        methodFilter(filter -> filter.and(args(Project.class)).and(returns(ConfiguringSupplier.class)));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Map<Class<?>, Function<Project, ConfiguringSupplier<?>>> get() {
+        final Map<Class<?>, Function<Project, ConfiguringSupplier<?>>> result = new LinkedHashMap<>();
+
+        introspect(e -> result.put(e.getKey(), e.getValue()));
+
+        return result;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected Optional<Map.Entry<Class<?>, Function<Project, ConfiguringSupplier<?>>>> map(Method m) {
+        final Type supplied = TypeUtils.getTypeArguments(m.getGenericReturnType(), Supplier.class).get(SUPPLIED_TYPE);
+        return Optional.of(Pair.of(TypeUtils.getRawType(supplied, null),
+            safeFunction(p -> (ConfiguringSupplier<?>) m.invoke(this, p))));
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/spi/DefaultProvider.java b/src/main/org/apache/ant/s3/build/spi/DefaultProvider.java
new file mode 100644
index 0000000..ff5be8b
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/spi/DefaultProvider.java
@@ -0,0 +1,33 @@
+/*
+ *  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.ant.s3.build.spi;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marker annotation for service provider implementations that should be installed
+ * earliest, in case another available implementation has reason to override their effects. 
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface DefaultProvider {
+}
diff --git a/src/main/org/apache/ant/s3/build/spi/IntrospectingProviderBase.java b/src/main/org/apache/ant/s3/build/spi/IntrospectingProviderBase.java
new file mode 100644
index 0000000..26f1e3b
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/spi/IntrospectingProviderBase.java
@@ -0,0 +1,183 @@
+/*
+ *  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.ant.s3.build.spi;
+
+import static java.lang.reflect.Modifier.FINAL;
+import static java.lang.reflect.Modifier.PUBLIC;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Member;
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import java.util.LinkedHashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.ant.s3.build.MethodSignature;
+import org.apache.commons.lang3.ClassUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.commons.lang3.reflect.TypeUtils;
+
+/**
+ * Base class for introspecting SPI providers.
+ */
+public abstract class IntrospectingProviderBase<E> {
+
+    private Predicate<Field> fieldFilter = mods(PUBLIC | FINAL);
+    private Predicate<Method> methodFilter = uniqueSignatures().and(mods(PUBLIC));
+
+    /**
+     * Override or augment the field filter {@link Predicate}.
+     * 
+     * @param mod
+     *            accepts existing filter
+     */
+    protected void fieldFilter(UnaryOperator<Predicate<Field>> mod) {
+        this.fieldFilter = mod.apply(fieldFilter);
+    }
+
+    /**
+     * Override or augment the method filter {@link Predicate}.
+     * 
+     * @param mod
+     *            accepts existing filter
+     */
+    protected void methodFilter(UnaryOperator<Predicate<Method>> mod) {
+        this.methodFilter = mod.apply(methodFilter);
+    }
+
+    /**
+     * Introspect this object's fields and methods, mapping and sending to the
+     * specified {@link Consumer}.
+     * 
+     * @param cmer
+     *            accepts mapped objects
+     * @see #map(Field)
+     * @see #map(Method)
+     */
+    protected void introspect(Consumer<E> cmer) {
+        final Stream<Optional<E>> fromFields =
+            Stream.of(FieldUtils.getAllFields(getClass())).filter(fieldFilter).map(this::map);
+
+        final Stream<Optional<E>> fromMethods =
+            StreamSupport.stream(ClassUtils.hierarchy(getClass()).spliterator(), false).map(Class::getDeclaredMethods)
+                .flatMap(Stream::of).filter(methodFilter).map(this::map);
+
+        Stream.concat(fromFields, fromMethods).filter(Optional::isPresent).map(Optional::get).forEach(cmer);
+    }
+
+    /**
+     * Map a {@link Field} that passed the {@link #fieldFilter} to {@code E}.
+     * 
+     * @param f
+     *            {@link Field} to map
+     * @return {@link Optional} of {@code E}
+     */
+    protected Optional<E> map(Field f) {
+        return Optional.empty();
+    }
+
+    /**
+     * Map a {@link Method} that passed the {@link #methodFilter} to {@code E}.
+     * 
+     * @param m
+     *            {@link Method} to map
+     * @return {@link Optional} of {@code E}
+     */
+    protected Optional<E> map(Method m) {
+        return Optional.empty();
+    }
+
+    /**
+     * Create a {@link Predicate} to select {@link Member}s by present Java
+     * modifiers.
+     * 
+     * @param <M>
+     *            {@link Member} type
+     * @param mods
+     *            mask
+     * @return {@link Predicate} of {@code <M>}
+     */
+    protected <M extends Member> Predicate<M> mods(int mods) {
+        return m -> (m.getModifiers() & mods) == mods;
+    }
+
+    /**
+     * Create a {@link Predicate} to select {@link Member}s by name.
+     * 
+     * @param <M>
+     *            {@link Member} type
+     * @param test
+     *            name {@link Predicate}
+     * @return {@link Predicate} of {@code <M>}
+     */
+    protected <M extends Member> Predicate<M> named(Predicate<String> test) {
+        return m -> test.test(m.getName());
+    }
+
+    /**
+     * Create a {@link Predicate} to select {@link Field}s by declared type
+     * assignability.
+     * 
+     * @param t
+     *            compare to {@link Field#getGenericType()}
+     * @return {@link Predicate} of {@link Field}
+     */
+    protected Predicate<Field> type(Type t) {
+        return f -> TypeUtils.isAssignable(f.getGenericType(), t);
+    }
+
+    /**
+     * Return a {@link Predicate} that discards a {@link Method} if its unique
+     * signature has already been processed.
+     * 
+     * @return {@link Predicate} of {@link Method}
+     */
+    protected Predicate<Method> uniqueSignatures() {
+        final Set<MethodSignature> signaturesEncountered = new LinkedHashSet<>();
+        return m -> signaturesEncountered.add(MethodSignature.of(m));
+    }
+
+    /**
+     * Return a {@link Predicate} to select {@link Method}s by argument type
+     * assignability.
+     * 
+     * @param args
+     *            compare to {@link Method#getParameterTypes()}
+     * @return {@link Predicate} of {@link Method}
+     */
+    protected Predicate<Method> args(Class<?>... args) {
+        return m -> ClassUtils.isAssignable(m.getParameterTypes(), args);
+    }
+
+    /**
+     * Return a {@link Predicate} to select {@link Method}s by return type
+     * assignability.
+     * 
+     * @param t
+     *            compare to {@link Method#getGenericReturnType()}
+     * @return {@link Predicate} of {@link Method}
+     */
+    protected Predicate<Method> returns(Type t) {
+        return m -> TypeUtils.isAssignable(m.getGenericReturnType(), t);
+    }
+}
diff --git a/src/main/org/apache/ant/s3/build/spi/Providers.java b/src/main/org/apache/ant/s3/build/spi/Providers.java
new file mode 100644
index 0000000..29259b2
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/spi/Providers.java
@@ -0,0 +1,64 @@
+/*
+ *  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.ant.s3.build.spi;
+
+import java.util.Comparator;
+import java.util.ServiceLoader;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * Static utility class for service providers.
+ */
+public class Providers {
+
+    /**
+     * Load from {@link ServiceLoader}, sorting first objects whose classes are
+     * annotated with {@link DefaultProvider}.
+     * 
+     * @param <T>
+     *            provider type
+     * @param type
+     *            provider type instance
+     * @return {@link Iterable} of {@code T}
+     */
+    public static <T> Iterable<T> load(Class<T> type) {
+        return stream(type)::iterator;
+    }
+
+    /**
+     * Load from {@link ServiceLoader}, sorting first objects whose classes are
+     * annotated with {@link DefaultProvider}.
+     *
+     * @param <T>
+     *            provider type
+     * @param type
+     *            provider type instance
+     * @return {@link Stream} of {@code T}
+     */
+    public static <T> Stream<T> stream(Class<T> type) {
+        return StreamSupport.stream(ServiceLoader.load(type, type.getClassLoader()).spliterator(), false)
+            .sorted(compareProviders());
+    }
+
+    private static final <T> Comparator<T> compareProviders() {
+        return Comparator.<T, Boolean> comparing(o -> o.getClass().getAnnotation(DefaultProvider.class) == null)
+            .thenComparingInt(Object::hashCode);
+    }
+
+    private Providers() {}
+}
diff --git a/src/main/org/apache/ant/s3/build/spi/StringConversionsProvider.java b/src/main/org/apache/ant/s3/build/spi/StringConversionsProvider.java
new file mode 100644
index 0000000..5b17022
--- /dev/null
+++ b/src/main/org/apache/ant/s3/build/spi/StringConversionsProvider.java
@@ -0,0 +1,94 @@
+/*
+ *  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.ant.s3.build.spi;
+
+import static software.amazon.awssdk.utils.FunctionalUtils.safeSupplier;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.commons.lang3.reflect.TypeUtils;
+import org.apache.commons.lang3.tuple.Pair;
+
+import software.amazon.awssdk.utils.FunctionalUtils.UnsafeSupplier;
+
+/**
+ * {@code abstract} SPI class for {@link String} conversions.
+ */
+public abstract class StringConversionsProvider
+    extends IntrospectingProviderBase<Map.Entry<Class<?>, Function<String, ?>>>
+    implements Supplier<Map<Class<?>, Function<String, ?>>> {
+
+    private static final TypeVariable<?> FUNCTION_RESULT = Function.class.getTypeParameters()[1];
+
+    /**
+     * Create a new {@link StringConversionsProvider}.
+     */
+    protected StringConversionsProvider() {
+        final Type t = TypeUtils.parameterize(Function.class, String.class, TypeUtils.wildcardType().build());
+
+        fieldFilter(filter -> filter.and(type(t)));
+
+        methodFilter(filter -> filter.and(args()).and(returns(t)));
+    }
+
+    /**
+     * Default implementation reflectively locates all {@code public} no-arg
+     * methods and {@code public final} fields returning {@link Function} of
+     * {@link String} to some other type.
+     * 
+     * @return {@link Map}
+     */
+    @Override
+    public final Map<Class<?>, Function<String, ?>> get() {
+        final Map<Class<?>, Function<String, ?>> result = new LinkedHashMap<>();
+
+        introspect(e -> result.put(e.getKey(), e.getValue()));
+
+        return result;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected Optional<Map.Entry<Class<?>, Function<String, ?>>> map(Field f) {
+        return map(f.getGenericType(), () -> f.get(this));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected Optional<Map.Entry<Class<?>, Function<String, ?>>> map(Method m) {
+        return map(m.getGenericReturnType(), () -> m.invoke(this));
+    }
+
+    @SuppressWarnings("unchecked")
+    private Optional<Map.Entry<Class<?>, Function<String, ?>>> map(Type t, UnsafeSupplier<?> fnSupplier) {
+        return Optional
+            .of(Pair.of(TypeUtils.getRawType(TypeUtils.getTypeArguments(t, Function.class).get(FUNCTION_RESULT), null),
+                (Function<String, ?>) safeSupplier(fnSupplier).get()));
+    }
+}
diff --git a/src/main/org/apache/ant/s3/credentials/CredentialsConfiguringSuppliersProvider.java b/src/main/org/apache/ant/s3/credentials/CredentialsConfiguringSuppliersProvider.java
new file mode 100644
index 0000000..701edc8
--- /dev/null
+++ b/src/main/org/apache/ant/s3/credentials/CredentialsConfiguringSuppliersProvider.java
@@ -0,0 +1,513 @@
+/*
+ *  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.ant.s3.credentials;
+
+import java.util.Locale;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.ant.s3.ProjectUtils;
+import org.apache.ant.s3.build.Builder;
+import org.apache.ant.s3.build.ClassFinder;
+import org.apache.ant.s3.build.ConfiguringSupplier;
+import org.apache.ant.s3.build.MetaBuilderByType;
+import org.apache.ant.s3.build.spi.ConfiguringSuppliersProvider;
+import org.apache.ant.s3.strings.ClassNames;
+import org.apache.ant.s3.strings.ClassNames.Direction;
+import org.apache.ant.s3.strings.PackageNames;
+import org.apache.commons.lang3.ClassUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.ProjectComponent;
+import org.kohsuke.MetaInfServices;
+
+import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProviderChain;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.ProcessCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.ProfileCredentialsProviderFactory;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.internal.LazyAwsCredentialsProvider;
+import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
+import software.amazon.awssdk.services.sts.auth.StsAssumeRoleWithSamlCredentialsProvider;
+import software.amazon.awssdk.services.sts.auth.StsAssumeRoleWithWebIdentityCredentialsProvider;
+import software.amazon.awssdk.services.sts.auth.StsGetFederationTokenCredentialsProvider;
+import software.amazon.awssdk.services.sts.auth.StsGetSessionTokenCredentialsProvider;
+import software.amazon.awssdk.utils.builder.Buildable;
+
+/**
+ * {@link ConfiguringSuppliersProvider} for AWS credentials.
+ */
+@MetaInfServices
+public class CredentialsConfiguringSuppliersProvider extends ConfiguringSuppliersProvider {
+    /**
+     * Base {@link ConfiguringSupplier} implementation for internal stuff.
+     *
+     * @param <T>
+     *            supplied type
+     */
+    public static abstract class BaseConfiguringSupplier<T> extends ProjectComponent
+        implements ConfiguringSupplier<T>, ProjectUtils {
+        /**
+         * Create a new {@link BaseConfiguringSupplier}.
+         * 
+         * @param project
+         *            Ant {@link Project}
+         */
+        protected BaseConfiguringSupplier(Project project) {
+            setProject(project);
+        }
+    }
+
+    /**
+     * {@link StaticCredentialsProvider} {@link ConfiguringSupplier}. Supports
+     * {@link AwsBasicCredentials} only.
+     */
+    public static class StaticCredentialsProviderConfiguringSupplier
+        extends BaseConfiguringSupplier<StaticCredentialsProvider> {
+        private String accessKey;
+        private String secretKey;
+
+        /**
+         * Create a new {@link StaticCredentialsProviderConfiguringSupplier}.
+         * 
+         * @param project
+         *            Ant {@link Project}
+         */
+        private StaticCredentialsProviderConfiguringSupplier(Project project) {
+            super(project);
+        }
+
+        /**
+         * Set the accessKey.
+         * 
+         * @param accessKey
+         *            {@link String}
+         */
+        public void setAccessKey(String accessKey) {
+            this.accessKey = accessKey;
+        }
+
+        /**
+         * Set the secretKey.
+         * 
+         * @param secretKey
+         *            {@link String}
+         */
+        public void setSecretKey(String secretKey) {
+            this.secretKey = secretKey;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void setDynamicAttribute(String uri, String localName, String qName, String value)
+            throws BuildException {
+            switch (StringUtils.lowerCase(localName, Locale.US)) {
+            case "accesskey":
+                setAccessKey(value);
+                break;
+            case "secretkey":
+                setSecretKey(value);
+                break;
+            default:
+                super.setDynamicAttribute(uri, localName, qName, value);
+            }
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public StaticCredentialsProvider get() {
+            return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));
+        }
+    }
+
+    /**
+     * {@link ConfiguringSupplier} implementation for concrete {@code *Builder}
+     * types that do not implement {@link Buildable}.
+     *
+     * @param <B>
+     *            {@code *Builder}
+     * @param <T>
+     *            built/supplied type
+     */
+    public static class NonBuildableBuilderBuildConfiguringSupplier<B, T> extends BaseConfiguringSupplier<T> {
+
+        private final B sdkBuilder;
+        private final Builder<B> builder;
+        private final Function<B, T> get;
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        private NonBuildableBuilderBuildConfiguringSupplier(Project project, B builder, Function<B, T> get) {
+            super(project);
+            this.sdkBuilder = builder;
+            this.builder = new Builder(builder.getClass(), project);
+            this.get = get;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void setDynamicAttribute(String uri, String localName, String qName, String value)
+            throws BuildException {
+            builder.setDynamicAttribute(uri, localName, qName, value);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+            return builder.createDynamicElement(uri, localName, qName);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public T get() {
+            builder.accept(sdkBuilder);
+            return get.apply(sdkBuilder);
+        }
+    }
+
+    /**
+     * {@link ConfiguringSupplier} for types that build with no configuration.
+     *
+     * @param <T>
+     *            supplied type
+     */
+    public static class NoConfigConfiguringSupplier<T> extends BaseConfiguringSupplier<T> {
+        private final Supplier<T> get;
+
+        private NoConfigConfiguringSupplier(Project project, Supplier<T> get) {
+            super(project);
+            this.get = get;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public T get() {
+            return get.get();
+        }
+    }
+
+    /**
+     * {@link ConfiguringSupplier} for {@link LazyAwsCredentialsProvider}.
+     * Requires a single nested {@code provider} element which is
+     * {@link CredentialsConfiguringSuppliersProvider#credentialsProviderConfiguringSupplier(Project)}.
+     */
+    public class LazyCredentialsProviderConfiguringSupplier
+        extends BaseConfiguringSupplier<LazyAwsCredentialsProvider> {
+        private ConfiguringSupplier<AwsCredentialsProvider> provider;
+
+        private LazyCredentialsProviderConfiguringSupplier(Project project) {
+            super(project);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+            if ("provider".equals(localName)) {
+                Exceptions.raiseUnless(provider == null, buildExceptionTriggered(), new IllegalStateException(),
+                    "provider already created");
+                provider = credentialsProviderConfiguringSupplier(getProject());
+                return provider;
+            }
+            return super.createDynamicElement(uri, localName, qName);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public LazyAwsCredentialsProvider get() {
+            Exceptions.raiseIf(provider == null, buildExceptionTriggered(), new IllegalStateException(),
+                "provider not configured");
+            return LazyAwsCredentialsProvider.create(provider);
+        }
+    }
+
+    /**
+     * Classname of only known implementation of
+     * {@link ProfileCredentialsProviderFactory}.
+     */
+    public static final String SSO_CPF_CLASSNAME =
+        "software.amazon.awssdk.services.sso.auth.SsoProfileCredentialsProviderFactory";
+
+    /**
+     * Package name of AWS services package.
+     */
+    public static final String SERVICES_PACKAGE = "software.amazon.awssdk.services";
+
+    /**
+     * Produce a {@link ConfiguringSupplier} of {@link AwsCredentialsProvider}.
+     * This is a {@link MetaBuilderByType} configured to search for
+     * {@link AwsCredentialsProvider} types given an {@code @impl} "fragment"
+     * that is:
+     * <ul>
+     * <li>Tested as a FQ classname</li>
+     * <li>Plugged into a matrix of: packages
+     * <ul>
+     * <li>0-2 ancestors of {@link AwsCredentialsProvider}</li>
+     * <li>this package</li>
+     * </ul>
+     * X classname suffixes as segments of {@link AwsCredentialsProvider},
+     * successively trimmed from the LHS.</li>
+     * <li>If unspecified, defaulted to {@link StaticCredentialsProvider}</li>
+     * </ul>
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link MetaBuilderByType} of {@link AwsCredentialsProvider}
+     */
+    public MetaBuilderByType<AwsCredentialsProvider> credentialsProviderConfiguringSupplier(Project project) {
+        return new MetaBuilderByType<>(project, AwsCredentialsProvider.class,
+            new ClassFinder(
+                PackageNames.of(AwsCredentialsProvider.class).ancestors(0, 2).andThen(PackageNames.of(getClass())),
+                ClassNames.of(AwsCredentialsProvider.class).segments(Direction.FROM_LEFT)),
+            StaticCredentialsProvider.class);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} of
+     * {@link ProfileCredentialsProviderFactory}. This is a
+     * {@link MetaBuilderByType} configured to search for
+     * {@link ProfileCredentialsProviderFactory} types given an {@code @impl}
+     * "fragment" that is:
+     * <ul>
+     * <li>Tested as a FQ classname</li>
+     * <li>Plugged into a matrix of: packages
+     * <ul>
+     * <li>Package of {@link ProfileCredentialsProviderFactory}</li>
+     * <li>Package of {@link #SSO_CPF_CLASSNAME} (if class present on classpath;
+     * prioritizes {@code sso} as explicit impl key)</li>
+     * <li>{@link #SERVICES_PACKAGE} ({@value #SERVICES_PACKAGE})</li>
+     * <li>1-2 ancestors of {@link ProfileCredentialsProviderFactory}</li>
+     * </ul>
+     * X classname suffixes as segments of
+     * {@link ProfileCredentialsProviderFactory}, successively trimmed from the
+     * LHS.</li>
+     * <li>If unspecified, defaulted to {@link #SSO_CPF_CLASSNAME} if present on
+     * classpath</li>
+     * </ul>
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link MetaBuilderByType} of
+     *         {@link ProfileCredentialsProviderFactory}
+     */
+    public MetaBuilderByType<ProfileCredentialsProviderFactory> profileCredentialsProviderFactoryConfiguringSupplier(
+        Project project) {
+        final PackageNames pcpf = PackageNames.of(ProfileCredentialsProviderFactory.class);
+        PackageNames packageNames = pcpf;
+
+        Class<? extends ProfileCredentialsProviderFactory> defaultImpl;
+        try {
+            defaultImpl = ClassUtils.getClass(SSO_CPF_CLASSNAME).asSubclass(ProfileCredentialsProviderFactory.class);
+            packageNames = PackageNames.of(defaultImpl).andThen(packageNames);
+        } catch (ClassNotFoundException e) {
+            defaultImpl = null;
+        }
+        packageNames = packageNames.andThen(PackageNames.of(SERVICES_PACKAGE)).andThen(pcpf.ancestors(1, 2));
+
+        return new MetaBuilderByType<>(project, ProfileCredentialsProviderFactory.class, new ClassFinder(packageNames,
+            ClassNames.of(ProfileCredentialsProviderFactory.class).segments(Direction.FROM_LEFT)), defaultImpl);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link StaticCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link StaticCredentialsProviderConfiguringSupplier}
+     */
+    public StaticCredentialsProviderConfiguringSupplier staticCredentialsProviderConfiguringSupplier(Project project) {
+        return new StaticCredentialsProviderConfiguringSupplier(project);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link DefaultCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<DefaultCredentialsProvider> defaultCredentialsProviderConfiguringSupplier(
+        Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project, DefaultCredentialsProvider.builder(),
+            DefaultCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link AwsCredentialsProviderChain}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<AwsCredentialsProviderChain> chain(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project, AwsCredentialsProviderChain.builder(),
+            AwsCredentialsProviderChain.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link ProcessCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<ProcessCredentialsProvider> process(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project, ProcessCredentialsProvider.builder(),
+            ProcessCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link StsAssumeRoleCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<StsAssumeRoleCredentialsProvider> stsAssumeRole(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project, StsAssumeRoleCredentialsProvider.builder(),
+            StsAssumeRoleCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link StsAssumeRoleWithSamlCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<StsAssumeRoleWithSamlCredentialsProvider> stsAssumeRoleSaml(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project,
+            StsAssumeRoleWithSamlCredentialsProvider.builder(),
+            StsAssumeRoleWithSamlCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link StsAssumeRoleWithWebIdentityCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<StsAssumeRoleWithWebIdentityCredentialsProvider> stsAssumeRoleWebId(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project,
+            StsAssumeRoleWithWebIdentityCredentialsProvider.builder(),
+            StsAssumeRoleWithWebIdentityCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link StsGetFederationTokenCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<StsGetFederationTokenCredentialsProvider> stsFederationToken(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project,
+            StsGetFederationTokenCredentialsProvider.builder(),
+            StsGetFederationTokenCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link StsGetSessionTokenCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NonBuildableBuilderBuildConfiguringSupplier}
+     */
+    public ConfiguringSupplier<StsGetSessionTokenCredentialsProvider> stsSessionToken(Project project) {
+        return new NonBuildableBuilderBuildConfiguringSupplier<>(project,
+            StsGetSessionTokenCredentialsProvider.builder(), StsGetSessionTokenCredentialsProvider.Builder::build);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link AnonymousCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NoConfigConfiguringSupplier}
+     */
+    public ConfiguringSupplier<AnonymousCredentialsProvider> anonymous(Project project) {
+        return new NoConfigConfiguringSupplier<>(project, AnonymousCredentialsProvider::create);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link EnvironmentVariableCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NoConfigConfiguringSupplier}
+     */
+    public ConfiguringSupplier<EnvironmentVariableCredentialsProvider> environmentVariable(Project project) {
+        return new NoConfigConfiguringSupplier<>(project, EnvironmentVariableCredentialsProvider::create);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link SystemPropertyCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link NoConfigConfiguringSupplier}
+     */
+    public ConfiguringSupplier<SystemPropertyCredentialsProvider> systemProperty(Project project) {
+        return new NoConfigConfiguringSupplier<>(project, SystemPropertyCredentialsProvider::create);
+    }
+
+    /**
+     * Produce a {@link ConfiguringSupplier} for
+     * {@link LazyAwsCredentialsProvider}.
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link LazyCredentialsProviderConfiguringSupplier}
+     */
+    public ConfiguringSupplier<LazyAwsCredentialsProvider> lazy(Project project) {
+        return new LazyCredentialsProviderConfiguringSupplier(project);
+    }
+}
diff --git a/src/main/org/apache/ant/s3/http/ClientConfiguringSuppliersProvider.java b/src/main/org/apache/ant/s3/http/ClientConfiguringSuppliersProvider.java
new file mode 100644
index 0000000..b2ddba9
--- /dev/null
+++ b/src/main/org/apache/ant/s3/http/ClientConfiguringSuppliersProvider.java
@@ -0,0 +1,82 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.http;
+
+import java.util.Map;
+
+import org.apache.ant.s3.InlineProperties;
+import org.apache.ant.s3.ProjectUtils;
+import org.apache.ant.s3.build.ConfiguringSupplier;
+import org.apache.ant.s3.build.StringConversions;
+import org.apache.ant.s3.build.spi.ConfiguringSuppliersProvider;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.ProjectComponent;
+import org.kohsuke.MetaInfServices;
+
+import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
+
+/**
+ * {@link ConfiguringSuppliersProvider} for {@link SdkHttpClient}.
+ */
+@MetaInfServices
+public class ClientConfiguringSuppliersProvider extends ConfiguringSuppliersProvider {
+
+    /**
+     * {@link SdkHttpClient} {@link ConfiguringSupplier}.
+     */
+    public static class HttpClientSupplier extends ProjectComponent
+        implements ConfiguringSupplier<SdkHttpClient>, ProjectUtils {
+        private final InlineProperties attributes;
+
+        private HttpClientSupplier(Project project) {
+            setProject(project);
+            attributes = new InlineProperties(project);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Object createDynamicElement(String uri, String localName, String qName) throws BuildException {
+            return attributes.createDynamicElement(uri, localName, qName);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        @Override
+        public SdkHttpClient get() {
+            return new DefaultSdkHttpClientBuilder().buildWithDefaults(
+                StringConversions.attributes(SdkHttpConfigurationOption.class, (Map) attributes.getProperties()));
+        }
+    }
+
+    /**
+     * Get an {@link HttpClientSupplier}
+     * 
+     * @param project
+     *            Ant {@link Project}
+     * @return {@link HttpClientSupplier}
+     */
+    public HttpClientSupplier httpClientSupplier(Project project) {
+        return new HttpClientSupplier(project);
+    }
+}
diff --git a/src/main/org/apache/ant/s3/strings/ClassNames.java b/src/main/org/apache/ant/s3/strings/ClassNames.java
new file mode 100644
index 0000000..cd3eb19
--- /dev/null
+++ b/src/main/org/apache/ant/s3/strings/ClassNames.java
@@ -0,0 +1,234 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.strings;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.commons.lang3.StringUtils;
+
+import software.amazon.awssdk.utils.Lazy;
+
+/**
+ * (Simple) class names.
+ */
+@FunctionalInterface
+public interface ClassNames extends Strings {
+
+    /**
+     * Segment direction.
+     */
+    public enum Direction {
+        FROM_LEFT {
+            @Override
+            List<String> window(List<String> source, int removeSegments) {
+                return source.subList(removeSegments, source.size());
+            }
+        },
+        FROM_RIGHT {
+            @Override
+            List<String> window(List<String> source, int removeSegments) {
+                return source.subList(0, source.size() - removeSegments);
+            }
+        };
+
+        /**
+         * Get a "window" (sublist) into the specified {@code source}
+         * {@link List} removing the specified number of segments (elements)
+         * from {@code this} direction.
+         * 
+         * @param source
+         * @param removeSegments
+         * @return {@link List} of {@link String}
+         */
+        abstract List<String> window(List<String> source, int removeSegments);
+    }
+
+    /**
+     * Get an empty {@link ClassNames}.
+     * 
+     * @return {@link ClassNames}
+     */
+    public static ClassNames empty() {
+        return Collections::emptyIterator;
+    }
+
+    /**
+     * Get a {@link ClassNames} representing the simple names of the specified
+     * classes.
+     * 
+     * @param clazz
+     *            first
+     * @param clazzes
+     *            additional
+     * @return {@link ClassNames}
+     */
+    public static ClassNames of(Class<?> clazz, Class<?>... clazzes) {
+        return of(Stream.concat(Stream.of(clazz), Stream.of(clazzes)).map(Class::getSimpleName)
+            .collect(Collectors.toCollection(LinkedHashSet::new)));
+    }
+
+    /**
+     * Get {@link ClassNames} of the specified values (not checked).
+     * 
+     * @param value
+     *            first
+     * @param values
+     *            additional
+     * @return {@link ClassNames}
+     */
+    public static ClassNames of(String value, String... values) {
+        return of(Strings.of(value, values));
+    }
+
+    /**
+     * Get {@link ClassNames} of the specified values.
+     *
+     * @param names
+     *            should be repeatable {@link Iterable}
+     * @return {@link ClassNames}
+     */
+    public static ClassNames of(Iterable<String> names) {
+        return names instanceof ClassNames ? (ClassNames) names : names::iterator;
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link ClassNames}
+     */
+    default ClassNames andThen(Iterable<String> next) {
+        return of(Strings.super.andThen(next));
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link ClassNames}
+     */
+    default ClassNames sorted() {
+        return of(Strings.super.sorted());
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link ClassNames}
+     */
+    default ClassNames sorted(Comparator<? super String> cmp) {
+        return of(Strings.super.sorted(cmp));
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link ClassNames}
+     */
+    @Override
+    default ClassNames reverse() {
+        return of(Strings.super.reverse());
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link ClassNames}
+     */
+    default ClassNames distinct() {
+        return of(Strings.super.distinct());
+    }
+
+    /**
+     * Get a {@link ClassNames} trimming {@code trimmed} segments from elements
+     * of {@code this} in the specified {@code direction}.
+     * 
+     * @param direction
+     *            from which to trim segments
+     * @param trimmed
+     *            number of segments to remove
+     * @return {@link ClassNames}
+     */
+    default ClassNames segment(Direction direction, int trimmed) {
+        return segments(direction, trimmed, trimmed);
+    }
+
+    /**
+     * Get a {@link ClassNames} trimming segments from elements of {@code this}
+     * in the specified {@code direction}.
+     * 
+     * @param direction
+     *            from which to trim segments
+     * @return {@link ClassNames}
+     */
+    default ClassNames segments(Direction direction) {
+        final Function<? super String, ? extends Stream<? extends String>> expand = s -> {
+            final List<String> segments = Arrays.asList(StringUtils.splitByCharacterTypeCamelCase(s));
+            if (segments.isEmpty()) {
+                return Stream.empty();
+            }
+            return IntStream.range(0, segments.size()).mapToObj(n -> direction.window(segments, n))
+                .map(w -> StringUtils.join(w, null));
+        };
+        @SuppressWarnings("resource")
+        final Lazy<Iterable<String>> lazy = new Lazy<>(() -> stream().flatMap(expand).collect(Collectors.toList()));
+
+        // use lambda to defer evaluation
+        return of(() -> lazy.getValue().iterator());
+    }
+
+    /**
+     * Get a {@link ClassNames} exposing variants removing successively more
+     * camel-case segments of the base content.
+     * 
+     * @param direction
+     *            from which to trim segments
+     * @param min
+     *            number of segments to remove
+     * @param max
+     *            number of segments to remove
+     * @return {@link ClassNames}
+     */
+    default ClassNames segments(Direction direction, int min, int max) {
+        Exceptions.raiseIf(direction == null || min < 0 || max < 0, IllegalArgumentException::new,
+            "Invalid arguments(%s, %d, %d)", direction, min, max);
+
+        if (min == 0 && max == 0) {
+            return this;
+        }
+        final Function<? super String, ? extends Stream<? extends String>> expand = s -> {
+            final List<String> segments = Arrays.asList(StringUtils.splitByCharacterTypeCamelCase(s));
+            if (min >= segments.size()) {
+                return Stream.empty();
+            }
+            return IntStream.rangeClosed(min, Math.min(max, segments.size() - 1))
+                .mapToObj(n -> direction.window(segments, n)).map(w -> StringUtils.join(w, null));
+        };
+        @SuppressWarnings("resource")
+        final Lazy<Iterable<String>> lazy = new Lazy<>(() -> stream().flatMap(expand).collect(Collectors.toList()));
+
+        // use lambda to defer evaluation
+        return of(() -> lazy.getValue().iterator());
+    }
+}
diff --git a/src/main/org/apache/ant/s3/strings/PackageNames.java b/src/main/org/apache/ant/s3/strings/PackageNames.java
new file mode 100644
index 0000000..7813b09
--- /dev/null
+++ b/src/main/org/apache/ant/s3/strings/PackageNames.java
@@ -0,0 +1,214 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.strings;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.ant.s3.Exceptions;
+import org.apache.commons.lang3.Range;
+
+import software.amazon.awssdk.utils.Lazy;
+
+/**
+ * Package names.
+ */
+@FunctionalInterface
+public interface PackageNames extends Strings {
+
+    /**
+     * Get an empty {@link PackageNames}.
+     * 
+     * @return {@link PackageNames}
+     */
+    public static PackageNames empty() {
+        return Collections::emptyIterator;
+    }
+
+    /**
+     * Get {@link PackageNames} of the specified root classes.
+     * 
+     * @param rootClass
+     *            first
+     * @param rootClasses
+     *            additional
+     * @return {@link PackageNames}
+     */
+    public static PackageNames of(Class<?> rootClass, Class<?>... rootClasses) {
+        return of(Stream.concat(Stream.of(rootClass), Stream.of(rootClasses)).map(c -> c.getPackage().getName())
+            .collect(Collectors.toCollection(LinkedHashSet::new)));
+    }
+
+    /**
+     * Get {@link PackageNames} of the specified values (not checked).
+     * 
+     * @param name
+     *            first
+     * @param names
+     *            additional
+     * @return {@link PackageNames}
+     */
+    public static PackageNames of(String name, String... names) {
+        return of(Strings.of(name, names));
+    }
+
+    /**
+     * Get {@link PackageNames} of the specified values (not checked).
+     * 
+     * @param names
+     *            should be repeatable {@link Iterable}
+     * @return {@link PackageNames}
+     */
+    public static PackageNames of(Iterable<String> names) {
+        return names instanceof PackageNames ? (PackageNames) names : names::iterator;
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link PackageNames}
+     */
+    @Override
+    default PackageNames andThen(Iterable<String> next) {
+        return of(Strings.super.andThen(next));
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link PackageNames}
+     */
+    @Override
+    default PackageNames sorted() {
+        return of(Strings.super.sorted());
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link PackageNames}
+     */
+    @Override
+    default PackageNames sorted(Comparator<? super String> cmp) {
+        return of(Strings.super.sorted(cmp));
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link PackageNames}
+     */
+    @Override
+    default PackageNames reverse() {
+        return of(Strings.super.reverse());
+    }
+
+    /**
+     * {@inheritDoc}
+     * 
+     * @return {@link PackageNames}
+     */
+    default PackageNames distinct() {
+        return of(Strings.super.distinct());
+    }
+
+    /**
+     * Get a {@link PackageNames} exposing the {@code Nth} ancestor package of
+     * {@code this}, where {@code N} is {@code displacement}. Syntactic sugar
+     * for {@link #ancestors(int, int)} with {@code displacement} as both
+     * {@code min} and {@code max}.
+     * 
+     * @param displacement
+     *            distance
+     * @return {@link PackageNames}
+     */
+    default PackageNames ancestor(int displacement) {
+        return ancestors(displacement, displacement);
+    }
+
+    /**
+     * Get a {@link PackageNames} exposing all ancestor packages of
+     * {@code this}.
+     * 
+     * @return {@link PackageNames}
+     */
+    default PackageNames ancestors() {
+        final PackageNames wrapped = this;
+
+        @SuppressWarnings("resource")
+        final Lazy<Iterable<String>> lazy = new Lazy<>(() -> wrapped.stream().flatMap(s -> {
+            final StringBuilder b = new StringBuilder(s);
+            final Stream.Builder<String> result = Stream.builder();
+
+            while (true) {
+                result.accept(b.toString());
+                final int lastDot = b.lastIndexOf(".");
+                if (lastDot < 0) {
+                    break;
+                }
+                b.setLength(lastDot);
+            }
+            return result.build();
+        }).collect(Collectors.toList()));
+
+        // use lambda to defer evaluation
+        return of(() -> lazy.getValue().iterator());
+    }
+
+    /**
+     * Get a {@link PackageNames} exposing ancestor packages of {@code this}.
+     * 
+     * @param min
+     *            levels
+     * @param max
+     *            levels
+     * @return {@link PackageNames}
+     */
+    default PackageNames ancestors(int min, int max) {
+        Exceptions.raiseIf(min < 0 || max < 0, IllegalArgumentException::new, "Invalid arguments(%d, %d)", min, max);
+
+        if (min == 0 && max == 0) {
+            return this;
+        }
+        final PackageNames wrapped = this;
+        final Range<Integer> generations = Range.between(min, max);
+
+        @SuppressWarnings("resource")
+        final Lazy<Iterable<String>> lazy = new Lazy<>(() -> wrapped.stream().flatMap(s -> {
+            final StringBuilder b = new StringBuilder(s);
+            final Stream.Builder<String> result = Stream.builder();
+
+            for (int n = 0; !generations.isBefore(n); n++) {
+                if (generations.contains(n)) {
+                    result.accept(b.toString());
+                }
+                final int lastDot = b.lastIndexOf(".");
+                if (lastDot < 0) {
+                    break;
+                }
+                b.setLength(lastDot);
+            }
+            return result.build();
+        }).collect(Collectors.toList()));
+
+        // use lambda to defer evaluation
+        return of(() -> lazy.getValue().iterator());
+    }
+}
diff --git a/src/main/org/apache/ant/s3/strings/Strings.java b/src/main/org/apache/ant/s3/strings/Strings.java
new file mode 100644
index 0000000..a747211
--- /dev/null
+++ b/src/main/org/apache/ant/s3/strings/Strings.java
@@ -0,0 +1,128 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.strings;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * Fun with {@link String}s.
+ */
+@FunctionalInterface
+public interface Strings extends Iterable<String> {
+
+    /**
+     * Get an empty {@link Strings}.
+     * 
+     * @return {@link Strings}
+     */
+    public static Strings empty() {
+        return Collections::emptyIterator;
+    }
+
+    /**
+     * Get {@link Strings} of the specified values.
+     * 
+     * @param value
+     *            first
+     * @param values
+     *            additional
+     * @return {@link Strings}
+     */
+    public static Strings of(String value, String... values) {
+        return of(
+            Stream.concat(Stream.of(value), Stream.of(values)).collect(Collectors.toCollection(LinkedHashSet::new)));
+    }
+
+    /**
+     * Get {@link Strings} of the specified values.
+     *
+     * @param names
+     *            should be repeatable {@link Iterable}
+     * @return {@link Strings}
+     */
+    public static Strings of(Iterable<String> names) {
+        return names instanceof Strings ? (Strings) names : names::iterator;
+    }
+
+    /**
+     * Get a {@link Strings} combining {@code this} with {@code next}.
+     * 
+     * @param next
+     *            subsequent {@link String}s
+     * @return {@link Strings}
+     */
+    default Strings andThen(Iterable<String> next) {
+        return () -> Stream.of(this, of(next)).flatMap(Strings::stream).iterator();
+    }
+
+    /**
+     * Get a {@link Strings} sorting {@code this} by natural order.
+     * 
+     * @return {@link Strings}
+     */
+    default Strings sorted() {
+        return sorted(Comparator.naturalOrder());
+    }
+
+    /**
+     * Get a {@link Strings} sorting {@code this} by the specified
+     * {@link Comparator}.
+     * 
+     * @param cmp
+     *            {@link Comparator} to sort by
+     * @return {@link Strings}
+     */
+    default Strings sorted(Comparator<? super String> cmp) {
+        return () -> stream().sorted(cmp).iterator();
+    }
+
+    /**
+     * Get a {@link Strings} reversing {@code this}.
+     * 
+     * @return {@link Strings}
+     */
+    default Strings reverse() {
+        final Deque<String> contents = new LinkedList<>();
+        this.forEach(contents::push);
+        return of(contents);
+    }
+
+    /**
+     * Get distinct {@link Strings} from {@code this}.
+     * 
+     * @return {@link Strings}
+     */
+    default Strings distinct() {
+        return () -> stream().distinct().iterator();
+    }
+
+    /**
+     * Get a {@link Stream} of our contents.
+     * 
+     * @return {@link Stream} of {@link String}
+     */
+    default Stream<String> stream() {
+        return StreamSupport.stream(spliterator(), false);
+    }
+}
diff --git a/src/tests/antunit/s3-test-base.xml b/src/tests/antunit/s3-test-base.xml
index 399c197..0d23b7d 100644
--- a/src/tests/antunit/s3-test-base.xml
+++ b/src/tests/antunit/s3-test-base.xml
@@ -49,12 +49,11 @@
     project.setProperty('s3.endpoint', "https://localhost:${DEFAULT_HTTPS_PORT}")
   </groovy>
 
-  <s3:client id="s3">
-    <credentials accesskey="foo" secretkey="bar" />
-    <http>
+  <s3:client id="s3" endpointoverride="${s3.endpoint}" region="us-east-1">
+    <credentialsprovider accesskey="foo" secretkey="bar" />
+    <httpclient>
       <TRUST_ALL_CERTIFICATES>true</TRUST_ALL_CERTIFICATES>
-    </http>
-    <builder endpointOverride="${s3.endpoint}" region="us-east-1" />
+    </httpclient>
   </s3:client>
 
   <groovy>
diff --git a/src/tests/junit/org/apache/ant/s3/build/StringConversionsTest.java b/src/tests/junit/org/apache/ant/s3/build/StringConversionsTest.java
new file mode 100644
index 0000000..ca48e19
--- /dev/null
+++ b/src/tests/junit/org/apache/ant/s3/build/StringConversionsTest.java
@@ -0,0 +1,156 @@
+/*
+ *  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.ant.s3.build;
+
+import static java.lang.reflect.Modifier.FINAL;
+import static java.lang.reflect.Modifier.PUBLIC;
+import static java.lang.reflect.Modifier.STATIC;
+import static org.assertj.core.api.Assertions.assertThat;
+import static software.amazon.awssdk.utils.FunctionalUtils.safeFunction;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URI;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.reflect.TypeLiteral;
+import org.junit.Test;
+
+import software.amazon.awssdk.awscore.defaultsmode.DefaultsMode;
+import software.amazon.awssdk.regions.Region;
+
+public class StringConversionsTest {
+    enum MetaSyntacticVariable {
+        FOO, BAR, BAZ;
+    }
+
+    @Test
+    public void testWrapperConversions() {
+        assertThat(StringConversions.<Byte> as(Byte.class, String.valueOf(Byte.MAX_VALUE)))
+            .isEqualTo(Byte.valueOf(Byte.MAX_VALUE));
+
+        assertThat(StringConversions.<Short> as(Short.class, String.valueOf(Short.MAX_VALUE)))
+            .isEqualTo(Short.MAX_VALUE);
+
+        assertThat(StringConversions.<Character> as(Character.class, String.valueOf(Character.MAX_VALUE)))
+            .isEqualTo(Character.MAX_VALUE);
+
+        assertThat(StringConversions.<Integer> as(Integer.class, String.valueOf(Integer.MAX_VALUE)))
+            .isEqualTo(Integer.MAX_VALUE);
+
+        assertThat(StringConversions.<Long> as(Long.class, String.valueOf(Long.MAX_VALUE))).isEqualTo(Long.MAX_VALUE);
+        assertThat(StringConversions.<Float> as(Float.class, String.valueOf(Float.MAX_VALUE)))
+            .isEqualTo(Float.MAX_VALUE);
+        assertThat(StringConversions.<Double> as(Double.class, String.valueOf(Double.MAX_VALUE)))
+            .isEqualTo(Double.MAX_VALUE);
+        assertThat(StringConversions.<Boolean> as(Boolean.class, "true")).isTrue();
+    }
+
+    @Test
+    public void testPrimitiveConversions() {
+        assertThat(StringConversions.<Byte> as(Byte.TYPE, String.valueOf(Byte.MAX_VALUE)))
+            .isEqualTo(Byte.valueOf(Byte.MAX_VALUE));
+
+        assertThat(StringConversions.<Short> as(Short.TYPE, String.valueOf(Short.MAX_VALUE)))
+            .isEqualTo(Short.MAX_VALUE);
+
+        assertThat(StringConversions.<Character> as(Character.TYPE, String.valueOf(Character.MAX_VALUE)))
+            .isEqualTo(Character.MAX_VALUE);
+
+        assertThat(StringConversions.<Integer> as(Integer.TYPE, String.valueOf(Integer.MAX_VALUE)))
+            .isEqualTo(Integer.MAX_VALUE);
+
+        assertThat(StringConversions.<Long> as(Long.TYPE, String.valueOf(Long.MAX_VALUE))).isEqualTo(Long.MAX_VALUE);
+        assertThat(StringConversions.<Float> as(Float.TYPE, String.valueOf(Float.MAX_VALUE)))
+            .isEqualTo(Float.MAX_VALUE);
+        assertThat(StringConversions.<Double> as(Double.TYPE, String.valueOf(Double.MAX_VALUE)))
+            .isEqualTo(Double.MAX_VALUE);
+        assertThat(StringConversions.<Boolean> as(Boolean.TYPE, "true")).isTrue();
+    }
+
+    @Test
+    public void testOtherDefaultConversions() {
+        assertThat(StringConversions.<String> as(String.class, "foo")).isEqualTo("foo");
+        assertThat(StringConversions.<Duration> as(Duration.class, "PT66H")).isEqualTo(Duration.ofHours(66));
+        assertThat(StringConversions.<URI> as(URI.class, "https://ant.apache.org")).hasScheme("https")
+            .hasHost("ant.apache.org").hasNoPort().hasPath("").hasNoFragment().hasNoParameters();
+
+        assertThat(StringConversions.<Path> as(Path.class, System.getProperty("user.dir")))
+            .isEqualTo(new File(System.getProperty("user.dir")).toPath());
+
+        assertThat(StringConversions.<BigDecimal> as(BigDecimal.class, "999.999")).isEqualTo("999.999");
+
+        assertThat(StringConversions.<BigInteger> as(BigInteger.class, "999")).isEqualTo("999");
+        assertThat(StringConversions.<byte[]> as(byte[].class, "foo")).containsExactly('f', 'o', 'o');
+        assertThat(StringConversions.<char[]> as(char[].class, "foo")).containsExactly('f', 'o', 'o');
+    }
+
+    @Test
+    public void testEnumConversions() {
+        assertThat(StringConversions.<MetaSyntacticVariable> as(MetaSyntacticVariable.class, "foo"))
+            .isSameAs(MetaSyntacticVariable.FOO);
+        assertThat(StringConversions.<MetaSyntacticVariable> as(MetaSyntacticVariable.class, "BAR"))
+            .isSameAs(MetaSyntacticVariable.BAR);
+    }
+
+    @Test
+    public void testDefaultAwsConversions() {
+        for (DefaultsMode dm : DefaultsMode.values()) {
+            assertThat(StringConversions.<DefaultsMode> as(DefaultsMode.class, dm.toString())).isSameAs(dm);
+        }
+
+        final int psf = PUBLIC | STATIC | FINAL;
+
+        Stream.of(Region.class.getDeclaredFields()).filter(f -> (f.getModifiers() & psf) == psf)
+            .filter(f -> Region.class.equals(f.getType())).map(safeFunction(f -> f.get(null)))
+            .map(Region.class::cast)
+            .forEach(region -> assertThat(StringConversions.<Region> as(Region.class, region.id())).isSameAs(region));
+    }
+
+    @Test
+    public void testCommaDelimitedArrayConversion() {
+        assertThat(StringConversions.<MetaSyntacticVariable[]> as(MetaSyntacticVariable[].class, "foo,baz"))
+            .containsExactly(MetaSyntacticVariable.FOO, MetaSyntacticVariable.BAZ);
+
+        assertThat(StringConversions.<int[]> as(int[].class, "5,7,9")).containsExactly(5, 7, 9);
+    }
+
+    @Test
+    public void testCommaDelimitedCollectionConversion() {
+        assertThat(StringConversions.as(new TypeLiteral<Set<MetaSyntacticVariable>>() {}, "bar,foo"))
+            .containsExactly(MetaSyntacticVariable.FOO, MetaSyntacticVariable.BAR).isInstanceOf(EnumSet.class);
+
+        assertThat(StringConversions.as(new TypeLiteral<List<Integer>>() {}, "2,4,6,8,4")).containsExactly(2, 4, 6, 8,
+            4);
+
+        assertThat(StringConversions.as(new TypeLiteral<Collection<Integer>>() {}, "2,4,6,8,4")).containsExactly(2, 4,
+            6, 8, 4);
+
+        assertThat(StringConversions.as(new TypeLiteral<Set<Integer>>() {}, "2,4,6,8,4")).containsExactly(2, 4, 6, 8);
+
+        assertThat(StringConversions.as(new TypeLiteral<ArrayDeque<String>>() {}, "moe,larry,curly"))
+            .containsExactly("moe", "larry", "curly");
+    }
+}
diff --git a/src/tests/junit/org/apache/ant/s3/strings/ClassNamesTest.java b/src/tests/junit/org/apache/ant/s3/strings/ClassNamesTest.java
new file mode 100644
index 0000000..d4c1b67
--- /dev/null
+++ b/src/tests/junit/org/apache/ant/s3/strings/ClassNamesTest.java
@@ -0,0 +1,345 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.strings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.ant.s3.strings.ClassNames.Direction;
+import org.junit.Test;
+
+/**
+ * Unit test {@link ClassNames}.
+ */
+public class ClassNamesTest {
+    @Test
+    public void testEmpty() {
+        assertThat(ClassNames.empty()).isEmpty();
+    }
+
+    @Test
+    public void testExplicitString() {
+        assertThat(ClassNames.of("foo"))
+
+            .containsExactly("foo")
+
+            .containsExactly("foo");
+    }
+
+    @Test
+    public void testExplicitStrings() {
+        assertThat(ClassNames.of("foo", "bar", "baz"))
+
+            .containsExactly("foo", "bar", "baz")
+
+            .containsExactly("foo", "bar", "baz");
+    }
+
+    @Test
+    public void testComposite() {
+        assertThat(
+
+            ClassNames.of("foo", "bar").andThen(
+
+                ClassNames.of("baz", "blah")
+
+            )
+
+        )
+
+            .isInstanceOf(ClassNames.class)
+
+            .containsExactly("foo", "bar", "baz", "blah")
+
+            .containsExactly("foo", "bar", "baz", "blah");
+    }
+
+    @Test
+    public void testChainedComposite() {
+        assertThat(
+
+            ClassNames.of("foo").andThen(
+
+                ClassNames.of("bar").andThen(
+
+                    ClassNames.of("baz").andThen(
+
+                        ClassNames.of("blah")
+
+                    )
+
+                )
+
+            )
+
+        )
+
+            .isInstanceOf(ClassNames.class)
+
+            .containsExactly("foo", "bar", "baz", "blah")
+
+            .containsExactly("foo", "bar", "baz", "blah");
+    }
+
+    @Test
+    public void testSorted() {
+        final ClassNames msv = ClassNames.of("foo", "bar", "baz");
+
+        assertThat(msv.sorted())
+
+            .isInstanceOf(ClassNames.class)
+
+            .containsExactly("bar", "baz", "foo")
+
+            .containsExactly("bar", "baz", "foo");
+
+        assertThat(msv.sorted(Comparator.reverseOrder()))
+
+            .isInstanceOf(ClassNames.class)
+
+            .containsExactly("foo", "baz", "bar")
+
+            .containsExactly("foo", "baz", "bar");
+    }
+
+    @Test
+    public void testReverse() {
+        assertThat(ClassNames.of("foo", "bar", "baz").reverse())
+
+            .isInstanceOf(ClassNames.class)
+
+            .containsExactly("baz", "bar", "foo")
+
+            .containsExactly("baz", "bar", "foo");
+    }
+
+    @Test
+    public void testDistinct() {
+        assertThat(ClassNames.of("foo", "bar", "baz", "foo", "bar", "baz").distinct())
+
+            .isInstanceOf(ClassNames.class)
+
+            .containsExactly("foo", "bar", "baz");
+    }
+
+    @Test
+    public void testExplicitClassname() {
+        assertThat(ClassNames.of(String.class)).containsExactlyElementsOf(simpleNames(String.class));
+    }
+
+    @Test
+    public void testExplicitClassnames() {
+        assertThat(ClassNames.of(String.class, Object.class, List.class))
+            .containsExactlyElementsOf(simpleNames(String.class, Object.class, List.class));
+    }
+
+    @Test
+    public void testSegmentsFromLeft() {
+        final ClassNames base = ClassNames.of("FooBarBaz");
+
+        assertThat(base.segment(Direction.FROM_LEFT, 0)).isSameAs(base);
+
+        assertThat(base.segment(Direction.FROM_LEFT, 1))
+
+            .containsExactly("BarBaz")
+
+            .containsExactly("BarBaz");
+
+        assertThat(base.segment(Direction.FROM_LEFT, 2))
+
+            .containsExactly("Baz")
+
+            .containsExactly("Baz");
+
+        assertThat(base.segment(Direction.FROM_LEFT, 3)).isEmpty();
+
+        assertThat(base.segments(Direction.FROM_LEFT, 0, 1))
+
+            .containsExactly("FooBarBaz", "BarBaz")
+
+            .containsExactly("FooBarBaz", "BarBaz");
+
+        assertThat(base.segments(Direction.FROM_LEFT, 1, 2))
+
+            .containsExactly("BarBaz", "Baz")
+
+            .containsExactly("BarBaz", "Baz");
+
+        for (int max = 2; max < 6; max++) {
+            assertThat(base.segments(Direction.FROM_LEFT, 0, max))
+
+                .containsExactly("FooBarBaz", "BarBaz", "Baz")
+
+                .containsExactly("FooBarBaz", "BarBaz", "Baz");
+        }
+
+        assertThat(base.segments(Direction.FROM_LEFT))
+
+            .containsExactly("FooBarBaz", "BarBaz", "Baz")
+
+            .containsExactly("FooBarBaz", "BarBaz", "Baz");
+    }
+
+    @Test
+    public void testSegmentsFromRight() {
+        final ClassNames base = ClassNames.of("FooBarBaz");
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 0)).isSameAs(base);
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 1))
+
+            .containsExactly("FooBar")
+
+            .containsExactly("FooBar");
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 2))
+
+            .containsExactly("Foo")
+
+            .containsExactly("Foo");
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 3)).isEmpty();
+
+        assertThat(base.segments(Direction.FROM_RIGHT, 0, 1))
+
+            .containsExactly("FooBarBaz", "FooBar")
+
+            .containsExactly("FooBarBaz", "FooBar");
+
+        assertThat(base.segments(Direction.FROM_RIGHT, 1, 2))
+
+            .containsExactly("FooBar", "Foo")
+
+            .containsExactly("FooBar", "Foo");
+
+        for (int max = 2; max < 6; max++) {
+            assertThat(base.segments(Direction.FROM_RIGHT, 0, max))
+
+                .containsExactly("FooBarBaz", "FooBar", "Foo")
+
+                .containsExactly("FooBarBaz", "FooBar", "Foo");
+        }
+
+        assertThat(base.segments(Direction.FROM_RIGHT))
+
+            .containsExactly("FooBarBaz", "FooBar", "Foo")
+
+            .containsExactly("FooBarBaz", "FooBar", "Foo");
+    }
+
+    @Test
+    public void testSegmentsFromLeftWithMultipleRoots() {
+        final ClassNames base = ClassNames.of("FooBarBaz", "DoReMi");
+
+        assertThat(base.segment(Direction.FROM_LEFT, 0)).isSameAs(base);
+
+        assertThat(base.segment(Direction.FROM_LEFT, 1))
+
+            .containsExactly("BarBaz", "ReMi")
+
+            .containsExactly("BarBaz", "ReMi");
+
+        assertThat(base.segment(Direction.FROM_LEFT, 2))
+
+            .containsExactly("Baz", "Mi")
+
+            .containsExactly("Baz", "Mi");
+
+        assertThat(base.segment(Direction.FROM_LEFT, 3)).isEmpty();
+
+        assertThat(base.segments(Direction.FROM_LEFT, 0, 1))
+
+            .containsExactly("FooBarBaz", "BarBaz", "DoReMi", "ReMi")
+
+            .containsExactly("FooBarBaz", "BarBaz", "DoReMi", "ReMi");
+
+        assertThat(base.segments(Direction.FROM_LEFT, 1, 2))
+
+            .containsExactly("BarBaz", "Baz", "ReMi", "Mi")
+
+            .containsExactly("BarBaz", "Baz", "ReMi", "Mi");
+
+        for (int max = 2; max < 6; max++) {
+            assertThat(base.segments(Direction.FROM_LEFT, 0, max))
+
+                .containsExactly("FooBarBaz", "BarBaz", "Baz", "DoReMi", "ReMi", "Mi")
+
+                .containsExactly("FooBarBaz", "BarBaz", "Baz", "DoReMi", "ReMi", "Mi");
+        }
+
+        assertThat(base.segments(Direction.FROM_LEFT))
+
+            .containsExactly("FooBarBaz", "BarBaz", "Baz", "DoReMi", "ReMi", "Mi")
+
+            .containsExactly("FooBarBaz", "BarBaz", "Baz", "DoReMi", "ReMi", "Mi");
+    }
+
+    @Test
+    public void testSegmentsFromRightWithMultipleRoots() {
+        final ClassNames base = ClassNames.of("FooBarBaz", "DoReMi");
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 0)).isSameAs(base);
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 1))
+
+            .containsExactly("FooBar", "DoRe")
+
+            .containsExactly("FooBar", "DoRe");
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 2))
+
+            .containsExactly("Foo", "Do")
+
+            .containsExactly("Foo", "Do");
+
+        assertThat(base.segment(Direction.FROM_RIGHT, 3)).isEmpty();
+
+        assertThat(base.segments(Direction.FROM_RIGHT, 0, 1))
+
+            .containsExactly("FooBarBaz", "FooBar", "DoReMi", "DoRe")
+
+            .containsExactly("FooBarBaz", "FooBar", "DoReMi", "DoRe");
+
+        assertThat(base.segments(Direction.FROM_RIGHT, 1, 2))
+
+            .containsExactly("FooBar", "Foo", "DoRe", "Do")
+
+            .containsExactly("FooBar", "Foo", "DoRe", "Do");
+
+        for (int max = 2; max < 6; max++) {
+            assertThat(base.segments(Direction.FROM_RIGHT, 0, max))
+
+                .containsExactly("FooBarBaz", "FooBar", "Foo", "DoReMi", "DoRe", "Do")
+
+                .containsExactly("FooBarBaz", "FooBar", "Foo", "DoReMi", "DoRe", "Do");
+        }
+
+        assertThat(base.segments(Direction.FROM_RIGHT))
+
+            .containsExactly("FooBarBaz", "FooBar", "Foo", "DoReMi", "DoRe", "Do")
+
+            .containsExactly("FooBarBaz", "FooBar", "Foo", "DoReMi", "DoRe", "Do");
+    }
+
+    private Iterable<String> simpleNames(Class<?>... clazzes) {
+        return Stream.of(clazzes).map(Class::getSimpleName).collect(Collectors.toList());
+    }
+}
diff --git a/src/tests/junit/org/apache/ant/s3/strings/PackageNamesTest.java b/src/tests/junit/org/apache/ant/s3/strings/PackageNamesTest.java
new file mode 100644
index 0000000..8fd62a5
--- /dev/null
+++ b/src/tests/junit/org/apache/ant/s3/strings/PackageNamesTest.java
@@ -0,0 +1,286 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.strings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.junit.Test;
+
+/**
+ * Unit test {@link PackageNames}.
+ */
+public class PackageNamesTest {
+    @Test
+    public void testEmpty() {
+        assertThat(PackageNames.empty()).isEmpty();
+    }
+
+    @Test
+    public void testExplicitString() {
+        assertThat(PackageNames.of("foo"))
+
+            .containsExactly("foo")
+
+            .containsExactly("foo");
+    }
+
+    @Test
+    public void testExplicitStrings() {
+        assertThat(PackageNames.of("foo", "bar", "baz"))
+
+            .containsExactly("foo", "bar", "baz")
+
+            .containsExactly("foo", "bar", "baz");
+    }
+
+    @Test
+    public void testComposite() {
+        assertThat(
+
+            PackageNames.of("foo", "bar").andThen(
+
+                PackageNames.of("baz", "blah")
+
+            )
+
+        )
+
+            .isInstanceOf(PackageNames.class)
+
+            .containsExactly("foo", "bar", "baz", "blah")
+
+            .containsExactly("foo", "bar", "baz", "blah");
+    }
+
+    @Test
+    public void testChainedComposite() {
+        assertThat(
+
+            PackageNames.of("foo").andThen(
+
+                PackageNames.of("bar").andThen(
+
+                    PackageNames.of("baz").andThen(
+
+                        PackageNames.of("blah")
+
+                    )
+
+                )
+
+            )
+
+        )
+
+            .isInstanceOf(PackageNames.class)
+
+            .containsExactly("foo", "bar", "baz", "blah")
+
+            .containsExactly("foo", "bar", "baz", "blah");
+    }
+
+    @Test
+    public void testSorted() {
+        final PackageNames msv = PackageNames.of("foo", "bar", "baz");
+
+        assertThat(msv.sorted())
+
+            .isInstanceOf(PackageNames.class)
+
+            .containsExactly("bar", "baz", "foo")
+
+            .containsExactly("bar", "baz", "foo");
+
+        assertThat(msv.sorted(Comparator.reverseOrder()))
+
+            .isInstanceOf(PackageNames.class)
+
+            .containsExactly("foo", "baz", "bar")
+
+            .containsExactly("foo", "baz", "bar");
+    }
+
+    @Test
+    public void testReverse() {
+        assertThat(PackageNames.of("foo", "bar", "baz").reverse())
+
+            .isInstanceOf(PackageNames.class)
+
+            .containsExactly("baz", "bar", "foo")
+
+            .containsExactly("baz", "bar", "foo");
+    }
+
+    @Test
+    public void testDistinct() {
+        assertThat(PackageNames.of("foo", "bar", "baz", "foo", "bar", "baz").distinct())
+
+            .isInstanceOf(PackageNames.class)
+
+            .containsExactly("foo", "bar", "baz");
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testAncestorInvalidMinGen() {
+        PackageNames.of("foo").ancestors(-1, 0);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testAncestorInvalidMaxGen() {
+        PackageNames.of("foo").ancestors(0, -1);
+    }
+
+    @Test
+    public void testAncestors() {
+        final PackageNames foo_bar_baz = PackageNames.of("foo.bar.baz");
+
+        assertThat(foo_bar_baz.ancestor(0))
+
+            .isSameAs(foo_bar_baz)
+
+            .containsExactly("foo.bar.baz")
+
+            .containsExactly("foo.bar.baz");
+
+        assertThat(foo_bar_baz.ancestor(1))
+
+            .containsExactly("foo.bar")
+
+            .containsExactly("foo.bar");
+
+        assertThat(foo_bar_baz.ancestor(2))
+
+            .containsExactly("foo")
+
+            .containsExactly("foo");
+
+        assertThat(foo_bar_baz.ancestor(3)).isEmpty();
+
+        assertThat(foo_bar_baz.ancestors(1, 2))
+
+            .containsExactly("foo.bar", "foo")
+
+            .containsExactly("foo.bar", "foo");
+
+        for (int max = 2; max < 5; max++) {
+            assertThat(foo_bar_baz.ancestors(0, max))
+
+                .containsExactly("foo.bar.baz", "foo.bar", "foo")
+
+                .containsExactly("foo.bar.baz", "foo.bar", "foo");
+        }
+
+        assertThat(foo_bar_baz.ancestors())
+
+            .containsExactly("foo.bar.baz", "foo.bar", "foo")
+
+            .containsExactly("foo.bar.baz", "foo.bar", "foo");
+    }
+
+    @Test
+    public void testAncestorsMultipleRoots() {
+        final PackageNames base = PackageNames.of("foo.bar.baz", "moe.larry.curly.shemp");
+
+        assertThat(base.ancestor(1))
+
+            .containsExactly("foo.bar", "moe.larry.curly")
+
+            .containsExactly("foo.bar", "moe.larry.curly");
+
+        assertThat(base.ancestor(2))
+
+            .containsExactly("foo", "moe.larry")
+
+            .containsExactly("foo", "moe.larry");
+
+        assertThat(base.ancestor(3))
+
+            .containsExactly("moe")
+
+            .containsExactly("moe");
+
+        assertThat(base.ancestor(4)).isEmpty();
+
+        assertThat(base.ancestors(1, 2))
+
+            .containsExactly("foo.bar", "foo", "moe.larry.curly", "moe.larry")
+
+            .containsExactly("foo.bar", "foo", "moe.larry.curly", "moe.larry");
+
+        assertThat(base.ancestors(1, 3))
+
+            .containsExactly("foo.bar", "foo", "moe.larry.curly", "moe.larry", "moe")
+
+            .containsExactly("foo.bar", "foo", "moe.larry.curly", "moe.larry", "moe");
+
+        assertThat(base.ancestors(2, 3))
+
+            .containsExactly("foo", "moe.larry", "moe")
+
+            .containsExactly("foo", "moe.larry", "moe");
+
+        for (int max = 3; max < 6; max++) {
+            assertThat(base.ancestors(0, max))
+
+                .containsExactly("foo.bar.baz", "foo.bar", "foo", "moe.larry.curly.shemp", "moe.larry.curly",
+                    "moe.larry", "moe")
+
+                .containsExactly("foo.bar.baz", "foo.bar", "foo", "moe.larry.curly.shemp", "moe.larry.curly",
+                    "moe.larry", "moe");
+        }
+
+        assertThat(base.ancestors())
+
+            .containsExactly("foo.bar.baz", "foo.bar", "foo", "moe.larry.curly.shemp", "moe.larry.curly", "moe.larry",
+                "moe")
+
+            .containsExactly("foo.bar.baz", "foo.bar", "foo", "moe.larry.curly.shemp", "moe.larry.curly", "moe.larry",
+                "moe");
+    }
+
+    @Test
+    public void testExplicitRootClass() {
+        final Iterable<String> expected = packageNames(String.class);
+
+        assertThat(PackageNames.of(String.class))
+
+            .containsExactlyElementsOf(expected)
+
+            .containsExactlyElementsOf(expected);
+    }
+
+    @Test
+    public void testExplicitRootClasses() {
+        final Iterable<String> expected = packageNames(String.class, List.class, Pattern.class);
+
+        assertThat(PackageNames.of(String.class, List.class, Pattern.class))
+
+            .containsExactlyElementsOf(expected)
+
+            .containsExactlyElementsOf(expected);
+    }
+
+    private Iterable<String> packageNames(Class<?>... clazzes) {
+        return Stream.of(clazzes).map(c -> c.getPackage().getName()).collect(Collectors.toList());
+    }
+}
diff --git a/src/tests/junit/org/apache/ant/s3/strings/StringsTest.java b/src/tests/junit/org/apache/ant/s3/strings/StringsTest.java
new file mode 100644
index 0000000..485be3a
--- /dev/null
+++ b/src/tests/junit/org/apache/ant/s3/strings/StringsTest.java
@@ -0,0 +1,114 @@
+/*
+ *  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
+ *
+ *      https://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.ant.s3.strings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Comparator;
+
+import org.junit.Test;
+
+/**
+ * Unit test {@link Strings}.
+ */
+public class StringsTest {
+    @Test
+    public void testEmpty() {
+        assertThat(Strings.empty()).isEmpty();
+    }
+
+    @Test
+    public void testExplicitString() {
+        assertThat(Strings.of("foo"))
+
+            .containsExactly("foo")
+
+            .containsExactly("foo");
+    }
+
+    @Test
+    public void testExplicitStrings() {
+        assertThat(Strings.of("foo", "bar", "baz"))
+
+            .containsExactly("foo", "bar", "baz")
+
+            .containsExactly("foo", "bar", "baz");
+    }
+
+    @Test
+    public void testComposite() {
+        assertThat(Strings.of("foo", "bar").andThen(Strings.of("baz", "blah")))
+
+            .containsExactly("foo", "bar", "baz", "blah")
+
+            .containsExactly("foo", "bar", "baz", "blah");
+    }
+
+    @Test
+    public void testChainedComposite() {
+        assertThat(
+
+            Strings.of("foo").andThen(
+
+                Strings.of("bar").andThen(
+
+                    Strings.of("baz").andThen(
+
+                        Strings.of("blah")
+
+                    )
+
+                )
+
+            )
+
+        )
+
+            .containsExactly("foo", "bar", "baz", "blah")
+
+            .containsExactly("foo", "bar", "baz", "blah");
+    }
+
+    @Test
+    public void testSorted() {
+        final Strings msv = Strings.of("foo", "bar", "baz");
+
+        assertThat(msv.sorted())
+
+            .containsExactly("bar", "baz", "foo")
+
+            .containsExactly("bar", "baz", "foo");
+
+        assertThat(msv.sorted(Comparator.reverseOrder()))
+
+            .containsExactly("foo", "baz", "bar")
+
+            .containsExactly("foo", "baz", "bar");
+    }
+
+    @Test
+    public void testReverse() {
+        assertThat(Strings.of("foo", "bar", "baz").reverse()).containsExactly("baz", "bar", "foo");
+    }
+
+    @Test
+    public void testDistinct() {
+        assertThat(Strings.of("foo", "bar", "baz", "foo", "bar", "baz").distinct())
+
+            .containsExactly("foo", "bar", "baz");
+    }
+}