From 128d592925e02f8b6541fdccf854a46850e600b0 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 30 Jul 2026 16:35:00 +0100 Subject: [PATCH] Document and update sort query parameter --- .../core/query/EntityInstanceListFilter.java | 2 +- .../core/query/EntityInstanceListSorter.java | 49 ++++-- .../core/query/EntityListSortParamParser.java | 55 ++++--- .../core/query/QueryFilterParams.java | 2 +- .../core/query/SortByFieldName.java | 4 +- .../query/EntityInstanceListSorterTest.java | 96 ++++++++++++ .../core/query/QueryFiltersBooleanTest.java | 4 +- .../core/query/QueryFiltersFloatTest.java | 16 +- .../core/query/QueryFiltersIdTest.java | 4 +- .../core/query/QueryFiltersIntegerTest.java | 22 +-- .../core/query/QueryFiltersStringTest.java | 16 +- .../query/SortingViaQueryFiltersTest.java | 64 +++++++- .../repository/ThingStoreContractTest.java | 140 +++++++++++++++++- .../swaggerizer/SwaggerizerTest.java | 16 ++ .../ApiRoutingDefinitionDocGenerator.java | 44 +++--- .../RestApiDocumentationGenerator.java | 62 +++++++- .../thingifier/swaggerizer/Swaggerizer.java | 46 ++++++ .../ApiRoutingDefinitionDocGeneratorTest.java | 4 + .../api/http/UrlQueryParamParserTest.java | 4 +- .../application/ThingQueryServiceTest.java | 2 +- .../RestApiDocumentationGeneratorTest.java | 38 +++++ .../SwaggerizerEntityDescriptionTest.java | 38 +++++ 22 files changed, 624 insertions(+), 104 deletions(-) create mode 100644 ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorterTest.java diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListFilter.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListFilter.java index 6ff83ff5..eae3a629 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListFilter.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListFilter.java @@ -9,7 +9,7 @@ public class EntityInstanceListFilter { /* Given a Map of FieldName,Value - sort_by,+-FieldName + _sortBy,+-FieldName */ public EntityInstanceListFilter(QueryFilterParams queryParams) { diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorter.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorter.java index 5fed1936..5c2a353f 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorter.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorter.java @@ -10,7 +10,7 @@ public class EntityInstanceListSorter { /* Given a Map of FieldName,Value - sort_by,+-FieldName + _sortBy,+-FieldName */ public EntityInstanceListSorter(final QueryFilterParams queryParams) { @@ -21,16 +21,23 @@ public List sort(final List foundItems) { List sorted = new ArrayList<>(foundItems); + Comparator comparator = null; for (SortByFieldName sortBy : instanceFilter.sortBys()) { - sorted = sortByField(sortBy.fieldName, sortBy.order, sorted); + Comparator sortByField = compareByField(sortBy, sorted); + if (sortByField != null) { + comparator = + comparator == null ? sortByField : comparator.thenComparing(sortByField); + } + } + + if (comparator != null) { + sorted.sort(comparator); } return sorted; } /** Sorted list of instances */ - - // TODO: unit tests for sorting public List sortByField( String fieldName, int order, final List itemsToSort) { @@ -41,11 +48,31 @@ public List sortByField( return sortedList; } - Field fieldDefn = sortedList.get(0).getEntity().getField(fieldName); + Comparator compareByFieldValue = + compareByField(fieldName, order, sortedList); + if (compareByFieldValue == null) { + return sortedList; + } + + sortedList.sort(compareByFieldValue); - // there is no field of that name + return sortedList; + } + + private Comparator compareByField( + final SortByFieldName sortBy, final List itemsToSort) { + return compareByField(sortBy.getFieldName(), sortBy.getOrder(), itemsToSort); + } + + private Comparator compareByField( + final String fieldName, final int order, final List itemsToSort) { + if (itemsToSort.isEmpty()) { + return null; + } + + Field fieldDefn = itemsToSort.get(0).getEntity().getField(fieldName); if (fieldDefn == null) { - return sortedList; + return null; } Comparator compareByFieldValue = @@ -65,13 +92,9 @@ public int compare(EntityInstance thing1, EntityInstance thing2) { }; if (order < 0) { - // (desc) - Collections.sort(sortedList, compareByFieldValue); + return compareByFieldValue; } else { - // low to high sort (asc) - Collections.sort(sortedList, compareByFieldValue.reversed()); + return compareByFieldValue.reversed(); } - - return sortedList; } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityListSortParamParser.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityListSortParamParser.java index 08c14b68..da6497dd 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityListSortParamParser.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/EntityListSortParamParser.java @@ -22,39 +22,50 @@ public EntityListSortParamParser(final List queryParams) { } /* - return all the sortBy values - currently sortBy=-FieldName or sortBy=+FieldName or sortBy=FieldName - or sort_by=etc. - - TODO: handle multiple sort fields e.g. sortBy=-FieldName1,+FieldName2 + return all the _sortBy values + currently _sortBy=-FieldName or _sortBy=+FieldName or _sortBy=FieldName + or multiple sort fields e.g. _sortBy=-FieldName1,+FieldName2 */ public List sortBys() { List sortbys = new ArrayList<>(); for (FilterBy field : params.sortBys()) { if (isSortByParam(field.fieldName)) { - final SortByFieldName aSortBy = new SortByFieldName(); - String sortByValue = field.fieldValue; - switch (sortByValue.charAt(0)) { - case '-': - aSortBy.order = 1; - aSortBy.fieldName = sortByValue.substring(1).trim(); - break; - case '+': - aSortBy.order = -1; - aSortBy.fieldName = sortByValue.substring(1).trim(); - break; - default: - aSortBy.order = -1; - aSortBy.fieldName = sortByValue.trim(); - break; + for (String sortByValue : field.fieldValue.split(",")) { + final SortByFieldName aSortBy = sortByFrom(sortByValue); + if (aSortBy != null) { + sortbys.add(aSortBy); + } } - sortbys.add(aSortBy); } } return sortbys; } + private SortByFieldName sortByFrom(final String value) { + String sortByValue = value.trim(); + if (sortByValue.isEmpty()) { + return null; + } + + final SortByFieldName aSortBy = new SortByFieldName(); + switch (sortByValue.charAt(0)) { + case '-': + aSortBy.order = 1; + aSortBy.fieldName = sortByValue.substring(1).trim(); + break; + case '+': + aSortBy.order = -1; + aSortBy.fieldName = sortByValue.substring(1).trim(); + break; + default: + aSortBy.order = -1; + aSortBy.fieldName = sortByValue; + break; + } + return aSortBy.fieldName.isEmpty() ? null : aSortBy; + } + public static boolean isSortByParam(final String key) { - return (key.equalsIgnoreCase("sortby") || key.equalsIgnoreCase("sort_by")); + return SortByFieldName.isSortByParam(key); } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/QueryFilterParams.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/QueryFilterParams.java index bef3ecfb..7cb96d31 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/QueryFilterParams.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/QueryFilterParams.java @@ -55,7 +55,7 @@ public FilterBy get(int i) { public boolean hasSortBy() { for (FilterBy filterBy : filterBys) { - if (filterBy.fieldName.equals("sortBy") || filterBy.fieldName.equals("sort_by")) { + if (SortByFieldName.isSortByParam(filterBy.fieldName)) { return true; } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/SortByFieldName.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/SortByFieldName.java index e979e121..9e1111db 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/SortByFieldName.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/SortByFieldName.java @@ -1,6 +1,8 @@ package uk.co.compendiumdev.thingifier.core.query; public class SortByFieldName { + public static final String PARAMETER_NAME = "_sortBy"; + int order = 1; String fieldName = ""; @@ -13,6 +15,6 @@ public String getFieldName() { } public static boolean isSortByParam(final String key) { - return (key.equalsIgnoreCase("sortby") || key.equalsIgnoreCase("sort_by")); + return PARAMETER_NAME.equals(key); } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorterTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorterTest.java new file mode 100644 index 00000000..ee786941 --- /dev/null +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/EntityInstanceListSorterTest.java @@ -0,0 +1,96 @@ +package uk.co.compendiumdev.thingifier.core.query; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.repository.MutableEntityInstance; + +public class EntityInstanceListSorterTest { + + private EntityDefinition thing; + + @BeforeEach + public void setupEntityDefinition() { + thing = + new EntityDefinition("thing", "things", -1) + .addFields( + Field.is("category", FieldType.STRING), + Field.is("priority", FieldType.INTEGER)); + } + + @Test + public void sortsBySingleFieldAscendingAndDescending() { + EntityInstance low = instance("beta", "1"); + EntityInstance high = instance("alpha", "3"); + List unsorted = List.of(low, high); + + QueryFilterParams ascending = new QueryFilterParams(); + ascending.put("_sortBy", "+priority"); + Assertions.assertEquals( + List.of(low, high), new EntityInstanceListSorter(ascending).sort(unsorted)); + + QueryFilterParams descending = new QueryFilterParams(); + descending.put("_sortBy", "-priority"); + Assertions.assertEquals( + List.of(high, low), new EntityInstanceListSorter(descending).sort(unsorted)); + } + + @Test + public void sortsByMultipleFieldsUsingLaterFieldsAsTieBreakers() { + EntityInstance alphaLow = instance("alpha", "1"); + EntityInstance alphaHigh = instance("alpha", "3"); + EntityInstance betaLow = instance("beta", "2"); + EntityInstance betaHigh = instance("beta", "4"); + List unsorted = List.of(betaLow, alphaLow, betaHigh, alphaHigh); + + QueryFilterParams params = new QueryFilterParams(); + params.put("_sortBy", "+category,-priority"); + + List sorted = new EntityInstanceListSorter(params).sort(unsorted); + + Assertions.assertEquals(List.of(alphaHigh, alphaLow, betaHigh, betaLow), sorted); + } + + @Test + public void trimsAndIgnoresBlankMultiFieldSortTokens() { + EntityInstance alphaLow = instance("alpha", "1"); + EntityInstance alphaHigh = instance("alpha", "3"); + EntityInstance betaLow = instance("beta", "2"); + EntityInstance betaHigh = instance("beta", "4"); + List unsorted = List.of(betaLow, alphaLow, betaHigh, alphaHigh); + + QueryFilterParams params = new QueryFilterParams(); + params.put("_sortBy", " +category, , -priority "); + + List sorted = new EntityInstanceListSorter(params).sort(unsorted); + + Assertions.assertEquals(List.of(alphaHigh, alphaLow, betaHigh, betaLow), sorted); + } + + @Test + public void ignoresUnknownSortFieldsAndDoesNotMutateInputList() { + EntityInstance low = instance("beta", "1"); + EntityInstance high = instance("alpha", "3"); + List unsorted = List.of(low, high); + + QueryFilterParams params = new QueryFilterParams(); + params.put("_sortBy", "+missing"); + + List sorted = new EntityInstanceListSorter(params).sort(unsorted); + + Assertions.assertEquals(List.of(low, high), sorted); + Assertions.assertNotSame(unsorted, sorted); + } + + private EntityInstance instance(final String category, final String priority) { + return MutableEntityInstance.forEntity(thing) + .setValue("category", category) + .setValue("priority", priority) + .toEntityInstance(); + } +} diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java index d3a44b87..313d3af4 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java @@ -110,7 +110,7 @@ public void canFilterBooleanMatchesNotTrue() { @Test public void canSortBooleanMatchesAsc() { QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "+truefalse"); + params.put("_sortBy", "+truefalse"); RepositoryQuery queryResults = queryThings(params); @@ -127,7 +127,7 @@ public void canSortBooleanMatchesAsc() { @Test public void canSortBooleanMatchesDesc() { QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "-truefalse"); + params.put("_sortBy", "-truefalse"); RepositoryQuery queryResults = queryThings(params); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java index dce839c8..e9b0f1d3 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java @@ -65,7 +65,7 @@ public void canFilterFloatNotEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("float", "!1.1"); - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); @@ -83,7 +83,7 @@ public void canFilterFloatCombinationOfConditions() { QueryFilterParams params = new QueryFilterParams(); params.put("float", ">1.1"); // greater than 1 params.put("float", "!3.3"); // and not equal to 3 - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); @@ -97,7 +97,7 @@ public void canFilterFloatCombinationOfConditions() { public void canFilterFloatGreaterThan() { QueryFilterParams params = new QueryFilterParams(); params.put("float", ">1.1"); - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); @@ -113,7 +113,7 @@ public void canFilterFloatGreaterThan() { public void canFilterFloatLessThan() { QueryFilterParams params = new QueryFilterParams(); params.put("float", "<2.0"); - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); @@ -127,7 +127,7 @@ public void canFilterFloatLessThan() { public void canFilterFloatLessThanNotMatching() { QueryFilterParams params = new QueryFilterParams(); params.put("float", "<1.1"); - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); @@ -140,7 +140,7 @@ public void canFilterFloatLessThanNotMatching() { public void canFilterFloatGreaterThanEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("float", ">=3.3"); - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); @@ -155,7 +155,7 @@ public void canFilterFloatGreaterThanEquals() { public void canFilterFloatLessThanEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("float", "<=3.3"); - params.put("sortby", "+float"); + params.put("_sortBy", "+float"); RepositoryQuery queryResults = queryThings(params); @@ -171,7 +171,7 @@ public void canFilterFloatLessThanEquals() { public void canFilterFloatLessThanEqualsSortDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("float", "<=3.3"); - params.put("sortby", "-float"); + params.put("_sortBy", "-float"); RepositoryQuery queryResults = queryThings(params); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java index 14c16540..82b5e583 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java @@ -54,7 +54,7 @@ public void canFilterAndSortIdAsc() { QueryFilterParams params = new QueryFilterParams(); params.put("id", ">=3"); - params.put("sortBy", "+id"); + params.put("_sortBy", "+id"); RepositoryQuery queryResults = queryThings(params); @@ -70,7 +70,7 @@ public void canFilterAndSortIdDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("id", "<3"); - params.put("sortBy", "-id"); + params.put("_sortBy", "-id"); RepositoryQuery queryResults = queryThings(params); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java index f066aabc..77e7a14d 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java @@ -70,7 +70,7 @@ public void canFilterIntegerNotEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "!1"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); @@ -87,7 +87,7 @@ public void canFilterIntegerCombinationOfConditions() { QueryFilterParams params = new QueryFilterParams(); params.put("int", ">1"); // greater than 1 params.put("int", "!3"); // and not equal to 3 - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); @@ -101,7 +101,7 @@ public void canFilterIntegerCombinationOfConditions() { public void canFilterIntegerGreaterThan() { QueryFilterParams params = new QueryFilterParams(); params.put("int", ">1"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); @@ -117,7 +117,7 @@ public void canFilterIntegerGreaterThan() { public void canFilterIntegerLessThan() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "<2"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); @@ -131,7 +131,7 @@ public void canFilterIntegerLessThan() { public void canFilterIntegerLessThanNotMatching() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "<1"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); @@ -144,7 +144,7 @@ public void canFilterIntegerLessThanNotMatching() { public void canFilterIntegerGreaterThanEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("int", ">=3"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); @@ -159,7 +159,7 @@ public void canFilterIntegerGreaterThanEquals() { public void canFilterIntegerLessThanEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "<=3"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); @@ -175,7 +175,7 @@ public void canFilterIntegerLessThanEquals() { public void canFilterIntegerLessThanSortedDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "<3"); - params.put("sortby", "-int"); + params.put("_sortBy", "-int"); RepositoryQuery queryResults = queryThings(params); @@ -189,7 +189,7 @@ public void canFilterIntegerLessThanSortedDesc() { @Test public void canIntegerSortedDesc() { QueryFilterParams params = new QueryFilterParams(); - params.put("sortby", "-int"); + params.put("_sortBy", "-int"); RepositoryQuery queryResults = queryThings(params); @@ -205,7 +205,7 @@ public void canIntegerSortedDesc() { @Test public void canIntegerSortedAsc() { QueryFilterParams params = new QueryFilterParams(); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); @@ -222,7 +222,7 @@ public void canIntegerSortedAsc() { public void canRegexFilterInteger() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "~=[1,2]"); - params.put("sortby", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery queryResults = queryThings(params); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java index ba1283dd..79886408 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java @@ -51,7 +51,7 @@ public void setupCollectionTestData() { public void canSortStringAsc() { QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "+string"); + params.put("_sortBy", "+string"); RepositoryQuery queryResults = queryThings(params); @@ -68,7 +68,7 @@ public void canSortStringAsc() { public void canSortStringDesc() { QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "-string"); + params.put("_sortBy", "-string"); RepositoryQuery queryResults = queryThings(params); @@ -101,7 +101,7 @@ public void canFilterAndSortStringUsingRegexAsc() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "~=.*o.*"); - params.put("sortBy", "+string"); + params.put("_sortBy", "+string"); RepositoryQuery queryResults = queryThings(params); @@ -119,7 +119,7 @@ public void canFilterAndSortStringUsingRegexDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "~=.*o.*"); - params.put("sortBy", "-string"); + params.put("_sortBy", "-string"); RepositoryQuery queryResults = queryThings(params); @@ -137,7 +137,7 @@ public void canFilterAndSortStringUsingWildcardAsc() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "*=*e"); - params.put("sortBy", "+string"); + params.put("_sortBy", "+string"); RepositoryQuery queryResults = queryThings(params); @@ -154,7 +154,7 @@ public void canFilterAndSortStringUsingWildcardAscForT() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "*=t*"); - params.put("sortBy", "+string"); + params.put("_sortBy", "+string"); RepositoryQuery queryResults = queryThings(params); @@ -171,7 +171,7 @@ public void canFilterAndSortStringUsingWildcardAscForTBit() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "*=t?*"); - params.put("sortBy", "+string"); + params.put("_sortBy", "+string"); RepositoryQuery queryResults = queryThings(params); @@ -188,7 +188,7 @@ public void canFilterAndSortStringUsingWildcardAscForTBitO() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "*=t?o"); - params.put("sortBy", "+string"); + params.put("_sortBy", "+string"); RepositoryQuery queryResults = queryThings(params); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java index 04e2c935..a10e58f8 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java @@ -14,8 +14,6 @@ /** Repository-backed URL query coverage for API-style entity reads. */ public class SortingViaQueryFiltersTest { - // todo: lower level testing at the EntityInstanceListSorter level - EntityDefinition thing; EntityRelModel erModel; @@ -49,7 +47,7 @@ public void canSortIntViaAQuery() { .create(EntityInstanceDraft.forEntity(thing).withField("int", "3")); QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "-int"); + params.put("_sortBy", "-int"); RepositoryQuery ascSortedResults = queryCollection(erModel, thing, params); @@ -63,7 +61,7 @@ public void canSortIntViaAQuery() { // then repeat sort and get different results params = new QueryFilterParams(); - params.put("sortBy", "+int"); + params.put("_sortBy", "+int"); RepositoryQuery descSortedResults = queryCollection(erModel, thing, params); @@ -75,7 +73,7 @@ public void canSortIntViaAQuery() { // check that default sort is ascending params = new QueryFilterParams(); - params.put("sortBy", "int"); + params.put("_sortBy", "int"); RepositoryQuery defaultSortedResults = queryCollection(erModel, thing, params); @@ -87,6 +85,56 @@ public void canSortIntViaAQuery() { Assertions.assertEquals(thing3, defaultSortedInstances.get(2)); } + @Test + public void sortByParameterNameIsExactAndCaseSensitive() { + Assertions.assertTrue(SortByFieldName.isSortByParam("_sortBy")); + Assertions.assertFalse(SortByFieldName.isSortByParam("_SortBy")); + Assertions.assertFalse(SortByFieldName.isSortByParam("sortBy")); + Assertions.assertFalse(SortByFieldName.isSortByParam("sortby")); + Assertions.assertFalse(SortByFieldName.isSortByParam("sort_by")); + } + + @Test + public void canSortByMultipleFieldsViaAQuery() { + final EntityInstance falseLow = + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(thing) + .withField("truefalse", "false") + .withField("int", "1")); + final EntityInstance falseHigh = + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(thing) + .withField("truefalse", "false") + .withField("int", "3")); + final EntityInstance trueLow = + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(thing) + .withField("truefalse", "true") + .withField("int", "2")); + final EntityInstance trueHigh = + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(thing) + .withField("truefalse", "true") + .withField("int", "4")); + + QueryFilterParams params = new QueryFilterParams(); + params.put("_sortBy", "+truefalse,-int"); + + RepositoryQuery sortedResults = queryCollection(erModel, thing, params); + + Assertions.assertEquals( + List.of(falseHigh, falseLow, trueHigh, trueLow), + sortedResults.getListEntityInstances()); + } + @Test public void canSortViaAQuery() { @@ -111,7 +159,7 @@ public void canSortViaAQuery() { .withField("truefalse", "false")); QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "-truefalse"); + params.put("_sortBy", "-truefalse"); RepositoryQuery ascSortedResults = queryCollection(aThingifier, thing, params); @@ -125,7 +173,7 @@ public void canSortViaAQuery() { // then repeat sort and get different results params = new QueryFilterParams(); - params.put("sortBy", "+truefalse"); + params.put("_sortBy", "+truefalse"); RepositoryQuery descSortedResults = queryCollection(aThingifier, thing, params); @@ -136,7 +184,7 @@ public void canSortViaAQuery() { // check that default sort is ascending params = new QueryFilterParams(); - params.put("sortBy", "truefalse"); + params.put("_sortBy", "truefalse"); RepositoryQuery defaultSortedResults = queryCollection(aThingifier, thing, params); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java index fe0c7282..268ed1e0 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java @@ -193,6 +193,36 @@ public void sqliteRepositoryQuotesGeneratedSqlIdentifiers() { } } + @Test + public void inMemoryRepositoryTreatsOldSortNamesAsFilterFields() { + ThingStore repository = new InMemoryThingStore(EntityRelModel.DEFAULT_DATABASE_NAME); + + exerciseOldSortNamesAsFilterFields(repository); + } + + @Test + public void sqliteRepositoryTreatsOldSortNamesAsFilterFields() { + try (ThingStore repository = + SqliteThingStore.inMemory(EntityRelModel.DEFAULT_DATABASE_NAME)) { + exerciseOldSortNamesAsFilterFields(repository); + } + } + + @Test + public void inMemoryRepositorySortsByMultipleFields() { + ThingStore repository = new InMemoryThingStore(EntityRelModel.DEFAULT_DATABASE_NAME); + + exerciseMultipleSortFields(repository); + } + + @Test + public void sqliteRepositorySortsByMultipleFields() { + try (ThingStore repository = + SqliteThingStore.inMemory(EntityRelModel.DEFAULT_DATABASE_NAME)) { + exerciseMultipleSortFields(repository); + } + } + @Test public void inMemoryRepositoryGeneratesAutoIdsThroughTheContract() { ThingStore repository = new InMemoryThingStore(EntityRelModel.DEFAULT_DATABASE_NAME); @@ -469,7 +499,7 @@ public void sqliteRelationshipReadsWorkAfterReopen() { "Wire repository", regexFilteredTasks.get(0).getFieldValue("title").asString()); QueryFilterParams sortedParams = new QueryFilterParams(); - sortedParams.put("sortBy", "-id"); + sortedParams.put("_sortBy", "-id"); List sortedTasks = reopened.relationships().listRelated(project, "tasks", sortedParams); @@ -679,7 +709,7 @@ private void exerciseRepositoryContract(final ThingStore repository) { Assertions.assertEquals(secondProject, filteredProjects.get(0)); QueryFilterParams sortedParams = new QueryFilterParams(); - sortedParams.put("sortBy", "-id"); + sortedParams.put("_sortBy", "-id"); List sortedProjects = repository.entityQueries().list(projectDefinition, sortedParams); Assertions.assertEquals("2", sortedProjects.get(0).getPrimaryKeyValue()); @@ -744,7 +774,7 @@ private void exerciseRepositoryContract(final ThingStore repository) { Assertions.assertEquals(task, filteredTasks.get(0)); QueryFilterParams relationshipSortParams = new QueryFilterParams(); - relationshipSortParams.put("sortBy", "-id"); + relationshipSortParams.put("_sortBy", "-id"); List sortedTasks = repository.relationships().listRelated(project, "tasks", relationshipSortParams); Assertions.assertEquals("2", sortedTasks.get(0).getPrimaryKeyValue()); @@ -973,6 +1003,78 @@ private EntityInstance createWithId( .withField("title", "Title " + id)); } + private void exerciseOldSortNamesAsFilterFields(final ThingStore repository) { + ERSchema schema = legacySortNameSchema(); + repository.administration().initializeFrom(schema); + + assertOldSortNameFilters(repository, schema.getEntityDefinitionNamed("camel"), "sortBy"); + assertOldSortNameFilters(repository, schema.getEntityDefinitionNamed("lower"), "sortby"); + assertOldSortNameFilters(repository, schema.getEntityDefinitionNamed("snake"), "sort_by"); + } + + private void assertOldSortNameFilters( + final ThingStore repository, final EntityDefinition entity, final String fieldName) { + EntityInstance other = + createLegacySortNameItem(repository, entity, fieldName, "other", "1"); + EntityInstance target = + createLegacySortNameItem(repository, entity, fieldName, "target", "2"); + + QueryFilterParams filterParams = new QueryFilterParams(); + filterParams.put(fieldName, "target"); + Assertions.assertEquals( + List.of(target), repository.entityQueries().list(entity, filterParams)); + + QueryFilterParams sortParams = new QueryFilterParams(); + sortParams.put("_sortBy", "+rank"); + Assertions.assertEquals( + List.of(other, target), repository.entityQueries().list(entity, sortParams)); + } + + private EntityInstance createLegacySortNameItem( + final ThingStore repository, + final EntityDefinition entity, + final String fieldName, + final String fieldValue, + final String rank) { + return repository + .entities() + .create( + EntityInstanceDraft.forEntity(entity) + .withField(fieldName, fieldValue) + .withField("rank", rank)); + } + + private void exerciseMultipleSortFields(final ThingStore repository) { + ERSchema schema = sortableItemSchema(); + EntityDefinition item = schema.getEntityDefinitionNamed("item"); + repository.administration().initializeFrom(schema); + + EntityInstance alphaLow = createSortableItem(repository, item, "alpha", "1"); + EntityInstance alphaHigh = createSortableItem(repository, item, "alpha", "3"); + EntityInstance betaLow = createSortableItem(repository, item, "beta", "2"); + EntityInstance betaHigh = createSortableItem(repository, item, "beta", "4"); + + QueryFilterParams sortParams = new QueryFilterParams(); + sortParams.put("_sortBy", "+category,-priority"); + + Assertions.assertEquals( + List.of(alphaHigh, alphaLow, betaHigh, betaLow), + repository.entityQueries().list(item, sortParams)); + } + + private EntityInstance createSortableItem( + final ThingStore repository, + final EntityDefinition entity, + final String category, + final String priority) { + return repository + .entities() + .create( + EntityInstanceDraft.forEntity(entity) + .withField("category", category) + .withField("priority", priority)); + } + private String exportDataAsJson(final ThingStore repository, final ERSchema schema) { return new RepositoryJsonExporter(schema, repository.entityQueries()).asJson(); } @@ -999,6 +1101,38 @@ private ERSchema ticketSchema(final int maxInstances) { return schema; } + private ERSchema legacySortNameSchema() { + ERSchema schema = new ERSchema(); + + EntityDefinition camel = schema.defineEntity("camel", "camels", -1); + camel.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + camel.addField(Field.is("sortBy", FieldType.STRING)); + camel.addField(Field.is("rank", FieldType.INTEGER)); + + EntityDefinition lower = schema.defineEntity("lower", "lowers", -1); + lower.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + lower.addField(Field.is("sortby", FieldType.STRING)); + lower.addField(Field.is("rank", FieldType.INTEGER)); + + EntityDefinition snake = schema.defineEntity("snake", "snakes", -1); + snake.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + snake.addField(Field.is("sort_by", FieldType.STRING)); + snake.addField(Field.is("rank", FieldType.INTEGER)); + + return schema; + } + + private ERSchema sortableItemSchema() { + ERSchema schema = new ERSchema(); + + EntityDefinition item = schema.defineEntity("item", "items", -1); + item.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + item.addField(Field.is("category", FieldType.STRING)); + item.addField(Field.is("priority", FieldType.INTEGER)); + + return schema; + } + private void assertExportedJsonContainsProjectAndTask(final String json) { Assertions.assertTrue(json.contains("\"projects\"")); Assertions.assertTrue(json.contains("\"tasks\"")); diff --git a/swaggerizer/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerTest.java b/swaggerizer/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerTest.java index c58e36f0..93fbe2be 100644 --- a/swaggerizer/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerTest.java +++ b/swaggerizer/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerTest.java @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.swaggerizer; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.junit.jupiter.api.Assertions; @@ -66,6 +67,8 @@ public void canCreateOpenApi32Swagger() { Assertions.assertEquals("3.2.0", openApiVersion(swagger)); Assertions.assertTrue(todos.has("query")); + Assertions.assertTrue(hasParameterNamed(todos.getAsJsonObject("get"), "_sortBy")); + Assertions.assertTrue(hasParameterNamed(query, "_sortBy")); Assertions.assertFalse(swagger.contains("\"x-query-operation\"")); Assertions.assertFalse(swagger.contains("\"x-http-method\"")); Assertions.assertFalse(swagger.contains("\"x-query-content-types\"")); @@ -75,6 +78,19 @@ public void canCreateOpenApi32Swagger() { .has("application/x-www-form-urlencoded")); } + private boolean hasParameterNamed(final JsonObject operation, final String name) { + if (!operation.has("parameters")) { + return false; + } + for (JsonElement parameterElement : operation.getAsJsonArray("parameters")) { + JsonObject parameter = parameterElement.getAsJsonObject(); + if (name.equals(parameter.get("name").getAsString())) { + return true; + } + } + return false; + } + private String openApiVersion(final String swagger) { return JsonParser.parseString(swagger).getAsJsonObject().get("openapi").getAsString(); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java index 54b369c5..739f0235 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java @@ -445,28 +445,32 @@ private void addRoutingsForRelationship( String aUrl = endPointPrefix + fromNameForUrl + "/" + uniqueIdentifier + "/" + relationshipName; - defn.addRouting( - String.format( - "%s %s %s related to %s, with given %s, by the relationship named %s", - relationshipGetDocumentationVerb, - toName, - relationshipGetTargetDescription, - fromName, - uniqueIdFieldName, - relationshipName), - RoutingVerb.GET, - aUrl, - RoutingStatus.returnedFromCall()) - .addRequestUrlParam(uniqueIdField) - .addPossibleStatus( - RoutingStatus.returnValue( - 200, + RoutingDefinition relationshipGetRoute = + defn.addRouting( String.format( - "%s related %s %s", - getReturnsSingle ? "the" : "all the", + "%s %s %s related to %s, with given %s, by the relationship named %s", + relationshipGetDocumentationVerb, toName, - relationshipGetTargetDescription))) - .returnPayload(200, relationshipGetReturnPayload(relationship)); + relationshipGetTargetDescription, + fromName, + uniqueIdFieldName, + relationshipName), + RoutingVerb.GET, + aUrl, + RoutingStatus.returnedFromCall()) + .addRequestUrlParam(uniqueIdField) + .addPossibleStatus( + RoutingStatus.returnValue( + 200, + String.format( + "%s related %s %s", + getReturnsSingle ? "the" : "all the", + toName, + relationshipGetTargetDescription))) + .returnPayload(200, relationshipGetReturnPayload(relationship)); + if (!getReturnsSingle) { + relationshipGetRoute.setAsFilterableFrom(relationship.getTo()); + } defn.addRouting( String.format( diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java index 3b0a63a3..453f874c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java @@ -23,6 +23,7 @@ import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipVectorDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.ValidationRule; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.query.SortByFieldName; public class RestApiDocumentationGenerator { private static final String DEFAULT_CANONICAL_HOST = "https://apichallenges.eviltester.com"; @@ -152,11 +153,28 @@ public String getApiDocumentation( output.append( paragraph( "Some requests can be filtered by adding query params of fieldname=value. Where only matching items will be returned.")); + output.append( + paragraph( + "Filter conditions can use field=value for equals, field!=value or field!value for not equals, field<value, field>value, field<=value, and field>=value for comparisons, field~=regex for regular expression matches, and field*=wildcard for wildcard matches where * matches many characters and ? matches one character. Multiple query params are combined as AND conditions.")); // TODO: generate the filter example string from the entity definitions // defns.toArray() output.append( paragraph("e.g. /thing?size=2&status=true

\n")); + output.append( + paragraph( + "Some requests can be sorted by adding the " + + SortByFieldName.PARAMETER_NAME + + " query param with a field name. Use " + + SortByFieldName.PARAMETER_NAME + + "=+field or " + + SortByFieldName.PARAMETER_NAME + + "=field for ascending order, and " + + SortByFieldName.PARAMETER_NAME + + "=-field for descending order. Multiple" + + " fields can be combined with commas, e.g. " + + SortByFieldName.PARAMETER_NAME + + "=+field,-other.")); } } @@ -419,6 +437,30 @@ public String getApiDocumentation( + exampleFilter + "")); } + + output.append( + paragraph( + "This endpoint can be sorted with the " + + SortByFieldName.PARAMETER_NAME + + " URL Query Parameter. Use " + + SortByFieldName.PARAMETER_NAME + + "=+field or " + + SortByFieldName.PARAMETER_NAME + + "=field for ascending order, and " + + SortByFieldName.PARAMETER_NAME + + "=-field for descending order. Multiple fields" + + " can be combined with commas, e.g. " + + SortByFieldName.PARAMETER_NAME + + "=+field,-other.")); + String exampleSort = getExampleSort(routingDefn.getFilterableEntity()); + if (exampleSort != null && !exampleSort.isEmpty()) { + output.append( + paragraph( + "e.g. " + + url(routingDefn.url()) + + exampleSort + + "")); + } } currentEndPoint = routingDefn.url(); @@ -442,7 +484,9 @@ public String getApiDocumentation( paragraph( "QUERY content uses Content-Type: " + ThingifierHttpApi.QUERY_CONTENT_TYPE - + " with fields such as title=Task&sortBy=-id.")); + + " with fields such as title=Task&" + + SortByFieldName.PARAMETER_NAME + + "=-id.")); } } } @@ -811,6 +855,22 @@ private String getExampleFilter(final EntityDefinition filterableEntity) { return exampleFilters; } + private String getExampleSort(final EntityDefinition filterableEntity) { + String fieldName = "field"; + if (filterableEntity != null) { + Field primaryKeyField = filterableEntity.getPrimaryKeyField(); + if (primaryKeyField != null) { + fieldName = primaryKeyField.getName(); + } else { + for (String name : filterableEntity.getFieldNames()) { + fieldName = name; + break; + } + } + } + return "?" + SortByFieldName.PARAMETER_NAME + "=+" + fieldName; + } + private String url(final String postUrl) { String midPath = ""; diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java index 981a7c3b..2f38b340 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java @@ -21,6 +21,7 @@ import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; @@ -28,6 +29,7 @@ import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.ValidationRule; +import uk.co.compendiumdev.thingifier.core.query.SortByFieldName; public class Swaggerizer { @@ -253,6 +255,11 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { new SecurityRequirement().addList("bearerAuth")); } + if (shouldDocumentSortParameter(thingifier, subroute)) { + operationParameters.add( + sortByParameter(subroute.getFilterableEntity())); + } + if (subroute.hasRequestUrlParams()) { List urlParameters = new ArrayList<>(); @@ -398,6 +405,45 @@ private void addUrlParametersAtEndpointLevel(PathItem path, List urlP } } + private boolean shouldDocumentSortParameter( + final Thingifier thingifier, final RoutingDefinition route) { + if (!route.isFilterable()) { + return false; + } + if (route.verb() == RoutingVerb.QUERY) { + return true; + } + return thingifier.apiConfig().forParams().willAllowFilteringThroughUrlParams(); + } + + private Parameter sortByParameter(final EntityDefinition filterableEntity) { + Parameter param = new Parameter(); + param.in("query") + .name(SortByFieldName.PARAMETER_NAME) + .required(false) + .description( + "Sort collection results by a field. Use +field or field for ascending" + + " order, and -field for descending order. Multiple fields can" + + " be combined with commas, e.g. +field,-other.") + .example("+" + sortExampleFieldName(filterableEntity)); + param.setSchema(new StringSchema()); + return param; + } + + private String sortExampleFieldName(final EntityDefinition filterableEntity) { + if (filterableEntity == null) { + return "field"; + } + Field primaryKeyField = filterableEntity.getPrimaryKeyField(); + if (primaryKeyField != null) { + return primaryKeyField.getName(); + } + for (String fieldName : filterableEntity.getFieldNames()) { + return fieldName; + } + return "field"; + } + private void setOperationVerb(RoutingDefinition subroute, PathItem path, Operation operation) { switch (subroute.verb()) { case GET: diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java index 86ccbb3d..4fe3cb51 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java @@ -73,6 +73,8 @@ public void relationshipWritesDocumentValidationAndConflictStatuses() { Assertions.assertEquals( Set.of("id"), parameterNames(route(definition, RoutingVerb.GET, "projects/:id/tasks"))); + Assertions.assertTrue( + route(definition, RoutingVerb.GET, "projects/:id/tasks").isFilterable()); Assertions.assertTrue( route(definition, RoutingVerb.GET, "projects/:id/tasks").hasReturnPayloadFor(200)); Assertions.assertEquals( @@ -84,6 +86,8 @@ public void relationshipWritesDocumentValidationAndConflictStatuses() { Assertions.assertEquals( "project", route(definition, RoutingVerb.GET, "todos/:id/tasksof").getReturnPayloadFor(200)); + Assertions.assertFalse( + route(definition, RoutingVerb.GET, "todos/:id/tasksof").isFilterable()); Assertions.assertEquals( "projects", route(definition, RoutingVerb.QUERY, "todos/:id/tasksof").getReturnPayloadFor(200)); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/UrlQueryParamParserTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/UrlQueryParamParserTest.java index 7355ff95..8acfa669 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/UrlQueryParamParserTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/UrlQueryParamParserTest.java @@ -12,9 +12,9 @@ public class UrlQueryParamParserTest { /* The default HTTP edge parsing for url params is a split by & and then a split by = - We want to be able to filter and sort e.g. ?id>=2&sortBy=-id&id<=16 + We want to be able to filter and sort e.g. ?id>=2&_sortBy=-id&id<=16 - This would come through as (id>,2) and (sortBy,-id) so we would lose the >= and it is on the wrong side for us. + This would come through as (id>,2) and (_sortBy,-id) so we would lose the >= and it is on the wrong side for us. */ diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java index 093ccd21..b73edbe2 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java @@ -153,7 +153,7 @@ public void collectionReadAppliesSorting() { createTask(storeFor(thingifier), task, "Last", "3"); createTask(storeFor(thingifier), task, "First", "1"); QueryFilterParams params = new QueryFilterParams(); - params.put("sortBy", "+priority"); + params.put("_sortBy", "+priority"); RepositoryQueryResult result = queryServiceFor(thingifier) diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java index ab64e252..9c650252 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java @@ -129,6 +129,43 @@ void apiDocumentationShowsConfiguredPatchInstanceRouteAsSupported() { + " allowed")); } + @Test + void apiDocumentationShowsFilteringAndSortingForFilterableCollectionRoutes() { + final Thingifier thingifier = new Thingifier(); + thingifier.setDocumentation("Task API", "Task API docs."); + final EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + task.addField(Field.is("title", FieldType.STRING)); + + final String docs = + new RestApiDocumentationGenerator(thingifier, new DefaultGUIHTML()) + .getApiDocumentation( + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"), + List.of(), + new ThingifierApiDocumentationDefn(), + "/api", + "https://example.com/api/docs"); + + Assertions.assertTrue(docs.contains("field=value for equals")); + Assertions.assertTrue(docs.contains("field!=value")); + Assertions.assertTrue(docs.contains("field!value")); + Assertions.assertTrue(docs.contains("field<value")); + Assertions.assertTrue(docs.contains("field>value")); + Assertions.assertTrue(docs.contains("field<=value")); + Assertions.assertTrue(docs.contains("field>=value")); + Assertions.assertTrue(docs.contains("field~=regex")); + Assertions.assertTrue(docs.contains("field*=wildcard")); + Assertions.assertTrue( + docs.contains("Multiple query params are combined as AND conditions.")); + Assertions.assertTrue(docs.contains("_sortBy=+field")); + Assertions.assertTrue(docs.contains("_sortBy=field")); + Assertions.assertTrue(docs.contains("_sortBy=-field")); + Assertions.assertTrue(docs.contains("_sortBy=+field,-other")); + Assertions.assertTrue(docs.contains("/api/tasks?_sortBy=+id")); + Assertions.assertTrue(docs.contains("title=Task&_sortBy=-id")); + Assertions.assertFalse(docs.contains("&sortBy=-id")); + } + @Test void apiDocumentationShowsTwoWayRelationshipsAsSeparateDirections() { final Thingifier thingifier = new Thingifier(); @@ -160,6 +197,7 @@ void apiDocumentationShowsTwoWayRelationshipsAsSeparateDirections() { Assertions.assertTrue( docs.contains("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")); Assertions.assertTrue(docs.contains("mermaid.initialize({ startOnLoad: true });")); + Assertions.assertTrue(docs.contains("/projects/:id/tasks?_sortBy=+id")); } @Test diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java index ba03ea56..d611d458 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java @@ -1,7 +1,9 @@ package uk.co.compendiumdev.thingifier.swaggerizer; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.parameters.Parameter; import java.util.Set; import java.util.stream.Collectors; import org.junit.jupiter.api.Assertions; @@ -86,6 +88,23 @@ void relationshipRoutesExposeEditablePathParametersAndPostBodies() { .allMatch(parameter -> Boolean.TRUE.equals(parameter.getRequired()))); } + @Test + void filterableCollectionOperationsExposeSortByParameter() { + final OpenAPI openApi = new Swaggerizer(apiDefn(relationshipModel())).swagger(); + + assertSortByParameter(openApi.getPaths().get("/projects").getGet()); + assertSortByParameter(queryOperation(openApi.getPaths().get("/projects"))); + assertSortByParameter(openApi.getPaths().get("/projects/{id}/tasks").getGet()); + assertSortByParameter(queryOperation(openApi.getPaths().get("/projects/{id}/tasks"))); + + Operation singleTargetRelationshipGet = + openApi.getPaths().get("/todos/{id}/project").getGet(); + Assertions.assertTrue( + singleTargetRelationshipGet.getParameters() == null + || singleTargetRelationshipGet.getParameters().stream() + .noneMatch(parameter -> "_sortBy".equals(parameter.getName()))); + } + private ThingifierApiDocumentationDefn apiDefn(final Thingifier thingifier) { return new ThingifierApiDocumentationDefn().setThingifier(thingifier); } @@ -111,4 +130,23 @@ private Set pathParameterNames(final PathItem pathItem) { .map(parameter -> parameter.getName()) .collect(Collectors.toSet()); } + + private Operation queryOperation(final PathItem pathItem) { + return (Operation) pathItem.getExtensions().get("x-query-operation"); + } + + private void assertSortByParameter(final Operation operation) { + Parameter sortBy = + operation.getParameters().stream() + .filter(parameter -> "_sortBy".equals(parameter.getName())) + .findFirst() + .orElseThrow(); + + Assertions.assertEquals("query", sortBy.getIn()); + Assertions.assertFalse(sortBy.getRequired()); + Assertions.assertEquals("+id", sortBy.getExample()); + Assertions.assertTrue(sortBy.getDescription().contains("ascending")); + Assertions.assertTrue(sortBy.getDescription().contains("descending")); + Assertions.assertTrue(sortBy.getDescription().contains("+field,-other")); + } }