Skip to content

Commit 4ca7128

Browse files
authored
feat: URL params to records via JSON (#23754)
* feat: URL params to records via JSON * fix: update to version 1.9.2 and single error as bad request * fix: compile errors * fix: data export OU with children filter * chore: extract validation service * chore: use record for DataValueChangelogQueryParams * chore: extracted Paged proeprties * fix: avoid web dependency * fix: sonar issues * chore: cleanup of pager creation and collapsed properties validation * feat: add JURL handling to input decoding * fix: auto-wrap in array if that is the target type
1 parent 575ba91 commit 4ca7128

31 files changed

Lines changed: 766 additions & 417 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.common.input;
31+
32+
import java.util.ArrayList;
33+
import java.util.HashMap;
34+
import java.util.List;
35+
import java.util.Map;
36+
import java.util.Set;
37+
import java.util.function.Function;
38+
import javax.annotation.Nonnull;
39+
import lombok.AccessLevel;
40+
import lombok.NoArgsConstructor;
41+
import org.hisp.dhis.common.UID;
42+
import org.hisp.dhis.feedback.BadRequestException;
43+
import org.hisp.dhis.jsontree.JsonAccess;
44+
import org.hisp.dhis.jsontree.JsonBuilder;
45+
import org.hisp.dhis.jsontree.JsonMixed;
46+
import org.hisp.dhis.jsontree.JsonNode;
47+
import org.hisp.dhis.jsontree.JsonObject;
48+
import org.hisp.dhis.jsontree.Jurl;
49+
import org.hisp.dhis.jsontree.Text;
50+
import org.hisp.dhis.jsontree.Validation;
51+
import org.hisp.dhis.period.Period;
52+
53+
/**
54+
* Utilities around generic input decoding and formal (static context) validation.
55+
*
56+
* @author Jan Bernitt
57+
* @since 2.44
58+
*/
59+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
60+
public final class InputUtils {
61+
62+
static {
63+
JsonAccess global = JsonAccess.GLOBAL;
64+
global.addStringAs(UID.class, UID::of);
65+
global.addStringAs(Period.class, Period::of);
66+
}
67+
68+
/**
69+
* Formal input validation against a schema {@link Class} which has {@link
70+
* org.hisp.dhis.jsontree.Validation} annotations for restrictions.
71+
*
72+
* <p>Formal input validation is all the validation that can be performed solely based on a target
73+
* schema. In other words, it is a validation of input within the static (type) context without
74+
* checking validity in the context the value will exist. Such semantic validation is performed in
75+
* later stages.
76+
*
77+
* @param schema the target type the input should conform to
78+
* @param input typically user input such as request bodies
79+
* @throws BadRequestException in case the input is not valid
80+
*/
81+
public static void validateInput(@Nonnull Class<?> schema, @Nonnull JsonObject input)
82+
throws BadRequestException {
83+
Validation.Result result = input.validate(schema, Validation.Mode.PROBE);
84+
if (!result.errors().isEmpty()) {
85+
Validation.Error e0 = result.errors().get(0);
86+
throw new BadRequestException(
87+
"URL parameter `"
88+
+ e0.path().segment()
89+
+ "` "
90+
+ e0.template().formatted(e0.args().toArray()));
91+
}
92+
}
93+
94+
@Nonnull
95+
public static <T extends Record> T decodeInput(
96+
@Nonnull Class<T> schema, @Nonnull Map<String, Object> properties) {
97+
return decodeInput(
98+
schema,
99+
name -> {
100+
Object value = properties.get(name);
101+
return value == null ? null : new String[] {value.toString()};
102+
})
103+
.to(schema);
104+
}
105+
106+
@Nonnull
107+
public static <T extends Record> T decodeInput(
108+
@Nonnull Class<T> schema, @Nonnull String properties) {
109+
Map<String, List<String>> map = new HashMap<>();
110+
for (String p : properties.split("&")) {
111+
int eqIndex = p.indexOf('=');
112+
String name = p.substring(0, eqIndex);
113+
String value = p.substring(eqIndex + 1);
114+
map.computeIfAbsent(name, key -> new ArrayList<>()).add(value);
115+
}
116+
return decodeInput(
117+
schema,
118+
name -> {
119+
List<String> values = map.get(name);
120+
return values == null ? null : values.toArray(String[]::new);
121+
})
122+
.to(schema);
123+
}
124+
125+
/**
126+
* Decodes key-value input such as request parameters into a JSON value based on the target
127+
* schema.
128+
*
129+
* @param schema the target the key-value data should conform to
130+
* @param propertyLookup a lookup function to return the values for a given key
131+
* @return a JSON object with the key-value data found in the given schema as provided by the
132+
* values lookup-function
133+
*/
134+
@Nonnull
135+
public static JsonObject decodeInput(
136+
@Nonnull Class<? extends Record> schema, @Nonnull Function<String, String[]> propertyLookup) {
137+
List<JsonObject.Property> properties = JsonObject.properties(schema);
138+
JsonNode object =
139+
JsonBuilder.createObject(
140+
obj -> {
141+
for (JsonObject.Property p : properties) {
142+
Text name = p.jsonName();
143+
String key = name.toString();
144+
String[] values = propertyLookup.apply(key);
145+
if (values == null) continue;
146+
Set<Validation.NodeType> types = p.types();
147+
if (types.isEmpty()) {
148+
// default behaviour
149+
addAutoComplex(name, values, true, obj);
150+
} else if (values.length == 0) {
151+
if (types.contains(Validation.NodeType.BOOLEAN)) {
152+
obj.addBoolean(name, true);
153+
} else if (types.contains(Validation.NodeType.ARRAY)) {
154+
obj.addArray(name, arr -> {});
155+
}
156+
} else {
157+
Validation.NodeType type = types.iterator().next();
158+
if (types.contains(Validation.NodeType.ARRAY)) type = Validation.NodeType.ARRAY;
159+
if (types.size() == 1) {
160+
switch (type) {
161+
case INTEGER, NUMBER, BOOLEAN, NULL ->
162+
obj.addMember(name, JsonNode.of(values[0]));
163+
case STRING ->
164+
obj.addString(
165+
name, values.length == 1 ? values[0] : String.join(",", values));
166+
case ARRAY, OBJECT -> addAutoComplex(name, values, false, obj);
167+
}
168+
}
169+
}
170+
}
171+
});
172+
return JsonMixed.of(object);
173+
}
174+
175+
private static void addAutoComplex(
176+
Text name, String[] values, boolean allowString, JsonBuilder.JsonObjectBuilder obj) {
177+
if (values.length == 1) {
178+
// assume JURL
179+
String value = values[0];
180+
if (value.startsWith("(")) {
181+
obj.addMember(name, Jurl.of(value).node());
182+
} else if (allowString) {
183+
obj.addString(name, value);
184+
} else {
185+
obj.addArray(name, arr -> arr.addString(value));
186+
}
187+
} else {
188+
obj.addArray(
189+
name,
190+
arr -> {
191+
for (String v : values) arr.addString(v);
192+
});
193+
}
194+
}
195+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.common.input;
31+
32+
import static org.hisp.dhis.jsontree.Validation.YesNo.NO;
33+
34+
import java.util.function.ToIntFunction;
35+
import org.hisp.dhis.common.OpenApi;
36+
import org.hisp.dhis.common.Pager;
37+
import org.hisp.dhis.jsontree.Validation;
38+
39+
/**
40+
* URL parameters for endpoints that offer paging.
41+
*
42+
* <p>Include via @{@link org.hisp.dhis.jsontree.Collapsed}.
43+
*
44+
* @param skipPaging override to {@link #paging()} to skip paging
45+
* @param paging paging on/off (default on)
46+
* @param page page no to show (default 1)
47+
* @param pageSize entries per page (default 50)
48+
*/
49+
public record PagedParams(
50+
Boolean skipPaging,
51+
@Validation(required = NO) boolean paging,
52+
@Validation(required = NO, minimum = 1) int page,
53+
@Validation(required = NO, minimum = 1, maximum = 1000) int pageSize) {
54+
55+
public static final PagedParams DEFAULT = new PagedParams(null, true, 1, 50);
56+
57+
@OpenApi.Ignore
58+
public boolean isPaged() {
59+
if (skipPaging != null) return !skipPaging;
60+
return paging;
61+
}
62+
63+
public int offset() {
64+
return (page - 1) * pageSize;
65+
}
66+
67+
public Pager toPager(int totalPages) {
68+
return !isPaged() ? null : new Pager(page, totalPages, pageSize);
69+
}
70+
71+
public <T> Pager toPager(T params, ToIntFunction<T> count) {
72+
return !isPaged() ? null : toPager(count.applyAsInt(params));
73+
}
74+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.common.input;
31+
32+
/**
33+
* Marker interface to be implemented by {@link Record} classes to be mapped via json-tree.
34+
*
35+
* <p>This is so this becomes an opt-in feature.
36+
*
37+
* @author Jan Bernitt
38+
* @since 2.44
39+
*/
40+
public interface UrlParams {}

dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueChangelogQueryParams.java

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,33 +29,47 @@
2929
*/
3030
package org.hisp.dhis.datavalue;
3131

32-
import java.util.ArrayList;
3332
import java.util.List;
34-
import lombok.Data;
35-
import lombok.experimental.Accessors;
36-
import org.hisp.dhis.common.Pager;
33+
import org.hisp.dhis.category.CategoryOptionCombo;
34+
import org.hisp.dhis.common.OpenApi;
3735
import org.hisp.dhis.common.UID;
36+
import org.hisp.dhis.common.input.PagedParams;
37+
import org.hisp.dhis.common.input.UrlParams;
38+
import org.hisp.dhis.dataelement.DataElement;
39+
import org.hisp.dhis.dataset.DataSet;
40+
import org.hisp.dhis.jsontree.Collapsed;
41+
import org.hisp.dhis.organisationunit.OrganisationUnit;
3842
import org.hisp.dhis.period.Period;
3943

4044
/**
4145
* Encapsulation of a web API request for data value audit records.
4246
*
4347
* @author Lars Helge Overland
4448
*/
45-
@Data
46-
@Accessors(chain = true)
47-
public class DataValueChangelogQueryParams {
49+
public record DataValueChangelogQueryParams(
50+
List<String> fields,
51+
@OpenApi.Property({UID[].class, DataSet.class}) List<UID> ds,
52+
@OpenApi.Property({UID[].class, DataElement.class}) List<UID> de,
53+
List<Period> pe,
54+
@OpenApi.Property({UID[].class, OrganisationUnit.class}) List<UID> ou,
55+
@OpenApi.Property({UID[].class, CategoryOptionCombo.class}) UID co, // COC
56+
@OpenApi.Property({UID[].class, CategoryOptionCombo.class}) UID cc, // AOC
57+
List<DataValueChangelogType> type,
58+
@Collapsed PagedParams paged)
59+
implements UrlParams {
4860

49-
private List<UID> dataSets = new ArrayList<>();
50-
private List<UID> dataElements = new ArrayList<>();
51-
private List<Period> periods = new ArrayList<>();
52-
private List<UID> orgUnits = new ArrayList<>();
53-
private UID categoryOptionCombo;
54-
private UID attributeOptionCombo;
55-
private List<DataValueChangelogType> types = new ArrayList<>();
56-
private Pager pager;
61+
public static final DataValueChangelogQueryParams DEFAULT = ofType();
5762

58-
public boolean hasPaging() {
59-
return pager != null;
63+
public static DataValueChangelogQueryParams ofType(DataValueChangelogType... types) {
64+
return new DataValueChangelogQueryParams(
65+
List.of(),
66+
List.of(),
67+
List.of(),
68+
List.of(),
69+
List.of(),
70+
null,
71+
null,
72+
List.of(types),
73+
PagedParams.DEFAULT);
6074
}
6175
}

0 commit comments

Comments
 (0)