attributes;
private final String attributeOf;
private final String attributeName;
@@ -119,6 +123,8 @@ protected SdkField(final String id, final String type, final String parentNodeId
this.repeatable = repeatable;
this.privacyCode = null;
this.privacySettings = null;
+ this.withholdingCondition = null;
+ this.undisclosedFieldSelector = null;
this.attributes = Collections.emptyList();
this.attributeOf = null;
this.attributeName = null;
@@ -132,9 +138,10 @@ protected SdkField(final JsonNode fieldNode) {
this.type = fieldNode.get("type").asText(null);
this.codelistId = extractCodelistId(fieldNode);
this.repeatable = extractRepeatable(fieldNode);
- final JsonNode privacyNode = fieldNode.get("privacy");
- this.privacyCode = privacyNode != null ? privacyNode.get("code").asText(null) : null;
- this.privacySettings = extractPrivacy(privacyNode);
+ this.privacyCode = this.extractDisclosureGroupId(fieldNode);
+ this.privacySettings = this.extractDisclosureControlFields(fieldNode);
+ this.withholdingCondition = this.extractWithholdingCondition(fieldNode);
+ this.undisclosedFieldSelector = this.extractUndisclosedFieldSelector(fieldNode);
this.attributes = extractAttributes(fieldNode);
this.attributeOf = fieldNode.has("attributeOf") ? fieldNode.get("attributeOf").asText(null) : null;
this.attributeName = fieldNode.has("attributeName") ? fieldNode.get("attributeName").asText(null) : null;
@@ -180,6 +187,63 @@ protected boolean extractRepeatable(final JsonNode fieldNode) {
return valueNode.asBoolean(false);
}
+ /**
+ * Returns the identifier of the disclosure group of the field, from the code of the privacy block
+ * of fields/fields.json. SDK 2 gives it as the groupId of the disclosureControl block instead
+ * (TEDEFO-5229).
+ *
+ *
+ * Called while this field is being constructed, so an override must read the given node only,
+ * and no state of its own.
+ *
+ */
+ protected String extractDisclosureGroupId(final JsonNode fieldNode) {
+ final JsonNode privacyNode = fieldNode.get("privacy");
+ return privacyNode != null ? privacyNode.get("code").asText(null) : null;
+ }
+
+ /**
+ * Returns the fields that hold the disclosure data of this field, from the privacy block of
+ * fields/fields.json. SDK 2 no longer gives them per field: they are the same for every field,
+ * and are linked to each field in TEDEFO-5230.
+ *
+ *
+ * Called while this field is being constructed, so an override must read the given node only,
+ * and no state of its own.
+ *
+ */
+ protected PrivacySettings extractDisclosureControlFields(final JsonNode fieldNode) {
+ return this.extractPrivacy(fieldNode.get("privacy"));
+ }
+
+ /**
+ * Returns the EFX condition under which this field is withheld, for the fields that are withheld
+ * only for some of their instances, or null for every other field. Only SDK 2 gives it, in the
+ * disclosureControl block (TEDEFO-5229). It is not translated.
+ *
+ *
+ * Called while this field is being constructed, so an override must read the given node only,
+ * and no state of its own.
+ *
+ */
+ protected String extractWithholdingCondition(final JsonNode fieldNode) {
+ return null;
+ }
+
+ /**
+ * Returns the EFX selector locating this field when it is withheld, for the fields that are
+ * withheld only for some of their instances, or null for every other field. Only SDK 2 gives it,
+ * in the disclosureControl block (TEDEFO-5229). It is not translated.
+ *
+ *
+ * Called while this field is being constructed, so an override must read the given node only,
+ * and no state of its own.
+ *
+ */
+ protected String extractUndisclosedFieldSelector(final JsonNode fieldNode) {
+ return null;
+ }
+
protected PrivacySettings extractPrivacy(final JsonNode privacyNode) {
if (privacyNode == null) {
return null;
@@ -215,6 +279,15 @@ public String getType() {
return this.type;
}
+ /**
+ * Returns the type as the SDK declares it, without any version-specific mapping. Lets a version
+ * that must not inherit such a mapping bypass it (see SdkFieldV1's measure/duration mapping,
+ * which applies to SDK 1 only).
+ */
+ protected final String getDeclaredType() {
+ return this.type;
+ }
+
public String getCodelistId() {
return this.codelistId;
}
@@ -275,6 +348,33 @@ public PrivacySettings getPrivacySettings() {
return this.privacySettings;
}
+ /**
+ * Sets the fields that hold the disclosure data of this field. SDK 1 names them in the privacy
+ * block of each withheld field, so they are read with the field; SDK 2 names them once, by their
+ * stable identifiers in fields/fwd/fields.json, so they are filled in afterwards (TEDEFO-5230).
+ */
+ public void setDisclosureControlFields(final PrivacySettings disclosureControlFields) {
+ this.privacySettings = disclosureControlFields;
+ }
+
+ /**
+ * @return the EFX condition ({@code {fieldId} ${conditionEfx}}) under which this field is
+ * withheld, as the SDK gives it and untranslated, or null when this field is not withheld
+ * or is withheld for all of its instances. Only SDK 2 gives it (TEDEFO-5229).
+ */
+ public String getWithholdingCondition() {
+ return this.withholdingCondition;
+ }
+
+ /**
+ * @return the EFX selector ({@code {ND-Root} &{/fieldId[conditionEfx]}}) locating this field
+ * when it is withheld, as the SDK gives it and untranslated, or null when this field is not
+ * withheld or is withheld for all of its instances. Only SDK 2 gives it (TEDEFO-5229).
+ */
+ public String getUndisclosedFieldSelector() {
+ return this.undisclosedFieldSelector;
+ }
+
public SdkNode getParentNode() {
return this.parentNode;
}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/entity/v2/SdkFieldV2.java b/src/main/java/eu/europa/ted/eforms/sdk/entity/v2/SdkFieldV2.java
index d69ad30..6d7be29 100644
--- a/src/main/java/eu/europa/ted/eforms/sdk/entity/v2/SdkFieldV2.java
+++ b/src/main/java/eu/europa/ted/eforms/sdk/entity/v2/SdkFieldV2.java
@@ -8,8 +8,26 @@
import eu.europa.ted.eforms.sdk.component.SdkComponentType;
import eu.europa.ted.eforms.sdk.entity.v1.SdkFieldV1;
+/**
+ * A field of SDK 2, read from fields/fwd/fields.json (TEDEFO-5229).
+ *
+ *
+ * Compared with fields/fields.json, the properties this entity reads differ as follows: repeatable
+ * is a plain boolean instead of an object with a value; the privacy block is replaced by
+ * disclosureControl, whose groupId is the privacy code and which also carries the two EFX
+ * expressions of the fields that are withheld only for some of their instances; and the four
+ * fields that held the disclosure data are no longer given per field, so they are not read here
+ * (TEDEFO-5230 links them to each field instead).
+ *
+ */
@SdkComponent(versions = {"2"}, componentType = SdkComponentType.FIELD)
public class SdkFieldV2 extends SdkFieldV1 {
+
+ /**
+ * Replaces the privacy block of fields/fields.json (TEDEFO-5128).
+ */
+ private static final String DISCLOSURE_CONTROL = "disclosureControl";
+
private final String alias;
public SdkFieldV2(String id, String type, String parentNodeId, String xpathAbsolute,
@@ -40,4 +58,91 @@ public SdkFieldV2(
public String getAlias() {
return alias;
}
+
+ /**
+ * fields/fwd/fields.json gives repeatable as a plain boolean, where fields/fields.json gives an
+ * object with a value. Both are read, because an SDK 2 field is read from either file.
+ */
+ @Override
+ protected boolean extractRepeatable(final JsonNode fieldNode) {
+ final JsonNode repeatableNode = fieldNode.get("repeatable");
+ if (repeatableNode != null && repeatableNode.isBoolean()) {
+ return repeatableNode.booleanValue();
+ }
+ return super.extractRepeatable(fieldNode);
+ }
+
+ /**
+ * fields/fwd/fields.json renames the field property from codeList to codelist (TEDEFO-5240),
+ * where fields/fields.json still gives codeList. An SDK 2 field is read from either file, so both
+ * are tried, the fwd name first.
+ */
+ @Override
+ protected String extractCodelistId(final JsonNode fieldNode) {
+ final JsonNode codelistNode = fieldNode.get("codelist");
+ if (codelistNode == null) {
+ return super.extractCodelistId(fieldNode);
+ }
+ final JsonNode valueNode = codelistNode.get("value");
+ if (valueNode == null) {
+ return null;
+ }
+ return valueNode.get("id").asText(null);
+ }
+
+ /**
+ * fields/fwd/fields.json gives the identifier of the disclosure group as the groupId of the
+ * disclosureControl block, where fields/fields.json gives it as the code of the privacy block.
+ */
+ @Override
+ protected String extractDisclosureGroupId(final JsonNode fieldNode) {
+ if (disclosureControlOf(fieldNode) == null) {
+ return super.extractDisclosureGroupId(fieldNode);
+ }
+ return textOfDisclosureControl(fieldNode, "groupId");
+ }
+
+ /**
+ * fields/fwd/fields.json no longer gives the fields that hold the disclosure data per field: they
+ * are the same for every field, and TEDEFO-5230 links them to each field instead. They are still
+ * read from the privacy block of fields/fields.json.
+ */
+ @Override
+ protected PrivacySettings extractDisclosureControlFields(final JsonNode fieldNode) {
+ if (disclosureControlOf(fieldNode) == null) {
+ return super.extractDisclosureControlFields(fieldNode);
+ }
+ return null;
+ }
+
+ @Override
+ protected String extractWithholdingCondition(final JsonNode fieldNode) {
+ return textOfDisclosureControl(fieldNode, "withholdingCondition");
+ }
+
+ @Override
+ protected String extractUndisclosedFieldSelector(final JsonNode fieldNode) {
+ return textOfDisclosureControl(fieldNode, "undisclosedFieldSelector");
+ }
+
+ /**
+ * SDK 2 separates duration from measure, so the mapping of measure onto duration that
+ * {@link SdkFieldV1} applies for SDK 1 must not apply here.
+ */
+ @Override
+ public String getType() {
+ return this.getDeclaredType();
+ }
+
+ private static JsonNode disclosureControlOf(final JsonNode fieldNode) {
+ return fieldNode.get(DISCLOSURE_CONTROL);
+ }
+
+ private static String textOfDisclosureControl(final JsonNode fieldNode, final String property) {
+ final JsonNode disclosureControl = disclosureControlOf(fieldNode);
+ if (disclosureControl == null || !disclosureControl.hasNonNull(property)) {
+ return null;
+ }
+ return disclosureControl.get(property).asText(null);
+ }
}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/MapFromJson.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/MapFromJson.java
index 11cdd54..ac6c3cb 100644
--- a/src/main/java/eu/europa/ted/eforms/sdk/repository/MapFromJson.java
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/MapFromJson.java
@@ -5,7 +5,11 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.MessageFormat;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.slf4j.Logger;
@@ -14,6 +18,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
+import eu.europa.ted.eforms.sdk.SdkConstants;
public abstract class MapFromJson extends HashMap {
private static final long serialVersionUID = 1L;
@@ -85,6 +90,37 @@ protected void populateMap(final JsonNode json, final Object... context)
populateMap(json);
}
+ /**
+ * Reads the map of stable identifiers ({@link SdkConstants#STABLE_IDS_KEY}), which
+ * fields/fwd/fields.json and fields/fwd/nodes.json of SDK 2 each start with.
+ *
+ *
+ * Applications use the stable identifier, such as "disclosureDate", instead of hard-coding the
+ * identifier of the field or the node.
+ *
+ *
+ * @param json the root of a file that may start with a map of stable identifiers
+ * @return the identifier of each field or node, by its stable identifier, in the order of the
+ * file, or an empty map for the SDK versions whose files carry no such map
+ */
+ protected static Map stableIdsOf(final JsonNode json) {
+ final JsonNode stableIds = json.get(SdkConstants.STABLE_IDS_KEY);
+ if (stableIds == null || !stableIds.isObject()) {
+ return Collections.emptyMap();
+ }
+
+ // Keep the order of the file, so that what is read back is what the SDK gives.
+ final Map idByStableId = new LinkedHashMap<>();
+ final Iterator names = stableIds.fieldNames();
+ while (names.hasNext()) {
+ final String stableId = names.next();
+ if (stableIds.hasNonNull(stableId)) {
+ idByStableId.put(stableId, stableIds.get(stableId).asText(null));
+ }
+ }
+ return Collections.unmodifiableMap(idByStableId);
+ }
+
/**
* @return A reusable Jackson object mapper instance.
*/
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepository.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepository.java
index 31de339..30fa0f1 100644
--- a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepository.java
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepository.java
@@ -16,6 +16,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
@@ -26,22 +28,41 @@
/**
* Repository of SDK data types.
*
- * Currently uses hardcoded type definitions, in the data-types.json resource of this library.
+ *
+ * SDK 2 publishes its data types in fields/fwd/data-types.json (TEDEFO-5231). SDK 1 publishes
+ * none, so its data types come from the data-types.json resource of this library, which has the
+ * same format. The data types do not depend on the SDK version.
+ *
*/
public class SdkDataTypeRepository extends HashMap {
private static final long serialVersionUID = 1L;
/**
- * Creates the repository of the data types, with their privacy masks, the attributes that their
- * fields carry, and the code lists of their values. They are read from the data-types.json
- * resource of this library, which has the format of fields/fwd/data-types.json in SDK 2. The
- * data types are the same for every SDK version.
+ * Creates the repository of the data types of the SDK in the given folder, with their privacy
+ * masks, the attributes that their fields carry, and the code lists of their values. They are
+ * read from the fields/fwd/data-types.json of the SDK, or, for an SDK that publishes no data
+ * types, from the data-types.json resource of this library.
+ *
+ * @param sdkPath the folder of the SDK
+ */
+ public SdkDataTypeRepository(final Path sdkPath) {
+ final Path dataTypesJson =
+ sdkPath.resolve(SdkConstants.SdkResource.FIELDS_FWD_DATA_TYPES.getPath());
+ this.addDataTypes(Files.exists(dataTypesJson) ? readSdkFile(dataTypesJson) : readResource());
+ }
+
+ /**
+ * Creates the repository of the data types from the data-types.json resource of this library.
+ *
+ * @deprecated Use {@link SdkRepositoryFactory#getSdkDataTypeRepository(String, Path)}, which
+ * reads the data types of an SDK from the folder of that SDK. This constructor always
+ * gives the list of data types built into this library, whatever the SDK: for SDK 2,
+ * which publishes its own list, the two can differ, for example in their masking
+ * values.
*/
+ @Deprecated(since = "1.10.0", forRemoval = true)
public SdkDataTypeRepository() {
- for (final JsonNode dataType : readResource().get(SdkConstants.DATA_TYPES_JSON_DATA_TYPES_KEY)) {
- final SdkDataType sdkDataType = new SdkDataType(dataType);
- this.put(sdkDataType.getId(), sdkDataType);
- }
+ this.addDataTypes(readResource());
}
private SdkDataTypeRepository(final Map dataTypes) {
@@ -52,8 +73,8 @@ private SdkDataTypeRepository(final Map dataTypes) {
* Creates a repository with the default set of SDK data types and their privacy masks. This is a
* temporary approach until data-types.json is available in the SDK.
*
- * @deprecated Use {@link #SdkDataTypeRepository()}, which also gives the attributes that the
- * fields of each data type carry.
+ * @deprecated Use {@link SdkRepositoryFactory#getSdkDataTypeRepository(String, Path)}, which
+ * also gives the attributes that the fields of each data type carry.
*/
@Deprecated
public static SdkDataTypeRepository createDefault() {
@@ -86,6 +107,13 @@ private void addType(String id, String privacyMask) {
this.put(id, new SdkDataType(id, privacyMask));
}
+ private void addDataTypes(final JsonNode json) {
+ for (final JsonNode dataType : json.get(SdkConstants.DATA_TYPES_JSON_DATA_TYPES_KEY)) {
+ final SdkDataType sdkDataType = new SdkDataType(dataType);
+ this.put(sdkDataType.getId(), sdkDataType);
+ }
+ }
+
private static JsonNode readResource() {
try (InputStream input =
SdkDataTypeRepository.class.getResourceAsStream(SdkConstants.DATA_TYPES_JSON_FILE_NAME)) {
@@ -94,4 +122,12 @@ private static JsonNode readResource() {
throw new UncheckedIOException(e);
}
}
+
+ private static JsonNode readSdkFile(final Path dataTypesPath) {
+ try (InputStream input = Files.newInputStream(dataTypesPath)) {
+ return new ObjectMapper().readTree(input);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepository.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepository.java
index a06f6aa..a07333a 100644
--- a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepository.java
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepository.java
@@ -2,20 +2,64 @@
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import eu.europa.ted.eforms.sdk.SdkConstants;
import eu.europa.ted.eforms.sdk.entity.SdkEntityFactory;
import eu.europa.ted.eforms.sdk.entity.SdkField;
+/**
+ * The fields of an SDK, by identifier.
+ *
+ *
+ * Each SDK version keeps its fields in its own file. The repository of an SDK version, which knows
+ * that file, is given by
+ * {@link SdkRepositoryFactory#getSdkFieldRepository(String, Path, SdkNodeRepository)}.
+ *
+ */
public class SdkFieldRepository extends MapFromJson {
private static final long serialVersionUID = 1L;
+ /**
+ * The identifier of each field that plays a particular role, by its stable identifier
+ * (TEDEFO-5230). Null for the SDK versions that publish no such map.
+ *
+ *
+ * Deliberately left without an initialiser: it is assigned while the map of fields is being
+ * populated, which happens during the call to super, and a field initialiser would run after
+ * that call and undo it.
+ *
+ */
+ private Map stableIds;
+
+ /**
+ * Creates the repository of the fields of the given file.
+ *
+ * @deprecated Use
+ * {@link SdkRepositoryFactory#getSdkFieldRepository(String, Path, SdkNodeRepository)},
+ * which reads the fields of an SDK version from the folder of that SDK. This
+ * constructor reads only the file it is given: given the fields/fwd/fields.json of
+ * SDK 2, it reads neither the stable identifiers nor the disclosure fields.
+ */
+ @Deprecated(since = "1.10.0", forRemoval = true)
public SdkFieldRepository(String sdkVersion, Path jsonPath) throws InstantiationException {
super(sdkVersion, jsonPath);
}
+ /**
+ * Creates the repository of the fields of the given file, each linked to its parent node.
+ *
+ * @deprecated Use
+ * {@link SdkRepositoryFactory#getSdkFieldRepository(String, Path, SdkNodeRepository)},
+ * which reads the fields of an SDK version from the folder of that SDK. This
+ * constructor reads only the file it is given: given the fields/fwd/fields.json of
+ * SDK 2, it reads neither the stable identifiers nor the disclosure fields.
+ */
+ @Deprecated(since = "1.10.0", forRemoval = true)
public SdkFieldRepository(String sdkVersion, Path jsonPath, SdkNodeRepository nodeRepository)
throws InstantiationException {
super(sdkVersion, jsonPath, nodeRepository);
@@ -23,33 +67,49 @@ public SdkFieldRepository(String sdkVersion, Path jsonPath, SdkNodeRepository no
@Override
protected void populateMap(final JsonNode json) throws InstantiationException {
- populateMap(json, new Object[0]);
+ this.populateMap(json, new Object[0]);
}
@Override
protected void populateMap(final JsonNode json, final Object... context)
throws InstantiationException {
- SdkNodeRepository nodes = (context.length > 0 && context[0] instanceof SdkNodeRepository)
+ this.addFields((ArrayNode) json.get(SdkConstants.FIELDS_JSON_FIELDS_KEY),
+ nodeRepositoryOf(context));
+ this.resolveReferences();
+ }
+
+ /**
+ * @return the repository of nodes given as context, to link each field to its parent node, or
+ * null to leave the parent nodes unlinked
+ */
+ protected static SdkNodeRepository nodeRepositoryOf(final Object... context) {
+ return context.length > 0 && context[0] instanceof SdkNodeRepository
? (SdkNodeRepository) context[0]
: null;
+ }
- final ArrayNode fields = (ArrayNode) json.get(SdkConstants.FIELDS_JSON_FIELDS_KEY);
-
- // First pass: create all field entities and add them to the map
+ /**
+ * Adds the given fields, each linked to its parent node when the nodes are given.
+ */
+ protected void addFields(final ArrayNode fields, final SdkNodeRepository nodes)
+ throws InstantiationException {
for (final JsonNode field : fields) {
- final SdkField sdkField = SdkEntityFactory.getSdkField(sdkVersion, field);
- put(sdkField.getId(), sdkField);
-
+ final SdkField sdkField = SdkEntityFactory.getSdkField(this.sdkVersion, field);
+ this.put(sdkField.getId(), sdkField);
if (nodes != null && sdkField.getParentNodeId() != null) {
sdkField.setParentNode(nodes.get(sdkField.getParentNodeId()));
}
}
+ }
- // Second pass: resolve cross-field references
+ /**
+ * Resolves the fields that each field refers to by identifier: its disclosure fields, its
+ * attribute fields and the field it is an attribute of.
+ */
+ protected void resolveReferences() {
for (final SdkField sdkField : this.values()) {
if (sdkField.getPrivacySettings() != null) {
- SdkField.PrivacySettings privacy = sdkField.getPrivacySettings();
-
+ final SdkField.PrivacySettings privacy = sdkField.getPrivacySettings();
if (privacy.getPrivacyCodeFieldId() != null) {
privacy.setPrivacyCodeField(this.get(privacy.getPrivacyCodeFieldId()));
}
@@ -66,9 +126,9 @@ protected void populateMap(final JsonNode json, final Object... context)
}
if (!sdkField.getAttributes().isEmpty()) {
- List attrFields = new ArrayList<>();
- for (String attrFieldId : sdkField.getAttributes()) {
- SdkField attrField = this.get(attrFieldId);
+ final List attrFields = new ArrayList<>();
+ for (final String attrFieldId : sdkField.getAttributes()) {
+ final SdkField attrField = this.get(attrFieldId);
if (attrField != null) {
attrFields.add(attrField);
}
@@ -81,4 +141,44 @@ protected void populateMap(final JsonNode json, final Object... context)
}
}
}
+
+ /**
+ * Reads the map of stable identifiers, which the given file starts with.
+ */
+ protected void readStableIds(final JsonNode json) {
+ this.stableIds = stableIdsOf(json);
+ }
+
+ /**
+ * Reads the map of stable identifiers, which the given file starts with, and completes it with the
+ * given defaults for the stable identifiers that the file does not give. A stable identifier that
+ * the file gives keeps the identifier of the file.
+ *
+ * @param json the root of the file
+ * @param defaults the identifier of a field by its stable identifier, for each stable identifier
+ * that the file may lack
+ */
+ protected void readStableIds(final JsonNode json, final Map defaults) {
+ final Map idByStableId = new LinkedHashMap<>(stableIdsOf(json));
+ defaults.forEach(idByStableId::putIfAbsent);
+ this.stableIds = Collections.unmodifiableMap(idByStableId);
+ }
+
+ /**
+ * @return the identifier of each field that plays a particular role, by its stable identifier, as
+ * the SDK gives them, completed with the defaults of the SDK version, if any. Empty for
+ * the SDK versions that publish no such map.
+ */
+ public Map getStableIds() {
+ return this.stableIds == null ? Collections.emptyMap() : this.stableIds;
+ }
+
+ /**
+ * @param stableId a stable identifier, such as "disclosureDate"
+ * @return the identifier of the field that plays that role, or null if the SDK names none. An
+ * unknown stable identifier is not an error.
+ */
+ public String getFieldIdByStableId(final String stableId) {
+ return this.getStableIds().get(stableId);
+ }
}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepository.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepository.java
index c853153..c0bba79 100644
--- a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepository.java
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepository.java
@@ -2,32 +2,69 @@
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import eu.europa.ted.eforms.sdk.SdkConstants;
import eu.europa.ted.eforms.sdk.entity.SdkEntityFactory;
import eu.europa.ted.eforms.sdk.entity.SdkNode;
+/**
+ * The nodes of an SDK, by identifier.
+ *
+ *
+ * Each SDK version keeps its nodes in its own file. The repository of an SDK version, which knows
+ * that file, is given by {@link SdkRepositoryFactory#getSdkNodeRepository(String, Path)}.
+ *
+ */
public class SdkNodeRepository extends MapFromJson {
private static final long serialVersionUID = 1L;
+ /**
+ * The identifier of each node that plays a particular role, by its stable identifier
+ * (TEDEFO-5230). Null for the SDK versions that publish no such map.
+ *
+ *
+ * Deliberately left without an initialiser: it is assigned while the map of nodes is being
+ * populated, which happens during the call to super, and a field initialiser would run after
+ * that call and undo it.
+ *
+ */
+ private Map stableIds;
+
+ /**
+ * Creates the repository of the nodes from the xmlStructure array of the given fields.json.
+ *
+ * @deprecated Use {@link SdkRepositoryFactory#getSdkNodeRepository(String, Path)}, which reads
+ * the nodes of an SDK version from the folder of that SDK. This constructor reads
+ * only the xmlStructure array of the file it is given, so it cannot read the
+ * fields/fwd/nodes.json of SDK 2.
+ */
+ @Deprecated(since = "1.10.0", forRemoval = true)
public SdkNodeRepository(String sdkVersion, Path jsonPath) throws InstantiationException {
super(sdkVersion, jsonPath);
}
@Override
protected void populateMap(final JsonNode json) throws InstantiationException {
- final ArrayNode nodes = (ArrayNode) json.get(SdkConstants.FIELDS_JSON_XML_STRUCTURE_KEY);
- List needsParentWiring = new ArrayList<>();
+ this.addNodes((ArrayNode) json.get(SdkConstants.FIELDS_JSON_XML_STRUCTURE_KEY));
+ }
+
+ /**
+ * Adds the given nodes, each linked to its parent node.
+ */
+ protected void addNodes(final ArrayNode nodes) throws InstantiationException {
+ final List needsParentWiring = new ArrayList<>();
// First pass: create all nodes, optimistically set parent if already loaded
for (final JsonNode node : nodes) {
- final SdkNode sdkNode = SdkEntityFactory.getSdkNode(sdkVersion, node);
- put(sdkNode.getId(), sdkNode);
+ final SdkNode sdkNode = SdkEntityFactory.getSdkNode(this.sdkVersion, node);
+ this.put(sdkNode.getId(), sdkNode);
if (sdkNode.getParentId() != null) {
- SdkNode parent = get(sdkNode.getParentId());
+ final SdkNode parent = this.get(sdkNode.getParentId());
if (parent != null) {
sdkNode.setParent(parent);
} else {
@@ -37,8 +74,32 @@ protected void populateMap(final JsonNode json) throws InstantiationException {
}
// Second pass: wire up any nodes whose parent wasn't loaded yet
- for (SdkNode sdkNode : needsParentWiring) {
- sdkNode.setParent(get(sdkNode.getParentId()));
+ for (final SdkNode sdkNode : needsParentWiring) {
+ sdkNode.setParent(this.get(sdkNode.getParentId()));
}
}
+
+ /**
+ * Reads the map of stable identifiers, which the given file starts with.
+ */
+ protected void readStableIds(final JsonNode json) {
+ this.stableIds = stableIdsOf(json);
+ }
+
+ /**
+ * @return the identifier of each node that plays a particular role, by its stable identifier, as
+ * the SDK gives them. Empty for the SDK versions that publish no such map.
+ */
+ public Map getStableIds() {
+ return this.stableIds == null ? Collections.emptyMap() : this.stableIds;
+ }
+
+ /**
+ * @param stableId a stable identifier, such as "root"
+ * @return the identifier of the node that plays that role, or null if the SDK names none. An
+ * unknown stable identifier is not an error.
+ */
+ public String getNodeIdByStableId(final String stableId) {
+ return this.getStableIds().get(stableId);
+ }
}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkRepositoryFactory.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkRepositoryFactory.java
new file mode 100644
index 0000000..3e6644e
--- /dev/null
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/SdkRepositoryFactory.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository;
+
+import java.nio.file.Path;
+import eu.europa.ted.eforms.sdk.component.SdkComponent;
+import eu.europa.ted.eforms.sdk.component.SdkComponentFactory;
+import eu.europa.ted.eforms.sdk.component.SdkComponentType;
+
+/**
+ * Gives the repositories of an SDK version. Each SDK version keeps its metadata in its own files,
+ * so each has its own repositories, registered with {@link SdkComponent} for its major version.
+ */
+public class SdkRepositoryFactory extends SdkComponentFactory {
+ public static final SdkRepositoryFactory INSTANCE = new SdkRepositoryFactory();
+
+ private SdkRepositoryFactory() {
+ super();
+ }
+
+ /**
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ * @return the nodes of that SDK, by identifier
+ * @throws InstantiationException if the nodes cannot be read for this SDK version
+ */
+ public static SdkNodeRepository getSdkNodeRepository(final String sdkVersion,
+ final Path sdkPath) throws InstantiationException {
+ return SdkRepositoryFactory.INSTANCE.getComponentImpl(sdkVersion,
+ SdkComponentType.NODE_REPOSITORY, SdkNodeRepository.class, sdkVersion, sdkPath);
+ }
+
+ /**
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ * @param nodeRepository the nodes of the same SDK, to link each field to its parent node; may be
+ * null to leave the parent nodes unlinked
+ * @return the fields of that SDK, by identifier
+ * @throws InstantiationException if the fields cannot be read for this SDK version
+ */
+ public static SdkFieldRepository getSdkFieldRepository(final String sdkVersion,
+ final Path sdkPath, final SdkNodeRepository nodeRepository) throws InstantiationException {
+ return SdkRepositoryFactory.INSTANCE.getComponentImpl(sdkVersion,
+ SdkComponentType.FIELD_REPOSITORY, SdkFieldRepository.class, sdkVersion, sdkPath,
+ nodeRepository);
+ }
+
+ /**
+ * The data types do not depend on the SDK version: they are read from the
+ * fields/fwd/data-types.json of the SDK, or from the resource of this library for an SDK that
+ * publishes none (SDK 1). The version is asked for all the same, so that every repository of an
+ * SDK is obtained in the same way.
+ *
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ * @return the data types of that SDK, by identifier
+ */
+ public static SdkDataTypeRepository getSdkDataTypeRepository(final String sdkVersion,
+ final Path sdkPath) {
+ return new SdkDataTypeRepository(sdkPath);
+ }
+}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/v1/SdkFieldRepositoryV1.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/v1/SdkFieldRepositoryV1.java
new file mode 100644
index 0000000..23761eb
--- /dev/null
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/v1/SdkFieldRepositoryV1.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository.v1;
+
+import java.nio.file.Path;
+import eu.europa.ted.eforms.sdk.SdkConstants;
+import eu.europa.ted.eforms.sdk.component.SdkComponent;
+import eu.europa.ted.eforms.sdk.component.SdkComponentType;
+import eu.europa.ted.eforms.sdk.repository.SdkFieldRepository;
+import eu.europa.ted.eforms.sdk.repository.SdkNodeRepository;
+
+/**
+ * The fields of an SDK 1, read from its fields/fields.json.
+ */
+@SdkComponent(versions = {"1"}, componentType = SdkComponentType.FIELD_REPOSITORY)
+public class SdkFieldRepositoryV1 extends SdkFieldRepository {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ * @param nodeRepository the nodes of the same SDK, to link each field to its parent node; may be
+ * null to leave the parent nodes unlinked
+ */
+ // The constructor of the base class is deprecated because applications should not choose the
+ // file to read. They use SdkRepositoryFactory, which creates this class, and this class passes
+ // the file of its own SDK version.
+ @SuppressWarnings("removal")
+ public SdkFieldRepositoryV1(final String sdkVersion, final Path sdkPath,
+ final SdkNodeRepository nodeRepository) throws InstantiationException {
+ super(sdkVersion, sdkPath.resolve(SdkConstants.SdkResource.FIELDS_JSON.getPath()),
+ nodeRepository);
+ }
+}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/v1/SdkNodeRepositoryV1.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/v1/SdkNodeRepositoryV1.java
new file mode 100644
index 0000000..d8de093
--- /dev/null
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/v1/SdkNodeRepositoryV1.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository.v1;
+
+import java.nio.file.Path;
+import eu.europa.ted.eforms.sdk.SdkConstants;
+import eu.europa.ted.eforms.sdk.component.SdkComponent;
+import eu.europa.ted.eforms.sdk.component.SdkComponentType;
+import eu.europa.ted.eforms.sdk.repository.SdkNodeRepository;
+
+/**
+ * The nodes of an SDK 1, read from the xmlStructure array of its fields/fields.json.
+ */
+@SdkComponent(versions = {"1"}, componentType = SdkComponentType.NODE_REPOSITORY)
+public class SdkNodeRepositoryV1 extends SdkNodeRepository {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ */
+ // The constructor of the base class is deprecated because applications should not choose the
+ // file to read. They use SdkRepositoryFactory, which creates this class, and this class passes
+ // the file of its own SDK version.
+ @SuppressWarnings("removal")
+ public SdkNodeRepositoryV1(final String sdkVersion, final Path sdkPath)
+ throws InstantiationException {
+ super(sdkVersion, sdkPath.resolve(SdkConstants.SdkResource.FIELDS_JSON.getPath()));
+ }
+}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/v2/SdkFieldRepositoryV2.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/v2/SdkFieldRepositoryV2.java
new file mode 100644
index 0000000..57871ff
--- /dev/null
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/v2/SdkFieldRepositoryV2.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository.v2;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import eu.europa.ted.eforms.sdk.SdkConstants;
+import eu.europa.ted.eforms.sdk.component.SdkComponent;
+import eu.europa.ted.eforms.sdk.component.SdkComponentType;
+import eu.europa.ted.eforms.sdk.entity.SdkField;
+import eu.europa.ted.eforms.sdk.repository.SdkFieldRepository;
+import eu.europa.ted.eforms.sdk.repository.SdkNodeRepository;
+
+/**
+ * The fields of an SDK 2, read from its fields/fwd/fields.json (TEDEFO-5229), with the map of
+ * stable identifiers, which the file starts with (TEDEFO-5230).
+ *
+ *
+ * SDK 2 names the four disclosure fields once, by their stable identifiers, instead of naming them
+ * in the privacy block of each field that can be withheld. This repository gives them to each such
+ * field, so that its privacy settings are the same as with SDK 1.
+ *
+ */
+@SdkComponent(versions = {"2"}, componentType = SdkComponentType.FIELD_REPOSITORY)
+public class SdkFieldRepositoryV2 extends SdkFieldRepository {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * The field of each disclosure stable identifier, for an SDK 2 whose fields/fwd/fields.json does
+ * not give it, such as 2.0.0-alpha.5, which was exported without its map of stable identifiers.
+ */
+ // TODO: Remove these defaults once SDK 2.0.0 is released, as from then on every SDK 2 gives its
+ // map of stable identifiers.
+ private static final Map DEFAULT_DISCLOSURE_FIELD_IDS =
+ defaultDisclosureFieldIds();
+
+ /**
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ * @param nodeRepository the nodes of the same SDK, to link each field to its parent node; may be
+ * null to leave the parent nodes unlinked
+ */
+ // The constructor of the base class is deprecated because applications should not choose the
+ // file to read. They use SdkRepositoryFactory, which creates this class, and this class passes
+ // the file of its own SDK version.
+ @SuppressWarnings("removal")
+ public SdkFieldRepositoryV2(final String sdkVersion, final Path sdkPath,
+ final SdkNodeRepository nodeRepository) throws InstantiationException {
+ super(sdkVersion, sdkPath.resolve(SdkConstants.SdkResource.FIELDS_FWD_FIELDS_JSON.getPath()),
+ nodeRepository);
+ }
+
+ @Override
+ protected void populateMap(final JsonNode json, final Object... context)
+ throws InstantiationException {
+ this.readStableIds(json, DEFAULT_DISCLOSURE_FIELD_IDS);
+ this.addFields((ArrayNode) json.get(SdkConstants.FIELDS_JSON_FIELDS_KEY),
+ nodeRepositoryOf(context));
+ this.linkTheDisclosureFields();
+ this.resolveReferences();
+ }
+
+ /**
+ * Gives the four disclosure fields, found by their stable identifiers, to each field that can be
+ * withheld, that is each field that has a disclosure control, and therefore a privacy code. A
+ * stable identifier that the file does not give falls back to
+ * {@link #DEFAULT_DISCLOSURE_FIELD_IDS}, never to the privacy blocks of fields/fields.json.
+ */
+ private void linkTheDisclosureFields() {
+ for (final SdkField sdkField : this.values()) {
+ if (sdkField.getPrivacyCode() != null) {
+ sdkField.setDisclosureControlFields(new SdkField.PrivacySettings(
+ this.getFieldIdByStableId(SdkConstants.STABLE_ID_DISCLOSURE_GROUP),
+ this.getFieldIdByStableId(SdkConstants.STABLE_ID_DISCLOSURE_JUSTIFICATION_CODE),
+ this.getFieldIdByStableId(
+ SdkConstants.STABLE_ID_DISCLOSURE_JUSTIFICATION_DESCRIPTION),
+ this.getFieldIdByStableId(SdkConstants.STABLE_ID_DISCLOSURE_DATE)));
+ }
+ }
+ }
+
+ private static Map defaultDisclosureFieldIds() {
+ final Map fieldIdByStableId = new LinkedHashMap<>();
+ fieldIdByStableId.put(SdkConstants.STABLE_ID_DISCLOSURE_GROUP, "BT-195-notice");
+ fieldIdByStableId.put(SdkConstants.STABLE_ID_DISCLOSURE_JUSTIFICATION_CODE, "BT-197-notice");
+ fieldIdByStableId.put(SdkConstants.STABLE_ID_DISCLOSURE_JUSTIFICATION_DESCRIPTION,
+ "BT-196-notice");
+ fieldIdByStableId.put(SdkConstants.STABLE_ID_DISCLOSURE_DATE, "BT-198-notice");
+ return Collections.unmodifiableMap(fieldIdByStableId);
+ }
+}
diff --git a/src/main/java/eu/europa/ted/eforms/sdk/repository/v2/SdkNodeRepositoryV2.java b/src/main/java/eu/europa/ted/eforms/sdk/repository/v2/SdkNodeRepositoryV2.java
new file mode 100644
index 0000000..d2b7463
--- /dev/null
+++ b/src/main/java/eu/europa/ted/eforms/sdk/repository/v2/SdkNodeRepositoryV2.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository.v2;
+
+import java.nio.file.Path;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import eu.europa.ted.eforms.sdk.SdkConstants;
+import eu.europa.ted.eforms.sdk.component.SdkComponent;
+import eu.europa.ted.eforms.sdk.component.SdkComponentType;
+import eu.europa.ted.eforms.sdk.repository.SdkNodeRepository;
+
+/**
+ * The nodes of an SDK 2, read from its fields/fwd/nodes.json (TEDEFO-5228), with the map of stable
+ * identifiers, which the file starts with (TEDEFO-5230).
+ */
+@SdkComponent(versions = {"2"}, componentType = SdkComponentType.NODE_REPOSITORY)
+public class SdkNodeRepositoryV2 extends SdkNodeRepository {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param sdkVersion the version of the SDK
+ * @param sdkPath the folder of the SDK
+ */
+ // The constructor of the base class is deprecated because applications should not choose the
+ // file to read. They use SdkRepositoryFactory, which creates this class, and this class passes
+ // the file of its own SDK version.
+ @SuppressWarnings("removal")
+ public SdkNodeRepositoryV2(final String sdkVersion, final Path sdkPath)
+ throws InstantiationException {
+ super(sdkVersion, sdkPath.resolve(SdkConstants.SdkResource.FIELDS_FWD_NODES_JSON.getPath()));
+ }
+
+ @Override
+ protected void populateMap(final JsonNode json) throws InstantiationException {
+ this.readStableIds(json);
+ this.addNodes((ArrayNode) json.get(SdkConstants.NODES_JSON_NODES_KEY));
+ }
+}
diff --git a/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepositoryTest.java b/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepositoryTest.java
index 8c66b92..79dd7fe 100644
--- a/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepositoryTest.java
+++ b/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkDataTypeRepositoryTest.java
@@ -15,20 +15,42 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.Test;
import eu.europa.ted.eforms.sdk.entity.SdkDataType;
class SdkDataTypeRepositoryTest {
+ /** SDK 1 publishes no data types: they come from the resource of this library. */
+ private static final String SDK_1 = "1.16";
+
+ /** SDK 2 publishes its data types in fields/fwd/data-types.json. */
+ private static final String SDK_2 = "2.0";
+
+ /**
+ * The folders of the SDKs, one per version. Only the one of SDK 2 holds a
+ * fields/fwd/data-types.json, deliberately different from the resource.
+ */
+ private static final Path SDK_ROOT = Path.of("src", "test", "resources", "sdk-fixture");
+
+ private static SdkDataTypeRepository ofSdk1() throws InstantiationException {
+ return SdkRepositoryFactory.getSdkDataTypeRepository(SDK_1, SDK_ROOT.resolve(SDK_1));
+ }
+
+ private static SdkDataTypeRepository ofSdk2() throws InstantiationException {
+ return SdkRepositoryFactory.getSdkDataTypeRepository(SDK_2, SDK_ROOT.resolve(SDK_2));
+ }
+
@Test
- void testAllDataTypesAreLoaded() {
- assertEquals(23, new SdkDataTypeRepository().size());
+ void testAllDataTypesAreLoaded() throws InstantiationException {
+ assertEquals(23, ofSdk1().size());
}
@Test
- void testAmountHasCurrencyAttribute() {
- SdkDataType amount = new SdkDataTypeRepository().get("amount");
+ void testAmountHasCurrencyAttribute() throws InstantiationException {
+ SdkDataType amount = ofSdk1().get("amount");
assertEquals("-1", amount.getPrivacyMask());
assertEquals("currencyID", amount.getAttributeName());
@@ -36,24 +58,24 @@ void testAmountHasCurrencyAttribute() {
}
@Test
- void testDurationAndMeasureHaveTheirOwnUnits() {
- SdkDataTypeRepository repository = new SdkDataTypeRepository();
+ void testDurationAndMeasureHaveTheirOwnUnits() throws InstantiationException {
+ SdkDataTypeRepository repository = ofSdk1();
assertEquals("duration-unit", repository.get("duration").getAttributeType());
assertEquals("measurement-unit", repository.get("measure").getAttributeType());
}
@Test
- void testCodelistOfTheUnitsOfDuration() {
- SdkDataTypeRepository repository = new SdkDataTypeRepository();
+ void testCodelistOfTheUnitsOfDuration() throws InstantiationException {
+ SdkDataTypeRepository repository = ofSdk1();
assertEquals("timeperiod",
repository.get(repository.get("duration").getAttributeType()).getListName());
}
@Test
- void testTypesWithRootCodelist() {
- SdkDataTypeRepository repository = new SdkDataTypeRepository();
+ void testTypesWithRootCodelist() throws InstantiationException {
+ SdkDataTypeRepository repository = ofSdk1();
assertEquals("currency", repository.get("currency").getListName());
assertEquals("timeperiod", repository.get("duration-unit").getListName());
@@ -63,8 +85,8 @@ void testTypesWithRootCodelist() {
}
@Test
- void testAttributeOfTypeText() {
- SdkDataTypeRepository repository = new SdkDataTypeRepository();
+ void testAttributeOfTypeText() throws InstantiationException {
+ SdkDataTypeRepository repository = ofSdk1();
assertEquals("listName", repository.get("code").getAttributeName());
assertEquals("text", repository.get("code").getAttributeType());
@@ -74,8 +96,47 @@ void testAttributeOfTypeText() {
}
@Test
- void testTypeWithoutAttribute() {
- SdkDataType date = new SdkDataTypeRepository().get("date");
+ void testTypeWithoutAttribute() throws InstantiationException {
+ SdkDataType date = ofSdk1().get("date");
+
+ assertEquals("1970-01-01Z", date.getPrivacyMask());
+ assertNull(date.getAttributeName());
+ assertNull(date.getAttributeType());
+ assertNull(date.getListName());
+ }
+
+ /**
+ * The point of TEDEFO-5231: with SDK 2 the data types come from the SDK, not from the copy kept
+ * in this library. The masking value of the fixture differs from the one of the resource, so
+ * only the file can be the source.
+ */
+ @Test
+ void testSdk2ReadsTheDataTypesFromTheSdk() throws InstantiationException {
+ SdkDataTypeRepository repository = ofSdk2();
+
+ assertEquals(5, repository.size(), "the SDK fixture holds fewer data types than the resource");
+ assertEquals("undisclosed", repository.get("amount").getPrivacyMask());
+ assertEquals("-1", ofSdk1().get("amount").getPrivacyMask(),
+ "the resource of SDK 1 must be left alone");
+ }
+
+ @Test
+ void testSdk2ReadsTheAttributesAndTheCodelistsFromTheSdk() throws InstantiationException {
+ SdkDataTypeRepository repository = ofSdk2();
+
+ assertEquals("unitCode", repository.get("duration").getAttributeName());
+ assertEquals("duration-unit", repository.get("duration").getAttributeType());
+ assertEquals("timeperiod", repository.get("duration-unit").getListName());
+ assertEquals("currency", repository.get("currency").getListName());
+ }
+
+ /**
+ * The file of the SDK also carries properties that a data type does not read (description,
+ * valueType, displayTypes). They must be ignored, not break the loading.
+ */
+ @Test
+ void testSdk2IgnoresThePropertiesThatADataTypeDoesNotRead() throws InstantiationException {
+ SdkDataType date = ofSdk2().get("date");
assertEquals("1970-01-01Z", date.getPrivacyMask());
assertNull(date.getAttributeName());
@@ -83,11 +144,12 @@ void testTypeWithoutAttribute() {
assertNull(date.getListName());
}
+
@Test
@SuppressWarnings("deprecation")
- void testCreateDefault_HasTheOriginalTypesWithTheirMasksOnly() {
+ void testCreateDefault_HasTheOriginalTypesWithTheirMasksOnly() throws InstantiationException {
final SdkDataTypeRepository defaults = SdkDataTypeRepository.createDefault();
- final SdkDataTypeRepository dataTypes = new SdkDataTypeRepository();
+ final SdkDataTypeRepository dataTypes = ofSdk1();
assertEquals(Set.of("text", "text-multilingual", "code", "internal-code", "id", "id-ref", "phone",
"email", "url", "date", "zoned-date", "time", "zoned-time", "indicator", "integer", "number",
@@ -99,4 +161,11 @@ void testCreateDefault_HasTheOriginalTypesWithTheirMasksOnly() {
assertNull(dataType.getListName());
}
}
+
+ /** The constructor without arguments reads the resource of this library. */
+ @Test
+ @SuppressWarnings("removal")
+ void testTheConstructorWithoutArgumentsReadsTheResource() {
+ assertEquals(23, new SdkDataTypeRepository().size());
+ }
}
diff --git a/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepositoryTest.java b/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepositoryTest.java
new file mode 100644
index 0000000..980213c
--- /dev/null
+++ b/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkFieldRepositoryTest.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import eu.europa.ted.eforms.sdk.entity.SdkField;
+import eu.europa.ted.eforms.sdk.entity.v1.SdkFieldV1;
+import eu.europa.ted.eforms.sdk.entity.v2.SdkFieldV2;
+
+/**
+ * Verifies where the fields of each SDK version are read from (TEDEFO-5229): fields/fields.json for
+ * SDK 1, fields/fwd/fields.json for SDK 2, and the properties whose structure differs between the
+ * two.
+ */
+class SdkFieldRepositoryTest {
+
+ /** SDK 1 publishes its fields in fields/fields.json. */
+ private static final String SDK_1 = "1.16";
+
+ /** SDK 2 publishes its fields in fields/fwd/fields.json. */
+ private static final String SDK_2 = "2.0";
+
+ /**
+ * The folders of the SDKs, one per version. The two hold different fields on purpose, so that a
+ * test can tell which one was read.
+ */
+ private static final Path SDK_ROOT = Path.of("src", "test", "resources", "sdk-fixture");
+
+ /** Only fields/fwd/fields.json holds this field. */
+ private static final String FIELD_OF_THE_FWD_FILE = "BT-Fwd-Only";
+
+ /**
+ * The fields of SDK 2 without their map of stable identifiers, as in an SDK exported without it,
+ * such as 2.0.0-alpha.5.
+ */
+ private static final String SDK_2_WITHOUT_STABLE_IDS = "2.0-without-stable-ids";
+
+ private static SdkFieldRepository ofSdk1() throws InstantiationException {
+ final Path sdk = SDK_ROOT.resolve(SDK_1);
+ return SdkRepositoryFactory.getSdkFieldRepository(SDK_1, sdk,
+ SdkRepositoryFactory.getSdkNodeRepository(SDK_1, sdk));
+ }
+
+ private static SdkFieldRepository ofSdk2() throws InstantiationException {
+ final Path sdk = SDK_ROOT.resolve(SDK_2);
+ return SdkRepositoryFactory.getSdkFieldRepository(SDK_2, sdk,
+ SdkRepositoryFactory.getSdkNodeRepository(SDK_2, sdk));
+ }
+
+ @Test
+ void testSdk1ReadsTheFieldsFromFieldsJson() throws InstantiationException {
+ SdkFieldRepository fields = ofSdk1();
+
+ assertEquals(4, fields.size());
+ assertNull(fields.get(FIELD_OF_THE_FWD_FILE),
+ "the field of fields/fwd/fields.json must not reach SDK 1");
+ }
+
+ /**
+ * The point of TEDEFO-5229: with SDK 2 the fields come from fields/fwd/fields.json. The fwd file
+ * holds a field that fields/fields.json does not, so only it can be the source.
+ */
+ @Test
+ void testSdk2ReadsTheFieldsFromTheFwdFile() throws InstantiationException {
+ SdkFieldRepository fields = ofSdk2();
+
+ assertEquals(10, fields.size());
+ assertNotNull(fields.get(FIELD_OF_THE_FWD_FILE),
+ "the fields of SDK 2 must come from fields/fwd/fields.json");
+ }
+
+ /** SDK 2 gives repeatable as a plain boolean, SDK 1 as an object with a value. */
+ @Test
+ void testRepeatableIsReadFromBothShapes() throws InstantiationException {
+ SdkFieldRepository ofSdk1 = ofSdk1();
+ SdkFieldRepository ofSdk2 = ofSdk2();
+
+ assertTrue(ofSdk1.get("BT-Plain").isRepeatable(), "SDK 1 gives an object with a value");
+ assertFalse(ofSdk1.get("BT-NotRepeatable").isRepeatable());
+
+ assertTrue(ofSdk2.get("BT-Plain").isRepeatable(), "SDK 2 gives a plain boolean");
+ assertFalse(ofSdk2.get("BT-NotRepeatable").isRepeatable());
+ }
+
+ /** fields/fwd/fields.json names the code list of a field codelist instead of codeList. */
+ @Test
+ void testSdk2ReadsTheCodelistOfTheFwdFile() throws InstantiationException {
+ assertEquals("non-publication-justification",
+ ofSdk2().get("BT-197-notice").getCodelistId());
+ assertNull(ofSdk2().get("BT-Plain").getCodelistId(), "a field without a code list has none");
+ }
+
+ /** SDK 2 gives the disclosure group as the groupId of the disclosureControl block. */
+ @Test
+ void testTheDisclosureGroupIdComesFromTheDisclosureControlForSdk2()
+ throws InstantiationException {
+ assertEquals("con-esti", ofSdk1().get("BT-Withheld").getPrivacyCode(),
+ "SDK 1 gives it as privacy.code");
+ assertEquals("con-esti", ofSdk2().get("BT-Withheld").getPrivacyCode(),
+ "SDK 2 gives it as disclosureControl.groupId");
+ assertNull(ofSdk2().get("BT-Plain").getPrivacyCode(), "a field that is not withheld has none");
+ }
+
+ /**
+ * The two EFX expressions are present on the fields that are withheld only for some of their
+ * instances, and are loaded as they are, without being translated.
+ */
+ @Test
+ void testTheExpressionsOfTheConditionallyWithheldFields() throws InstantiationException {
+ SdkField withheldSometimes = ofSdk2().get("BT-WithheldSometimes");
+
+ assertEquals("cro-bor-law", withheldSometimes.getPrivacyCode());
+ assertEquals("{BT-WithheldSometimes} ${BT-Plain == 'x'}",
+ withheldSometimes.getWithholdingCondition());
+ assertEquals("{ND-Root} &{/BT-WithheldSometimes[BT-Plain == 'x']}",
+ withheldSometimes.getUndisclosedFieldSelector());
+ }
+
+ /** A field withheld for all of its instances carries no expression. */
+ @Test
+ void testTheFieldsWithoutExpressions() throws InstantiationException {
+ SdkFieldRepository ofSdk2 = ofSdk2();
+
+ assertNull(ofSdk2.get("BT-Withheld").getWithholdingCondition());
+ assertNull(ofSdk2.get("BT-Withheld").getUndisclosedFieldSelector());
+ assertNull(ofSdk2.get("BT-Plain").getWithholdingCondition());
+ assertNull(ofSdk2.get("BT-Plain").getUndisclosedFieldSelector());
+ }
+
+ /**
+ * SDK 1 names the four disclosure fields in the privacy block of every withheld field.
+ */
+ @Test
+ void testSdk1NamesTheDisclosureFieldsPerField() throws InstantiationException {
+ SdkField.PrivacySettings ofSdk1 = ofSdk1().get("BT-Withheld").getPrivacySettings();
+
+ assertNotNull(ofSdk1, "SDK 1 names them in the privacy block");
+ assertEquals("BT-195(BT-Withheld)-Lot", ofSdk1.getPrivacyCodeFieldId());
+ assertEquals("BT-197(BT-Withheld)-Lot", ofSdk1.getJustificationCodeFieldId());
+ assertEquals("BT-196(BT-Withheld)-Lot", ofSdk1.getJustificationDescriptionFieldId());
+ assertEquals("BT-198(BT-Withheld)-Lot", ofSdk1.getPublicationDateFieldId());
+ }
+
+ /**
+ * SDK 2 names them once, by their stable identifiers, so every field that can be withheld gets
+ * them from there instead (TEDEFO-5230). The EFX Toolkit resolves the privacy properties of
+ * EFX 2 through these, so they must stay available per field.
+ */
+ @Test
+ void testSdk2TakesTheDisclosureFieldsFromTheStableIds() throws InstantiationException {
+ SdkField.PrivacySettings ofSdk2 = ofSdk2().get("BT-Withheld").getPrivacySettings();
+
+ assertNotNull(ofSdk2, "a withheld field must still have its disclosure fields");
+ assertEquals("BT-195-notice", ofSdk2.getPrivacyCodeFieldId());
+ assertEquals("BT-197-notice", ofSdk2.getJustificationCodeFieldId());
+ assertEquals("BT-196-notice", ofSdk2.getJustificationDescriptionFieldId());
+ assertEquals("BT-198-notice", ofSdk2.getPublicationDateFieldId());
+ }
+
+ /** The identifiers are resolved to the fields themselves, as they are for SDK 1. */
+ @Test
+ void testTheDisclosureFieldsAreResolvedToFields() throws InstantiationException {
+ SdkField.PrivacySettings ofSdk2 = ofSdk2().get("BT-Withheld").getPrivacySettings();
+
+ assertEquals("BT-195-notice", ofSdk2.getPrivacyCodeField().getId());
+ assertEquals("BT-197-notice", ofSdk2.getJustificationCodeField().getId());
+ assertEquals("BT-196-notice", ofSdk2.getJustificationDescriptionField().getId());
+ assertEquals("BT-198-notice", ofSdk2.getPublicationDateField().getId());
+ }
+
+ /** Only the fields that can be withheld get them. */
+ @Test
+ void testAFieldThatCannotBeWithheldHasNoDisclosureFields() throws InstantiationException {
+ assertNull(ofSdk2().get("BT-Plain").getPrivacySettings(),
+ "a field that is not withheld has no disclosure fields");
+ }
+
+ /** The stable identifiers of the fields are read in full, and each gives its field. */
+ @Test
+ void testTheStableIdsOfTheFieldsAreLoaded() throws InstantiationException {
+ SdkFieldRepository ofSdk2 = ofSdk2();
+
+ assertEquals(5, ofSdk2.getStableIds().size());
+ assertEquals("BT-198-notice", ofSdk2.getFieldIdByStableId("disclosureDate"));
+ assertEquals("OPP-070-notice", ofSdk2.getFieldIdByStableId("noticeSubType"));
+ }
+
+ /**
+ * An SDK 2 that gives no map of stable identifiers still gets the disclosure fields, from their
+ * defaults, and nothing else.
+ */
+ @Test
+ void testTheDisclosureFieldsDefaultWhenTheSdkGivesNoStableIds() throws InstantiationException {
+ final SdkFieldRepository fields = SdkRepositoryFactory.getSdkFieldRepository(SDK_2,
+ SDK_ROOT.resolve(SDK_2_WITHOUT_STABLE_IDS), null);
+
+ assertEquals("BT-198-notice", fields.getFieldIdByStableId("disclosureDate"));
+ assertNull(fields.getFieldIdByStableId("noticeSubType"),
+ "only the disclosure fields have a default");
+ final SdkField.PrivacySettings disclosureFields =
+ fields.get("BT-Withheld").getPrivacySettings();
+ assertEquals("BT-195-notice", disclosureFields.getPrivacyCodeField().getId());
+ assertEquals("BT-197-notice", disclosureFields.getJustificationCodeField().getId());
+ assertEquals("BT-196-notice", disclosureFields.getJustificationDescriptionField().getId());
+ assertEquals("BT-198-notice", disclosureFields.getPublicationDateField().getId());
+ }
+
+ /** An unknown key gives no result, and is not an error. */
+ @Test
+ void testAnUnknownStableIdGivesNothing() throws InstantiationException {
+ assertNull(ofSdk2().getFieldIdByStableId("thereIsNoSuchKey"));
+ }
+
+ /** SDK 1 has no such map, so the lookup gives no result. */
+ @Test
+ void testSdk1HasNoStableIds() throws InstantiationException {
+ SdkFieldRepository ofSdk1 = ofSdk1();
+
+ assertTrue(ofSdk1.getStableIds().isEmpty());
+ assertNull(ofSdk1.getFieldIdByStableId("disclosureDate"));
+ }
+
+ /** The fields are linked to the nodes loaded from the file of the same SDK version. */
+ @Test
+ void testTheFieldsAreLinkedToTheirParentNode() throws InstantiationException {
+ assertEquals("ND-Lot", ofSdk1().get("BT-Plain").getParentNode().getId());
+ assertEquals("ND-Lot", ofSdk2().get("BT-Plain").getParentNode().getId());
+ }
+
+ /**
+ * The nodes are optional: without them, the fields are read without their parent node, and the
+ * disclosure fields of SDK 2 are still attached and resolved.
+ */
+ @Test
+ void testWithoutTheNodesTheFieldsHaveNoParentNode() throws InstantiationException {
+ final SdkFieldRepository ofSdk1 =
+ SdkRepositoryFactory.getSdkFieldRepository(SDK_1, SDK_ROOT.resolve(SDK_1), null);
+ final SdkFieldRepository ofSdk2 =
+ SdkRepositoryFactory.getSdkFieldRepository(SDK_2, SDK_ROOT.resolve(SDK_2), null);
+
+ assertNull(ofSdk1.get("BT-Plain").getParentNode());
+ assertNull(ofSdk2.get("BT-Plain").getParentNode());
+ assertEquals("BT-198-notice",
+ ofSdk2.get("BT-Withheld").getPrivacySettings().getPublicationDateField().getId());
+ }
+
+ /**
+ * SDK 1 maps measure onto duration, because it had no duration type. SDK 2 separates the two, so
+ * it must report the type the SDK declares.
+ */
+ @Test
+ void testMeasureIsOnlyMappedOntoDurationForSdk1() throws InstantiationException {
+ assertEquals("duration", ofSdk1().get("BT-Measure").getType(),
+ "SDK 1 has no duration type, so measure means duration");
+ assertEquals("measure", ofSdk2().get("BT-Measure").getType(),
+ "SDK 2 separates duration from measure");
+ }
+
+ /** Each SDK version gets its own field implementation, as before. */
+ @Test
+ void testEachSdkVersionGetsItsOwnFields() throws InstantiationException {
+ SdkField ofSdk1 = ofSdk1().get("BT-Plain");
+ SdkField ofSdk2 = ofSdk2().get("BT-Plain");
+
+ assertTrue(ofSdk1 instanceof SdkFieldV1, "SDK 1 gives " + ofSdk1.getClass());
+ assertTrue(ofSdk2 instanceof SdkFieldV2, "SDK 2 gives " + ofSdk2.getClass());
+ assertEquals("plain", ((SdkFieldV2) ofSdk2).getAlias(), "SDK 2 also reads the alias");
+ }
+
+ /** The constructor that takes a fields.json reads the file it is given. */
+ @Test
+ @SuppressWarnings("removal")
+ void testTheConstructorReadsTheFileItIsGiven() throws InstantiationException {
+ SdkFieldRepository fields = new SdkFieldRepository(SDK_1,
+ SDK_ROOT.resolve(Path.of("1.16", "fields", "fields.json")));
+
+ assertEquals(4, fields.size());
+ assertEquals("con-esti", fields.get("BT-Withheld").getPrivacyCode());
+ }
+
+ /**
+ * The EFX Toolkit still reads the fields of SDK 2 from fields/fields.json, with the constructor
+ * that takes the file. There an SDK 2 field takes repeatable from the object, its code list from
+ * codeList, and its privacy code and disclosure fields from its privacy block.
+ */
+ @Test
+ @SuppressWarnings("removal")
+ void testSdk2FieldsJsonIsReadWithItsPrivacyBlocks() throws InstantiationException {
+ final Path fieldsJson = SDK_ROOT.resolve(Path.of(SDK_2, "fields", "fields.json"));
+ final SdkFieldRepository fields = new SdkFieldRepository(SDK_2, fieldsJson,
+ new SdkNodeRepository(SDK_2, fieldsJson));
+
+ assertTrue(fields.get("BT-Plain") instanceof SdkFieldV2);
+ assertTrue(fields.get("BT-Plain").isRepeatable());
+ assertFalse(fields.get("BT-Withheld").isRepeatable());
+ assertEquals("non-publication-justification", fields.get("BT-197-notice").getCodelistId());
+ assertEquals("con-esti", fields.get("BT-Withheld").getPrivacyCode());
+ final SdkField.PrivacySettings privacy = fields.get("BT-Withheld").getPrivacySettings();
+ assertEquals("BT-195-notice", privacy.getPrivacyCodeField().getId());
+ assertEquals("BT-197-notice", privacy.getJustificationCodeField().getId());
+ assertEquals("BT-196-notice", privacy.getJustificationDescriptionField().getId());
+ assertEquals("BT-198-notice", privacy.getPublicationDateField().getId());
+ assertEquals("measure", fields.get("BT-Measure").getType(),
+ "SDK 2 separates duration from measure, whichever file it is read from");
+ }
+}
diff --git a/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepositoryTest.java b/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepositoryTest.java
new file mode 100644
index 0000000..7f0a90a
--- /dev/null
+++ b/src/test/java/eu/europa/ted/eforms/sdk/repository/SdkNodeRepositoryTest.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2026 European Union
+ *
+ * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European
+ * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in
+ * compliance with the Licence. You may obtain a copy of the Licence at:
+ * https://joinup.ec.europa.eu/software/page/eupl
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the Licence
+ * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the Licence for the specific language governing permissions and limitations under
+ * the Licence.
+ */
+package eu.europa.ted.eforms.sdk.repository;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.nio.file.Path;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+import eu.europa.ted.eforms.sdk.entity.SdkNode;
+import eu.europa.ted.eforms.sdk.entity.v1.SdkNodeV1;
+import eu.europa.ted.eforms.sdk.entity.v2.SdkNodeV2;
+
+/**
+ * Verifies where the nodes of each SDK version are read from (TEDEFO-5228): the xmlStructure array
+ * of fields/fields.json for SDK 1, the nodes array of fields/fwd/nodes.json for SDK 2.
+ */
+class SdkNodeRepositoryTest {
+
+ /** SDK 1 publishes its nodes in fields/fields.json. */
+ private static final String SDK_1 = "1.16";
+
+ /** SDK 2 publishes its nodes in fields/fwd/nodes.json. */
+ private static final String SDK_2 = "2.0";
+
+ /**
+ * The folders of the SDKs, one per version. The two hold different nodes on purpose, so that a
+ * test can tell which one was read.
+ */
+ private static final Path SDK_ROOT = Path.of("src", "test", "resources", "sdk-fixture");
+
+ /** Only fields/fwd/nodes.json holds this node. */
+ private static final String NODE_OF_THE_FWD_FILE = "ND-Fwd-Only";
+
+ @Test
+ void testSdk1ReadsTheNodesFromFieldsJson() throws InstantiationException {
+ SdkNodeRepository nodes = SdkRepositoryFactory.getSdkNodeRepository(SDK_1, SDK_ROOT.resolve(SDK_1));
+
+ assertEquals(2, nodes.size());
+ assertEquals("/*/cac:ProcurementProjectLot", nodes.get("ND-Lot").getXpathAbsolute());
+ assertNull(nodes.get(NODE_OF_THE_FWD_FILE),
+ "the node of fields/fwd/nodes.json must not reach SDK 1");
+ }
+
+ /**
+ * The point of TEDEFO-5228: with SDK 2 the nodes come from fields/fwd/nodes.json. The fwd file
+ * holds a node that fields/fields.json does not, so only it can be the source.
+ */
+ @Test
+ void testSdk2ReadsTheNodesFromTheFwdFile() throws InstantiationException {
+ SdkNodeRepository nodes = SdkRepositoryFactory.getSdkNodeRepository(SDK_2, SDK_ROOT.resolve(SDK_2));
+
+ assertEquals(3, nodes.size());
+ assertNotNull(nodes.get(NODE_OF_THE_FWD_FILE),
+ "the nodes of SDK 2 must come from fields/fwd/nodes.json");
+ assertEquals("/*/cac:ProcurementProjectLot/cac:FwdOnly",
+ nodes.get(NODE_OF_THE_FWD_FILE).getXpathAbsolute());
+ }
+
+ /** The nodes have the same properties in both files, so the node entities do not change. */
+ @Test
+ void testTheNodePropertiesAreTheSameInBothFiles() throws InstantiationException {
+ SdkNode ofSdk1 = SdkRepositoryFactory.getSdkNodeRepository(SDK_1, SDK_ROOT.resolve(SDK_1)).get("ND-Lot");
+ SdkNode ofSdk2 = SdkRepositoryFactory.getSdkNodeRepository(SDK_2, SDK_ROOT.resolve(SDK_2)).get("ND-Lot");
+
+ assertEquals(ofSdk1.getId(), ofSdk2.getId());
+ assertEquals(ofSdk1.getParentId(), ofSdk2.getParentId());
+ assertEquals(ofSdk1.getXpathAbsolute(), ofSdk2.getXpathAbsolute());
+ assertEquals(ofSdk1.getXpathRelative(), ofSdk2.getXpathRelative());
+ assertEquals(ofSdk1.isRepeatable(), ofSdk2.isRepeatable());
+ assertTrue(ofSdk2.isRepeatable(), "the fixture's lot is repeatable");
+ }
+
+ /** The parents are wired whichever file the nodes come from, so the ancestry resolves. */
+ @Test
+ void testTheParentsAreWiredForBothVersions() throws InstantiationException {
+ SdkNodeRepository ofSdk1 = SdkRepositoryFactory.getSdkNodeRepository(SDK_1, SDK_ROOT.resolve(SDK_1));
+ SdkNodeRepository ofSdk2 = SdkRepositoryFactory.getSdkNodeRepository(SDK_2, SDK_ROOT.resolve(SDK_2));
+
+ assertEquals("ND-Root", ofSdk1.get("ND-Lot").getParent().getId());
+ assertNull(ofSdk1.get("ND-Root").getParent(), "the root has no parent");
+
+ // The fwd file declares its nodes before their parents in one case, which the second pass wires.
+ assertEquals("ND-Lot", ofSdk2.get(NODE_OF_THE_FWD_FILE).getParent().getId());
+ assertEquals(Arrays.asList(NODE_OF_THE_FWD_FILE, "ND-Lot", "ND-Root"),
+ ofSdk2.get(NODE_OF_THE_FWD_FILE).getAncestry());
+ }
+
+ /** The stable identifiers of the nodes are read in full, and each gives its node. */
+ @Test
+ void testTheStableIdsOfTheNodesAreLoaded() throws InstantiationException {
+ SdkNodeRepository ofSdk2 = SdkRepositoryFactory.getSdkNodeRepository(SDK_2, SDK_ROOT.resolve(SDK_2));
+
+ assertEquals(2, ofSdk2.getStableIds().size());
+ assertEquals("ND-Root", ofSdk2.getNodeIdByStableId("root"));
+ assertEquals("ND-Organizations", ofSdk2.getNodeIdByStableId("organizations"));
+ assertNotNull(ofSdk2.get(ofSdk2.getNodeIdByStableId("root")),
+ "the key must give an identifier that the nodes can be looked up by");
+ }
+
+ /** An unknown key gives no result, and is not an error. */
+ @Test
+ void testAnUnknownStableIdGivesNothing() throws InstantiationException {
+ assertNull(SdkRepositoryFactory.getSdkNodeRepository(SDK_2, SDK_ROOT.resolve(SDK_2)).getNodeIdByStableId("thereIsNoSuchKey"));
+ }
+
+ /** SDK 1 has no such map, so the lookup gives no result. */
+ @Test
+ void testSdk1HasNoStableIds() throws InstantiationException {
+ SdkNodeRepository ofSdk1 = SdkRepositoryFactory.getSdkNodeRepository(SDK_1, SDK_ROOT.resolve(SDK_1));
+
+ assertTrue(ofSdk1.getStableIds().isEmpty());
+ assertNull(ofSdk1.getNodeIdByStableId("root"));
+ }
+
+ /** Each SDK version gets its own node implementation, as before. */
+ @Test
+ void testEachSdkVersionGetsItsOwnNodes() throws InstantiationException {
+ SdkNode ofSdk1 = SdkRepositoryFactory.getSdkNodeRepository(SDK_1, SDK_ROOT.resolve(SDK_1)).get("ND-Root");
+ SdkNode ofSdk2 = SdkRepositoryFactory.getSdkNodeRepository(SDK_2, SDK_ROOT.resolve(SDK_2)).get("ND-Root");
+
+ assertTrue(ofSdk1 instanceof SdkNodeV1, "SDK 1 gives " + ofSdk1.getClass());
+ assertTrue(ofSdk2 instanceof SdkNodeV2, "SDK 2 gives " + ofSdk2.getClass());
+ assertEquals("root", ((SdkNodeV2) ofSdk2).getAlias(), "SDK 2 also reads the alias");
+ }
+
+ /**
+ * The constructor that takes a fields.json reads the xmlStructure array of the file it is given.
+ */
+ @Test
+ @SuppressWarnings("removal")
+ void testTheConstructorReadsTheXmlStructureOfTheFileItIsGiven() throws InstantiationException {
+ SdkNodeRepository nodes = new SdkNodeRepository(SDK_1,
+ SDK_ROOT.resolve(Path.of("1.16", "fields", "fields.json")));
+
+ assertEquals(2, nodes.size());
+ assertEquals("ND-Root", nodes.get("ND-Lot").getParent().getId());
+ }
+}
diff --git a/src/test/resources/sdk-fixture/1.16/fields/fields.json b/src/test/resources/sdk-fixture/1.16/fields/fields.json
new file mode 100644
index 0000000..04c5904
--- /dev/null
+++ b/src/test/resources/sdk-fixture/1.16/fields/fields.json
@@ -0,0 +1,63 @@
+{
+ "sdkVersion" : "eforms-sdk-1.16.0",
+ "xmlStructure" : [ {
+ "id" : "ND-Root",
+ "xpathAbsolute" : "/*",
+ "xpathRelative" : "/*",
+ "repeatable" : false
+ }, {
+ "id" : "ND-Lot",
+ "parentId" : "ND-Root",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot",
+ "xpathRelative" : "cac:ProcurementProjectLot",
+ "repeatable" : true
+ } ],
+ "fields" : [ {
+ "id" : "BT-Plain",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Plain",
+ "xpathRelative" : "cbc:Plain",
+ "type" : "text",
+ "repeatable" : {
+ "value" : true,
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-NotRepeatable",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:NotRepeatable",
+ "xpathRelative" : "cbc:NotRepeatable",
+ "type" : "text",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-Withheld",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Withheld",
+ "xpathRelative" : "cbc:Withheld",
+ "type" : "text",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ },
+ "privacy" : {
+ "code" : "con-esti",
+ "unpublishedFieldId" : "BT-195(BT-Withheld)-Lot",
+ "reasonCodeFieldId" : "BT-197(BT-Withheld)-Lot",
+ "reasonDescriptionFieldId" : "BT-196(BT-Withheld)-Lot",
+ "publicationDateFieldId" : "BT-198(BT-Withheld)-Lot"
+ }
+ }, {
+ "id" : "BT-Measure",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Measure",
+ "xpathRelative" : "cbc:Measure",
+ "type" : "measure",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ }
+ } ]
+}
diff --git a/src/test/resources/sdk-fixture/2.0-without-stable-ids/fields/fwd/fields.json b/src/test/resources/sdk-fixture/2.0-without-stable-ids/fields/fwd/fields.json
new file mode 100644
index 0000000..c5223af
--- /dev/null
+++ b/src/test/resources/sdk-fixture/2.0-without-stable-ids/fields/fwd/fields.json
@@ -0,0 +1,93 @@
+{
+ "fields" : [
+ {
+ "id" : "BT-Plain",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Plain",
+ "xpathRelative" : "cbc:Plain",
+ "type" : "text",
+ "repeatable" : true,
+ "alias" : "plain"
+ },
+ {
+ "id" : "BT-NotRepeatable",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:NotRepeatable",
+ "xpathRelative" : "cbc:NotRepeatable",
+ "type" : "text",
+ "repeatable" : false
+ },
+ {
+ "id" : "BT-Withheld",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Withheld",
+ "xpathRelative" : "cbc:Withheld",
+ "type" : "text",
+ "repeatable" : false,
+ "disclosureControl" : {
+ "groupId" : "con-esti"
+ }
+ },
+ {
+ "id" : "BT-WithheldSometimes",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:WithheldSometimes",
+ "xpathRelative" : "cbc:WithheldSometimes",
+ "type" : "text",
+ "repeatable" : false,
+ "disclosureControl" : {
+ "groupId" : "cro-bor-law",
+ "withholdingCondition" : "{BT-WithheldSometimes} ${BT-Plain == 'x'}",
+ "undisclosedFieldSelector" : "{ND-Root} &{/BT-WithheldSometimes[BT-Plain == 'x']}"
+ }
+ },
+ {
+ "id" : "BT-Measure",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Measure",
+ "xpathRelative" : "cbc:Measure",
+ "type" : "measure",
+ "repeatable" : false
+ },
+ {
+ "id" : "BT-Fwd-Only",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:FwdOnly",
+ "xpathRelative" : "cbc:FwdOnly",
+ "type" : "text",
+ "repeatable" : false
+ },
+ {
+ "id" : "BT-195-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/efbc:FieldIdentifierCode",
+ "xpathRelative" : "efac:FieldsPrivacy/efbc:FieldIdentifierCode",
+ "type" : "code",
+ "repeatable" : false
+ },
+ {
+ "id" : "BT-196-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/efbc:ReasonDescription",
+ "xpathRelative" : "efac:FieldsPrivacy/efbc:ReasonDescription",
+ "type" : "text",
+ "repeatable" : false
+ },
+ {
+ "id" : "BT-197-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/cbc:ReasonCode",
+ "xpathRelative" : "efac:FieldsPrivacy/cbc:ReasonCode",
+ "type" : "code",
+ "repeatable" : false
+ },
+ {
+ "id" : "BT-198-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/efbc:PublicationDate",
+ "xpathRelative" : "efac:FieldsPrivacy/efbc:PublicationDate",
+ "type" : "date",
+ "repeatable" : false
+ }
+ ]
+}
diff --git a/src/test/resources/sdk-fixture/2.0/fields/fields.json b/src/test/resources/sdk-fixture/2.0/fields/fields.json
new file mode 100644
index 0000000..bca716d
--- /dev/null
+++ b/src/test/resources/sdk-fixture/2.0/fields/fields.json
@@ -0,0 +1,100 @@
+{
+ "sdkVersion" : "eforms-sdk-2.0.0",
+ "xmlStructure" : [ {
+ "id" : "ND-Root",
+ "xpathAbsolute" : "/*",
+ "xpathRelative" : "/*",
+ "repeatable" : false
+ }, {
+ "id" : "ND-Lot",
+ "parentId" : "ND-Root",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot",
+ "xpathRelative" : "cac:ProcurementProjectLot",
+ "repeatable" : true
+ } ],
+ "fields" : [ {
+ "id" : "BT-Plain",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Plain",
+ "xpathRelative" : "cbc:Plain",
+ "type" : "text",
+ "repeatable" : {
+ "value" : true,
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-Withheld",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Withheld",
+ "xpathRelative" : "cbc:Withheld",
+ "type" : "text",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ },
+ "privacy" : {
+ "code" : "con-esti",
+ "unpublishedFieldId" : "BT-195-notice",
+ "reasonCodeFieldId" : "BT-197-notice",
+ "reasonDescriptionFieldId" : "BT-196-notice",
+ "publicationDateFieldId" : "BT-198-notice"
+ }
+ }, {
+ "id" : "BT-195-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:NoticeInformation/efac:Nondisclosure/efbc:NondisclosureID",
+ "xpathRelative" : "efac:NoticeInformation/efac:Nondisclosure/efbc:NondisclosureID",
+ "type" : "id",
+ "repeatable" : {
+ "value" : true,
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-196-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:NoticeInformation/cbc:ReasonDescription",
+ "xpathRelative" : "efac:NoticeInformation/cbc:ReasonDescription",
+ "type" : "text-multilingual",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-197-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:NoticeInformation/cbc:ReasonCode",
+ "xpathRelative" : "efac:NoticeInformation/cbc:ReasonCode",
+ "type" : "code",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ },
+ "codeList" : {
+ "value" : {
+ "id" : "non-publication-justification",
+ "type" : "flat"
+ },
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-198-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:NoticeInformation/efbc:PublicationDate",
+ "xpathRelative" : "efac:NoticeInformation/efbc:PublicationDate",
+ "type" : "date",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-Measure",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Measure",
+ "xpathRelative" : "cbc:Measure",
+ "type" : "measure",
+ "repeatable" : {
+ "value" : false,
+ "severity" : "ERROR"
+ }
+ } ]
+}
diff --git a/src/test/resources/sdk-fixture/2.0/fields/fwd/data-types.json b/src/test/resources/sdk-fixture/2.0/fields/fwd/data-types.json
new file mode 100644
index 0000000..f05d2a9
--- /dev/null
+++ b/src/test/resources/sdk-fixture/2.0/fields/fwd/data-types.json
@@ -0,0 +1,45 @@
+{
+ "dataTypes" : [ {
+ "type" : "amount",
+ "description" : "Monetary value with currency",
+ "valueType" : "decimal",
+ "maskingValue" : "undisclosed",
+ "displayTypes" : [ "TEXTBOX" ],
+ "attribute" : {
+ "name" : "currencyID",
+ "description" : "ISO 4217 currency code",
+ "type" : "currency"
+ }
+ }, {
+ "type" : "currency",
+ "description" : "ISO 4217 currency code",
+ "valueType" : "string",
+ "maskingValue" : "undisclosed",
+ "displayTypes" : [ "COMBOBOX" ],
+ "listName" : "currency"
+ }, {
+ "type" : "duration",
+ "description" : "Duration",
+ "valueType" : "decimal",
+ "maskingValue" : "undisclosed",
+ "displayTypes" : [ "TEXTBOX" ],
+ "attribute" : {
+ "name" : "unitCode",
+ "description" : "UN/ECE Recommendation 20 unit code",
+ "type" : "duration-unit"
+ }
+ }, {
+ "type" : "duration-unit",
+ "description" : "Unit of duration",
+ "valueType" : "string",
+ "maskingValue" : "undisclosed",
+ "displayTypes" : [ "COMBOBOX" ],
+ "listName" : "timeperiod"
+ }, {
+ "type" : "date",
+ "description" : "Date value with timezone",
+ "valueType" : "date",
+ "maskingValue" : "1970-01-01Z",
+ "displayTypes" : [ "TEXTBOX" ]
+ } ]
+}
diff --git a/src/test/resources/sdk-fixture/2.0/fields/fwd/fields.json b/src/test/resources/sdk-fixture/2.0/fields/fwd/fields.json
new file mode 100644
index 0000000..77687e0
--- /dev/null
+++ b/src/test/resources/sdk-fixture/2.0/fields/fwd/fields.json
@@ -0,0 +1,96 @@
+{
+ "specialPurpose" : {
+ "disclosureDate" : "BT-198-notice",
+ "disclosureGroup" : "BT-195-notice",
+ "disclosureJustificationCode" : "BT-197-notice",
+ "disclosureJustificationDescription" : "BT-196-notice",
+ "noticeSubType" : "OPP-070-notice"
+ },
+ "fields" : [ {
+ "id" : "BT-Plain",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Plain",
+ "xpathRelative" : "cbc:Plain",
+ "type" : "text",
+ "repeatable" : true,
+ "alias" : "plain"
+ }, {
+ "id" : "BT-NotRepeatable",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:NotRepeatable",
+ "xpathRelative" : "cbc:NotRepeatable",
+ "type" : "text",
+ "repeatable" : false
+ }, {
+ "id" : "BT-Withheld",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Withheld",
+ "xpathRelative" : "cbc:Withheld",
+ "type" : "text",
+ "repeatable" : false,
+ "disclosureControl" : {
+ "groupId" : "con-esti"
+ }
+ }, {
+ "id" : "BT-WithheldSometimes",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:WithheldSometimes",
+ "xpathRelative" : "cbc:WithheldSometimes",
+ "type" : "text",
+ "repeatable" : false,
+ "disclosureControl" : {
+ "groupId" : "cro-bor-law",
+ "withholdingCondition" : "{BT-WithheldSometimes} ${BT-Plain == 'x'}",
+ "undisclosedFieldSelector" : "{ND-Root} &{/BT-WithheldSometimes[BT-Plain == 'x']}"
+ }
+ }, {
+ "id" : "BT-Measure",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:Measure",
+ "xpathRelative" : "cbc:Measure",
+ "type" : "measure",
+ "repeatable" : false
+ }, {
+ "id" : "BT-Fwd-Only",
+ "parentNodeId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cbc:FwdOnly",
+ "xpathRelative" : "cbc:FwdOnly",
+ "type" : "text",
+ "repeatable" : false
+ }, {
+ "id" : "BT-195-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/efbc:FieldIdentifierCode",
+ "xpathRelative" : "efac:FieldsPrivacy/efbc:FieldIdentifierCode",
+ "type" : "code",
+ "repeatable" : false
+ }, {
+ "id" : "BT-196-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/efbc:ReasonDescription",
+ "xpathRelative" : "efac:FieldsPrivacy/efbc:ReasonDescription",
+ "type" : "text",
+ "repeatable" : false
+ }, {
+ "id" : "BT-197-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/cbc:ReasonCode",
+ "xpathRelative" : "efac:FieldsPrivacy/cbc:ReasonCode",
+ "type" : "code",
+ "repeatable" : false,
+ "codelist" : {
+ "value" : {
+ "id" : "non-publication-justification",
+ "type" : "flat"
+ },
+ "severity" : "ERROR"
+ }
+ }, {
+ "id" : "BT-198-notice",
+ "parentNodeId" : "ND-Root",
+ "xpathAbsolute" : "/*/efac:FieldsPrivacy/efbc:PublicationDate",
+ "xpathRelative" : "efac:FieldsPrivacy/efbc:PublicationDate",
+ "type" : "date",
+ "repeatable" : false
+ } ]
+}
diff --git a/src/test/resources/sdk-fixture/2.0/fields/fwd/nodes.json b/src/test/resources/sdk-fixture/2.0/fields/fwd/nodes.json
new file mode 100644
index 0000000..adc565a
--- /dev/null
+++ b/src/test/resources/sdk-fixture/2.0/fields/fwd/nodes.json
@@ -0,0 +1,26 @@
+{
+ "specialPurpose" : {
+ "root" : "ND-Root",
+ "organizations" : "ND-Organizations"
+ },
+ "nodes" : [ {
+ "id" : "ND-Root",
+ "xpathAbsolute" : "/*",
+ "xpathRelative" : "/*",
+ "repeatable" : false,
+ "alias" : "root"
+ }, {
+ "id" : "ND-Fwd-Only",
+ "parentId" : "ND-Lot",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot/cac:FwdOnly",
+ "xpathRelative" : "cac:FwdOnly",
+ "repeatable" : false
+ }, {
+ "id" : "ND-Lot",
+ "parentId" : "ND-Root",
+ "xpathAbsolute" : "/*/cac:ProcurementProjectLot",
+ "xpathRelative" : "cac:ProcurementProjectLot",
+ "repeatable" : true,
+ "alias" : "lot"
+ } ]
+}