[java-sp-server] branch main updated: Park DDF code here temporarily.

Scott Cantor cantor.2 at osu.edu
Thu Sep 1 18:15:19 UTC 2022


This is an automated email from the git hooks/post-receive script.

scantor pushed a commit to branch main
in repository java-sp-server.

View the commit online:
http://git.shibboleth.net/view/?p=java-sp-server.git;a=commit;h=f58522d53ed3bc93b144379e98b83217e78b50f4

The following commit(s) were added to refs/heads/main by this push:
     new f58522d  Park DDF code here temporarily.
f58522d is described below

commit f58522d53ed3bc93b144379e98b83217e78b50f4
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Thu Sep 1 14:15:16 2022 -0400

    Park DDF code here temporarily.
---
 .../net/shibboleth/sp/conf/services-system.xml     |    2 +-
 sp-server-api/pom.xml                              |   25 -
 .../main/java/net/shibboleth/sp/Application.java   |   30 +
 .../java/support/ddf/ByteArrayToDDFConverter.java  |   41 +
 .../shibboleth/utilities/java/support/ddf/DDF.java | 1602 ++++++++++++++++++++
 .../utilities/java/support/ddf/DDFSupport.java     |  101 ++
 .../java/support/ddf/DDFToByteArrayConverter.java  |   42 +
 .../support/ddf/RemotedHttpServletRequest.java     |  688 +++++++++
 .../support/ddf/RemotedHttpServletResponse.java    |  504 ++++++
 .../utilities/java/support/ddf/package-info.java   |   28 +
 .../utilities/java/support/ddf/DDFSupportTest.java |   96 ++
 .../utilities/java/support/ddf/DDFTest.java        |  480 ++++++
 .../support/ddf/RemotedHttpServletRequestTest.java |  171 +++
 .../ddf/RemotedHttpServletResponseTest.java        |  143 ++
 sp-server-impl/pom.xml                             |   25 -
 .../net/shibboleth/sp/impl/BasicApplication.java   |   46 +
 16 files changed, 3973 insertions(+), 51 deletions(-)

diff --git a/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/services-system.xml b/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/services-system.xml
index e6b31c2..dd8bd86 100644
--- a/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/services-system.xml
+++ b/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/services-system.xml
@@ -28,7 +28,7 @@
     -->
     
     <bean id="shibboleth.LoggingService"
-        class="%{sp.service.logging.class:net.shibboleth.ext.spring.service.LogbackLoggingService}"
+        class="%{sp.service.logging.class:net.shibboleth.shared.service.impl.LogbackLoggingService}"
         p:loggingConfiguration="%{sp.service.logging.resource:%{sp.home}/conf/logback.xml}"
         p:fallbackConfiguration="classpath:/logback.xml"
         p:reloadCheckDelay="%{sp.service.logging.checkInterval:PT0S}"
diff --git a/sp-server-api/pom.xml b/sp-server-api/pom.xml
index a57c11e..be5dc5f 100644
--- a/sp-server-api/pom.xml
+++ b/sp-server-api/pom.xml
@@ -21,18 +21,6 @@
         
     <dependencies>
         <!-- compile dependencies -->
-        <dependency>
-            <groupId>net.shibboleth.utilities</groupId>
-            <artifactId>java-support</artifactId>
-            <version>${java-support.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.shibboleth.ext</groupId>
-            <artifactId>spring-extensions</artifactId>
-            <version>${spring-extensions.version}</version>
-            <scope>compile</scope>
-        </dependency>
         <dependency>
             <groupId>${spring.groupId}</groupId>
             <artifactId>spring-beans</artifactId>
@@ -61,19 +49,6 @@
             <artifactId>spring-test</artifactId>
             <scope>test</scope>
         </dependency>
-        <dependency>
-            <groupId>net.shibboleth.ext</groupId>
-            <artifactId>spring-extensions</artifactId>
-            <type>test-jar</type>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-	       <groupId>net.shibboleth.utilities</groupId>
-	       <artifactId>java-support</artifactId>
-	       <version>${java-support.version}</version>
-	       <type>test-jar</type>
-	       <scope>test</scope>
-        </dependency>
     </dependencies>
 
 </project>
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/Application.java b/sp-server-api/src/main/java/net/shibboleth/sp/Application.java
index a24b3cc..d924d47 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/Application.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/Application.java
@@ -21,6 +21,7 @@ import javax.annotation.concurrent.ThreadSafe;
 import net.shibboleth.sp.remoting.Endpoint;
 import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
 import net.shibboleth.utilities.java.support.component.IdentifiedComponent;
+import net.shibboleth.utilities.java.support.service.ReloadableService;
 
 /**
  * A collection of resources protected by the SP and treated as a unit for the purposes of
@@ -39,4 +40,33 @@ public interface Application extends IdentifiedComponent, Endpoint {
     @Nonnull @NotEmpty static String OPERATION = "op";
     
     // TODO: Expose various injected services for use by ApplicationEndpoints.
+    
+    /**
+     * Get {@link MetadataResolver} for this {@link Application}.
+     * 
+     * @return the metadata source to use
+     */
+    @Nonnull ReloadableService<Object> getMetadataResolver();
+
+    /**
+     * Get {@link AttributeTranscoderRegistry} for this {@link Application}.
+     * 
+     * @return the attribute filter to use
+     */
+    @Nonnull ReloadableService<Object> getAttributeTranscoderRegistry();
+
+    /**
+     * Get {@link AttributeResolver} for this {@link Application}.
+     * 
+     * @return the attribute resolver to use
+     */
+    @Nonnull ReloadableService<Object> getAttributeResolver();
+
+    /**
+     * Get {@link AttributeFilter} for this {@link Application}.
+     * 
+     * @return the attribute filter to use
+     */
+    @Nonnull ReloadableService<Object> getAttributeFilter();
+
 }
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/ByteArrayToDDFConverter.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/ByteArrayToDDFConverter.java
new file mode 100644
index 0000000..22c31e1
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/ByteArrayToDDFConverter.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import org.springframework.core.convert.converter.Converter;
+
+/**
+ * Spring converter from byte array to {@link DDF} object.
+ * 
+ * @since 7.0.0
+ */
+public class ByteArrayToDDFConverter implements Converter<byte[], DDF> {
+
+    /** {@inheritDoc} */
+    public DDF convert(final byte[] source) {
+        try (final ByteArrayInputStream bais = new ByteArrayInputStream(source)) {
+            return DDF.deserialize(bais);
+        } catch (final IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDF.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDF.java
new file mode 100644
index 0000000..29273e7
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDF.java
@@ -0,0 +1,1602 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullElements;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotLive;
+import net.shibboleth.utilities.java.support.annotation.constraint.Unmodifiable;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+
+/**
+ * The core object in the DDF mode, this is a node in a tree of objects that
+ * make up the entire data structure.
+ * 
+ * <p>Each node contains references to its parant and children, if any,
+ * as well as data type and possibly a value if a leaf node.</p>
+ * 
+ * <p>Most of the types are self-explanatory, but strings may be "safe" or "unsafe".
+ * Safe strings are understood to be Unicode that can be safely converted between UTF-8
+ * and UTF-16. Unsafe strings are represented as Java String objects but have an unknown
+ * character encoding so the individual code points above 127 are essentially undefined
+ * and cannot be assumed to represent the "correct" value. They may only be compared
+ * with other values that are understood to represent the same range of values.</p> 
+ * 
+ * <p>The method names do not align to normal Java conventions for compatibility with
+ * the other version(s) of the same API.</p>
+ */
+ at NotThreadSafe
+public class DDF implements Iterable<DDF> {
+
+    /** Name of node. */
+    @Nullable private String name;
+    
+    /** Parent node. */
+    @Nullable private DDF parent;
+
+    /** Type enum. */
+    public enum DDFType {
+
+        /** A null node. */
+        DDF_NULL(-1),
+
+        /** An empty node with no value. */
+        DDF_EMPTY(0),
+        
+        /** A string value. */
+        DDF_STRING(1),
+        
+        /** An integral value of no more than 32-bits. */
+        DDF_INT(2),
+        
+        /** A floating point value. */
+        DDF_FLOAT(3),
+        
+        /** A structure with named children. */
+        DDF_STRUCT(4),
+        
+        /** An ordered list. */
+        DDF_LIST(5),
+        
+        /** A reference to any object. */
+        DDF_POINTER(6),
+        
+        /** A string that cannot be assumed to be UTF-8 (see above docs). */
+        DDF_STRING_UNSAFE(7),
+        
+        /** An integral value of no more than 64-bits. */
+        DDF_LONG(8);
+        
+        /** Type value. */
+        private final int value;
+        
+        /**
+         * Constructor.
+         * 
+         * @param val value of the type enum
+         */
+        private DDFType(final int val) {
+            value = val;
+        }
+        
+        /**
+         * Get the type value.
+         * 
+         * @return type value
+         */
+        public int getValue() {
+            return value;
+        }
+        
+        /**
+         * Convert an integer into the corresponding enum value.
+         * 
+         * @param val input type
+         * 
+         * @return enum constant
+         * 
+         * @throws IllegalArgumentException if the type is out of range
+         */
+// Checkstyle: CyclomaticComplexity OFF
+        public static DDFType valueOf(final int val) throws IllegalArgumentException {
+            final DDFType type;
+            switch (val) {
+                case -1:
+                    type = DDF_NULL;
+                    break;
+                        
+                case 0:
+                    type = DDF_EMPTY;
+                    break;
+                        
+                case 1:
+                    type = DDF_STRING;
+                    break;
+                        
+                case 2:
+                    type = DDF_INT;
+                    break;
+
+                case 3:
+                    type = DDF_FLOAT;
+                    break;
+                    
+                case 4:
+                    type = DDF_STRUCT;
+                    break;
+                    
+                case 5:
+                    type = DDF_LIST;
+                    break;
+
+                case 6:
+                    type = DDF_POINTER;
+                    break;
+                    
+                case 7:
+                    type = DDF_STRING_UNSAFE;
+                    break;
+                
+                case 8:
+                    type = DDF_LONG;
+                    break;
+                    
+                default:
+                    throw new IllegalArgumentException("Unrecognized DDF type");
+            }
+            return type;
+        }
+        
+    };
+// Checkstyle: CyclomaticComplexity ON
+    
+    /** Node type. */
+    @Nonnull private DDFType type;
+    
+    /** Reference to the value, which depends on the type. */
+    @Nullable private Object value;
+    
+    /** Constructor. */
+    public DDF() {
+        type = DDFType.DDF_NULL;
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     */
+    public DDF(@Nullable @NotEmpty final String n) {
+        type = DDFType.DDF_EMPTY;
+        name(n);
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     * @param val string value, assumed to be "safe" Unicode
+     */
+    public DDF(@Nullable @NotEmpty final String n, @Nullable final String val) {
+        this(n);
+        string(val);
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     * @param val byte array value, handled without knowledge of the encoding
+     */
+    public DDF(@Nullable @NotEmpty final String n, @Nullable final byte[] val) {
+        this(n);
+        unsafe_string(val);
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     * @param val integer value
+     */
+    public DDF(@Nullable @NotEmpty final String n, final int val) {
+        this(n);
+        integer(val);
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     * @param val long integer value
+     */
+    public DDF(@Nullable @NotEmpty final String n, final long val) {
+        this(n);
+        longinteger(val);
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     * @param val floating value
+     */
+    public DDF(@Nullable @NotEmpty final String n, final double val) {
+        this(n);
+        floating(val);
+    }
+
+    /**
+     * Constructor.
+     *
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     *
+     * @param n node name
+     * @param val object value
+     */
+    public DDF(@Nullable @NotEmpty final String n, @Nullable final Object val) {
+        this(n);
+        pointer(val);
+    }
+
+    /**
+     * Destroys a node's content, resets it to a null object and clears its name.
+     * 
+     * <p>This is primarily for tree maintenance, given the lack of need for explicit
+     * memory management.</p>
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF destroy() {
+        remove().empty().name(null);
+        type = DDFType.DDF_NULL;
+        return this;
+    }
+    
+    /**
+     * Performs a deep copy of the node and all children, if any.
+     * 
+     * @return the copy
+     */
+// Checkstyle: CyclomaticComplexity OFF
+    @SuppressWarnings("unchecked")
+    @Nonnull DDF copy() {
+        final DDF dup = new DDF(name);
+        
+        switch (type) {
+            case DDF_NULL:
+                dup.destroy();
+                break;
+                
+            case DDF_EMPTY:
+                break;
+                
+            case DDF_STRING:
+                dup.string((String) value);
+                break;
+                
+            case DDF_STRING_UNSAFE:
+                dup.unsafe_string((byte[]) value);
+                break;
+                
+            case DDF_INT:
+                dup.integer((Integer) value);
+                break;
+
+            case DDF_LONG:
+                dup.longinteger((Long) value);
+                break;
+                
+            case DDF_FLOAT:
+                dup.floating((Double) value);
+                break;
+                
+            case DDF_STRUCT:
+                dup.structure();
+                for (final DDF ddf : ((Map<String,DDF>) value).values()) {
+                    dup.add(ddf.copy());
+                }
+                break;
+                
+            case DDF_LIST:
+                dup.list();
+                for (final DDF ddf : (List<DDF>) value) {
+                    dup.add(ddf.copy());
+                }
+                break;
+                
+            default:
+        }
+        
+        return dup;
+    }
+// Checkstyle: CyclomaticComplexity ON
+    
+    /**
+     * Get the node name.
+     * 
+     * @return the name
+     */
+    @Nullable public String name() {
+        return name;
+    }
+    
+    /**
+     * Set the node name.
+     * 
+     * <p>For compatibility, the name is constrained to no more than 255 characters.</p>
+     * 
+     * <p>The name will not be set if the node is already a child of a structure.</p>
+     * 
+     * @param n the new name
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF name(@Nullable @NotEmpty final String n) {
+        if (!isnull() && (parent == null || !parent.isstruct())) {
+            if (n != null) {
+                name = Constraint.isNotEmpty(n.substring(0,Integer.min(n.length(), 255)), "Name cannot be empty");
+            } else {
+                name = null;
+            }
+        }
+        return this;
+    }
+    
+    /**
+     * Returns true iff the node is null.
+     * 
+     * @return true iff the node is null
+     */
+    public boolean isnull() {
+        return type == DDFType.DDF_NULL;
+    }
+    
+    /**
+     * Returns true iff the node is empty.
+     * 
+     * @return true iff the node is empty
+     */
+    public boolean isempty() {
+        return type == DDFType.DDF_EMPTY;
+    }
+
+    /**
+     * Returns true iff the node is a string.
+     * 
+     * @return true iff the node is a string
+     */
+    public boolean isstring() {
+        return type == DDFType.DDF_STRING;
+    }
+
+    /**
+     * Returns true iff the node is an unsafe string.
+     * 
+     * @return true iff the node is an unsafe string
+     */
+    public boolean isunsafestring() {
+        return type == DDFType.DDF_STRING_UNSAFE;
+    }
+
+    /**
+     * Returns true iff the node is an integer.
+     * 
+     * @return true iff the node is an integer
+     */
+    public boolean isint() {
+        return type == DDFType.DDF_INT;
+    }
+
+    /**
+     * Returns true iff the node is a long integer.
+     * 
+     * @return true iff the node is a long integer
+     */
+    public boolean islong() {
+        return type == DDFType.DDF_LONG;
+    }
+    
+    /**
+     * Returns true iff the node is a floating point.
+     * 
+     * @return true iff the node is a floating point
+     */
+    public boolean isfloat() {
+        return type == DDFType.DDF_FLOAT;
+    }
+
+    /**
+     * Returns true iff the node is a structure.
+     * 
+     * @return true iff the node is a structure
+     */
+    public boolean isstruct() {
+        return type == DDFType.DDF_STRUCT;
+    }
+
+    /**
+     * Returns true iff the node is a list/array.
+     * 
+     * @return true iff the node is a list/array.
+     */
+    public boolean islist() {
+        return type == DDFType.DDF_LIST;
+    }
+
+    /**
+     * Returns true iff the node is a pointer (i.e., object reference).
+     * 
+     * @return true iff the node is a pointer (i.e., object reference)
+     */
+    public boolean ispointer() {
+        return type == DDFType.DDF_POINTER;
+    }
+
+    /**
+     * Get the string value of this node.
+     * 
+     * <p>The string value of a non-string value is null.</p>
+     * 
+     * @return the string value or null
+     */
+    @Nullable public String string() {
+        return isstring() ? (String) value : null;
+    }
+
+    /**
+     * Get the byte array value of this node if an unsafe string.
+     * 
+     * @return the byte array value or null
+     */
+// Checkstyle: MethodName OFF
+    @Nullable public byte[] unsafe_string() {
+        return isunsafestring() ? (byte[]) value : null;
+    }
+// Checkstyle: MethodName ON
+
+    /**
+     * Get the integer value of this node.
+     * 
+     * <p>Integers are coerced from other types based on numeric conversions
+     * or the count of a structure or list.</p> 
+     * 
+     * @return the integer value or null
+     */
+    @Nullable public Integer integer() {
+        
+        switch(type) {
+            case DDF_INT:
+                return (Integer) value;
+            case DDF_LONG:
+                return ((Long) value).intValue();
+            case DDF_FLOAT:
+                return ((Double) value).intValue();
+            case DDF_STRING:
+                try {
+                    return Integer.valueOf((String) value);
+                } catch (final NumberFormatException e) {
+                    // Swallow.
+                    return null;
+                }
+            case DDF_STRUCT:
+                return ((Map<?,?>) value).size();
+            case DDF_LIST:
+                return ((List<?>) value).size();
+            default:
+                break;
+        }
+        
+        return null;
+    }
+
+    /**
+     * Get the long integer value of this node.
+     * 
+     * <p>Longs are coerced from other types based on numeric conversions
+     * or the count of a structure or list.</p> 
+     * 
+     * @return the long integer value or null
+     */
+    @Nullable public Long longinteger() {
+        
+        switch(type) {
+            case DDF_INT:
+                return ((Integer) value).longValue();
+            case DDF_LONG:
+                return (Long) value;
+            case DDF_FLOAT:
+                return ((Double) value).longValue();
+            case DDF_STRING:
+                try {
+                    return Long.valueOf((String) value);
+                } catch (final NumberFormatException e) {
+                    // Swallow.
+                    return null;
+                }
+            case DDF_STRUCT:
+                return (long) ((Map<?,?>) value).size();
+            case DDF_LIST:
+                return (long) ((List<?>) value).size();
+            default:
+                break;
+        }
+        
+        return null;
+    }
+    
+    /**
+     * Get the floating point value of this node.
+     * 
+     * <p>Doubles are coerced from other types based on numeric conversions
+     * or the count of a structure or list.</p> 
+     *
+     * @return the floating point value or null
+     */
+    @Nullable public Double floating() {
+        
+        switch(type) {
+            case DDF_INT:
+                return ((Integer) value).doubleValue();
+            case DDF_LONG:
+                return ((Long) value).doubleValue();
+            case DDF_FLOAT:
+                return (Double) value;
+            case DDF_STRING:
+                try {
+                    return Double.valueOf((String) value);
+                } catch (final NumberFormatException e) {
+                    // Swallow.
+                    return null;
+                }
+            case DDF_STRUCT:
+                return (double) ((Map<?,?>) value).size();
+            case DDF_LIST:
+                return (double) ((List<?>) value).size();
+            default:
+                break;
+        }
+        
+        return null;
+    }
+
+    /**
+     * Get the pointer/reference value of this node, which is just an {@link Object}.
+     * 
+     * @return pointer/reference value or null
+     */
+    @Nullable public Object pointer() {
+        return ispointer() ? value : null;
+    }
+
+
+    /**
+     * Converts this node to an empty type/value.
+     * 
+     * <p>All children should be considered disposed of, though in Java this is
+     * circumventable by means of maintaining references to them.</p>
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF empty() {
+        type = DDFType.DDF_EMPTY;
+        value = null;
+        return this;
+    }
+
+    /**
+     * Converts this node to a string type/value.
+     * 
+     * @param val the value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF string(@Nullable final String val) {
+        empty();
+        value = val;
+        type = DDFType.DDF_STRING;
+        return this;
+    }
+
+    /**
+     * Converts this node to an unsafe string type/value.
+     * 
+     * @param val the value to inject
+     * 
+     * @return this object
+     */
+// Checkstyle: MethodName OFF
+    @Nonnull public DDF unsafe_string(@Nullable final byte[] val) {
+        empty();
+        value = val;
+        type = DDFType.DDF_STRING_UNSAFE;
+        return this;
+    }
+// Checkstyle: MethodName ON
+
+    /**
+     * Converts this node to a string type/value based on the converted form of the input.
+     * 
+     * @param val input value
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF string(final int val) {
+        return string(Integer.toString(val));
+    }
+
+    /**
+     * Converts this node to a string type/value based on the converted form of the input.
+     * 
+     * @param val input value
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF string(final long val) {
+        return string(Long.toString(val));
+    }
+    
+    /**
+     * Converts this node to a string type/value based on the converted form of the input.
+     * 
+     * @param val input value
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF string(final double val) {
+        return string(Double.toString(val));
+    }
+
+    /**
+     * Converts this node to an integer type/value.
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF integer(final int val) {
+        empty();
+        value = Integer.valueOf(val);
+        type = DDFType.DDF_INT;
+        return this;
+    }
+
+    /**
+     * Converts this node to an integer type/value based on the converted form of the input.
+     * 
+     * <p>A conversion error will assign zero as the value.</p>
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF integer(@Nonnull @NotEmpty final String val) {
+        empty();
+        try {
+            return integer(Integer.valueOf(val));
+        } catch (final NumberFormatException e) {
+            return integer(0);
+        }
+    }
+
+    /**
+     * Converts this node to a long integer type/value.
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF longinteger(final long val) {
+        empty();
+        value = Long.valueOf(val);
+        type = DDFType.DDF_LONG;
+        return this;
+    }
+
+    /**
+     * Converts this node to a long integer type/value based on the converted form of the input.
+     * 
+     * <p>A conversion error will assign zero as the value.</p>
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF longinteger(@Nonnull @NotEmpty final String val) {
+        empty();
+        try {
+            return longinteger(Long.valueOf(val));
+        } catch (final NumberFormatException e) {
+            return longinteger(0);
+        }
+    }
+
+    /**
+     * Converts this node to an floating point type/value.
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF floating(final double val) {
+        empty();
+        value = Double.valueOf(val);
+        type = DDFType.DDF_FLOAT;
+        return this;
+    }
+
+    /**
+     * Converts this node to a floating point type/value based on the converted form of the input.
+     * 
+     * <p>A conversion error will assign zero as the value.</p>
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF floating(@Nonnull @NotEmpty final String val) {
+        empty();
+        try {
+            return floating(Double.valueOf(val));
+        } catch (final NumberFormatException e) {
+            return floating(0.0);
+        }
+    }
+
+    /**
+     * Converts this node to a structure.
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF structure() {
+        empty();
+        value = new LinkedHashMap<String,DDF>();
+        type = DDFType.DDF_STRUCT;
+        return this;
+    }
+
+    /**
+     * Converts this node to a list/array.
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF list() {
+        empty();
+        value = new ArrayList<DDF>();
+        type = DDFType.DDF_LIST;
+        return this;
+    }
+
+    /**
+     * Converts this node to a pointer/reference type.
+     * 
+     * @param val value to inject
+     * 
+     * @return this object
+     */
+    @Nonnull public DDF pointer(@Nonnull final Object val) {
+        empty();
+        value = val;
+        type = DDFType.DDF_POINTER;
+        return this;
+    }
+
+    /**
+     * Adds a node to the end of a struct or list and returns it.
+     * 
+     * <p>If this node is not a struct or list or the child is a null node, then it is returned
+     * with no further action.</p>
+     * 
+     * <p>If this node is a struct with an existing member by the same name, the input
+     * replaces that member.</p>
+     * 
+     * @param child the child to add
+     * 
+     * @return the child
+     */
+    @SuppressWarnings("unchecked")
+    @Nonnull public DDF add(@Nonnull final DDF child) {
+        if ((!isstruct() && !islist()) || child.isnull() || this == child.parent) {
+            return child;
+        }
+
+        if (isstruct()) {
+            if (child.name == null) {
+                return child;
+            }
+            getmember(child.name).destroy();
+            child.remove();
+            ((Map<String,DDF>) value).put(child.name, child);
+        } else {
+            child.remove();
+            ((List<DDF>) value).add(child);
+        }
+
+        child.parent = this;
+        return child;
+    }
+
+    /**
+     * Adds a node to a list prior to a specified node.
+     * 
+     * <p>If this node is not a list, does not contain the second parameter, or
+     * either parameter is a null node, then the first parameter is returned with
+     * no further action.</p> 
+     * 
+     * @param child the child to add
+     * @param before the node to insert the child before
+     * 
+     * @return the child
+     */
+    @Nonnull public DDF addbefore(@Nonnull final DDF child, @Nonnull final DDF before) {
+        if (!islist() || child.isnull() || before.parent != this) {
+            return child;
+        }
+
+        child.remove();
+        @SuppressWarnings("unchecked")
+        final List<DDF> list = (List<DDF>) value;
+        list.add(list.indexOf(before), child);
+        child.parent = this;
+        return child;
+    }
+
+    /**
+     * Adds a node to a list after a specified node.
+     * 
+     * <p>If this node is not a list, does not contain the second parameter, or
+     * either parameter is a null node, then the first parameter is returned with
+     * no further action.</p> 
+     * 
+     * @param child the child to add
+     * @param after the node to insert the child after
+     * 
+     * @return the child
+     */
+    @Nonnull public DDF addafter(@Nonnull final DDF child, @Nonnull final DDF after) {
+        if (!islist() || child.isnull() || after.parent != this) {
+            return child;
+        }
+
+        child.remove();
+        
+        @SuppressWarnings("unchecked")
+        final List<DDF> list = (List<DDF>) value;
+        
+        final int i = list.indexOf(after);
+        if (i == list.size() - 1) {
+            list.add(child);
+        } else {
+            list.add(i + 1, child);
+        }
+        child.parent = this;
+        
+        return child;
+    }
+    
+    /**
+     * Isolate this object from its surrounding nodes and return it.
+     * 
+     * @return this object
+     */
+    @SuppressWarnings("unchecked")
+    @Nonnull public DDF remove() {
+        if (parent != null) {
+            if (parent.isstruct()) {
+                ((Map<String,DDF>) parent.value).remove(name);
+            } else {
+                ((List<DDF>) parent.value).remove(this);
+            }
+            
+            parent = null;
+        }
+        return this;
+    }
+    
+    /**
+     * Get the parent node.
+     * 
+     * @return parent node
+     */
+    @Nullable public DDF parent() {
+        return parent;
+    }
+    
+    /**
+     * Expose an immutable map representing a structure node.
+     * 
+     * @return immutable map, or null if the node is not a structure
+     */
+    @SuppressWarnings("unchecked")
+    @Nonnull @NonnullElements @Unmodifiable @NotLive public Map<String,DDF> asMap() {
+        if (isstruct()) {
+            return Map.copyOf((Map<String,DDF>) value);
+        }
+        
+        return Collections.emptyMap();
+    }
+
+    /**
+     * Expose an immutable list representing a structure or list node.
+     * 
+     * @return immutable list, or null if the node is not a structure or list
+     */
+    @SuppressWarnings("unchecked")
+    @Nonnull @NonnullElements @Unmodifiable @NotLive public List<DDF> asList() {
+        if (isstruct()) {
+            return List.copyOf(((Map<String,DDF>) value).values());
+        } else if (islist()) {
+            return List.copyOf((List<DDF>) value);
+        }
+        
+        return Collections.emptyList();
+    }
+
+    /**
+     * Adds a new empty node to a structure, possibly creating nested structures based
+     * on dotted path notation (existing nodes matching the path segments are not altered
+     * other than to convert them to structures).
+     * 
+     * <p>The input path MUST contain at least one non-empty path segment.</p>
+     * 
+     * <p>This node will be converted to a structure if not already one.</p>
+     * 
+     * @param path dotted path to use
+     * 
+     * @return the last node added to the nested tree, or a null node if unable to do so
+     */
+    @Nonnull public DDF addmember(@Nonnull @NotEmpty final String path) {
+        final String[] tokens = Constraint.isNotEmpty(path, "Path cannot be null").split("\\.");
+        Constraint.isNotEmpty(tokens, "Path did not produce an array of path segments");
+        
+        if (!isnull()) {
+            DDF base = this;
+            for (final String segment : tokens) {
+                if (!base.isstruct()) {
+                    base.structure();
+                }
+                
+                DDF node = base.getmember(segment);
+                if (node.isnull()) {
+                    node = base.add(new DDF(segment));
+                }
+                
+                base = node;
+            }
+            
+            return base;
+        }
+        
+        return new DDF();
+    }
+
+    /**
+     * Access a (possibly nested) structure member via dotted path notation, also allowing access to
+     * list elements via "[n]" array notation.
+     * 
+     * <p>Failure to navigate the tree at any point will cause a null node to be returned.</p>
+     * 
+     * @param path dotted path to use
+     * 
+     * @return the matching node, or a null node
+     */
+// Checkstyle: CyclomaticComplexity OFF
+    @SuppressWarnings("unchecked")
+    @Nonnull public DDF getmember(@Nonnull @NotEmpty final String path) {
+        final String[] tokens = path.split("\\.");
+        if (tokens == null || tokens.length == 0 || isnull()) {
+            return new DDF();
+        }
+
+        DDF current = this;
+
+        for (int i = 0; i < tokens.length;) {
+            if (tokens[i].startsWith("[") && tokens[i].endsWith("]")) {
+                // Attempt to access a list entry via [n] notation and advance the path.
+                int index;
+                try {
+                    index = Integer.valueOf(tokens[i].substring(1, tokens[i].length() - 1));
+                } catch(final NumberFormatException e) {
+                    index = 0;
+                }
+                if (current.islist() && index < ((List<DDF>) current.value).size()) {
+                    current = ((List<DDF>) current.value).get(index);
+                } else {
+                    return new DDF();
+                }
+                i++;
+            } else if (current.isstruct()) {
+                // Access the named element and advance the path.
+                current = ((Map<String,DDF>) current.value).get(tokens[i]);
+                if (current == null) {
+                    return new DDF();
+                }
+                i++;
+            } else if (current.islist()) {
+                // Access first element of list, don't advance the path.
+                current = ((List<DDF>) current.value).get(0);
+                if (current == null) {
+                    return new DDF();
+                }
+            } else {
+                return new DDF();
+            }
+        }
+        
+        return current;
+    }
+
+    /** {@inheritDoc} */
+    @Nonnull public Iterator<DDF> iterator() {
+        final List<DDF> list = asList();
+        if (list != null) {
+            return list.iterator();
+        }
+        return Collections.emptyListIterator();
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        
+        if (obj == null) {
+            return false;
+        }
+        
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        
+        final DDF other = (DDF) obj;
+        if (name == null) {
+            if (other.name != null) {
+                return false;
+            }
+        } else if (!name.equals(other.name)) {
+            return false;
+        }
+        
+        if (type != other.type) {
+            return false;
+        }
+        
+        if (value == null) {
+            if (other.value != null) {
+                return false;
+            }
+        } else if (!value.equals(other.value)) {
+            return false;
+        }
+        
+        return true;
+    }
+// Checkstyle: CyclomaticComplexity ON
+        
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + ((name == null) ? 0 : name.hashCode());
+        result = prime * result + ((parent == null) ? 0 : parent.hashCode());
+        result = prime * result + ((type == null) ? 0 : type.hashCode());
+        result = prime * result + ((value == null) ? 0 : value.hashCode());
+        return result;
+    }
+
+    /** 
+     * {@inheritDoc}
+     * 
+     * <p>The string output is for debugging purposes and should not be used when serializing.</p>
+     */
+    @Override
+    @Nonnull public String toString() {
+        return dump(new StringBuilder(), 0).toString();
+    }
+
+// Checkstyle: MethodLength|CyclomaticComplexity OFF
+    /**
+     * Helper method to dump to a string for debugging.
+     * 
+     * @param builder string builder to use
+     * @param indent size of indent
+     * 
+     * @return the first parameter
+     */
+    @Nonnull private StringBuilder dump(@Nonnull final StringBuilder builder, final long indent) {
+        
+        for (long i = 0; i < indent; ++i) {
+            builder.append(' ');
+        }
+        
+        switch (type) {
+
+            case DDF_NULL:
+                builder.append("null");
+                break;
+                
+            case DDF_EMPTY:
+                builder.append("empty");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                break;
+
+            case DDF_STRING:
+                builder.append("String");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = ");
+                if (value != null) {
+                    builder.append('"').append(((String) value).replace("\"", "\\\"")).append('"');
+                } else {
+                    builder.append("null");
+                }
+                break;
+                
+            case DDF_STRING_UNSAFE:
+                builder.append("byte[]");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = ");
+                if (value != null) {
+                    builder.append('{');
+                    for (final byte b : (byte[]) value) {
+                        builder.append(Integer.toHexString(b)).append(", ");
+                    }
+                    builder.append('}');
+                } else {
+                    builder.append("null");
+                }
+                break;
+
+            case DDF_INT:
+                builder.append("Integer");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = ").append(value);
+                break;
+
+            case DDF_LONG:
+                builder.append("Long");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = ").append(value);
+                break;
+
+            case DDF_FLOAT:
+                builder.append("Double");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = ").append(value);
+                break;
+
+            case DDF_STRUCT:
+                builder.append("struct");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = {");
+                if (!((Map<?,?>) value).isEmpty()) {
+                    builder.append('\n');
+                    for (final DDF child : this) {
+                        child.dump(builder, indent + 2);
+                    }
+                }
+                for (long i = 0; i < indent; ++i) {
+                    builder.append(' ');
+                }
+                builder.append('}');
+                break;
+
+            case DDF_LIST:
+                builder.append("DDF[").append(((List<?>) value).size()).append(']');
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = {");
+
+                if (!((List<?>) value).isEmpty()) {
+                    builder.append('\n');
+                    for (final DDF child : this) {
+                        child.dump(builder, indent + 2);
+                    }
+                }
+                for (long i = 0; i < indent; ++i) {
+                    builder.append(' ');
+                }
+                builder.append('}');
+                break;
+
+            case DDF_POINTER:
+                builder.append("Object");
+                if (name != null) {
+                    builder.append(' ').append(name);
+                }
+                builder.append(" = ");
+                if (value != null) {
+                    builder.append(value);
+                } else {
+                    builder.append("null");
+                }
+                break;
+
+            default:
+                builder.append("UNKNOWN -- WARNING: ILLEGAL VALUE");
+        }
+        builder.append(";\n");
+        
+        return builder;
+    }
+    
+    /**
+     * Serialize this object to a provided stream.
+     * 
+     * @param os output stream
+     *
+     * @return the output stream
+     * 
+     * @throws IOException if an error occurs
+     */
+    @Nonnull public OutputStream serialize(@Nonnull final OutputStream os) throws IOException {
+        if (!isnull()) {
+            if (name != null) {
+                encode(os, name.getBytes("UTF8"));
+            } else {
+                os.write('.');
+            }
+            os.write(' ');
+
+            switch (type) {
+                case DDF_EMPTY:
+                case DDF_POINTER:
+                    os.write(Integer.toString(DDFType.DDF_EMPTY.getValue()).getBytes("UTF8"));
+                    os.write('\n');
+                    break;
+
+                case DDF_STRING:
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    if (value != null) {
+                        os.write(' ');
+                        encode(os, ((String) value).getBytes("UTF-8"));
+                    }
+                    os.write('\n');
+                    break;
+
+                case DDF_STRING_UNSAFE:
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    if (value != null) {
+                        os.write(' ');
+                        encode(os, (byte[]) value);
+                    }
+                    os.write('\n');
+                    break;
+
+                case DDF_INT:
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    os.write(' ');
+                    os.write(Integer.toString((Integer) value).getBytes("UTF8"));
+                    os.write('\n');
+                    break;
+
+                case DDF_LONG:
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    os.write(' ');
+                    os.write(Long.toString((Long) value).getBytes("UTF8"));
+                    os.write('\n');
+                    break;
+
+                case DDF_FLOAT:
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    os.write(' ');
+                    os.write(Double.toString((Double) value).getBytes("UTF8"));
+                    os.write('\n');
+                    break;
+
+                case DDF_STRUCT:
+                    @SuppressWarnings("unchecked")
+                    final Collection<DDF> members = ((Map<String,DDF>) value).values();
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    os.write(' ');
+                    os.write(Integer.toString(members.size()).getBytes("UTF8"));
+                    os.write('\n');
+                    for (final DDF child : members) {
+                        child.serialize(os);
+                    }
+                    break;
+
+                case DDF_LIST:
+                    @SuppressWarnings("unchecked")
+                    final Collection<DDF> children = (List<DDF>) value;
+                    os.write(Integer.toString(type.getValue()).getBytes("UTF8"));
+                    os.write(' ');
+                    os.write(Integer.toString(children.size()).getBytes("UTF8"));
+                    os.write('\n');
+                    for (final DDF child : children) {
+                        child.serialize(os);
+                    }
+                    break;
+
+                default:
+                    break;
+            }
+        }
+        
+        return os;
+    }
+    
+    /**
+     * Parses a seralized DDF from an input stream.
+     * 
+     * @param is input stream
+     * 
+     * @return the parsed object
+     * 
+     * @throws IOException if an error occurs
+     */
+// Checkstyle: ReturnCount OFF
+    @Nonnull public static DDF deserialize(@Nonnull final InputStream is) throws IOException {
+        
+        int ch;
+        final StringBuilder nameBuilder = new StringBuilder();
+
+        // First field is the name.
+        while ((ch = is.read()) != -1 && !Character.isWhitespace(ch)) {
+            if (ch >= 0 && ch <= 127) {
+                // The int is a code point from 0..255, but our grammar constrains this to 0..127 so
+                // this is a safe append, to promote the ASCII into Unicode.
+                nameBuilder.appendCodePoint(ch);
+            } else {
+                throw new IOException("Invalid code point outside US-ASCII range");
+            }
+        }
+        
+        if (ch != 0x20) {
+            // Name has to be followed by a space.
+            // This will also cover an early line or stream termination.
+            throw new IOException("Name not followed by space character");
+        }
+        
+        final String name = nameBuilder.toString();
+        if (name.isEmpty()) {
+            // No name field.
+            throw new IOException("Name field missing");
+        }
+        
+        final DDF obj = new DDF(null);
+        if (!".".equals(name)) {
+            // The name is stipulated to be UTF-8 safe so any high order ASCII characters are
+            // assumed to be part of a multi-byte sequence.
+            try {
+                obj.name(URLDecoder.decode(name, "UTF-8"));
+            } catch (final IllegalArgumentException e) {
+                throw new IOException(e);
+            }
+        }
+        
+        // Next field is the numeric type designation.
+        final StringBuilder typeBuilder = new StringBuilder();
+        while ((ch = is.read()) != -1 && Character.isDigit(ch)) {
+            // This is safe because the byte contract of the stream disallows
+            // any non-ASCII digit from satisfying the isDigit check.
+            typeBuilder.appendCodePoint(ch);
+        }
+        
+        // Before continuing, we convert the string into a DDF type.
+        final DDFType type;
+        try {
+            type = DDFType.valueOf(Integer.valueOf(typeBuilder.toString()));
+        } catch (final IllegalArgumentException e) {
+            throw new IOException("Invalid DDF type");
+        }
+
+        // Process typical value types.
+        final StringBuilder valueBuilder = new StringBuilder();
+        switch (type) {
+            case DDF_EMPTY:
+            case DDF_POINTER:
+                if (ch != 0x0A) {
+                    throw new IOException("Empty/pointer record not terminated by linefeed");
+                }
+                // Nothing else to do, it's already empty.
+                return obj;
+            
+            case DDF_STRING:
+            case DDF_STRING_UNSAFE:
+                if (ch == 0x0A) {
+                    if (type == DDFType.DDF_STRING) {
+                        return obj.string(null);
+                    }
+                    return obj.unsafe_string(null);
+                } else if (ch != 0x20) {
+                    throw new IOException("Type field not followed by space character");
+                }
+                
+                while ((ch = is.read()) != -1 && !Character.isWhitespace(ch)) {
+                    if (ch >= 0 && ch <= 127) {
+                        // The int is a code point from 0..255, but our grammar constrains this to 0..127 so
+                        // this is a safe append, to promote the ASCII into Unicode.
+                        valueBuilder.appendCodePoint(ch);
+                    } else {
+                        throw new IOException("Invalid code point outside US-ASCII range");
+                    }
+                }
+                
+                if (ch != 0x0A) {
+                    throw new IOException("String value not followed by linefeed");
+                }
+                
+                try {
+                    if (type == DDFType.DDF_STRING) {
+                        // String values are handled as UTF-8.
+                        return obj.string(URLDecoder.decode(valueBuilder.toString(), "UTF-8"));
+                    }
+                    
+                    // Unsafe string values are processed as ISO-8859-1.
+                    // They may be anything, but it will guarantee a single byte encoding.
+                    return obj.unsafe_string(
+                            URLDecoder.decode(valueBuilder.toString(), "ISO-8859-1").getBytes("ISO-8859-1"));
+                    
+                } catch (final IllegalArgumentException e) {
+                    throw new IOException(e);
+                }
+
+            case DDF_INT:
+            case DDF_LONG:
+            case DDF_FLOAT:
+                if (ch != 0x20) {
+                    throw new IOException("Type field not followed by space character");
+                }
+                
+                while ((ch = is.read()) != -1 && !Character.isWhitespace(ch)) {
+                    if (ch >= 0 && ch <= 127) {
+                        // The int is a code point from 0..255, but our grammar constrains this to 0..127 so
+                        // this is a safe append, to promote the ASCII into Unicode.
+                        valueBuilder.appendCodePoint(ch);
+                    } else {
+                        throw new IOException("Invalid code point outside US-ASCII range");
+                    }
+                }
+                
+                if (ch != 0x0A) {
+                    throw new IOException("Numeric value not followed by linefeed");
+                } else if (valueBuilder.length() == 0) {
+                    throw new IOException("Numeric value missing");
+                }
+                
+                if (type == DDFType.DDF_INT) {
+                    return obj.integer(valueBuilder.toString());
+                } else if (type == DDFType.DDF_LONG) {
+                    return obj.longinteger(valueBuilder.toString());
+                }
+                return obj.floating(valueBuilder.toString());
+                
+            case DDF_STRUCT:
+            case DDF_LIST:
+                if (ch != 0x20) {
+                    throw new IOException("Type field not followed by space character");
+                }
+
+                while ((ch = is.read()) != -1 && Character.isDigit(ch)) {
+                    // This is safe because the byte contract of the stream disallows
+                    // any non-ASCII digit from satisfying the isDigit check.
+                    valueBuilder.appendCodePoint(ch);
+                }
+                
+                if (ch != 0x0A) {
+                    throw new IOException("Record count not followed by linefeed");
+                } else if (valueBuilder.length() == 0) {
+                    throw new IOException("Record count missing");
+                }
+                
+                int count;
+                try {
+                    count = Integer.valueOf(valueBuilder.toString());
+                } catch (final NumberFormatException e) {
+                    throw new IOException("Invalid record count");
+                }
+                
+                if (type == DDFType.DDF_STRUCT) {
+                    obj.structure();
+                } else {
+                    obj.list();
+                }
+                
+                for (; count > 0; --count) {
+                    obj.add(deserialize(is));
+                }
+                return obj;
+
+            default:
+                throw new IOException("Unexpected record type");
+        }
+    }
+// Checkstyle: MethodLength|CyclomaticComplexity|ReturnCount ON
+
+    /**
+     * A simple encoder for non-ASCII characters.
+     * 
+     * <p>Made this package-accessible for unit testing.</p>
+     * 
+     * @param os output stream
+     * @param bytes bytes to encode
+     * 
+     * @throws IOException if an error occurs
+     */
+    static void encode(@Nonnull final OutputStream os, @Nonnull final byte[] bytes) throws IOException {
+        for (final byte b : bytes) {
+            final int i = Byte.toUnsignedInt(b);
+            if (i < 0x30 || i > 0x7A) {
+                os.write('%');
+                os.write(hexchar(i >>> 4));
+                os.write(hexchar(i & 0x0F));
+            } else {
+                os.write(b);
+            }
+        }
+    }
+    
+    /**
+     * Converts a byte into a hex character.
+     * 
+     * @param b input byte
+     * 
+     * @return the hex character equivalent (capitalized)
+     */
+    private static int hexchar(final int b) {
+        // 48 is '0' and 65 is 'A'
+        return (b <= 9) ? (48 + b) : (65 + b - 10);
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDFSupport.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDFSupport.java
new file mode 100644
index 0000000..72ddffe
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDFSupport.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import javax.annotation.Nonnull;
+
+import org.w3c.dom.Attr;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+import net.shibboleth.utilities.java.support.xml.ElementSupport;
+
+/**
+ * Helper methods for {@link DDF} usage.
+ * 
+ * @since 9.0.0
+ */
+public final class DDFSupport {
+
+    /** Name of child element member created by {@link #fromElement(Element)} method. */
+    @Nonnull @NotEmpty public static final String CHILD_ELEMENTS_MEMBER = "_children";
+
+    /** Name of content member created by {@link #fromElement(Element)} method. */
+    @Nonnull @NotEmpty public static final String CONTENT_MEMBER = "_content";
+
+    /** Private constructor. */
+    private DDFSupport() {
+        
+    }
+
+    /**
+     * Converts a DOM tree rooted at the input element into a {@link DDF} mirroring the tree.
+     *
+     * <p>The name of the object is the local name of the element. 
+     * 
+     * <p>Attributes are converted into named structure members based on the local names.</p>
+     * 
+     * <p>Child elements are recursively processed into a list named {@link #CHILD_ELEMENTS_MEMBER}.</p>
+     * 
+     * <p>Text content is stored in a structure member named {@link #CONTENT_MEMBER}.</p>
+     * 
+     * <p>Namespaces are ignored.</p>
+     * 
+     * @param element input element
+     * 
+     * @return the converted object
+     */
+    @Nonnull public static DDF fromElement(@Nonnull final Element element) {
+        
+        Constraint.isNotNull(element, "Element cannot be null");
+        
+        // Named after element.
+        final DDF obj = new DDF(element.getLocalName());
+        
+        // Each attribute is added as a string member.
+        final NamedNodeMap attrs = element.getAttributes();
+        if (attrs != null) {
+            for (int i = 0; i < attrs.getLength(); ++i) {
+                final Attr attr = (Attr) attrs.item(i);
+                obj.addmember(attr.getLocalName()).string(attr.getValue());
+            }
+        }
+
+        DDF children = null;
+        
+        // Recursively convert each child to a child DDF and add it to a list. 
+        Element child = ElementSupport.getFirstChildElement(element);
+        while (child != null) {
+            if (children == null) {
+                children = obj.addmember(CHILD_ELEMENTS_MEMBER).list();
+            }
+            children.add(fromElement(child));
+            child = ElementSupport.getNextSiblingElement(child);
+        }
+        
+        final String content = ElementSupport.getElementContentAsString(element);
+        if (content != null && !content.isBlank()) {
+            obj.addmember(CONTENT_MEMBER).string(content);
+        }
+        
+        return obj;
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDFToByteArrayConverter.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDFToByteArrayConverter.java
new file mode 100644
index 0000000..c2adc0e
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/DDFToByteArrayConverter.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import org.springframework.core.convert.converter.Converter;
+
+/**
+ * Spring converter from {@link DDF} to byte array.
+ * 
+ * @since 7.0.0
+ */
+public class DDFToByteArrayConverter implements Converter<DDF, byte[]> {
+
+    /** {@inheritDoc} */
+    public byte[] convert(final DDF source) {
+        try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+            source.serialize(baos);
+            return baos.toByteArray();
+        } catch (final IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletRequest.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletRequest.java
new file mode 100644
index 0000000..bf0a04e
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletRequest.java
@@ -0,0 +1,688 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.security.Principal;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.NotThreadSafe;
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.DispatcherType;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.RequestDispatcher;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import jakarta.servlet.http.HttpUpgradeHandler;
+import jakarta.servlet.http.Part;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullElements;
+import net.shibboleth.utilities.java.support.collection.Pair;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+import net.shibboleth.utilities.java.support.net.URISupport;
+
+/**
+ * Wraps a {@link DDF} object reflecting a remoted message encapsulating an HTTP request.
+ * 
+ * <p>Potential TODOs are noted in various places. One outstanding issue is header case.
+ * The SP code never did case-folding of the header names, so we may need to adjust that
+ * on the C++ side to force-lower them in transit and then lower them on access.</p>
+ * 
+ */
+ at NotThreadSafe
+public class RemotedHttpServletRequest implements HttpServletRequest {
+
+    /** Empty byte array for empty bodies. */
+    @Nonnull private static final byte[] EMPTY_BODY = new byte[0];
+    
+    /** UTF-8 decoder. */
+    @Nonnull private static final CharsetDecoder UTF_8 =
+            Charset.forName("UTF-8").newDecoder()
+                .onMalformedInput(CodingErrorAction.REPORT)
+                .onUnmappableCharacter(CodingErrorAction.REPORT);
+
+    /** ISO single byte decoder. */
+    @Nonnull private static final CharsetDecoder ISO_8859_1 =
+            Charset.forName("ISO-8859-1").newDecoder()
+                .onMalformedInput(CodingErrorAction.REPORT)
+                .onUnmappableCharacter(CodingErrorAction.REPORT);
+
+    /** Underlying object containing remoted data. */
+    @Nonnull private final DDF obj;
+    
+    /** Cookie array. */
+    @Nullable @NonnullElements private List<Cookie> cookies;
+    
+    /** Parameter map. */
+    @Nullable private Map<String, String[]> parameters;
+    
+    /**
+     * Constructor.
+     *
+     * @param ddf remoted request information
+     */
+    public RemotedHttpServletRequest(@Nonnull final DDF ddf) {
+        obj = Constraint.isNotNull(ddf, "DDF cannot be null");
+    }
+    
+    /**
+     * Gets the underlying object containing the remoted data.
+     * 
+     * @return remoted data object
+     */
+    @Nonnull public DDF getDDF() {
+        return obj;
+    }
+    
+    /** {@inheritDoc} */
+    public Object getAttribute(final String name) {
+        // TODO: might use this to expose certain "standard" pieces of information
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public Enumeration<String> getAttributeNames() {
+        // TODO: adjust if we support the method above.
+        return Collections.emptyEnumeration();
+    }
+
+    /** {@inheritDoc} */
+    public String getCharacterEncoding() {
+        return "UTF-8";
+    }
+
+    /** {@inheritDoc} */
+    public void setCharacterEncoding(final String env) throws UnsupportedEncodingException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public int getContentLength() {
+        final Integer i = obj.getmember("content_length").integer();
+        return i != null ? i : -1;
+    }
+
+    /** {@inheritDoc} */
+    public long getContentLengthLong() {
+        return getContentLength();
+    }
+
+    /** {@inheritDoc} */
+    public String getContentType() {
+        return obj.getmember("content_type").string();
+    }
+
+    /** {@inheritDoc} */
+    public ServletInputStream getInputStream() throws IOException {
+        final String body = obj.getmember("body").string();
+        if (body != null) {
+            // The body is always assumed to be safely encoded for our use cases.
+            return new BodyInputStream(body.getBytes("UTF-8"));
+        }
+        return new BodyInputStream(EMPTY_BODY);
+    }
+
+    /** {@inheritDoc} */
+    public String getParameter(final String name) {
+        final String[] values = getParameterMap().get(name);
+        if (values != null) {
+            return values[0];
+        }
+        
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public Enumeration<String> getParameterNames() {
+        return Collections.enumeration(getParameterMap().keySet());
+    }
+
+    /** {@inheritDoc} */
+    public String[] getParameterValues(final String name) {
+        return getParameterMap().get(name);
+    }
+
+    /** {@inheritDoc} */
+    public Map<String, String[]> getParameterMap() {
+        if (parameters == null) {
+            parameters = new HashMap<>();
+            final Multimap<String,String> multimap = ArrayListMultimap.create();
+            final String qs = getQueryString();
+            if (qs != null) {
+                final List<Pair<String,String>> qparams = URISupport.parseQueryString(qs);
+                for (final Pair<String,String> p : qparams) {
+                    multimap.put(p.getFirst(), p.getSecond());
+                }
+            }
+            
+            if ("application/x-www-form-urlencoded".equals(getContentType())) {
+                final String body = obj.getmember("body").string();
+                if (body != null) {
+                    final List<Pair<String,String>> qparams = URISupport.parseQueryString(body);
+                    for (final Pair<String,String> p : qparams) {
+                        multimap.put(p.getFirst(), p.getSecond());
+                    }
+                }
+            }
+            
+            for (final Map.Entry<String,Collection<String>> entry : multimap.asMap().entrySet()) {
+                parameters.put(entry.getKey(), entry.getValue().toArray(new String[entry.getValue().size()]));
+            }
+        }
+        
+        return parameters;
+    }
+
+    /** {@inheritDoc} */
+    public String getProtocol() {
+        final String protocol = obj.getmember("protocol").string();
+        return protocol != null ? protocol : "HTTP/1.1";
+    }
+
+    /** {@inheritDoc} */
+    public String getScheme() {
+        return obj.getmember("scheme").string();
+    }
+
+    /** {@inheritDoc} */
+    public String getServerName() {
+        return decodeUnsafeString(obj.getmember("hostname").unsafe_string());
+    }
+
+    /** {@inheritDoc} */
+    public int getServerPort() {
+        final Integer i = obj.getmember("port").integer();
+        return i != null ? i : -1;
+    }
+
+    /** {@inheritDoc} */
+    public BufferedReader getReader() throws IOException {
+        return new BufferedReader(new StringReader(obj.getmember("body").string()));
+    }
+
+    /** {@inheritDoc} */
+    public String getRemoteAddr() {
+        return obj.getmember("client_addr").string();
+    }
+
+    /** {@inheritDoc} */
+    public String getRemoteHost() {
+        return obj.getmember("client_addr").string();
+    }
+
+    /** {@inheritDoc} */
+    public void setAttribute(final String name, final Object o) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void removeAttribute(final String name) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public Locale getLocale() {
+        throw new UnsupportedOperationException();        
+    }
+
+    /** {@inheritDoc} */
+    public Enumeration<Locale> getLocales() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public boolean isSecure() {
+        return "https".equals(getScheme());
+    }
+
+    /** {@inheritDoc} */
+    public RequestDispatcher getRequestDispatcher(final String path) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public String getRealPath(final String path) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public int getRemotePort() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public String getLocalName() {
+        // TODO: If we need this, should be configurable via the c'tor.
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public String getLocalAddr() {
+        // TODO: If we need this, should be configurable via the c'tor.
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public int getLocalPort() {
+        return getServerPort();
+    }
+
+    /** {@inheritDoc} */
+    public ServletContext getServletContext() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public AsyncContext startAsync() throws IllegalStateException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public AsyncContext startAsync(final ServletRequest servletRequest, final ServletResponse servletResponse)
+            throws IllegalStateException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public boolean isAsyncStarted() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public boolean isAsyncSupported() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public AsyncContext getAsyncContext() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public DispatcherType getDispatcherType() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public String getAuthType() {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public Cookie[] getCookies() {
+        if (cookies == null) {
+            final String header = getHeader("Cookie");
+            if (header != null) {
+                final String[] carray = header.split(";");
+                if (carray != null) {
+                    cookies = new ArrayList<>(carray.length);
+                    for (final String c : carray) {
+                        final String[] nvpair = c.split("=", -1);
+                        if (nvpair.length == 2) {
+                            final String name = nvpair[0].trim();
+                            // This is a fallback cookie used for Safari to work around SameSite bugs.
+                            if (name.endsWith("_fgwars")) {
+                                name.substring(0, name.length() - 7);
+                            }
+                            cookies.add(new Cookie(name, nvpair[1]));
+                        }
+                    }
+                } else {
+                    cookies = Collections.emptyList();
+                }
+            } else {
+                cookies = Collections.emptyList();
+            }
+        }
+        
+        if (cookies.isEmpty()) {
+            return null;
+        }
+        return cookies.toArray(new Cookie[cookies.size()]);
+    }
+
+    /** {@inheritDoc} */
+    public long getDateHeader(final String name) {
+        final DDF h = obj.getmember("headers").getmember(name);
+        if (h.isstring()) {
+            try {
+                // TODO: there are a ton of valid formats but I don't think we really will need this anyway.
+                final SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
+                formatter.setTimeZone(TimeZone.getTimeZone("GMT"));
+                final Date d = formatter.parse(h.string());
+                if (d != null) {
+                    return d.getTime();
+                }
+            } catch (final Exception e) {
+                // Ignore for now.
+            }
+        }
+        return -1;
+    }
+
+    /** {@inheritDoc} */
+    public String getHeader(final String name) {
+        return decodeUnsafeString(obj.getmember("headers").getmember(name).unsafe_string());
+    }
+
+    /** {@inheritDoc} */
+    public Enumeration<String> getHeaders(final String name) {
+        final String s = decodeUnsafeString(obj.getmember("headers").getmember(name).unsafe_string());
+        if (s != null) {
+            return Collections.enumeration(Collections.singletonList(s));
+        }
+        return Collections.emptyEnumeration();
+    }
+
+    /** {@inheritDoc} */
+    public Enumeration<String> getHeaderNames() {
+        return Collections.enumeration(obj.getmember("headers").asMap().keySet());
+    }
+
+    /** {@inheritDoc} */
+    public int getIntHeader(final String name) {
+        final DDF h = obj.getmember("headers").getmember(name);
+        if (h.isstring()) {
+            return Integer.parseInt(decodeUnsafeString(h.unsafe_string()));
+        }
+        return -1;
+    }
+
+    /** {@inheritDoc} */
+    public String getMethod() {
+        return obj.getmember("method").string();
+    }
+
+    /** {@inheritDoc} */
+    public String getPathInfo() {
+        // TODO: If we need this, should be configurable via the c'tor.
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public String getPathTranslated() {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public String getContextPath() {
+        // TODO: If we need this, should be configurable via the c'tor.
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public String getQueryString() {
+        return obj.getmember("query").string();
+    }
+
+    /** {@inheritDoc} */
+    public String getRemoteUser() {
+        return obj.getmember("remote_user").string();
+    }
+
+    /** {@inheritDoc} */
+    public boolean isUserInRole(final String role) {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public Principal getUserPrincipal() {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public String getRequestedSessionId() {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public String getRequestURI() {
+        return decodeUnsafeString(obj.getmember("uri").unsafe_string());
+    }
+
+    /** {@inheritDoc} */
+    public StringBuffer getRequestURL() {
+        final String url = decodeUnsafeString(obj.getmember("url").unsafe_string());
+        return new StringBuffer(url != null ? url : "");
+    }
+
+    /** {@inheritDoc} */
+    public String getServletPath() {
+        // TODO: If we need this, should be configurable via the c'tor.
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public HttpSession getSession(final boolean create) {
+        if (create) {
+            throw new UnsupportedOperationException();
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public HttpSession getSession() {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public String changeSessionId() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public boolean isRequestedSessionIdValid() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public boolean isRequestedSessionIdFromCookie() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public boolean isRequestedSessionIdFromURL() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public boolean isRequestedSessionIdFromUrl() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public boolean authenticate(final HttpServletResponse response) throws IOException, ServletException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void login(final String username, final String password) throws ServletException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void logout() throws ServletException {
+        
+    }
+
+    /** {@inheritDoc} */
+    public Collection<Part> getParts() throws IOException, ServletException {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public Part getPart(final String name) throws IOException, ServletException {
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public <T extends HttpUpgradeHandler> T upgrade(final Class<T> handlerClass) throws IOException, ServletException {
+        throw new UnsupportedOperationException();
+    }
+    
+    /**
+     * Helper method to decode a byte buffer into either UTF-8 or ISO-8859-1.
+     * 
+     * @param buffer input buffer
+     * 
+     * @return encoded String form of the data
+     */
+    @Nullable private static String decodeUnsafeString(final byte[] buffer) {
+        
+        if (buffer == null) {
+            return null;
+        }
+        
+        final ByteBuffer wrapper = ByteBuffer.wrap(buffer);
+        
+        try {
+            return UTF_8.decode(wrapper).toString();
+        } catch (final CharacterCodingException e) {
+            
+        }
+        
+        try {
+            return ISO_8859_1.decode(wrapper).toString();
+        } catch (final CharacterCodingException e) {
+            
+        }
+        
+        return null;
+    }
+
+    /** Helper class cribbed from Spring. */
+    private static class BodyInputStream extends ServletInputStream {
+
+        /** Underlying stream. */
+        @Nonnull private final InputStream delegate;
+
+        /**
+         * Constructor.
+         *
+         * @param body body data
+         */
+        public BodyInputStream(final byte[] body) {
+            delegate = new ByteArrayInputStream(body);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isFinished() {
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isReady() {
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void setReadListener(final ReadListener readListener) {
+            throw new UnsupportedOperationException();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int read() throws IOException {
+            return delegate.read();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int read(final byte[] b, final int off, final int len) throws IOException {
+            return delegate.read(b, off, len);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int read(final byte[] b) throws IOException {
+            return delegate.read(b);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public long skip(final long n) throws IOException {
+            return delegate.skip(n);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int available() throws IOException {
+            return delegate.available();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void close() throws IOException {
+            delegate.close();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public synchronized void mark(final int readlimit) {
+            delegate.mark(readlimit);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public synchronized void reset() throws IOException {
+            delegate.reset();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean markSupported() {
+            return delegate.markSupported();
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletResponse.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletResponse.java
new file mode 100644
index 0000000..b9ff05b
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletResponse.java
@@ -0,0 +1,504 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.Charset;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.TimeZone;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.NotThreadSafe;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletResponse;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullElements;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+
+/**
+ * Uses a {@link DDF} object to reflect an HTTP response back to a remote caller.
+ */
+ at NotThreadSafe
+public class RemotedHttpServletResponse implements HttpServletResponse {
+
+    /** Underlying object for remoted data. */
+    @Nonnull private final DDF obj;
+    
+    /** Size of each character buffer for output. */
+    private int bufferSize;
+    
+    /** Tracks committing of response. */
+    private boolean committed;
+    
+    /** A materialized output stream. */
+    @Nullable private BodyOutputStream outputStream;
+
+    /**
+     * Constructor.
+     *
+     * @param ddf object to capture response
+     */
+    public RemotedHttpServletResponse(@Nonnull final DDF ddf) {
+        obj = Constraint.isNotNull(ddf, "DDF cannot be null");
+        obj.structure();
+        bufferSize = 1024;
+    }
+    
+    /**
+     * Gets the underlying object containing the response data.
+     * 
+     * @return remoted data object
+     */
+    @Nonnull public DDF getDDF() {
+        return obj;
+    }
+    
+    /** {@inheritDoc} */
+    public String getCharacterEncoding() {
+        return "UTF-8";
+    }
+
+    /** {@inheritDoc} */
+    public String getContentType() {
+        return getHeader("Content-Type");
+    }
+
+    /** {@inheritDoc} */
+    public ServletOutputStream getOutputStream() throws IOException {
+        if (committed) {
+            throw new IllegalStateException("Response already committed");
+        }
+
+        if (outputStream == null) {
+            outputStream = new BodyOutputStream();
+        }
+        return outputStream;
+    }
+
+    /** {@inheritDoc} */
+    public PrintWriter getWriter() throws IOException {
+        return new PrintWriter(getOutputStream(), false, Charset.forName(getCharacterEncoding()));
+    }
+
+    /** {@inheritDoc} */
+    public void setCharacterEncoding(final String charset) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void setContentLength(final int len) {
+        setIntHeader("Content-Length", len);
+    }
+
+    /** {@inheritDoc} */
+    public void setContentLengthLong(final long len) {
+        setHeader("Content-Length", Long.toString(len));
+    }
+
+    /** {@inheritDoc} */
+    public void setContentType(final String type) {
+        setHeader("Content-Type", type);
+    }
+
+    /** {@inheritDoc} */
+    public void setBufferSize(final int size) {
+        bufferSize = size;
+    }
+
+    /** {@inheritDoc} */
+    public int getBufferSize() {
+        return bufferSize;
+    }
+
+    /** {@inheritDoc} */
+    public void flushBuffer() throws IOException {
+        // Just mark as committed to signal that status and headers are frozen.
+        committed = true;
+    }
+
+    /** {@inheritDoc} */
+    public void resetBuffer() {
+        if (committed) {
+            throw new IllegalStateException("Response already committed");
+        }
+        
+        if (outputStream != null) {
+            outputStream.reset();
+        }
+    }
+
+    /** {@inheritDoc} */
+    public boolean isCommitted() {
+        return committed;
+    }
+
+    /** {@inheritDoc} */
+    public void reset() {
+        if (committed) {
+            throw new IllegalStateException("Response already committed");
+        }
+        outputStream = null;
+        obj.structure();
+    }
+
+    /** {@inheritDoc} */
+    public void setLocale(final Locale loc) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public Locale getLocale() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void addCookie(final Cookie cookie) {
+        // The C++ side already manages SameSite independently, so we'll likely continue that.
+        final StringBuffer buffer = new StringBuffer(cookie.getName()).append('=');
+        if (cookie.getValue() != null) {
+            buffer.append(cookie.getValue());
+        }
+        if (cookie.getMaxAge() >= 0) {
+            buffer.append("; MaxAge=").append(cookie.getMaxAge());
+        }
+        if (cookie.getPath() != null) {
+            buffer.append("; ").append("Path=").append(cookie.getPath());
+        }
+        if (cookie.getDomain() != null) {
+            buffer.append("; ").append("Domain=").append(cookie.getDomain());
+        }
+        if (cookie.getSecure()) {
+            buffer.append("; Secure");
+        }
+        if (cookie.isHttpOnly()) {
+            buffer.append("; HttpOnly");
+        }
+        addHeader("Set-Cookie", buffer.toString());
+    }
+
+    /** {@inheritDoc} */
+    public boolean containsHeader(final String name) {
+        for (final DDF header : obj.getmember("headers").asList()) {
+            if (name.equals(header.name())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    public String encodeURL(final String url) {
+        return url;
+    }
+
+    /** {@inheritDoc} */
+    public String encodeRedirectURL(final String url) {
+        return url;
+    }
+
+    /** {@inheritDoc} */
+    public String encodeUrl(final String url) {
+        return url;
+    }
+
+    /** {@inheritDoc} */
+    public String encodeRedirectUrl(final String url) {
+        return url;
+    }
+
+    /** {@inheritDoc} */
+    public void sendError(final int sc, final String msg) throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void sendError(final int sc) throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    public void sendRedirect(final String location) throws IOException {
+        if (committed) {
+            throw new IllegalStateException("Response already committed");
+        }
+
+        obj.getmember("response").remove();
+        obj.addmember("redirect").string(location);
+        committed = true;
+        outputStream = null;
+    }
+
+    /** {@inheritDoc} */
+    public void setDateHeader(final String name, final long date) {
+        unsetHeader(name);
+        addDateHeader(name, date);
+    }
+
+    /** {@inheritDoc} */
+    public void addDateHeader(final String name, final long date) {
+        final SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
+        formatter.setTimeZone(TimeZone.getTimeZone("GMT"));
+        addHeader(name, formatter.format(Date.from(Instant.ofEpochMilli(date))));
+    }
+
+    /** {@inheritDoc} */
+    public void setHeader(final String name, final String value) {
+        unsetHeader(name);
+        addHeader(name, value);
+    }
+
+    /** {@inheritDoc} */
+    public void addHeader(final String name, final String value) {
+        getHeaderList().add(new DDF(name).string(value));
+    }
+
+    /** {@inheritDoc} */
+    public void setIntHeader(final String name, final int value) {
+        unsetHeader(name);
+        addIntHeader(name, value);
+    }
+
+    /** {@inheritDoc} */
+    public void addIntHeader(final String name, final int value) {
+        getHeaderList().add(new DDF(name).integer(value));
+    }
+
+    /** {@inheritDoc} */
+    public void setStatus(final int sc) {
+        obj.addmember("response.status").integer(sc);
+    }
+
+    /** {@inheritDoc} */
+    public void setStatus(final int sc, final String sm) {
+        setStatus(sc);
+        obj.addmember("response.status_message").string(sm);
+    }
+
+    /** {@inheritDoc} */
+    public int getStatus() {
+        final Integer i = obj.getmember("response.status").integer();
+        return i != null ? i : -1;
+    }
+
+    /** {@inheritDoc} */
+    public String getHeader(final String name) {
+        final Optional<DDF> header =
+                obj.getmember("headers").asList()
+                    .stream()
+                    .filter(ddf -> name.equalsIgnoreCase(ddf.name()))
+                    .findFirst();
+        if (header.isPresent()) {
+            if (header.orElseThrow().isstring()) {
+                return header.orElseThrow().string();
+            }
+            return header.orElseThrow().integer().toString();
+        }
+        
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    public Collection<String> getHeaders(final String name) {
+        return obj.getmember("headers").asList()
+            .stream()
+            .filter(ddf -> name.equalsIgnoreCase(ddf.name()))
+            .map(DDF::string)
+            .collect(Collectors.toUnmodifiableList());
+    }
+
+    /** {@inheritDoc} */
+    public Collection<String> getHeaderNames() {
+        return obj.getmember("headers").asList()
+                .stream()
+                .map(DDF::name)
+                .collect(Collectors.toUnmodifiableSet());
+    }
+    
+    /**
+     * Removes any existing header(s) of this type.
+     * 
+     * @param name name of header to remove
+     */
+    private void unsetHeader(final @Nonnull @NotEmpty String name) {
+        
+        if (committed) {
+            throw new IllegalStateException("Response already committed");
+        }
+        
+        // This is safe because the asList copy is divorced from the original list
+        // but the DDF child objects are the same.
+        obj.getmember("headers").asList()
+            .stream()
+            .filter(ddf -> name.equalsIgnoreCase(ddf.name()))
+            .forEach(DDF::remove);
+    }
+    
+    /**
+     * Get the list node to which headers should be added.
+     * 
+     * @return a pre-existing list or a new one for mutation.
+     */
+    @Nonnull private DDF getHeaderList() {
+        
+        if (committed) {
+            throw new IllegalStateException("Response already committed");
+        }
+        
+        final DDF headers = obj.getmember("headers");
+        if (headers.islist()) {
+            return headers;
+        }
+        return obj.addmember("headers").list();
+    }
+
+    /** Wrapper allowing use of containers of arrays. */
+    private static final class ByteArrayWrapper {
+        
+        /** Current write position. */
+        private int offset;
+        
+        /** Wrapped array. */
+        private final byte[] buffer;
+        
+        /**
+         * Constructor.
+         *
+         * @param size buffer size
+         */
+        private ByteArrayWrapper(final int size) {
+            buffer = new byte[size];
+            offset = 0;
+        }
+        
+        /**
+         * Gets the buffer.
+         * 
+         * @return the buffer
+         */
+        @Nonnull private byte[] getBuffer() {
+            return buffer;
+        }
+        
+        /**
+         * Gets the offset.
+         * 
+         * @return the offset
+         */
+        private int getOffset() {
+            return offset;
+        }
+        
+        /**
+         * Writes a byte to the buffer.
+         * 
+         * @param b byte to write
+         * 
+         * @return true iff the buffer was large enough to accommodate the byte
+         */
+        private boolean write(final int b) {
+            if (offset < buffer.length) {
+                buffer[offset++] = Integer.valueOf(b).byteValue();
+                return true;
+            }
+            
+            return false;
+        }
+    }
+
+    /** A pseudo {@link ServletOutputStream} to catch output. */
+    private class BodyOutputStream extends ServletOutputStream {
+
+        
+        /** Internal buffers. */
+        @Nonnull @NonnullElements private final ArrayList<ByteArrayWrapper> bufferList;
+        
+        /** The currently filling buffer. */
+        @Nonnull private ByteArrayWrapper currentBuffer;
+        
+        /** Constructor. */
+        public BodyOutputStream() {
+            currentBuffer = new ByteArrayWrapper(bufferSize);
+            bufferList = new ArrayList<>(1);
+            bufferList.add(currentBuffer);
+        }
+        
+        /** {@inheritDoc} */
+        @Override
+        public boolean isReady() {
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void setWriteListener(final WriteListener writeListener) {
+            throw new UnsupportedOperationException();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void write(final int b) throws IOException {
+            if (!currentBuffer.write(b)) {
+                currentBuffer = new ByteArrayWrapper(bufferSize);
+                bufferList.add(currentBuffer);
+                Constraint.isTrue(currentBuffer.write(b), "Fresh buffer cannot fail to accept data");
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void flush() throws IOException {
+            
+            int offset = 0;
+            final byte[] copy = new byte[((bufferList.size() - 1) * bufferSize) +
+                                         bufferList.get(bufferList.size() - 1).getOffset()];
+
+            for (final ByteArrayWrapper b : bufferList) {
+                System.arraycopy(b.getBuffer(), 0, copy, offset, b.getOffset());
+                offset += b.getOffset();
+            }
+            obj.addmember("response.data").unsafe_string(copy);
+            committed = true;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void close() throws IOException {
+            flush();
+        }
+
+        /** Clear all data written. */
+        private void reset() {
+            currentBuffer = new ByteArrayWrapper(bufferSize);
+            bufferList.clear();
+            bufferList.add(currentBuffer);
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/package-info.java b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/package-info.java
new file mode 100644
index 0000000..2cf59d4
--- /dev/null
+++ b/sp-server-api/src/main/java/net/shibboleth/utilities/java/support/ddf/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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.
+ */
+
+/**
+ * Implementation of the Dynamic Dataflow abstraction used in the
+ * Service Provider for interprocess communication.
+ * 
+ * <p>DDFs are somewhat JSON-like, but with a (subjectively) friendlier
+ * API and allow for arbitrary serialization. They support dynamic RPC
+ * interfaces that don't require pre-definition of a data contract or
+ * compilation of client/server stubs.</p>
+ */
+
+package net.shibboleth.utilities.java.support.ddf;
\ No newline at end of file
diff --git a/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/DDFSupportTest.java b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/DDFSupportTest.java
new file mode 100644
index 0000000..382b09b
--- /dev/null
+++ b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/DDFSupportTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import java.io.IOException;
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Ignore;
+import org.testng.annotations.Test;
+import org.w3c.dom.Document;
+
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+import net.shibboleth.utilities.java.support.xml.BasicParserPool;
+import net.shibboleth.utilities.java.support.xml.XMLParserException;
+
+/**
+ * Unit test for {@link DDFSupport}.
+ */
+ at Ignore
+public class DDFSupportTest {
+    
+    @Nullable private BasicParserPool parserPool;
+    
+    /**
+     * Init parser.
+     * 
+     * @throws ComponentInitializationException 
+     */
+    @BeforeClass
+    public void setUp() throws ComponentInitializationException {
+        parserPool = new BasicParserPool();
+        parserPool.initialize();
+    }
+    
+   /**
+    * Teardown. 
+    */
+    @AfterClass
+   public void tearDown() {
+       parserPool.destroy();
+   }
+    
+    /**
+     * Test conversion.
+     * 
+     * @throws IOException 
+     * @throws XMLParserException 
+     */
+    @Test
+    public void test() throws XMLParserException, IOException {
+        final Document doc = parserPool.parse(getClass().getResourceAsStream("zork.xml"));
+        final DDF ddf = DDFSupport.fromElement(doc.getDocumentElement());
+        
+        Assert.assertNotNull(ddf);
+        Assert.assertEquals(ddf.name(), "root");
+        Assert.assertEquals(ddf.getmember("foo").string(), "bar");
+
+        final List<DDF> children = ddf.getmember(DDFSupport.CHILD_ELEMENTS_MEMBER).asList();
+        Assert.assertEquals(children.size(), 2);
+        
+        DDF child = children.get(0);
+        Assert.assertEquals(child.name(), "zork");
+        Assert.assertEquals(child.getmember("zorkmids").integer(), 10);
+        Assert.assertEquals(child.getmember("underground").string(), "true");
+        Assert.assertTrue(child.getmember(DDFSupport.CONTENT_MEMBER).isnull());
+        Assert.assertTrue(child.getmember(DDFSupport.CHILD_ELEMENTS_MEMBER).isnull());
+
+        child = children.get(1);
+        Assert.assertEquals(child.name(), "frobnitz");
+        Assert.assertEquals(child.getmember(DDFSupport.CONTENT_MEMBER).string(), "grue");
+        Assert.assertTrue(child.getmember(DDFSupport.CHILD_ELEMENTS_MEMBER).isnull());
+
+        Assert.assertTrue(ddf.getmember(DDFSupport.CONTENT_MEMBER).isnull());
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/DDFTest.java b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/DDFTest.java
new file mode 100644
index 0000000..086697f
--- /dev/null
+++ b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/DDFTest.java
@@ -0,0 +1,480 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import static org.testng.Assert.*;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+
+import javax.annotation.Nonnull;
+
+import org.testng.annotations.Ignore;
+import org.testng.annotations.Test;
+
+import net.shibboleth.utilities.java.support.collection.Pair;
+
+/**
+ * DDF unit tests.
+ */
+ at Ignore
+public class DDFTest {
+
+    @Test
+    public void testConstructors() {
+        DDF obj = new DDF();
+        assertNull(obj.name());
+        assertTrue(obj.isnull());
+        
+        obj = new DDF("foo");
+        assertEquals(obj.name(), "foo");
+        assertTrue(obj.isempty());
+        
+        obj = new DDF("foo", "bar");
+        assertEquals(obj.name(), "foo");
+        assertTrue(obj.isstring());
+        assertEquals(obj.string(), "bar");
+
+        obj = new DDF("foo", 42);
+        assertEquals(obj.name(), "foo");
+        assertTrue(obj.isint());
+        assertEquals(obj.integer(), Integer.valueOf(42));
+
+        obj = new DDF("foo", 42000000000L);
+        assertEquals(obj.name(), "foo");
+        assertTrue(obj.islong());
+        assertEquals(obj.longinteger(), Long.valueOf(42000000000L));
+
+        obj = new DDF("foo", 42.42);
+        assertEquals(obj.name(), "foo");
+        assertTrue(obj.isfloat());
+        assertEquals(obj.floating(), Double.valueOf(42.42));
+    }
+
+    @Test
+    public void testConversions() throws UnsupportedEncodingException {
+        final DDF obj = new DDF("foo");
+        obj.string("bar");
+        assertTrue(obj.isstring());
+        assertEquals(obj.string(), "bar");
+        assertNull(obj.integer());
+        assertNull(obj.floating());
+        
+        obj.string(42);
+        assertTrue(obj.isstring());
+        assertEquals(obj.string(), "42");
+        assertEquals(obj.integer(), Integer.valueOf(42));
+        assertEquals(obj.floating(), Double.valueOf(42));
+
+        obj.string(42.42);
+        assertTrue(obj.isstring());
+        assertEquals(obj.string(), "42.42");
+        assertNull(obj.integer());
+        assertEquals(obj.floating(), Double.valueOf(42.42));
+        
+        obj.integer(42);
+        assertTrue(obj.isint());
+        assertEquals(obj.integer(), Integer.valueOf(42));
+        assertEquals(obj.floating(), Double.valueOf(42));
+        
+        obj.integer("42");
+        assertTrue(obj.isint());
+        assertEquals(obj.integer(), Integer.valueOf(42));
+        assertEquals(obj.floating(), Double.valueOf(42));
+                
+        obj.longinteger(42000000000L);
+        assertTrue(obj.islong());
+        assertEquals(obj.longinteger(), Long.valueOf(42000000000L));
+        assertEquals(obj.floating(), Double.valueOf(42000000000L));
+
+        obj.longinteger("42000000000");
+        assertTrue(obj.islong());
+        assertEquals(obj.longinteger(), Long.valueOf(42000000000L));
+        assertEquals(obj.floating(), Double.valueOf(42000000000L));
+
+        obj.floating(42.42);
+        assertTrue(obj.isfloat());
+        assertEquals(obj.integer(), Integer.valueOf(42));
+        assertEquals(obj.floating(), Double.valueOf(42.42));
+        
+        obj.unsafe_string("bar".getBytes("ISO-8859-1"));
+        System.out.print(obj);
+    }
+    
+    @Test
+    public void testLists() {
+        final DDF obj = new DDF().list();
+        assertTrue(obj.islist());
+        assertEquals(obj.integer(), Integer.valueOf(0));
+        
+        obj.add(new DDF("foo", "bar"));
+        obj.add(new DDF("foo2", 42));
+        obj.add(new DDF("foo3").pointer(new Pair<>()));
+        assertEquals(obj.integer(), Integer.valueOf(3));
+        
+        for (final DDF el : obj) {
+            switch (el.name()) {
+                case "foo":
+                    assertEquals(el.string(), "bar");
+                    break;
+                
+                case "foo2":
+                    assertEquals(el.integer(), Integer.valueOf(42));
+                    break;
+                        
+                case "foo3":
+                    assertEquals(el.pointer(), new Pair<>());
+                    break;
+                        
+                default:
+                    fail("Node unrecognized");
+            }
+        }
+        
+        assertEquals(obj.getmember("[0]"), new DDF("foo", "bar"));
+        assertEquals(obj.getmember("[1]"), new DDF("foo2", 42));
+        assertTrue(obj.getmember("[3]").isnull());
+        
+        obj.addafter(new DDF(null), obj.getmember("[0]"));
+        assertEquals(obj.integer(), Integer.valueOf(4));
+        assertTrue(obj.getmember("[1]").isempty());
+
+        obj.addbefore(new DDF("foo4"), obj.getmember("[2]"));
+        assertEquals(obj.integer(), Integer.valueOf(5));
+        assertTrue(obj.getmember("[2]").name().equals("foo4"));
+        
+        assertTrue(obj.asList().get(4).remove().ispointer());
+        assertEquals(obj.integer(), Integer.valueOf(4));
+    }
+
+    @Test
+    public void testStructures() {
+        final DDF obj = new DDF().structure();
+        assertTrue(obj.isstruct());
+        assertEquals(obj.integer(), Integer.valueOf(0));
+        
+        obj.add(new DDF("foo", "bar"));
+        assertEquals(obj.integer(), Integer.valueOf(1));
+        assertTrue(obj.getmember("foo").name().equals("foo"));
+        assertTrue(obj.getmember("foo").string().equals("bar"));
+        
+        obj.addmember("foo2").integer(42);
+        assertEquals(obj.integer(), Integer.valueOf(2));
+        
+        obj.addmember("foo2.foo3").string("bar3");
+        assertEquals(obj.integer(), Integer.valueOf(2));
+        assertTrue(obj.getmember("foo2").isstruct());
+        assertEquals(obj.getmember("foo2").integer(), Integer.valueOf(1));
+        assertTrue(obj.getmember("foo2").getmember("foo3").string().equals("bar3"));
+        assertTrue(obj.getmember("foo2.foo3").string().equals("bar3"));
+    }
+    
+    @Test
+    public void testEncoder() throws IOException {
+        try (final ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
+            DDF.encode(sink, "foo".getBytes("UTF8"));
+            assertEquals(sink.toString(), "foo");
+            sink.reset();
+            
+            DDF.encode(sink, "foo bar".getBytes("UTF8"));
+            assertEquals(sink.toString(), "foo%20bar");
+            sink.reset();
+            
+            DDF.encode(sink, "foo\nbar".getBytes("UTF8"));
+            assertEquals(sink.toString(), "foo%0Abar");
+            sink.reset();
+            
+            DDF.encode(sink, "foo☯️bar".getBytes("UTF8"));
+            assertEquals(sink.toString(), "foo%E2%98%AF%EF%B8%8Fbar");
+            sink.reset();
+            
+            // -128 corresponds to 128, which is the extended ASCII Euro symbol.
+            // This test demonstrates that round-tripping through such an encoding
+            // will preserve the original 0x80 hex value in that position in the string
+            // rather than converting through the UTF-8 representation.
+            final byte[] unsafe = {102, 111, 111, -128, 98, 97, 114};
+            DDF.encode(sink, new String(unsafe, "ISO-8859-1").getBytes("ISO-8859-1"));
+            assertEquals(sink.toString(), "foo%80bar");
+            sink.reset();
+        }
+    }
+    
+    @Test
+    public void testSerialize() throws IOException {
+        
+        try (final ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
+            DDF obj = new DDF(null);
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("empty-noname.ddf"));
+            sink.reset();
+            
+            obj.name("foo bar");
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("empty-name.ddf"));
+            sink.reset();
+            
+            obj.string("zorkmid☯️");
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("string-name.ddf"));
+            sink.reset();
+
+            final byte[] unsafe = {102, 111, 111, -128, 98, 97, 114};
+            obj.unsafe_string(unsafe);
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("unsafestring-name.ddf"));
+            sink.reset();
+            
+            obj.integer(42);
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("int-name.ddf"));
+            sink.reset();
+
+            obj.longinteger(42000000000L);
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("long-name.ddf"));
+            sink.reset();
+
+            obj.floating(42.1315927);
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("float-name.ddf"));
+            sink.reset();
+            
+            obj.structure();
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("struct-empty.ddf"));
+            sink.reset();
+            
+            obj.addmember("infocom.zork").list().add(new DDF().integer(1));
+            obj.getmember("infocom.zork").add(new DDF().integer(2));
+            obj.getmember("infocom.zork").add(new DDF().integer(3));
+            obj.serialize(sink);
+            assertEquals(sink.toByteArray(), testFile("struct-complex.ddf"));
+            sink.reset();
+        }
+    }
+    
+    /**
+     * Convert test file contents to a byte array.
+     * 
+     * @param name file name
+     * 
+     * @return byte array
+     * 
+     * @throws IOException on error
+     */
+    private byte[] testFile(@Nonnull final String name) throws IOException {
+        return getClass().getResourceAsStream(name).readAllBytes();
+    }
+    
+    @Test
+    public void testDeserialize() throws IOException {
+        try (final InputStream is = getClass().getResourceAsStream("empty-noname.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isempty());
+            assertNull(obj.name());
+        }
+        
+        try (final InputStream is = getClass().getResourceAsStream("empty-name.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isempty());
+            assertEquals(obj.name(), "foo bar");
+        }
+        
+        try (final InputStream is = getClass().getResourceAsStream("string-name.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isstring());
+            assertEquals(obj.name(), "foo bar");
+            assertEquals(obj.string(), "zorkmid☯️");
+        }
+
+        try (final InputStream is = getClass().getResourceAsStream("unsafestring-name.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isunsafestring());
+            assertEquals(obj.name(), "foo bar");
+            final byte[] unsafe = {102, 111, 111, -128, 98, 97, 114};
+            assertEquals(obj.unsafe_string(), unsafe);
+        }
+
+        try (final InputStream is = getClass().getResourceAsStream("int-name.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isint());
+            assertEquals(obj.name(), "foo bar");
+            assertEquals(obj.integer(), Integer.valueOf(42));
+        }
+        
+        try (final InputStream is = getClass().getResourceAsStream("long-name.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.islong());
+            assertEquals(obj.name(), "foo bar");
+            assertEquals(obj.longinteger(), Long.valueOf(42000000000L));
+        }
+
+        try (final InputStream is = getClass().getResourceAsStream("float-name.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isfloat());
+            assertEquals(obj.name(), "foo bar");
+            assertEquals(obj.floating(), Double.valueOf(42.1315927));
+        }
+
+        try (final InputStream is = getClass().getResourceAsStream("struct-empty.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isstruct());
+            assertEquals(obj.name(), "foo bar");
+            assertEquals(obj.integer(), Integer.valueOf(0));
+        }
+
+        try (final InputStream is = getClass().getResourceAsStream("struct-complex.ddf")) {
+            final DDF obj = DDF.deserialize(is);
+            assertTrue(obj.isstruct());
+            assertEquals(obj.name(), "foo bar");
+            assertEquals(obj.integer(), Integer.valueOf(1));
+            assertTrue(obj.getmember("infocom").isstruct());
+            assertTrue(obj.getmember("infocom.zork").islist());
+            assertEquals(obj.getmember("infocom.zork").integer(), Integer.valueOf(3));
+            assertEquals(obj.getmember("infocom.zork.[0]").integer(), Integer.valueOf(1));
+            assertEquals(obj.getmember("infocom.zork.[1]").integer(), Integer.valueOf(2));
+            assertEquals(obj.getmember("infocom.zork.[2]").integer(), Integer.valueOf(3));
+        }
+    }
+    
+    @Test
+    public void testBadInputs() {
+        try (final InputStream is = new ByteArrayInputStream(new String().getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String("\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(" ").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(".\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". \n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". -2").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 0 \n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 1 foo \n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 2\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 2 \n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 3\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 3 \n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 4 \n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 4\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 4 2\n. 1 foo\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+        
+        try (final InputStream is = new ByteArrayInputStream(new String(". 5 foo\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+
+        try (final InputStream is = new ByteArrayInputStream(new String(". 5 1\n").getBytes("UTF-8"))) {
+            DDF.deserialize(is);
+            fail("Should have thrown IOException");
+        } catch (final IOException e) {
+            
+        }
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletRequestTest.java b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletRequestTest.java
new file mode 100644
index 0000000..255d8d2
--- /dev/null
+++ b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletRequestTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import static org.testng.Assert.*;
+
+import java.io.IOException;
+import java.util.List;
+
+import jakarta.servlet.http.Cookie;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Ignore;
+import org.testng.annotations.Test;
+import org.testng.reporters.Files;
+
+/**
+ * Unit test for {@link RemotedHttpServletRequest}.
+ */
+ at Ignore
+public class RemotedHttpServletRequestTest {
+    
+    private DDF obj;
+    private RemotedHttpServletRequest req;
+    
+    @BeforeMethod
+    public void setUp() {
+        obj = new DDF();
+        req = new RemotedHttpServletRequest(obj);
+    }
+    
+    @Test
+    public void testEmpty() throws IOException {
+        assertEquals(req.getContentLength(), -1);
+        assertEquals(req.getContentType(), null);
+        assertEquals(req.getCookies(), null);
+        assertEquals(req.getHeader("foo"), null);
+        assertEquals(req.getInputStream().read(), -1);
+        assertEquals(req.getMethod(), null);
+        assertEquals(req.getParameter("foo"), null);
+        assertEquals(req.getQueryString(), null);
+        assertEquals(req.getRemoteAddr(), null);
+        assertEquals(req.getRemoteUser(), null);
+        assertEquals(req.getRequestURI(), null);
+        assertEquals(req.getRequestURL().toString(), "");
+        assertEquals(req.getScheme(), null);
+        assertFalse(req.isSecure());
+        assertEquals(req.getServerPort(), -1);
+    }
+
+    @Test
+    public void testBasics() throws IOException {
+        obj.structure();
+        obj.addmember("content_length").integer(100);
+        obj.addmember("content_type").string("text/xml");
+        obj.addmember("method").string("POST");
+        obj.addmember("body").string("<foo/>");
+        obj.addmember("port").integer(80);
+        obj.addmember("client_addr").string("127.0.0.1");
+        obj.addmember("remote_user").string("jdoe");
+        obj.addmember("hostname").unsafe_string("localhost".getBytes("UTF-8"));
+        obj.addmember("uri").unsafe_string("/endpoint".getBytes("UTF-8"));
+        obj.addmember("url").unsafe_string("http://localhost/endpoint".getBytes("UTF-8"));
+        obj.addmember("scheme").string("http");
+        
+        assertEquals(req.getContentLength(), 100);
+        assertEquals(req.getContentType(), "text/xml");
+        assertEquals(req.getCookies(), null);
+        assertEquals(req.getHeader("foo"), null);
+        assertEquals(Files.streamToString(req.getInputStream()), "<foo/>");
+        assertEquals(req.getMethod(), "POST");
+        assertEquals(req.getParameter("foo"), null);
+        assertEquals(req.getQueryString(), null);
+        assertEquals(req.getRemoteAddr(), "127.0.0.1");
+        assertEquals(req.getRemoteUser(), "jdoe");
+        assertEquals(req.getServerName(), "localhost");
+        assertEquals(req.getRequestURI(), "/endpoint");
+        assertEquals(req.getRequestURL().toString(), "http://localhost/endpoint");
+        assertEquals(req.getScheme(), "http");
+        assertFalse(req.isSecure());
+        assertEquals(req.getServerPort(), 80);        
+    }
+    
+    @Test
+    public void testOneQueryParameter() throws IOException {
+        obj.structure();
+        obj.addmember("content_type").string("text/xml");
+        obj.addmember("body").string("<foo/>");
+        obj.addmember("query").string("foo=bar+baz");
+        
+        assertEquals(req.getParameterNames().nextElement(), "foo");
+        assertEquals(req.getParameter("foo"), "bar baz");
+    }
+
+    @Test
+    public void testMultiQueryParameters() throws IOException {
+        obj.structure();
+        obj.addmember("content_type").string("text/xml");
+        obj.addmember("body").string("<foo/>");
+        obj.addmember("query").string("foo=bar+baz&zork=grue&foo=baf");
+        
+        assertEquals(req.getParameter("foo"), "bar baz");
+        assertEquals(req.getParameterValues("foo"), List.of("bar baz", "baf").toArray());
+        assertEquals(req.getParameter("zork"), "grue");
+    }
+
+    @Test
+    public void testFormParameters() throws IOException {
+        obj.structure();
+        obj.addmember("content_type").string("application/x-www-form-urlencoded");
+        obj.addmember("body").string("foo=baf");
+        obj.addmember("query").string("foo=bar+baz&zork=grue");
+        
+        assertEquals(req.getParameter("foo"), "bar baz");
+        assertEquals(req.getParameterValues("foo"), List.of("bar baz", "baf").toArray());
+        assertEquals(req.getParameter("zork"), "grue");
+    }
+
+    @Test
+    public void testHeaders() throws IOException {
+        obj.structure();
+        obj.addmember("headers.foo").unsafe_string("bar".getBytes("UTF-8"));
+        obj.addmember("headers.zork").unsafe_string("grue".getBytes("UTF-8"));
+        
+        assertEquals(req.getHeaders("foo").nextElement(), "bar");
+        assertEquals(req.getHeader("zork"), "grue");
+        assertNull(req.getHeader("baz"));
+    }
+
+    @Test
+    public void testCookie() throws IOException {
+        obj.structure();
+        obj.addmember("headers.Cookie").unsafe_string("foo=bar;".getBytes("UTF-8"));
+        
+        final Cookie[] cookies = req.getCookies();
+        
+        assertEquals(cookies.length, 1);
+        assertEquals(cookies[0].getName(), "foo");
+        assertEquals(cookies[0].getValue(), "bar");
+    }
+
+    @Test
+    public void testCookies() throws IOException {
+        obj.structure();
+        obj.addmember("headers.Cookie").unsafe_string("foo=bar; zork=grue".getBytes("UTF-8"));
+        
+        final Cookie[] cookies = req.getCookies();
+        
+        assertEquals(cookies.length, 2);
+        assertEquals(cookies[0].getName(), "foo");
+        assertEquals(cookies[0].getValue(), "bar");
+        assertEquals(cookies[1].getName(), "zork");
+        assertEquals(cookies[1].getValue(), "grue");
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletResponseTest.java b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletResponseTest.java
new file mode 100644
index 0000000..5853dac
--- /dev/null
+++ b/sp-server-api/src/test/java/net/shibboleth/utilities/java/support/ddf/RemotedHttpServletResponseTest.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID 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 net.shibboleth.utilities.java.support.ddf;
+
+import static org.testng.Assert.*;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.time.Instant;
+import java.util.Set;
+
+import jakarta.servlet.http.Cookie;
+
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Ignore;
+import org.testng.annotations.Test;
+
+/**
+ * Unit test for {@link RemotedHttpServletResponse}.
+ */
+ at Ignore
+public class RemotedHttpServletResponseTest {
+    
+    private DDF obj;
+    private RemotedHttpServletResponse resp;
+    
+    @BeforeMethod
+    public void setUp() {
+        obj = new DDF();
+        resp = new RemotedHttpServletResponse(obj);
+    }
+    
+    @Test
+    public void testBasics() throws IOException {
+        
+        resp.setContentType("text/xml");
+        resp.setHeader("Cache-Control", "private");
+        resp.addHeader("foo", "foo");
+        resp.setHeader("Foo", "bar");
+        resp.addDateHeader("Foo", Instant.now().toEpochMilli());
+        resp.addIntHeader("Bar", 42);
+        resp.setContentLength(42);
+        
+        final Cookie cookie = new Cookie("cookie1", "value1");
+        cookie.setSecure(true);
+        cookie.setHttpOnly(true);
+        cookie.setPath("/idp");
+        resp.addCookie(cookie);
+        
+        assertEquals(resp.getContentType(), "text/xml");
+        assertEquals((Set<String>) resp.getHeaderNames(), Set.of("Content-Type", "Cache-Control", "Foo", "Bar", "Content-Length", "Set-Cookie"));
+        assertTrue(resp.getHeaders("foo").contains("bar"));
+        assertEquals(resp.getHeader("Bar"), "42");
+        
+        final DDF headers = obj.getmember("headers");
+        assertTrue(headers.islist());
+        final DDF cheader = headers.asList().stream().filter(ddf -> "Set-Cookie".equalsIgnoreCase(ddf.name())).findFirst().orElseThrow();
+        assertEquals(cheader.string(), "cookie1=value1; Path=/idp; Secure; HttpOnly");
+    }
+
+    @Test
+    public void testRedirect() throws IOException {
+        resp.sendRedirect("http://localhost");
+
+        assertTrue(resp.isCommitted());
+        assertEquals(obj.getmember("redirect").string(), "http://localhost");
+        
+        try {
+            resp.getOutputStream();
+            fail("Response should have been committed.");
+        } catch (final IllegalStateException e) {
+            
+        }
+    }
+    
+    @Test
+    public void testResponseStream() throws IOException {
+        resp.setBufferSize(3);
+        resp.setStatus(200);
+        try (final OutputStream os = resp.getOutputStream()) {
+            os.write("zorkmid".getBytes("UTF-8"));
+        }
+        
+        assertTrue(resp.isCommitted());
+        assertEquals(obj.getmember("response.status").integer(), Integer.valueOf(200));
+        assertEquals(obj.getmember("response.data").unsafe_string(), "zorkmid".getBytes("UTF-8"));
+    }
+    
+    @Test
+    public void testResponseStream2() throws IOException {
+        resp.setBufferSize(3);
+        resp.setStatus(200);
+        try (final OutputStream os = resp.getOutputStream()) {
+            os.write("zorkmid☯️".getBytes("ISO-8859-1"));
+        }
+        
+        assertTrue(resp.isCommitted());
+        assertEquals(obj.getmember("response.status").integer(), Integer.valueOf(200));
+        assertEquals(obj.getmember("response.data").unsafe_string(), "zorkmid☯️".getBytes("ISO-8859-1"));
+    }
+    
+    @Test
+    public void testWriter() throws IOException {
+        resp.setBufferSize(3);
+        resp.setStatus(200);
+        try (final PrintWriter pw = resp.getWriter()) {
+            pw.print("zorkmid");
+        }
+        
+        assertTrue(resp.isCommitted());
+        assertEquals(obj.getmember("response.status").integer(), Integer.valueOf(200));
+        assertEquals(obj.getmember("response.data").unsafe_string(), "zorkmid".getBytes("UTF-8"));
+    }
+
+    @Test
+    public void testWriter2() throws IOException {
+        resp.setBufferSize(3);
+        resp.setStatus(200);
+        try (final PrintWriter pw = resp.getWriter()) {
+            pw.print("zorkmid☯️");
+        }
+        
+        assertTrue(resp.isCommitted());
+        assertEquals(obj.getmember("response.status").integer(), Integer.valueOf(200));
+        assertEquals(obj.getmember("response.data").unsafe_string(), "zorkmid☯️".getBytes("UTF-8"));
+    }
+}
\ No newline at end of file
diff --git a/sp-server-impl/pom.xml b/sp-server-impl/pom.xml
index 83eeebc..ee0df5f 100644
--- a/sp-server-impl/pom.xml
+++ b/sp-server-impl/pom.xml
@@ -27,18 +27,6 @@
             <version>${project.version}</version>
             <scope>compile</scope>
         </dependency>
-        <dependency>
-            <groupId>net.shibboleth.utilities</groupId>
-            <artifactId>java-support</artifactId>
-            <version>${java-support.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.shibboleth.ext</groupId>
-            <artifactId>spring-extensions</artifactId>
-            <version>${spring-extensions.version}</version>
-            <scope>compile</scope>
-        </dependency>
         <dependency>
             <groupId>org.opensaml</groupId>
             <artifactId>opensaml-core</artifactId>
@@ -78,19 +66,6 @@
             <artifactId>spring-test</artifactId>
             <scope>test</scope>
         </dependency>
-        <dependency>
-            <groupId>net.shibboleth.ext</groupId>
-            <artifactId>spring-extensions</artifactId>
-            <type>test-jar</type>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-	       <groupId>net.shibboleth.utilities</groupId>
-	       <artifactId>java-support</artifactId>
-	       <version>${java-support.version}</version>
-	       <type>test-jar</type>
-	       <scope>test</scope>
-        </dependency>
     </dependencies>
 
 </project>
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicApplication.java b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicApplication.java
index 390beb0..3e5a1fd 100644
--- a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicApplication.java
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicApplication.java
@@ -31,6 +31,7 @@ import net.shibboleth.utilities.java.support.component.ComponentInitializationEx
 import net.shibboleth.utilities.java.support.component.ComponentSupport;
 import net.shibboleth.utilities.java.support.ddf.DDF;
 import net.shibboleth.utilities.java.support.logic.Constraint;
+import net.shibboleth.utilities.java.support.service.ReloadableService;
 
 /**
  * Basic implementation of an {@link Application}.
@@ -43,6 +44,18 @@ public class BasicApplication extends AbstractEndpoint implements Application {
     /** Component endpoints. */
     @NonnullAfterInit private ApplicationEndpointManager endpointManager;
     
+    /** Metadata source. */
+    @NonnullAfterInit private ReloadableService<Object> metadataResolver;
+    
+    /** Transcoding registry. */
+    @NonnullAfterInit private ReloadableService<Object> transcodingRegistry;
+    
+    /** Attribute source. */
+    @NonnullAfterInit private ReloadableService<Object> attributeResolver;
+    
+    /** Filter engine. */
+    @NonnullAfterInit private ReloadableService<Object> attributeFilter;
+    
     /**
      * Sets the {@link ApplicationEndpointManager} to use.
      * 
@@ -62,6 +75,8 @@ public class BasicApplication extends AbstractEndpoint implements Application {
         if (endpointManager == null) {
             throw new ComponentInitializationException("ApplicationEndpointManager cannot be null");
         }
+        
+        // TODO: enforce services or install default versions?
     }
     
     /** {@inheritDoc} */
@@ -69,6 +84,37 @@ public class BasicApplication extends AbstractEndpoint implements Application {
         return getId();
     }
 
+    /** {@inheritDoc} */
+    @Nonnull public ReloadableService<Object> getMetadataResolver() {
+        return metadataResolver;
+    }
+    
+    /**
+     * Set the {@link MetadataResolver} to use.
+     * 
+     * @param service metadata resolver service
+     */
+    public void setMetadataResolver(@Nonnull final ReloadableService<Object> service) {
+        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
+        
+        metadataResolver = Constraint.isNotNull(service, "MetadataResolver service cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Nonnull public ReloadableService<Object> getAttributeTranscoderRegistry() {
+        return transcodingRegistry;
+    }
+
+    /** {@inheritDoc} */
+    @Nonnull public ReloadableService<Object> getAttributeResolver() {
+        return attributeResolver;
+    }
+
+    /** {@inheritDoc} */
+    @Nonnull public ReloadableService<Object> getAttributeFilter() {
+        return attributeFilter;
+    }
+
     /** {@inheritDoc} */
     @Override
     @Nonnull public DDF doReceive(@Nonnull final DDF input) throws RemoteProcessingException {

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list