Skip to content

Commit f5c98e4

Browse files
fix: DSD response drops metadata attribute usages, MSD reference, and annotation values (#81)
1 parent 606e5ba commit f5c98e4

31 files changed

Lines changed: 5108 additions & 713 deletions

File tree

docs/designs/022-sdmxsource-overrides-catalog/DESIGN.md

Lines changed: 1583 additions & 0 deletions
Large diffs are not rendered by default.

docs/designs/025-dsd-conversion-fidelity/DESIGN.md

Lines changed: 509 additions & 0 deletions
Large diffs are not rendered by default.

docs/designs/026-data-conversion-fidelity-sdmx-json-2-0/DESIGN.md

Lines changed: 526 additions & 0 deletions
Large diffs are not rendered by default.

docs/designs/027-remove-metadata-attribute-usage-to-attribute/DESIGN.md

Lines changed: 353 additions & 0 deletions
Large diffs are not rendered by default.

sdmx-proxy-config/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,12 @@ Fixtures are applied as a chain of responsibility in the order listed.
152152

153153
#### `StructureFixtureType`
154154

155-
| Value | Description |
156-
|-----------------------------------------|---------------------------------------------------------------------------|
157-
| `DSD_ATTRIBUTE_ATTACHMENT_LEVEL` | Repair invalid DSD attribute attachment levels |
158-
| `VERSION_WILDCARD` | Patch wildcard version handling on structure responses |
159-
| `METADATA_ATTRIBUTE_USAGE_TO_ATTRIBUTE` | Convert `metadataAttributeUsage` references to plain attribute references |
155+
| Value | Description |
156+
|--------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
157+
| `DSD_ATTRIBUTE_ATTACHMENT_LEVEL` | Repair invalid DSD attribute attachment levels |
158+
| `VERSION_WILDCARD` | Patch wildcard version handling on structure responses |
159+
| `ANNOTATION_VALUE_TO_TEXT` | Rewrite annotation `value` (non-localised string) into `text` so sdmx-core's SDMX-JSON 2.0 reader preserves the field. Applied only when `text`/`texts` is absent on the annotation |
160+
| `PRESERVE_METADATA_ATTRIBUTE_USAGES` | Capture raw `metadataAttributeUsages` arrays before conversion and re-inject them onto the matching DSD in the converted output. Works on the single-leg structure path; restores the field sdmx-core's bean model omits |
160161

161162
#### `AvailabilityFixtureType`
162163

sdmx-proxy-config/src/main/java/com/epam/sdmxproxy/configuration/data/fixture/StructureFixtureType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
public enum StructureFixtureType implements FixtureType {
44
DSD_ATTRIBUTE_ATTACHMENT_LEVEL,
55
VERSION_WILDCARD,
6-
METADATA_ATTRIBUTE_USAGE_TO_ATTRIBUTE
6+
ANNOTATION_VALUE_TO_TEXT,
7+
PRESERVE_METADATA_ATTRIBUTE_USAGES
78
}

sdmx-proxy-config/src/main/resources/sdmx_registries_config.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@
100100
],
101101
"fixtures": [
102102
{
103-
"type": "METADATA_ATTRIBUTE_USAGE_TO_ATTRIBUTE",
103+
"type": "PRESERVE_METADATA_ATTRIBUTE_USAGES",
104+
"config": {}
105+
},
106+
{
107+
"type": "ANNOTATION_VALUE_TO_TEXT",
104108
"config": {}
105109
},
106110
{

sdmx-proxy-e2e/src/test/java/com/epam/sdmxproxy/e2e/tests/registry/IMF_3_0_RegistryTestSuit.java

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,22 @@
33
import com.epam.sdmxproxy.configuration.data.ProxyConfiguration;
44
import com.epam.sdmxproxy.e2e.tests.framework.BaseRegistryTestSuite;
55
import com.epam.sdmxproxy.e2e.tests.framework.config.RegistryTestSuitConfiguration;
6-
import lombok.SneakyThrows;
6+
import com.fasterxml.jackson.databind.JsonNode;
77
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import io.restassured.response.Response;
9+
import lombok.SneakyThrows;
10+
import org.junit.jupiter.api.DisplayName;
11+
import org.junit.jupiter.api.Test;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
814

915

1016
public class IMF_3_0_RegistryTestSuit extends BaseRegistryTestSuite {
17+
private static final String DSD_WEO_PATH =
18+
"/statgpt/sdmx-proxy/api/v0/sdmx/3.0/structure/datastructure/IMF.RES/DSD_WEO/9.0.0?references=none&detail=full";
19+
private static final String STRUCTURE_JSON_MEDIA_TYPE =
20+
"application/vnd.sdmx.structure+json;version=2.0.0";
21+
1122
private final ObjectMapper objectMapper = new ObjectMapper();
1223

1324
@Override
@@ -21,4 +32,80 @@ protected ProxyConfiguration getProxyConfig() {
2132
protected RegistryTestSuitConfiguration getTestConfig() {
2233
return objectMapper.readValue(this.getClass().getResourceAsStream("imf/3_0/imf_3_0_test_config.json").readAllBytes(), RegistryTestSuitConfiguration.class);
2334
}
35+
36+
/**
37+
* Regression test for issue #79: the four DSD fields the proxy used to drop when converting
38+
* an SDMX-JSON 2.0 DSD response from IMF.
39+
* <ul>
40+
* <li>DSD-level {@code metadata} URN (Stage 1 mapper fix)</li>
41+
* <li>Dimension {@code conceptRoles} on FREQUENCY (Stage 1 mapper fix)</li>
42+
* <li>Annotation {@code value} (rescued into {@code text} by ANNOTATION_VALUE_TO_TEXT fixture)</li>
43+
* <li>{@code metadataAttributeUsages} on the attribute list (rescued by PRESERVE_METADATA_ATTRIBUTE_USAGES)</li>
44+
* </ul>
45+
* Driven against the live IMF SDMX Central {@code IMF.RES:DSD_WEO(9.0.0)} response.
46+
*/
47+
@Test
48+
@DisplayName("Issue #79: DSD_WEO metadata URN, conceptRoles, annotation text, metadataAttributeUsages all preserved")
49+
@SneakyThrows
50+
void testDsdConversionFidelity_issue79() {
51+
Response response = restClient.getResponseWithAccept(DSD_WEO_PATH, STRUCTURE_JSON_MEDIA_TYPE);
52+
53+
assertThat(response.getStatusCode())
54+
.as("DSD_WEO structure request must return HTTP 200")
55+
.isEqualTo(200);
56+
57+
JsonNode root = objectMapper.readTree(response.getBody().asByteArray());
58+
JsonNode dsd = root.path("data").path("dataStructures").get(0);
59+
assertThat(dsd).as("dataStructures[0] must be present").isNotNull();
60+
assertThat(dsd.path("id").asText()).isEqualTo("DSD_WEO");
61+
assertThat(dsd.path("agencyID").asText()).isEqualTo("IMF.RES");
62+
63+
// Issue 3: DSD `metadata` URN preserved (Stage 1 mapper fix)
64+
String metadataUrn = dsd.path("metadata").asText();
65+
assertThat(metadataUrn)
66+
.as("DSD `metadata` URN must point at MSD_WEO_METADATA_EXTERNAL")
67+
.contains("MetadataStructure=IMF.RES:MSD_WEO_METADATA_EXTERNAL");
68+
69+
// Issue 4: FREQUENCY dimension conceptRoles preserved (Stage 1 mapper fix)
70+
JsonNode dimensions = dsd.path("dataStructureComponents").path("dimensionList").path("dimensions");
71+
assertThat(dimensions.isArray()).as("dimensions must be an array").isTrue();
72+
JsonNode frequency = null;
73+
for (JsonNode dim : dimensions) {
74+
if ("FREQUENCY".equals(dim.path("id").asText())) {
75+
frequency = dim;
76+
break;
77+
}
78+
}
79+
assertThat(frequency).as("FREQUENCY dimension must be present").isNotNull();
80+
JsonNode conceptRoles = frequency.path("conceptRoles");
81+
assertThat(conceptRoles.isArray() && conceptRoles.size() >= 1)
82+
.as("FREQUENCY conceptRoles must contain at least one entry, got: %s", conceptRoles)
83+
.isTrue();
84+
assertThat(conceptRoles.get(0).asText())
85+
.as("FREQUENCY conceptRoles[0] must end with SDMX_CONCEPT_ROLES(1.0).FREQ")
86+
.endsWith("SDMX_CONCEPT_ROLES(1.0).FREQ");
87+
88+
// Issue 1: annotation `value` rescued into `text` by ANNOTATION_VALUE_TO_TEXT fixture
89+
JsonNode annotations = dsd.path("annotations");
90+
assertThat(annotations.isArray() && !annotations.isEmpty())
91+
.as("DSD annotations must be non-empty")
92+
.isTrue();
93+
JsonNode origin = null;
94+
for (JsonNode a : annotations) {
95+
if ("origin".equals(a.path("id").asText())) {
96+
origin = a;
97+
break;
98+
}
99+
}
100+
assertThat(origin).as("origin annotation must be present").isNotNull();
101+
assertThat(origin.path("text").asText())
102+
.as("origin annotation `text` must carry the original `value` (rewritten by ANNOTATION_VALUE_TO_TEXT)")
103+
.isNotEmpty();
104+
105+
// Issue 2: metadataAttributeUsages restored by PRESERVE_METADATA_ATTRIBUTE_USAGES preserver
106+
JsonNode usages = dsd.path("dataStructureComponents").path("attributeList").path("metadataAttributeUsages");
107+
assertThat(usages.isArray() && !usages.isEmpty())
108+
.as("metadataAttributeUsages must be restored as a non-empty array")
109+
.isTrue();
110+
}
24111
}

sdmx-proxy-e2e/src/test/resources/com/epam/sdmxproxy/e2e/tests/registry/imf/3_0/imf_3_0_registry_config.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@
2323
],
2424
"fixtures": [
2525
{
26-
"type": "VERSION_WILDCARD",
26+
"type": "PRESERVE_METADATA_ATTRIBUTE_USAGES",
2727
"config": {}
2828
},
2929
{
30-
"type": "METADATA_ATTRIBUTE_USAGE_TO_ATTRIBUTE",
30+
"type": "ANNOTATION_VALUE_TO_TEXT",
31+
"config": {}
32+
},
33+
{
34+
"type": "VERSION_WILDCARD",
3135
"config": {}
3236
}
3337
]

sdmx-proxy/src/main/java/com/epam/sdmxproxy/common/mapping/DataStructureMapper.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ public DataStructureDefinition map(DataStructureBean dataStructure) {
5656
dsd.setDescription(textMapper.map(dataStructure.getDescriptions()));
5757
dsd.setAnnotations(annotationMapper.map(dataStructure.getAnnotations()));
5858

59+
if (dataStructure.getMSDRef() != null) {
60+
dsd.setMetadataStructure(referenceMapper.mapMaintainable(dataStructure.getMSDRef(), StructureClassImpl.METADATA_STRUCTURE));
61+
}
62+
5963
var dimensionDescriptor = new DimensionDescriptorImpl();
6064
dimensionDescriptor.setId("DimensionDescriptor");
6165
dsd.setDimensionDescriptor(dimensionDescriptor);
@@ -107,9 +111,21 @@ private DimensionComponent mapDimension(DimensionBean dimensionBean) {
107111
? new TimeDimensionImpl()
108112
: new DimensionImpl();
109113
mapComponent(dim, dimensionBean);
114+
if (dim instanceof DimensionImpl dimensionImpl) {
115+
mapDimensionConceptRoles(dimensionImpl, dimensionBean);
116+
}
110117
return dim;
111118
}
112119

120+
private void mapDimensionConceptRoles(DimensionImpl dim, DimensionBean bean) {
121+
if (bean.getConceptRole() == null || bean.getConceptRole().isEmpty()) {
122+
return;
123+
}
124+
dim.setConceptRoles(bean.getConceptRole().stream()
125+
.map(role -> referenceMapper.mapItem(role, StructureClassImpl.CONCEPT))
126+
.toList());
127+
}
128+
113129
private void mapComponent(ComponentImpl component, ComponentBean componentBean) {
114130
component.setId(componentBean.getId());
115131
component.setConceptIdentity(referenceMapper.mapItem(componentBean.getConceptRef(), StructureClassImpl.CONCEPT));
@@ -153,9 +169,19 @@ private DataAttribute mapAttribute(AttributeBean attributeBean, DataStructureBea
153169
var a = new DataAttributeImpl();
154170
mapComponent(a, attributeBean);
155171
mapRelationship(a, attributeBean, dataStructure);
172+
mapAttributeConceptRoles(a, attributeBean);
156173
return a;
157174
}
158175

176+
private void mapAttributeConceptRoles(DataAttributeImpl attr, AttributeBean bean) {
177+
if (bean.getConceptRoles() == null || bean.getConceptRoles().isEmpty()) {
178+
return;
179+
}
180+
attr.setConceptRoles(bean.getConceptRoles().stream()
181+
.map(role -> referenceMapper.mapItem(role, StructureClassImpl.CONCEPT))
182+
.toList());
183+
}
184+
159185
private void mapRelationship(DataAttributeImpl a, AttributeBean attributeBean, DataStructureBean dataStructure) {
160186
final ATTRIBUTE_ATTACHMENT_LEVEL attachmentLevel = attributeBean.getAttachmentLevel();
161187
final AttributeRelationship relationship = mapRelationship(attributeBean, dataStructure, attachmentLevel);

0 commit comments

Comments
 (0)