šŸ§‘ā€šŸ’» Use new and improved identity parser next
authorDavid ā€˜Bombe’ Roden <bombe@freenetproject.org>
Sat, 24 Jan 2026 06:38:49 +0000 (07:38 +0100)
committerDavid ā€˜Bombe’ Roden <bombe@freenetproject.org>
Sat, 24 Jan 2026 06:38:49 +0000 (07:38 +0100)
src/main/java/net/pterodactylus/fcp/plugin/IdentityParser.java [deleted file]
src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV1.java [deleted file]
src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV2.java [deleted file]
src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV3.java [new file with mode: 0644]
src/main/java/net/pterodactylus/fcp/plugin/WebOfTrustPlugin.java
src/test/java/net/pterodactylus/fcp/plugin/IdentityParserV3Test.java [new file with mode: 0644]

diff --git a/src/main/java/net/pterodactylus/fcp/plugin/IdentityParser.java b/src/main/java/net/pterodactylus/fcp/plugin/IdentityParser.java
deleted file mode 100644 (file)
index a97333f..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-package net.pterodactylus.fcp.plugin;
-
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Interface for parsers for WebOfTrust’s on-wire identity formats.
- */
-interface IdentityParser {
-
-       /**
-        * Whether this parser can parse an identity from the given fields.
-        *
-        * @param fields The fields to parse the identity from
-        * @param prefix A prefix used for all accessed fields, may be an empty String
-        * @return {@code true} if this parse can parse identities from the given
-        *              fields, {@code false} otherwise
-        */
-       boolean canParse(Map<String, String> fields, String prefix);
-
-       /**
-        * Parses an identity from the given fields.
-        *
-        * @param fields The fields to parse the identity from
-        * @param prefix A prefix used for all access fields, may be an empty String
-        * @param identityGenerator An identity generator
-        * @param <I> The type of the identity to parse
-        * @return The generated identity
-        */
-       <I extends Identity> I parseSingleIdentity(Map<String, String> fields, String prefix, IdentityGenerator<I> identityGenerator);
-
-       /**
-        * Parses multiple identities from the given fields.
-        *
-        * @param fields The fields to parse the identities from
-        * @param prefix A prefix used for all identities, may be an empty String
-        * @param identityGenerator An identity generator
-        * @param <I> The type of the identity to parse
-        * @return The generated identities
-        */
-       <I extends Identity> Set<I> parseMultipleIdentities(Map<String, String> fields, String prefix, IdentityGenerator<I> identityGenerator);
-
-}
diff --git a/src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV1.java b/src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV1.java
deleted file mode 100644 (file)
index a36c056..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-package net.pterodactylus.fcp.plugin;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-
-/**
- * {@link IdentityParser} for the first WebOfTrust identity format. This
- * format is now deprecated for most of the transferred identities, but not
- * all of them; e.g. {@code GetOwnIdentities} still uses this format.
- */
-class IdentityParserV1 implements IdentityParser {
-
-       @Override
-       public boolean canParse(Map<String, String> fields, String prefix) {
-               return fields.containsKey(prefix + "Identity0");
-       }
-
-       @Override
-       public <I extends Identity> I parseSingleIdentity(Map<String, String> fields, String prefix, IdentityGenerator<I> identityGenerator) {
-               return parseIdentity(fields, field -> prefix + field, identityGenerator);
-       }
-
-       @Override
-       public <I extends Identity> Set<I> parseMultipleIdentities(Map<String, String> fields, String prefix, IdentityGenerator<I> identityGenerator) {
-               Set<I> identities = new HashSet<>();
-               for (int identityIndex = 0; fields.containsKey(prefix + "Identity" + identityIndex); identityIndex++) {
-                       int index = identityIndex;
-                       identities.add(parseIdentity(fields, field -> prefix + field + index, identityGenerator));
-               }
-               return identities;
-       }
-
-       private static <I extends Identity> I parseIdentity(Map<String, String> fields, Function<String, String> fieldPackager, IdentityGenerator<I> identityGenerator) {
-               String id = fields.get(fieldPackager.apply("Identity"));
-               /* sometimes WoT doesn’t send an Identity field, but an ID field. */
-               if (id == null) {
-                       id = fields.get(fieldPackager.apply("ID"));
-               }
-               String name = fields.get(fieldPackager.apply("Nickname"));
-               String requestUri = fields.get(fieldPackager.apply("RequestURI"));
-               String insertUri = fields.get(fieldPackager.apply("InsertURI"));
-               Set<String> contexts = new HashSet<>();
-               for (int contextIndex = 0; fields.containsKey(fieldPackager.apply("Contexts") + ".Context" + contextIndex); contextIndex++) {
-                       contexts.add(fields.get(fieldPackager.apply("Contexts") + ".Context" + contextIndex));
-               }
-               Map<String, String> properties = new HashMap<>();
-               for (int propertyIndex = 0; fields.containsKey(fieldPackager.apply("Properties") + ".Property" + propertyIndex + ".Name"); propertyIndex++) {
-                       String key = fields.get(fieldPackager.apply("Properties") + ".Property" + propertyIndex + ".Name");
-                       String value = fields.get(fieldPackager.apply("Properties") + ".Property" + propertyIndex + ".Value");
-                       properties.put(key, value);
-               }
-               return identityGenerator.createIdentity(id, name, requestUri, insertUri, contexts, properties);
-       }
-
-}
diff --git a/src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV2.java b/src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV2.java
deleted file mode 100644 (file)
index 714009f..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-package net.pterodactylus.fcp.plugin;
-
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-
-/**
- * {@link IdentityParser} for the second (and current) WebOfTrust identity
- * format. This format is more consequent in how the data is structured, but
- * it is not yet used for all WebOfTrust messages.
- */
-class IdentityParserV2 implements IdentityParser {
-
-       @Override
-       public boolean canParse(Map<String, String> fields, String prefix) {
-               return fields.containsKey(prefix + "Identities.0.ID");
-       }
-
-       @Override
-       public <I extends Identity> I parseSingleIdentity(Map<String, String> fields, String prefix, IdentityGenerator<I> identityGenerator) {
-               return parseIdentity(fields, field -> prefix + field, identityGenerator);
-       }
-
-       @Override
-       public <I extends Identity> Set<I> parseMultipleIdentities(Map<String, String> fields, String prefix, IdentityGenerator<I> identityGenerator) {
-               Set<I> identities = new HashSet<>();
-               for (int identityIndex = 0; fields.containsKey(prefix + "Identities." + identityIndex + ".ID"); identityIndex++) {
-                       int index = identityIndex;
-                       identities.add(parseIdentity(fields, field -> prefix + "Identities." + index + "." + field, identityGenerator));
-               }
-               return identities;
-       }
-
-       private <I extends Identity> I parseIdentity(Map<String, String> fields, Function<String, String> fieldPackager, IdentityGenerator<I> identityGenerator) {
-               String id = fields.get(fieldPackager.apply("ID"));
-               String name = fields.get(fieldPackager.apply("Nickname"));
-               String requestUri = fields.get(fieldPackager.apply("RequestURI"));
-               String insertUri = fields.get(fieldPackager.apply("InsertURI"));
-               Set<String> contexts = new HashSet<>();
-               for (int contextIndex = 0; fields.containsKey(fieldPackager.apply("Contexts.") + contextIndex + ".Name"); contextIndex++) {
-                       contexts.add(fields.get(fieldPackager.apply("Contexts.") + contextIndex + ".Name"));
-               }
-               Map<String, String> properties = new HashMap<>();
-               for (int propertyIndex = 0; fields.containsKey(fieldPackager.apply("Properties.") + propertyIndex + ".Name"); propertyIndex++) {
-                       String key = fields.get(fieldPackager.apply("Properties.") + propertyIndex + ".Name");
-                       String value = fields.get(fieldPackager.apply("Properties.") + propertyIndex + ".Value");
-                       properties.put(key, value);
-               }
-               return identityGenerator.createIdentity(id, name, requestUri, insertUri, contexts, properties);
-       }
-
-}
diff --git a/src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV3.java b/src/main/java/net/pterodactylus/fcp/plugin/IdentityParserV3.java
new file mode 100644 (file)
index 0000000..1ff9452
--- /dev/null
@@ -0,0 +1,211 @@
+package net.pterodactylus.fcp.plugin;
+
+import java.text.MessageFormat;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static java.util.Arrays.stream;
+
+/**
+ * Parser for Web of Trust identities, recognizing all formats the Web of
+ * Trust plugin writes (and trying them in order of least-deprecated) and
+ * allowing retrieval of per-identity extraneous values.
+ *
+ * <p>
+ * This parser is called V3 because it succeeds two other, less successful
+ * parsers.
+ * </p>
+ *
+ * <h2>Usage</h2>
+ * <p>
+ * This identity parser knows about all formats that the Web of Trust plugin
+ * writes identities (including contexts and properties) in; additional
+ * fields (such as the trust- and score-related ones) can be added by
+ * specifying {@link FieldNamer}s.
+ * </p>
+ * <pre>
+ * var identityParser = new IdentityParserV3&lt;Identity>(fields, identityGenerator,
+ *     new FieldNamer("Trust", "Identities.{0}.Trust.{1}.Value", "Trust{0}.Value", "Trust")
+ * );
+ * for (var iterator = identityParser.iterate(); iterator.hasNext();) {
+ *     Identity identity = iterator.next();
+ *     String trust = iterator.getExtraFieldValue("Trust");
+ *     identityTrusts.put(identity, trust);
+ * }
+ * </pre>
+ *
+ * @param <I> The type of identity to parse
+ */
+class IdentityParserV3<I extends Identity> implements Iterable<I> {
+
+       public IdentityParserV3(Map<String, String> fields, IdentityGenerator<I> identityGenerator, FieldNamer... extraFields) {
+               this.fields = fields;
+               this.identityGenerator = identityGenerator;
+               this.extraFields = extraFields;
+       }
+
+       public IdentityIterator iterator() {
+               return new IdentityIterator();
+       }
+
+       /**
+        * Specialized {@link Iterator} over parsed identities which can provide
+        * the values of additional fields. The extra fields are parsed together
+        * with the next identity, so they need to be accessed either before
+        * {@link #next()} is called, or after {@link #next()} but before the
+        * next {@link #next()} or {@link #hasNext()}.
+        */
+       class IdentityIterator implements Iterator<I> {
+
+               @Override
+               public boolean hasNext() {
+                       getNext();
+                       return nextIdentity != null;
+               }
+
+               @Override
+               public I next() {
+                       if (!hasNext()) {
+                               throw new NoSuchElementException();
+                       }
+                       try {
+                               return nextIdentity;
+                       } finally {
+                               nextIdentity = null;
+                       }
+               }
+
+               public String getExtraField(String field) {
+                       return extraFieldValues.get(field);
+               }
+
+               private void getNext() {
+                       if (nextIdentity == null) {
+                               extraFieldValues.clear();
+                               nextIdentity = parseIdentity(identityIndex++, extraFieldValues);
+                       }
+               }
+
+               private int identityIndex = 0;
+               private I nextIdentity;
+               private final Map<String, String> extraFieldValues = new HashMap<>();
+
+       }
+
+       /**
+        * Defines where a field is located in a Web of Trust response.
+        */
+       static class FieldNamer {
+
+               /**
+                * Creates a new field namer. The additional field names can contain
+                * placeholders for indices.
+                * {@link MessageFormat#format(String, Object...)} is used to combine
+                * the field names and the indices; e.g. an additional field name of
+                * {@code Foo.{0}.Value{1}} together with the indices 3 and 5 will
+                * result in a field name of {@code Foo.3.Value5}. When multiple
+                * field names are specified, the first field name that is contained
+                * in the Web of Trust responses will be used. The name of the
+                * field is the name that can be used with
+                * {@link IdentityIterator#getExtraField(String)} to retrieve the
+                * value of the field.
+                *
+                * @param name The name of the field
+                * @param additionalFieldNames Field names using
+                *        {@link MessageFormat}-style placeholders for indices
+                */
+               public FieldNamer(String name, String... additionalFieldNames) {
+                       this.name = name;
+                       this.fieldNames = additionalFieldNames;
+               }
+
+               public String getName() {
+                       return name;
+               }
+
+               public String getFieldValue(Map<String, String> fields, Integer... indices) {
+                       for (String fieldName : getFieldNames(indices)) {
+                               if (fields.containsKey(fieldName)) {
+                                       return fields.get(fieldName);
+                               }
+                       }
+                       return null;
+               }
+
+               private List<String> getFieldNames(Integer... indices) {
+                       return Stream.of(fieldNames)
+                                       .map(fieldName -> MessageFormat.format(fieldName, (Object[]) indices))
+                                       .collect(Collectors.toList());
+               }
+
+               private final String name;
+               private final String[] fieldNames;
+
+       }
+
+       private I parseIdentity(int identityIndex, Map<String, String> extraFieldValues) {
+               I result = parseIdentity(identityIndex);
+               if (result == null) {
+                       return null;
+               }
+               stream(extraFields).forEach(fieldNamer -> {
+                       for (String fieldName : fieldNamer.getFieldNames(identityIndex)) {
+                               extraFieldValues.putIfAbsent(fieldNamer.getName(), fields.get(fieldName));
+                       }
+               });
+               return result;
+       }
+
+       private I parseIdentity(int identityIndex) {
+               String insertUri = insertUriNamer.getFieldValue(fields, identityIndex);
+               String requestUri = requestUriNamer.getFieldValue(fields, identityIndex);
+               String nickname = nicknameNamer.getFieldValue(fields, identityIndex);
+               String id = identityNamer.getFieldValue(fields, identityIndex);
+               if (id == null) {
+                       return null;
+               }
+               Set<String> contexts = new HashSet<>();
+               int contextIndex = 0;
+               while (true) {
+                       String context = contextNamer.getFieldValue(fields, identityIndex, contextIndex);
+                       if (context == null) {
+                               break;
+                       }
+                       contexts.add(context);
+                       contextIndex++;
+               }
+               Map<String, String> properties = new HashMap<>();
+               int propertyIndex = 0;
+               while (true) {
+                       String name = propertyNameNamer.getFieldValue(fields, identityIndex, propertyIndex);
+                       if (name == null) {
+                               break;
+                       }
+                       String value = propertyValueNamer.getFieldValue(fields, identityIndex, propertyIndex);
+                       properties.put(name, value);
+                       propertyIndex++;
+               }
+
+               return identityGenerator.createIdentity(id, nickname, requestUri, insertUri, contexts, properties);
+       }
+
+       private final Map<String, String> fields;
+       private final IdentityGenerator<I> identityGenerator;
+       private final FieldNamer[] extraFields;
+
+       private final FieldNamer insertUriNamer = new FieldNamer("InsertURI", "Identities.{0}.InsertURI", "InsertURI{0}", "InsertURI");
+       private final FieldNamer requestUriNamer = new FieldNamer("RequestURI", "Identities.{0}.RequestURI", "RequestURI{0}", "RequestURI");
+       private final FieldNamer nicknameNamer = new FieldNamer("Nickname", "Identities.{0}.Nickname", "Nickname{0}", "Nickname");
+       private final FieldNamer identityNamer = new FieldNamer("Identity", "Identities.{0}.ID", "Identities.{0}.Identity", "ID{0}", "Identity{0}", "ID", "Identity");
+       private final FieldNamer contextNamer = new FieldNamer("Context", "Identities.{0}.Contexts.{1}.Name", "Contexts{0}.Context{1}", "Context{1}");
+       private final FieldNamer propertyNameNamer = new FieldNamer("Name", "Identities.{0}.Properties.{1}.Name", "Properties{0}.Property{1}.Name", "Property{1}.Name");
+       private final FieldNamer propertyValueNamer = new FieldNamer("Value", "Identities.{0}.Properties.{1}.Value", "Properties{0}.Property{1}.Value", "Property{1}.Value");
+
+}
index 6df8149..25e3b2b 100644 (file)
@@ -17,6 +17,7 @@
 
 package net.pterodactylus.fcp.plugin;
 
+import java.util.HashSet;
 import net.pterodactylus.fcp.highlevel.FcpClient;
 import net.pterodactylus.fcp.highlevel.FcpException;
 
@@ -275,13 +276,14 @@ public class WebOfTrustPlugin {
                        throw new FcpException("WebOfTrust Plugin did not reply with ā€œIdentitiesā€ message!");
                }
                Map<Identity, IdentityTrust> identityTrusts = new HashMap<>();
-               for (int identityIndex = 0; replies.containsKey("Identity" + identityIndex); identityIndex++) {
-                       String identifier = replies.get("Identity" + identityIndex);
-                       String nickname = replies.get("Nickname" + identityIndex);
-                       String requestUri = replies.get("RequestURI" + identityIndex);
-                       byte trust = Byte.parseByte(replies.get("Value" + identityIndex));
-                       String comment = replies.get("Comment" + identityIndex);
-                       identityTrusts.put(new Identity(identifier, nickname, requestUri, emptySet(), emptyMap()), new IdentityTrust(trust, comment));
+               IdentityParserV3<Identity> parser = new IdentityParserV3<>(replies, WebOfTrustPlugin::createIdentity,
+                               new IdentityParserV3.FieldNamer("Value", "Value{0}"),
+                               new IdentityParserV3.FieldNamer("Comment", "Comment{0}")
+               );
+               for (IdentityParserV3<Identity>.IdentityIterator parsedIdentity = parser.iterator(); parsedIdentity.hasNext();) {
+                       byte trust = Byte.parseByte(parsedIdentity.getExtraField("Value"));
+                       String comment = parsedIdentity.getExtraField("Comment");
+                       identityTrusts.put(parsedIdentity.next(), new IdentityTrust(trust, comment));
                }
                return identityTrusts;
        }
@@ -446,18 +448,15 @@ public class WebOfTrustPlugin {
        }
 
        private static <I extends Identity> I parseIdentity(Map<String, String> replies, IdentityGenerator<I> identityGenerator) {
-               IdentityParser parser = v2IdentityParser.canParse(replies, "") ? v2IdentityParser : v1IdentityParser;
-               return parser.parseSingleIdentity(replies, "", identityGenerator);
+               return new IdentityParserV3<>(replies, identityGenerator).iterator().next();
        }
 
        private static <I extends Identity> Set<I> parseIdentities(Map<String, String> replies, IdentityGenerator<I> identityGenerator) {
-               IdentityParser parser = v2IdentityParser.canParse(replies, "") ? v2IdentityParser : v1IdentityParser;
-               return parser.parseMultipleIdentities(replies, "", identityGenerator);
+               Set<I> identities = new HashSet<>();
+               new IdentityParserV3<>(replies, identityGenerator).iterator().forEachRemaining(identities::add);
+               return identities;
        }
 
-       private static final IdentityParser v1IdentityParser = new IdentityParserV1();
-       private static final IdentityParser v2IdentityParser = new IdentityParserV2();
-
        private static Identity createIdentity(String identity, String nickname, String requestUri, @SuppressWarnings("unused") String insertUri, Set<String> contexts, Map<String, String> properties) {
                return new Identity(identity, nickname, requestUri, contexts, properties);
        }
diff --git a/src/test/java/net/pterodactylus/fcp/plugin/IdentityParserV3Test.java b/src/test/java/net/pterodactylus/fcp/plugin/IdentityParserV3Test.java
new file mode 100644 (file)
index 0000000..dbd420e
--- /dev/null
@@ -0,0 +1,281 @@
+package net.pterodactylus.fcp.plugin;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import net.pterodactylus.fcp.plugin.IdentityParserV3.FieldNamer;
+import org.hamcrest.Matcher;
+import org.hamcrest.MatcherAssert;
+import org.junit.Test;
+
+import static net.pterodactylus.fcp.test.IdentityMatchers.hasContexts;
+import static net.pterodactylus.fcp.test.IdentityMatchers.hasId;
+import static net.pterodactylus.fcp.test.IdentityMatchers.hasInsertUri;
+import static net.pterodactylus.fcp.test.IdentityMatchers.hasNickname;
+import static net.pterodactylus.fcp.test.IdentityMatchers.hasProperties;
+import static net.pterodactylus.fcp.test.IdentityMatchers.hasRequestUri;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasEntry;
+import static org.junit.Assert.assertThrows;
+
+public class IdentityParserV3Test {
+
+       @Test
+       public void identityParserCanParseFirstDeprecatedFormat() {
+               addFieldsWithIdentityAndWithoutSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("first", 0));
+               });
+       }
+
+       @Test
+       public void identityParserCanParseFirstDeprecatedFormatWithID() {
+               addFieldsWithIDAndWithoutSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("first", 0));
+               });
+       }
+
+       @Test
+       public void identityParserCanParseSecondAlsoDeprecatedFormat() {
+               addFieldsWithIdentityAndWithNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("second", 0));
+               });
+       }
+
+       @Test
+       public void identityParserCanParseSecondAlsoDeprecatedFormatWithID() {
+               addFieldsWithIDAndWithNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("second", 0));
+               });
+       }
+
+       @Test
+       public void identityParserCanParseThirdNotDeprecatedFormat() {
+               addFieldsWithIdentityAndWithDotNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("third", 0));
+               });
+       }
+
+       @Test
+       public void identityParserCanParseThirdNotDeprecatedFormatWithID() {
+               addFieldsWithIDAndWithDotNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("third", 0));
+               });
+       }
+
+       @Test
+       public void identityParserPrefersSecondOverFirstFormat() {
+               addFieldsWithIdentityAndWithoutSuffix();
+               addFieldsWithIdentityAndWithNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("second", 0));
+               });
+       }
+
+       @Test
+       public void identityParserPrefersThirdOverFirstFormat() {
+               addFieldsWithIdentityAndWithoutSuffix();
+               addFieldsWithIdentityAndWithDotNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("third", 0));
+               });
+       }
+
+       @Test
+       public void identityParserPrefersThirdOverSecondFormat() {
+               addFieldsWithIdentityAndWithNumericSuffix();
+               addFieldsWithIdentityAndWithDotNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       assertThat(iterator.next(), matches("third", 0));
+               });
+       }
+
+       @Test
+       public void identityParserParsesExactlyOneIdentity() {
+               addFieldsWithIdentityAndWithDotNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       iterator.next();
+                       assertThat(iterator.hasNext(), equalTo(false));
+               });
+       }
+
+       @Test
+       public void identityParserThrowsExceptionWhenIteratingPastTheEndOfTheIterator() {
+               addFieldsWithIdentityAndWithDotNumericSuffix();
+               createParserAndVerifyIterator(iterator -> {
+                       iterator.next();
+                       assertThrows(NoSuchElementException.class, () -> iterator.next());
+               });
+       }
+
+       @Test
+       public void identityParserParsesAllIdentitiesFromFields() {
+               addFieldsWithIdentityAndWithDotNumericSuffix("Identity", 5);
+               createParserAndVerify(parser -> {
+                       assertThat(parser, containsInAnyOrder(
+                                       matches("third", 0),
+                                       matches("third", 1),
+                                       matches("third", 2),
+                                       matches("third", 3),
+                                       matches("third", 4)
+                       ));
+               });
+       }
+
+       @Test
+       public void identityParserParsesExtraFieldsCorrectlyWithFirstFormat() {
+               addFieldsWithIDAndWithoutSuffix();
+               fields.put("Extra", "extra");
+               fields.put("Field", "field");
+               IdentityParserV3<OwnIdentity> parser = new IdentityParserV3<>(fields, ownIdentityGenerator,
+                               new FieldNamer("Extra", "Extras.{0}.Extra", "Extra{0}", "Extra"),
+                               new FieldNamer("Field", "Extras.{0}.Field", "Field{0}", "Field")
+               );
+               IdentityParserV3<?>.IdentityIterator identityIterator = parser.iterator();
+               identityIterator.next();
+               assertThat(identityIterator.getExtraField("Extra"), equalTo("extra"));
+               assertThat(identityIterator.getExtraField("Field"), equalTo("field"));
+       }
+
+       @Test
+       public void identityParserParsesExtraFieldsCorrectlyWithSecondFormat() {
+               addFieldsWithIDAndWithoutSuffix();
+               fields.put("Extra0", "extra");
+               fields.put("Field0", "field");
+               IdentityParserV3<OwnIdentity> parser = new IdentityParserV3<>(fields, ownIdentityGenerator,
+                               new FieldNamer("Extra", "Extras.{0}.Extra", "Extra{0}", "Extra"),
+                               new FieldNamer("Field", "Extras.{0}.Field", "Field{0}", "Field")
+               );
+               IdentityParserV3<?>.IdentityIterator identityIterator = parser.iterator();
+               identityIterator.next();
+               assertThat(identityIterator.getExtraField("Extra"), equalTo("extra"));
+               assertThat(identityIterator.getExtraField("Field"), equalTo("field"));
+       }
+
+       @Test
+       public void identityParserParsesExtraFieldsCorrectlyWithThirdFormat() {
+               addFieldsWithIDAndWithoutSuffix();
+               fields.put("Extras.0.Extra", "extra");
+               fields.put("Extras.0.Field", "field");
+               IdentityParserV3<OwnIdentity> parser = new IdentityParserV3<>(fields, ownIdentityGenerator,
+                               new FieldNamer("Extra", "Extras.{0}.Extra", "Extra{0}", "Extra"),
+                               new FieldNamer("Field", "Extras.{0}.Field", "Field{0}", "Field")
+               );
+               IdentityParserV3<?>.IdentityIterator identityIterator = parser.iterator();
+               identityIterator.next();
+               MatcherAssert.assertThat(identityIterator.getExtraField("Extra"), equalTo("extra"));
+               MatcherAssert.assertThat(identityIterator.getExtraField("Field"), equalTo("field"));
+       }
+
+       private Matcher<OwnIdentity> matches(String prefix, int index) {
+               return allOf(
+                               hasId(prefix + "-id" + index),
+                               hasNickname(prefix + "-nick" + index),
+                               hasRequestUri(prefix + "-r" + index),
+                               hasInsertUri(prefix + "-i" + index),
+                               hasContexts(containsInAnyOrder(prefix + "-context-" + index, prefix + "-context-" + (index + 1))),
+                               hasProperties(allOf(hasEntry(prefix + "-name-" + index, prefix + "-value-" + index), hasEntry(prefix + "-name-" + (index + 1), prefix + "-value-" + (index + 1))))
+               );
+       }
+
+       private void addFieldsWithIdentityAndWithoutSuffix() {
+               fields.put("Identity", "first-id0");
+               addFieldsWithoutSuffix();
+       }
+
+       private void addFieldsWithIDAndWithoutSuffix() {
+               fields.put("ID", "first-id0");
+               addFieldsWithoutSuffix();
+       }
+
+       private void addFieldsWithoutSuffix() {
+               fields.put("Nickname", "first-nick0");
+               fields.put("RequestURI", "first-r0");
+               fields.put("InsertURI", "first-i0");
+               fields.put("Context0", "first-context-0");
+               fields.put("Context1", "first-context-1");
+               fields.put("Property0.Name", "first-name-0");
+               fields.put("Property0.Value", "first-value-0");
+               fields.put("Property1.Name", "first-name-1");
+               fields.put("Property1.Value", "first-value-1");
+       }
+
+       private void addFieldsWithIdentityAndWithNumericSuffix() {
+               fields.put("Identity0", "second-id0");
+               addFieldsWithNumericSuffix();
+       }
+
+       private void addFieldsWithIDAndWithNumericSuffix() {
+               fields.put("ID0", "second-id0");
+               addFieldsWithNumericSuffix();
+       }
+
+       private void addFieldsWithNumericSuffix() {
+               fields.put("Nickname0", "second-nick0");
+               fields.put("RequestURI0", "second-r0");
+               fields.put("InsertURI0", "second-i0");
+               fields.put("Contexts0.Context0", "second-context-0");
+               fields.put("Contexts0.Context1", "second-context-1");
+               fields.put("Properties0.Property0.Name", "second-name-0");
+               fields.put("Properties0.Property0.Value", "second-value-0");
+               fields.put("Properties0.Property1.Name", "second-name-1");
+               fields.put("Properties0.Property1.Value", "second-value-1");
+       }
+
+       private void addFieldsWithIdentityAndWithDotNumericSuffix() {
+               addFieldsWithIdentityAndWithDotNumericSuffix("Identity", 1);
+       }
+
+       private void addFieldsWithIDAndWithDotNumericSuffix() {
+               addFieldsWithIdentityAndWithDotNumericSuffix("ID", 1);
+       }
+
+       private void addFieldsWithIdentityAndWithDotNumericSuffix(String idField, int count) {
+               Stream.iterate(0, index -> index + 1)
+                               .limit(count)
+                               .forEach(index -> {
+                                       fields.put("Identities." + index + "." + idField, "third-id" + index);
+                                       fields.put("Identities." + index + ".Nickname", "third-nick" + index);
+                                       fields.put("Identities." + index + ".RequestURI", "third-r" + index);
+                                       fields.put("Identities." + index + ".InsertURI", "third-i" + index);
+                                       fields.put("Identities." + index + ".Contexts.0.Name", "third-context-" + index);
+                                       fields.put("Identities." + index + ".Contexts.1.Name", "third-context-" + (index + 1));
+                                       fields.put("Identities." + index + ".Properties.0.Name", "third-name-" + index);
+                                       fields.put("Identities." + index + ".Properties.0.Value", "third-value-" + index);
+                                       fields.put("Identities." + index + ".Properties.1.Name", "third-name-" + (index + 1));
+                                       fields.put("Identities." + index + ".Properties.1.Value", "third-value-" + (index + 1));
+                               });
+       }
+
+       private void createParserAndVerify(Consumer<IdentityParserV3<OwnIdentity>> iteratorConsumer, FieldNamer... extraFields) {
+               IdentityParserV3<OwnIdentity> parser = new IdentityParserV3<>(fields, ownIdentityGenerator, extraFields);
+               iteratorConsumer.accept(parser);
+       }
+
+       private void createParserAndVerifyIterator(Consumer<Iterator<OwnIdentity>> iteratorConsumer) {
+               createParserAndVerify(parser -> {
+                       Iterator<OwnIdentity> iterator = parser.iterator();
+                       iteratorConsumer.accept(iterator);
+               });
+       }
+
+       private OwnIdentity createOwnIdentity(String identity, String nickname, String requestUri, String insertUri, Set<String> contexts, Map<String, String> properties) {
+               return new OwnIdentity(identity, nickname, requestUri, insertUri, contexts, properties);
+       }
+
+       private final Map<String, String> fields = new HashMap<>();
+       private final IdentityGenerator<OwnIdentity> ownIdentityGenerator = this::createOwnIdentity;
+
+}