diff --git a/README.md b/README.md index e95e90a..77a84ce 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,97 @@ Just add the dependency. Swagger UI automatically shows: Works with JPA, MongoDB, and Predicate modules. +## Pagination, Sorting and Field Selection + +The `page-sort` module provides annotations for pagination, sorting, and field selection. + +```xml + + com.turkraft.springfilter + page-sort + 3.2.2 + +``` + +### Basic Usage + +```java +@GetMapping("/cars") +Page search(@Filter Specification spec, @Page Pageable page) { + return repository.findAll(spec, page); +} +``` + +Usage: `?page=0&size=20&sort=-year` (prefix `-` for descending) + +### Custom Parameter Names + +```java +@GetMapping("/cars") +Page search( + @Page(pageParameter = "p", sizeParameter = "limit", sortParameter = "order") Pageable page) { + return repository.findAll(page); +} +``` + +Now use `?p=0&limit=50&order=-year` + +### Sort Parameter + +```java +@GetMapping("/cars") +List search(@Sort org.springframework.data.domain.Sort sort) { + return repository.findAll(sort); +} +``` + +Use `?sort=-year` or `?sort=-year,name` + +### Field Selection + +```java +@Fields +@GetMapping("/cars") +List search() { + return repository.findAll(); +} +``` + +Use `?fields=id,brand.name,year` to return only specified fields. Uses Jackson's filtering internally. + +```java +// Include specific fields +?fields= id,name,email + +// Exclude fields +?fields= *,-password,-ssn + +// Nested fields +?fields= id,brand.name,brand.country + +// Wildcards +?fields= user.* +``` + +### Combined Example + +```java +@Fields +@GetMapping("/cars") +Page search( + @Filter Specification spec, + @Page Pageable page) { + return repository.findAll(spec, page); +} +``` + +Use all features together: +``` +/cars?filter=year>2020&page=0&size=20&sort=-year&fields=id,brand.name,year +``` + +The `openapi` module automatically generates documentation for these parameters when both dependencies are present. + ## Frontend Integration ### JavaScript diff --git a/jpa-example/pom.xml b/jpa-example/pom.xml index 968f350..37838f1 100644 --- a/jpa-example/pom.xml +++ b/jpa-example/pom.xml @@ -28,6 +28,12 @@ ${revision} + + com.turkraft.springfilter + page-sort + ${revision} + + com.turkraft.springfilter openapi diff --git a/jpa-example/src/main/java/com/turkraft/springfilter/example/SpringFilterJpaExampleApplication.java b/jpa-example/src/main/java/com/turkraft/springfilter/example/SpringFilterJpaExampleApplication.java index 2467a1f..46e6083 100644 --- a/jpa-example/src/main/java/com/turkraft/springfilter/example/SpringFilterJpaExampleApplication.java +++ b/jpa-example/src/main/java/com/turkraft/springfilter/example/SpringFilterJpaExampleApplication.java @@ -1,7 +1,9 @@ package com.turkraft.springfilter.example; import com.github.javafaker.Faker; +import com.turkraft.springfilter.boot.Fields; import com.turkraft.springfilter.boot.Filter; +import com.turkraft.springfilter.boot.Page; import com.turkraft.springfilter.converter.FilterSpecification; import com.turkraft.springfilter.example.model.Address; import com.turkraft.springfilter.example.model.Company; @@ -28,6 +30,7 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -59,7 +62,9 @@ public void run(String... args) { List industries = new ArrayList<>(); for (int i = 0; i < 3; i++) { Industry industry = new Industry(); - industry.setName(faker.company().industry()); + industry.setName(faker + .company() + .industry()); industries.add(industry); } industryRepository.saveAll(industries); @@ -67,11 +72,23 @@ public void run(String... args) { List companies = new ArrayList<>(); for (int i = 0; i < 5; i++) { Company company = new Company(); - company.setName(faker.company().name()); - company.setIndustry(faker.options().nextElement(industries)); + company.setName(faker + .company() + .name()); + company.setIndustry(faker + .options() + .nextElement(industries)); company.setWebsites( - Stream.generate(() -> Map.entry(faker.company().buzzword(), faker.company().url())) - .limit(3).skip(faker.random().nextInt(0, 3)) + Stream + .generate(() -> Map.entry(faker + .company() + .buzzword(), faker + .company() + .url())) + .limit(3) + .skip(faker + .random() + .nextInt(0, 3)) .collect(Collectors.toMap(Entry::getKey, Entry::getValue))); companies.add(company); } @@ -80,21 +97,48 @@ public void run(String... args) { List employees = new ArrayList<>(); for (int i = 0; i < 30; i++) { Employee employee = new Employee(); - employee.setFirstName(faker.name().firstName()); - employee.setLastName(faker.name().lastName()); - employee.setBirthDate(faker.date().birthday()); - employee.setMaritalStatus(faker.options().option(MaritalStatus.class)); - employee.setSalary(faker.random().nextInt(1000, 10000)); - employee.setCompany(faker.options().nextElement(companies)); - employee.setManager(employees.isEmpty() ? null : faker.options().nextElement(employees)); + employee.setFirstName(faker + .name() + .firstName()); + employee.setLastName(faker + .name() + .lastName()); + employee.setBirthDate(faker + .date() + .birthday()); + employee.setMaritalStatus(faker + .options() + .option(MaritalStatus.class)); + employee.setSalary(faker + .random() + .nextInt(1000, 10000)); + employee.setCompany(faker + .options() + .nextElement(companies)); + employee.setManager(employees.isEmpty() ? null : faker + .options() + .nextElement(employees)); employee.setChildren( - Stream.generate(faker.name()::firstName).limit(5).skip(faker.random().nextInt(0, 5)) + Stream + .generate(faker.name()::firstName) + .limit(5) + .skip(faker + .random() + .nextInt(0, 5)) .collect(Collectors.toList())); employee.setAddress(new Address() {{ - setCity(faker.address().city()); - setCountry(faker.address().country()); - setPostalCode(faker.address().zipCode()); - setStreetAndNumber(faker.address().streetAddress()); + setCity(faker + .address() + .city()); + setCountry(faker + .address() + .country()); + setPostalCode(faker + .address() + .zipCode()); + setStreetAndNumber(faker + .address() + .streetAddress()); }}); employees.add(employee); } @@ -103,8 +147,12 @@ public void run(String... args) { List payslips = new ArrayList<>(); for (int i = 0; i < 50; i++) { Payslip payslip = new Payslip(); - payslip.setEmployee(faker.options().nextElement(employees)); - payslip.setDate(faker.date().past(360, TimeUnit.DAYS)); + payslip.setEmployee(faker + .options() + .nextElement(employees)); + payslip.setDate(faker + .date() + .past(360, TimeUnit.DAYS)); payslips.add(payslip); } payslipRepository.saveAll(payslips); @@ -113,32 +161,49 @@ public void run(String... args) { @Operation(hidden = true) @GetMapping("/") - public void index(HttpServletResponse response) throws IOException { + public void index(HttpServletResponse response) + throws IOException { response.sendRedirect("swagger-ui.html"); } - // With springfilter-openapi module, @Filter parameters are automatically documented! - // No need for manual @Operation and @Parameter annotations. - // The documentation, examples, and schema are generated automatically from the entity class. - @GetMapping(value = "industry") - public List getIndustries(@Filter FilterSpecification filter) { - return industryRepository.findAll(filter); + @Fields + public List getIndustries( + @Filter FilterSpecification filter, + @Page Pageable pageable) { + return industryRepository + .findAll(filter, pageable) + .getContent(); } @GetMapping(value = "company") - public List getCompanies(@Filter FilterSpecification filter) { - return companyRepository.findAll(filter); + @Fields + public List getCompanies( + @Filter FilterSpecification filter, + @Page Pageable pageable) { + return companyRepository + .findAll(filter, pageable) + .getContent(); } @GetMapping(value = "employee") - public List getEmployees(@Filter FilterSpecification filter) { - return employeeRepository.findAll(filter); + @Fields + public List getEmployees( + @Filter FilterSpecification filter, + @Page Pageable pageable) { + return employeeRepository + .findAll(filter, pageable) + .getContent(); } @GetMapping(value = "payslip") - public List getPayslips(@Filter FilterSpecification filter) { - return payslipRepository.findAll(filter); + @Fields + public List getPayslips( + @Filter FilterSpecification filter, + @Page Pageable pageable) { + return payslipRepository + .findAll(filter, pageable) + .getContent(); } } diff --git a/mongo-example/pom.xml b/mongo-example/pom.xml index 7ab81e4..b970b04 100644 --- a/mongo-example/pom.xml +++ b/mongo-example/pom.xml @@ -30,6 +30,12 @@ ${revision} + + com.turkraft.springfilter + page-sort + ${revision} + + com.turkraft.springfilter openapi diff --git a/mongo-example/src/main/java/com/turkraft/springfilter/example/SpringFilterMongoExampleApplication.java b/mongo-example/src/main/java/com/turkraft/springfilter/example/SpringFilterMongoExampleApplication.java index dc1cc1a..d6ba645 100644 --- a/mongo-example/src/main/java/com/turkraft/springfilter/example/SpringFilterMongoExampleApplication.java +++ b/mongo-example/src/main/java/com/turkraft/springfilter/example/SpringFilterMongoExampleApplication.java @@ -1,14 +1,15 @@ package com.turkraft.springfilter.example; import com.github.javafaker.Faker; +import com.turkraft.springfilter.boot.Fields; import com.turkraft.springfilter.boot.Filter; +import com.turkraft.springfilter.boot.Page; import com.turkraft.springfilter.example.model.Company; import com.turkraft.springfilter.example.model.Employee; import com.turkraft.springfilter.example.model.Employee.MaritalStatus; import com.turkraft.springfilter.example.model.Industry; import com.turkraft.springfilter.example.model.Payslip; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; @@ -24,8 +25,10 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.BasicQuery; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -177,27 +180,43 @@ public void index(HttpServletResponse response) } @GetMapping(value = "industry") + @Fields public List getIndustries( - @Parameter(hidden = true) @Filter(entityClass = Industry.class) Document filter) { - return mongoTemplate.find(new BasicQuery(filter), Industry.class); + @Filter(entityClass = Industry.class) Document filter, + @Page Pageable pageable) { + Query query = new BasicQuery(filter); + query.with(pageable); + return mongoTemplate.find(query, Industry.class); } @GetMapping(value = "company") + @Fields public List getCompanies( - @Parameter(hidden = true) @Filter(entityClass = Company.class) Document filter) { - return mongoTemplate.find(new BasicQuery(filter), Company.class); + @Filter(entityClass = Company.class) Document filter, + @Page Pageable pageable) { + Query query = new BasicQuery(filter); + query.with(pageable); + return mongoTemplate.find(query, Company.class); } @GetMapping(value = "employee") + @Fields public List getEmployees( - @Parameter(hidden = true) @Filter(entityClass = Employee.class) Document filter) { - return mongoTemplate.find(new BasicQuery(filter), Employee.class); + @Filter(entityClass = Employee.class) Document filter, + @Page Pageable pageable) { + Query query = new BasicQuery(filter); + query.with(pageable); + return mongoTemplate.find(query, Employee.class); } @GetMapping(value = "payslip") + @Fields public List getPayslips( - @Parameter(hidden = true) @Filter(entityClass = Payslip.class) Document filter) { - return mongoTemplate.find(new BasicQuery(filter), Payslip.class); + @Filter(entityClass = Payslip.class) Document filter, + @Page Pageable pageable) { + Query query = new BasicQuery(filter); + query.with(pageable); + return mongoTemplate.find(query, Payslip.class); } } diff --git a/openapi/pom.xml b/openapi/pom.xml index 63b655b..25a6b14 100644 --- a/openapi/pom.xml +++ b/openapi/pom.xml @@ -32,6 +32,13 @@ ${revision} + + com.turkraft.springfilter + page-sort + ${revision} + true + + org.springframework.boot spring-boot-autoconfigure @@ -44,6 +51,12 @@ true + + org.springframework.data + spring-data-commons + test + + org.springframework.boot spring-boot-starter-test diff --git a/openapi/src/main/java/com/turkraft/springfilter/openapi/FilterOpenApiAutoConfiguration.java b/openapi/src/main/java/com/turkraft/springfilter/openapi/FilterOpenApiAutoConfiguration.java index 36205ad..71bf97f 100644 --- a/openapi/src/main/java/com/turkraft/springfilter/openapi/FilterOpenApiAutoConfiguration.java +++ b/openapi/src/main/java/com/turkraft/springfilter/openapi/FilterOpenApiAutoConfiguration.java @@ -7,6 +7,7 @@ import com.turkraft.springfilter.openapi.generator.FilterSchemaGenerator; import com.turkraft.springfilter.openapi.introspection.EntityIntrospector; import com.turkraft.springfilter.openapi.springdoc.FilterParameterCustomizer; +import com.turkraft.springfilter.openapi.springdoc.PageSortParameterCustomizer; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -67,4 +68,16 @@ public FilterParameterCustomizer filterParameterCustomizer( return new FilterParameterCustomizer(filterSchemaGenerator, filterExampleGenerator); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnClass(name = { + "com.turkraft.springfilter.boot.Sort", + "com.turkraft.springfilter.boot.Page", + "com.turkraft.springfilter.boot.Fields" + }) + public PageSortParameterCustomizer pageSortParameterCustomizer( + EntityIntrospector entityIntrospector) { + return new PageSortParameterCustomizer(entityIntrospector); + } + } diff --git a/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/BaseParameterCustomizer.java b/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/BaseParameterCustomizer.java new file mode 100644 index 0000000..ef76415 --- /dev/null +++ b/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/BaseParameterCustomizer.java @@ -0,0 +1,133 @@ +package com.turkraft.springfilter.openapi.springdoc; + +import com.turkraft.springfilter.boot.Filter; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; +import java.lang.reflect.Method; +import org.springframework.lang.Nullable; + +public abstract class BaseParameterCustomizer { + + protected void removeAutoDetectedParameter(Operation operation, + java.lang.reflect.Parameter parameter) { + + if (operation.getParameters() == null) { + return; + } + + String paramName = parameter.getName(); + if (paramName == null) { + return; + } + + operation + .getParameters() + .removeIf(param -> + paramName.equals(param.getName()) || + (param.getName() != null && param + .getName() + .startsWith("arg")) + ); + + } + + protected Parameter findOrCreateParameter(Operation operation, String parameterName) { + + if (operation.getParameters() != null) { + for (Parameter param : operation.getParameters()) { + if (param != null && parameterName.equals(param.getName())) { + return param; + } + } + } + + Parameter parameter = new Parameter(); + parameter.setName(parameterName); + return parameter; + + } + + protected void ensureParameterInOperation(Operation operation, Parameter parameter) { + + if (operation.getParameters() == null) { + operation.setParameters(new java.util.ArrayList<>()); + } + + String parameterName = parameter.getName(); + if (parameterName == null) { + return; + } + + boolean exists = false; + for (int i = 0; i < operation + .getParameters() + .size(); i++) { + Parameter existingParam = operation + .getParameters() + .get(i); + if (existingParam != null && parameterName.equals(existingParam.getName())) { + operation + .getParameters() + .set(i, parameter); + exists = true; + break; + } + } + + if (!exists) { + operation + .getParameters() + .add(parameter); + } + + } + + @Nullable + protected Class findEntityClassFromMethod(Method method) { + + if (method == null) { + return null; + } + + java.lang.reflect.Parameter[] parameters = method.getParameters(); + if (parameters == null) { + return null; + } + + for (java.lang.reflect.Parameter parameter : parameters) { + Filter filterAnnotation = parameter.getAnnotation(Filter.class); + + if (filterAnnotation != null) { + + Class entityClass = filterAnnotation.entityClass(); + if (entityClass != null && entityClass != Void.class) { + return entityClass; + } + + java.lang.reflect.Type parameterType = parameter.getParameterizedType(); + + if (parameterType instanceof java.lang.reflect.ParameterizedType parameterizedType) { + + java.lang.reflect.Type[] typeArguments = parameterizedType.getActualTypeArguments(); + + if (typeArguments.length > 0) { + if (typeArguments[0] instanceof java.lang.reflect.ParameterizedType innerType) { + java.lang.reflect.Type[] innerTypeArguments = innerType.getActualTypeArguments(); + if (innerTypeArguments.length > 0 && innerTypeArguments[0] instanceof Class) { + return (Class) innerTypeArguments[0]; + } + } else if (typeArguments[0] instanceof Class) { + return (Class) typeArguments[0]; + } + } + } + + } + + } + + return null; + + } + +} diff --git a/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/FilterParameterCustomizer.java b/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/FilterParameterCustomizer.java index 5404529..7070fe0 100644 --- a/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/FilterParameterCustomizer.java +++ b/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/FilterParameterCustomizer.java @@ -13,7 +13,8 @@ import org.springframework.lang.Nullable; import org.springframework.web.method.HandlerMethod; -public class FilterParameterCustomizer implements OperationCustomizer { +public class FilterParameterCustomizer extends BaseParameterCustomizer + implements OperationCustomizer { private final FilterSchemaGenerator filterSchemaGenerator; private final FilterExampleGenerator filterExampleGenerator; @@ -68,28 +69,6 @@ public Operation customize(@Nullable Operation operation, @Nullable HandlerMetho } - private void removeAutoDetectedParameter(Operation operation, java.lang.reflect.Parameter parameter) { - - if (operation.getParameters() == null) { - return; - } - - String paramName = parameter.getName(); - if (paramName == null) { - return; - } - - operation - .getParameters() - .removeIf(param -> - paramName.equals(param.getName()) || - (param.getName() != null && param - .getName() - .startsWith("arg")) - ); - - } - private boolean isFilterType(java.lang.reflect.Parameter parameter) { Class type = parameter.getType(); @@ -221,55 +200,4 @@ private void customizeGenericFilterParameter(Operation operation, String paramet } - private Parameter findOrCreateParameter(Operation operation, String parameterName) { - - if (operation.getParameters() != null) { - for (Parameter param : operation.getParameters()) { - if (param != null && parameterName.equals(param.getName())) { - return param; - } - } - } - - Parameter parameter = new Parameter(); - parameter.setName(parameterName); - return parameter; - - } - - private void ensureParameterInOperation(Operation operation, Parameter parameter) { - - if (operation.getParameters() == null) { - operation.setParameters(new java.util.ArrayList<>()); - } - - String parameterName = parameter.getName(); - if (parameterName == null) { - return; - } - - boolean exists = false; - for (int i = 0; i < operation - .getParameters() - .size(); i++) { - Parameter existingParam = operation - .getParameters() - .get(i); - if (existingParam != null && parameterName.equals(existingParam.getName())) { - operation - .getParameters() - .set(i, parameter); - exists = true; - break; - } - } - - if (!exists) { - operation - .getParameters() - .add(parameter); - } - - } - } diff --git a/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/PageSortParameterCustomizer.java b/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/PageSortParameterCustomizer.java new file mode 100644 index 0000000..921c413 --- /dev/null +++ b/openapi/src/main/java/com/turkraft/springfilter/openapi/springdoc/PageSortParameterCustomizer.java @@ -0,0 +1,376 @@ +package com.turkraft.springfilter.openapi.springdoc; + +import com.turkraft.springfilter.openapi.introspection.EntityIntrospector; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import java.lang.reflect.Method; +import java.util.List; +import java.util.stream.Collectors; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.lang.Nullable; +import org.springframework.web.method.HandlerMethod; + +public class PageSortParameterCustomizer extends BaseParameterCustomizer + implements OperationCustomizer { + + private final EntityIntrospector entityIntrospector; + + public PageSortParameterCustomizer(EntityIntrospector entityIntrospector) { + this.entityIntrospector = entityIntrospector; + } + + @Nullable + @Override + public Operation customize(@Nullable Operation operation, @Nullable HandlerMethod handlerMethod) { + + if (operation == null || handlerMethod == null) { + return operation; + } + + Method method = handlerMethod.getMethod(); + if (method == null) { + return operation; + } + + Class entityClass = findEntityClassFromMethod(method); + + try { + Class fieldsAnnotationClass = Class.forName("com.turkraft.springfilter.boot.Fields"); + Object fieldsAnnotation = method.getAnnotation((Class) fieldsAnnotationClass); + + if (fieldsAnnotation != null) { + customizeFieldsParameter(operation, fieldsAnnotation, entityClass); + } + } catch (ClassNotFoundException ignored) { + } + + java.lang.reflect.Parameter[] parameters = method.getParameters(); + if (parameters == null) { + return operation; + } + + for (java.lang.reflect.Parameter parameter : parameters) { + + try { + + Class sortAnnotation = Class.forName("com.turkraft.springfilter.boot.Sort"); + Class pageAnnotation = Class.forName("com.turkraft.springfilter.boot.Page"); + + Object sort = parameter.getAnnotation((Class) sortAnnotation); + Object page = parameter.getAnnotation((Class) pageAnnotation); + + if (sort != null || isSortType(parameter)) { + removeAutoDetectedParameter(operation, parameter); + customizeSortParameter(operation, sort, parameter, entityClass); + } + + if (page != null || isPageType(parameter)) { + removeAutoDetectedParameter(operation, parameter); + customizePageParameter(operation, page, parameter, entityClass); + } + + } catch (ClassNotFoundException ignored) { + } + + } + + return operation; + + } + + private boolean isSortType(java.lang.reflect.Parameter parameter) { + String typeName = parameter + .getType() + .getName(); + return typeName.equals("org.springframework.data.domain.Sort"); + } + + private boolean isPageType(java.lang.reflect.Parameter parameter) { + String typeName = parameter + .getType() + .getName(); + return typeName.equals("org.springframework.data.domain.Pageable"); + } + + + private void customizeSortParameter(Operation operation, @Nullable Object sortAnnotation, + java.lang.reflect.Parameter parameter, @Nullable Class entityClass) { + + try { + + String parameterName = "sort"; + boolean required = false; + String defaultValue = ""; + int maxFields = 10; + + if (sortAnnotation != null) { + Class sortClass = sortAnnotation.getClass(); + parameterName = (String) sortClass + .getMethod("parameter") + .invoke(sortAnnotation); + required = (Boolean) sortClass + .getMethod("required") + .invoke(sortAnnotation); + defaultValue = (String) sortClass + .getMethod("defaultValue") + .invoke(sortAnnotation); + maxFields = (Integer) sortClass + .getMethod("maxFields") + .invoke(sortAnnotation); + } + + Parameter openApiParameter = findOrCreateParameter(operation, parameterName); + openApiParameter.setIn("query"); + openApiParameter.setRequired(required); + + String description = "Sort fields (comma-separated). Use `-` prefix for descending order. " + + "Example: `-createdAt,name` sorts by createdAt descending, then name ascending. " + + "Max fields: " + + maxFields; + + openApiParameter.setDescription(description); + + Schema schema = new StringSchema(); + schema.setType("string"); + + if (!defaultValue.isEmpty()) { + schema.setDefault(defaultValue); + } + + String example = generateSortExample(entityClass); + schema.setExample(example); + + openApiParameter.setSchema(schema); + + ensureParameterInOperation(operation, openApiParameter); + + } catch (Exception ignored) { + } + + } + + private String generateSortExample(@Nullable Class entityClass) { + + if (entityClass == null) { + return "-createdAt,name"; + } + + try { + EntityIntrospector.EntitySchema schema = entityIntrospector.introspect(entityClass); + List rootFields = schema + .getRootFields() + .stream() + .filter(f -> !f.isCollection() && !f.isRelation()) + .map(f -> f.getPath()) + .limit(2) + .collect(Collectors.toList()); + + if (rootFields.isEmpty()) { + return "-createdAt,name"; + } + + if (rootFields.size() == 1) { + return "-" + rootFields.get(0); + } + + return "-" + rootFields.get(0) + "," + rootFields.get(1); + } catch (Exception e) { + return "-createdAt,name"; + } + + } + + private void customizePageParameter(Operation operation, @Nullable Object pageAnnotation, + java.lang.reflect.Parameter parameter, @Nullable Class entityClass) { + + try { + + String pageParam = "page"; + String sizeParam = "size"; + String sortParam = "sort"; + int defaultPage = 0; + int defaultSize = 20; + int maxSize = 100; + int sortMaxFields = 10; + boolean enableSort = true; + + if (pageAnnotation != null) { + Class pageClass = pageAnnotation.getClass(); + pageParam = (String) pageClass + .getMethod("pageParameter") + .invoke(pageAnnotation); + sizeParam = (String) pageClass + .getMethod("sizeParameter") + .invoke(pageAnnotation); + sortParam = (String) pageClass + .getMethod("sortParameter") + .invoke(pageAnnotation); + defaultPage = (Integer) pageClass + .getMethod("defaultPage") + .invoke(pageAnnotation); + defaultSize = (Integer) pageClass + .getMethod("defaultSize") + .invoke(pageAnnotation); + maxSize = (Integer) pageClass + .getMethod("maxSize") + .invoke(pageAnnotation); + sortMaxFields = (Integer) pageClass + .getMethod("sortMaxFields") + .invoke(pageAnnotation); + enableSort = (Boolean) pageClass + .getMethod("enableSort") + .invoke(pageAnnotation); + } + + Parameter pageParameter = findOrCreateParameter(operation, pageParam); + pageParameter.setIn("query"); + pageParameter.setRequired(false); + pageParameter.setDescription("Page number (zero-based)"); + + Schema pageSchema = new IntegerSchema(); + pageSchema.setType("integer"); + pageSchema.setDefault(defaultPage); + pageSchema.setMinimum(java.math.BigDecimal.ZERO); + pageSchema.setExample(0); + + pageParameter.setSchema(pageSchema); + ensureParameterInOperation(operation, pageParameter); + + Parameter sizeParameter = findOrCreateParameter(operation, sizeParam); + sizeParameter.setIn("query"); + sizeParameter.setRequired(false); + sizeParameter.setDescription("Page size (max: " + maxSize + ")"); + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setType("integer"); + sizeSchema.setDefault(defaultSize); + sizeSchema.setMinimum(java.math.BigDecimal.ONE); + sizeSchema.setMaximum(java.math.BigDecimal.valueOf(maxSize)); + sizeSchema.setExample(defaultSize); + + sizeParameter.setSchema(sizeSchema); + ensureParameterInOperation(operation, sizeParameter); + + if (enableSort) { + Parameter sortParameter = findOrCreateParameter(operation, sortParam); + sortParameter.setIn("query"); + sortParameter.setRequired(false); + + String description = "Sort fields (comma-separated). Use `-` prefix for descending order. " + + "Example: `-createdAt,name` sorts by createdAt descending, then name ascending. " + + "Max fields: " + + sortMaxFields; + + sortParameter.setDescription(description); + + Schema sortSchema = new StringSchema(); + sortSchema.setType("string"); + + String example = generateSortExample(entityClass); + sortSchema.setExample(example); + + sortParameter.setSchema(sortSchema); + ensureParameterInOperation(operation, sortParameter); + } + + } catch (Exception ignored) { + } + + } + + private void customizeFieldsParameter(Operation operation, @Nullable Object fieldsAnnotation, + @Nullable Class entityClass) { + + try { + + String parameterName = "fields"; + boolean required = false; + String defaultValue = ""; + + if (fieldsAnnotation != null) { + Class fieldsClass = fieldsAnnotation.getClass(); + parameterName = (String) fieldsClass + .getMethod("parameter") + .invoke(fieldsAnnotation); + required = (Boolean) fieldsClass + .getMethod("required") + .invoke(fieldsAnnotation); + defaultValue = (String) fieldsClass + .getMethod("defaultValue") + .invoke(fieldsAnnotation); + } + + Parameter openApiParameter = findOrCreateParameter(operation, parameterName); + openApiParameter.setIn("query"); + openApiParameter.setRequired(required); + + String description = "Fields to include in response (comma-separated). " + + "Use `-` or `!` prefix to exclude. Supports wildcards (`*`) and nested paths. " + + "Examples: `id,name,email` or `*,-password` or `user.*`"; + + openApiParameter.setDescription(description); + + Schema schema = new StringSchema(); + schema.setType("string"); + + if (!defaultValue.isEmpty()) { + schema.setDefault(defaultValue); + } + + String example = generateFieldsExample(entityClass); + schema.setExample(example); + + openApiParameter.setSchema(schema); + + ensureParameterInOperation(operation, openApiParameter); + + } catch (Exception ignored) { + } + + } + + private String generateFieldsExample(@Nullable Class entityClass) { + + if (entityClass == null) { + return "id,name,email"; + } + + try { + + EntityIntrospector.EntitySchema schema = entityIntrospector.introspect(entityClass); + List allFields = schema + .getRootFields() + .stream() + .filter(f -> !f.isCollection() && !f.isRelation()) + .map(f -> f.getPath()) + .collect(Collectors.toList()); + + if (allFields.isEmpty()) { + return "id,name,email"; + } + + List fieldsForExample = allFields + .stream() + .skip(2) + .limit(3) + .collect(Collectors.toList()); + + if (fieldsForExample.isEmpty()) { + fieldsForExample = allFields + .stream() + .limit(3) + .collect(Collectors.toList()); + } + + return String.join(",", fieldsForExample); + + } catch (Exception e) { + return "id,name,email"; + } + + } + +} diff --git a/openapi/src/test/java/com/turkraft/springfilter/openapi/PageSortParameterCustomizerTest.java b/openapi/src/test/java/com/turkraft/springfilter/openapi/PageSortParameterCustomizerTest.java new file mode 100644 index 0000000..3ef1761 --- /dev/null +++ b/openapi/src/test/java/com/turkraft/springfilter/openapi/PageSortParameterCustomizerTest.java @@ -0,0 +1,397 @@ +package com.turkraft.springfilter.openapi; + +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.turkraft.springfilter.boot.Fields; +import com.turkraft.springfilter.boot.Page; +import com.turkraft.springfilter.boot.Sort; +import com.turkraft.springfilter.openapi.introspection.EntityIntrospector; +import com.turkraft.springfilter.openapi.springdoc.PageSortParameterCustomizer; +import org.springframework.data.domain.Pageable; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; + +class PageSortParameterCustomizerTest { + + private PageSortParameterCustomizer customizer; + + @BeforeEach + void setUp() { + EntityIntrospector entityIntrospector = new EntityIntrospector(); + customizer = new PageSortParameterCustomizer(entityIntrospector); + } + + @Test + void shouldCustomizeSortParameter() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withSort", org.springframework.data.domain.Sort.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + assertEquals(1, result + .getParameters() + .size()); + + Parameter param = result + .getParameters() + .get(0); + assertEquals("sort", param.getName()); + assertEquals("query", param.getIn()); + assertFalse(param.getRequired()); + assertTrue(param + .getDescription() + .contains("prefix for descending")); + assertTrue(param + .getDescription() + .contains("Example")); + } + + @Test + void shouldCustomizeSortParameterWithAnnotation() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withSortAnnotation", org.springframework.data.domain.Sort.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + + Optional sortParam = result + .getParameters() + .stream() + .filter(p -> "customSort".equals(p.getName())) + .findFirst(); + + assertTrue(sortParam.isPresent()); + assertEquals("customSort", sortParam + .get() + .getName()); + assertTrue(sortParam + .get() + .getRequired()); + } + + @Test + void shouldCustomizePageParameter() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withPage", Pageable.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + assertEquals(3, result + .getParameters() + .size()); + + Optional pageParam = result + .getParameters() + .stream() + .filter(p -> "page".equals(p.getName())) + .findFirst(); + + Optional sizeParam = result + .getParameters() + .stream() + .filter(p -> "size".equals(p.getName())) + .findFirst(); + + Optional sortParam = result + .getParameters() + .stream() + .filter(p -> "sort".equals(p.getName())) + .findFirst(); + + assertTrue(pageParam.isPresent()); + assertTrue(sizeParam.isPresent()); + assertTrue(sortParam.isPresent()); + + assertEquals("query", pageParam + .get() + .getIn()); + assertEquals("integer", pageParam + .get() + .getSchema() + .getType()); + assertEquals(BigDecimal.ZERO, pageParam + .get() + .getSchema() + .getMinimum()); + + assertEquals("query", sizeParam + .get() + .getIn()); + assertEquals("integer", sizeParam + .get() + .getSchema() + .getType()); + assertTrue(sizeParam + .get() + .getDescription() + .contains("max")); + + assertEquals("query", sortParam + .get() + .getIn()); + assertEquals("string", sortParam + .get() + .getSchema() + .getType()); + assertTrue(sortParam + .get() + .getDescription() + .contains("prefix for descending")); + } + + @Test + void shouldCustomizePageParameterWithAnnotation() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withPageAnnotation", Pageable.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + + Optional pageParam = result + .getParameters() + .stream() + .filter(p -> "p".equals(p.getName())) + .findFirst(); + + Optional sizeParam = result + .getParameters() + .stream() + .filter(p -> "s".equals(p.getName())) + .findFirst(); + + Optional sortParam = result + .getParameters() + .stream() + .filter(p -> "order".equals(p.getName())) + .findFirst(); + + assertTrue(pageParam.isPresent()); + assertTrue(sizeParam.isPresent()); + assertTrue(sortParam.isPresent()); + + assertEquals(1, pageParam + .get() + .getSchema() + .getDefault()); + assertEquals(50, sizeParam + .get() + .getSchema() + .getDefault()); + assertEquals(BigDecimal.valueOf(200), sizeParam + .get() + .getSchema() + .getMaximum()); + } + + @Test + void shouldCustomizeFieldsParameter() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withFields"); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + assertEquals(1, result + .getParameters() + .size()); + + Parameter param = result + .getParameters() + .get(0); + assertEquals("fields", param.getName()); + assertEquals("query", param.getIn()); + assertFalse(param.getRequired()); + assertTrue(param + .getDescription() + .contains("include in response")); + assertTrue(param + .getDescription() + .contains("wildcard")); + } + + @Test + void shouldCustomizeFieldsParameterWithAnnotation() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withFieldsAnnotation"); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + + Optional fieldsParam = result + .getParameters() + .stream() + .filter(p -> "select".equals(p.getName())) + .findFirst(); + + assertTrue(fieldsParam.isPresent()); + assertEquals("select", fieldsParam + .get() + .getName()); + assertTrue(fieldsParam + .get() + .getRequired()); + assertEquals("id,name", fieldsParam + .get() + .getSchema() + .getDefault()); + } + + @Test + void shouldCustomizeAllParametersTogether() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withAllAnnotations", + org.springframework.data.domain.Sort.class, Pageable.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + assertEquals(4, result + .getParameters() + .size()); + + assertTrue(result + .getParameters() + .stream() + .anyMatch(p -> "sort".equals(p.getName()))); + assertTrue(result + .getParameters() + .stream() + .anyMatch(p -> "page".equals(p.getName()))); + assertTrue(result + .getParameters() + .stream() + .anyMatch(p -> "size".equals(p.getName()))); + assertTrue(result + .getParameters() + .stream() + .anyMatch(p -> "fields".equals(p.getName()))); + } + + @Test + void shouldNotIncludeSortParameterWhenDisabled() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withPageNoSort", Pageable.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation operation = new Operation(); + Operation result = customizer.customize(operation, handlerMethod); + + assertNotNull(result); + assertNotNull(result.getParameters()); + assertEquals(2, result.getParameters().size()); + + assertTrue(result.getParameters().stream().anyMatch(p -> "page".equals(p.getName()))); + assertTrue(result.getParameters().stream().anyMatch(p -> "size".equals(p.getName()))); + assertFalse(result.getParameters().stream().anyMatch(p -> "sort".equals(p.getName()))); + } + + @Test + void shouldHandleNullOperation() throws Exception { + TestController controller = new TestController(); + Method method = TestController.class.getMethod("withSort", org.springframework.data.domain.Sort.class); + HandlerMethod handlerMethod = new HandlerMethod(controller, method); + + Operation result = customizer.customize(null, handlerMethod); + + assertEquals(null, result); + } + + @Test + void shouldHandleNullHandlerMethod() { + Operation operation = new Operation(); + Operation result = customizer.customize(operation, null); + + assertEquals(operation, result); + } + + @RestController + static class TestController { + + @GetMapping("/sort") + public String withSort(@Sort org.springframework.data.domain.Sort sort) { + return "ok"; + } + + @GetMapping("/sort-annotation") + public String withSortAnnotation(@Sort(parameter = "customSort", required = true) org.springframework.data.domain.Sort sort) { + return "ok"; + } + + @GetMapping("/page") + public String withPage(@Page Pageable page) { + return "ok"; + } + + @GetMapping("/page-annotation") + public String withPageAnnotation( + @Page(pageParameter = "p", sizeParameter = "s", sortParameter = "order", defaultPage = 1, defaultSize = 50, maxSize = 200) Pageable page) { + return "ok"; + } + + @GetMapping("/page-no-sort") + public String withPageNoSort(@Page(enableSort = false) Pageable page) { + return "ok"; + } + + @Fields + @GetMapping("/fields") + public String withFields() { + return "ok"; + } + + @Fields(parameter = "select", required = true, defaultValue = "id,name") + @GetMapping("/fields-annotation") + public String withFieldsAnnotation() { + return "ok"; + } + + @Fields + @GetMapping("/all") + public String withAllAnnotations( + @Sort org.springframework.data.domain.Sort sort, + @Page Pageable page) { + return "ok"; + } + + } + +} diff --git a/page-sort/pom.xml b/page-sort/pom.xml new file mode 100644 index 0000000..133e3e7 --- /dev/null +++ b/page-sort/pom.xml @@ -0,0 +1,74 @@ + + + + 4.0.0 + + + com.turkraft.springfilter + parent + ${revision} + + + page-sort + + ${project.groupId}:${project.artifactId} + + + + + com.turkraft.springfilter + core + ${revision} + compile + + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework.data + spring-data-commons + provided + + + + org.springframework + spring-webmvc + provided + true + + + + com.fasterxml.jackson.core + jackson-databind + provided + true + + + + com.fasterxml.jackson.core + jackson-annotations + provided + true + + + + jakarta.servlet + jakarta.servlet-api + provided + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/Fields.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/Fields.java new file mode 100644 index 0000000..e707864 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/Fields.java @@ -0,0 +1,20 @@ +package com.turkraft.springfilter.boot; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Fields { + + String DEFAULT_PARAMETER_NAME = "fields"; + + String parameter() default DEFAULT_PARAMETER_NAME; + + boolean required() default false; + + String defaultValue() default ""; + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/FieldsFilterAdvice.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/FieldsFilterAdvice.java new file mode 100644 index 0000000..6bdeea7 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/FieldsFilterAdvice.java @@ -0,0 +1,122 @@ +package com.turkraft.springfilter.boot; + +import com.fasterxml.jackson.databind.ser.FilterProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import com.turkraft.springfilter.pagesort.AntPathFilterMixin; +import com.turkraft.springfilter.pagesort.AntPathPropertyFilter; +import com.turkraft.springfilter.pagesort.FieldsExpression; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice +@ConditionalOnClass(name = "com.fasterxml.jackson.databind.ObjectMapper") +public class FieldsFilterAdvice implements ResponseBodyAdvice { + + @Override + public boolean supports(MethodParameter returnType, + Class> converterType) { + + if (returnType.getMethod() == null) { + return false; + } + + Fields fieldsAnnotation = returnType.getMethodAnnotation(Fields.class); + if (fieldsAnnotation == null) { + return false; + } + + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return false; + } + + HttpServletRequest request = attributes.getRequest(); + String parameterName = fieldsAnnotation.parameter(); + String fieldsValue = request.getParameter(parameterName); + + if (fieldsValue == null || fieldsValue.trim().isEmpty()) { + String defaultValue = fieldsAnnotation.defaultValue(); + if (defaultValue != null && !defaultValue.isEmpty()) { + return true; + } + if (fieldsAnnotation.required()) { + return true; + } + return false; + } + + return true; + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + + if (returnType.getMethod() == null) { + return body; + } + + Fields fieldsAnnotation = returnType.getMethodAnnotation(Fields.class); + if (fieldsAnnotation == null) { + return body; + } + + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return body; + } + + HttpServletRequest servletRequest = attributes.getRequest(); + String parameterName = fieldsAnnotation.parameter(); + String fieldsValue = servletRequest.getParameter(parameterName); + + if (fieldsValue == null || fieldsValue.trim().isEmpty()) { + if (fieldsAnnotation.required()) { + throw new IllegalArgumentException( + "Required fields parameter '" + parameterName + "' is missing"); + } + + String defaultValue = fieldsAnnotation.defaultValue(); + if (defaultValue != null && !defaultValue.isEmpty()) { + fieldsValue = defaultValue; + } else { + return body; + } + } + + FieldsExpression fields = new FieldsExpression(fieldsValue); + if (fields.isEmpty()) { + return body; + } + + MappingJacksonValue wrapper; + if (body instanceof MappingJacksonValue) { + wrapper = (MappingJacksonValue) body; + } else { + wrapper = new MappingJacksonValue(body); + } + + FilterProvider filterProvider = new SimpleFilterProvider() + .addFilter(AntPathFilterMixin.FILTER, new AntPathPropertyFilter(fields)) + .setFailOnUnknownId(false); + + wrapper.setFilters(filterProvider); + return wrapper; + + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/Page.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/Page.java new file mode 100644 index 0000000..b372ab8 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/Page.java @@ -0,0 +1,42 @@ +package com.turkraft.springfilter.boot; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Page { + + String DEFAULT_PAGE_PARAMETER = "page"; + + String DEFAULT_SIZE_PARAMETER = "size"; + + String DEFAULT_SORT_PARAMETER = "sort"; + + int DEFAULT_PAGE = 0; + + int DEFAULT_SIZE = 20; + + int DEFAULT_MAX_SIZE = 100; + + int DEFAULT_SORT_MAX_FIELDS = 10; + + String pageParameter() default DEFAULT_PAGE_PARAMETER; + + String sizeParameter() default DEFAULT_SIZE_PARAMETER; + + String sortParameter() default DEFAULT_SORT_PARAMETER; + + int defaultPage() default DEFAULT_PAGE; + + int defaultSize() default DEFAULT_SIZE; + + int maxSize() default DEFAULT_MAX_SIZE; + + int sortMaxFields() default DEFAULT_SORT_MAX_FIELDS; + + boolean enableSort() default true; + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/PageArgumentResolver.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/PageArgumentResolver.java new file mode 100644 index 0000000..050621f --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/PageArgumentResolver.java @@ -0,0 +1,86 @@ +package com.turkraft.springfilter.boot; + +import com.turkraft.springfilter.pagesort.SortExpression; +import com.turkraft.springfilter.pagesort.SortParser; +import java.util.Objects; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class PageArgumentResolver implements HandlerMethodArgumentResolver { + + protected final SortParser sortParser; + + public PageArgumentResolver(SortParser sortParser) { + this.sortParser = Objects.requireNonNull(sortParser, "sortParser must not be null"); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Page.class) + && Pageable.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + Page pageAnnotation = parameter.getParameterAnnotation(Page.class); + if (pageAnnotation == null) { + return PageRequest.of(Page.DEFAULT_PAGE, Page.DEFAULT_SIZE); + } + + String pageParam = webRequest.getParameter(pageAnnotation.pageParameter()); + int page = pageAnnotation.defaultPage(); + if (pageParam != null && !pageParam.trim().isEmpty()) { + try { + page = Integer.parseInt(pageParam); + if (page < 0) { + throw new IllegalArgumentException("Page number must be >= 0, got: " + page); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid page number: " + pageParam, e); + } + } + + String sizeParam = webRequest.getParameter(pageAnnotation.sizeParameter()); + int size = pageAnnotation.defaultSize(); + if (sizeParam != null && !sizeParam.trim().isEmpty()) { + try { + size = Integer.parseInt(sizeParam); + if (size <= 0) { + throw new IllegalArgumentException("Page size must be > 0, got: " + size); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid page size: " + sizeParam, e); + } + } + + int maxSize = pageAnnotation.maxSize(); + if (size > maxSize) { + throw new IllegalArgumentException( + "Page size " + size + " exceeds maximum allowed: " + maxSize); + } + + org.springframework.data.domain.Sort sort = org.springframework.data.domain.Sort.unsorted(); + + if (pageAnnotation.enableSort()) { + String sortParam = webRequest.getParameter(pageAnnotation.sortParameter()); + + if (sortParam != null && !sortParam.trim().isEmpty()) { + SortExpression sortExpression = sortParser.parse(sortParam, + pageAnnotation.sortMaxFields()); + sort = sortExpression.toSpringSort(); + } + } + + return PageRequest.of(page, size, sort); + + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/PageArgumentResolverConfigurer.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/PageArgumentResolverConfigurer.java new file mode 100644 index 0000000..f1a9dc0 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/PageArgumentResolverConfigurer.java @@ -0,0 +1,26 @@ +package com.turkraft.springfilter.boot; + +import com.turkraft.springfilter.pagesort.SortParser; +import java.util.List; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Component +@Conditional(WebMvcConfigurerCondition.class) +public class PageArgumentResolverConfigurer implements WebMvcConfigurer { + + protected final SortParser sortParser; + + public PageArgumentResolverConfigurer(@Lazy SortParser sortParser) { + this.sortParser = sortParser; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new PageArgumentResolver(sortParser)); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/PageSortAutoConfiguration.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/PageSortAutoConfiguration.java new file mode 100644 index 0000000..4c41dcf --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/PageSortAutoConfiguration.java @@ -0,0 +1,37 @@ +package com.turkraft.springfilter.boot; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.turkraft.springfilter.pagesort.AntPathFilterMixin; +import com.turkraft.springfilter.pagesort.SimpleSortParser; +import com.turkraft.springfilter.pagesort.SortParser; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class PageSortAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public SortParser sortParser() { + return new SimpleSortParser(); + } + + @Bean + @ConditionalOnClass(ObjectMapper.class) + public Jackson2ObjectMapperBuilderCustomizer fieldFilterCustomizer() { + return builder -> builder.postConfigurer(objectMapper -> { + objectMapper.setDefaultMergeable(true); + objectMapper.addMixIn(Object.class, AntPathFilterMixin.class); + com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider defaultFilterProvider = + new com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider() + .addFilter(AntPathFilterMixin.FILTER, + com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter.serializeAll()) + .setFailOnUnknownId(false); + objectMapper.setFilterProvider(defaultFilterProvider); + }); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/Sort.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/Sort.java new file mode 100644 index 0000000..df20a3f --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/Sort.java @@ -0,0 +1,24 @@ +package com.turkraft.springfilter.boot; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Sort { + + String DEFAULT_PARAMETER_NAME = "sort"; + + int DEFAULT_MAX_FIELDS = 10; + + String parameter() default DEFAULT_PARAMETER_NAME; + + boolean required() default false; + + String defaultValue() default ""; + + int maxFields() default DEFAULT_MAX_FIELDS; + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/SortArgumentResolver.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/SortArgumentResolver.java new file mode 100644 index 0000000..9984e49 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/SortArgumentResolver.java @@ -0,0 +1,59 @@ +package com.turkraft.springfilter.boot; + +import com.turkraft.springfilter.pagesort.SortExpression; +import com.turkraft.springfilter.pagesort.SortParser; +import java.util.Objects; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class SortArgumentResolver implements HandlerMethodArgumentResolver { + + protected final SortParser sortParser; + + public SortArgumentResolver(SortParser sortParser) { + this.sortParser = Objects.requireNonNull(sortParser, "sortParser must not be null"); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Sort.class) + && org.springframework.data.domain.Sort.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + Sort sortAnnotation = parameter.getParameterAnnotation(Sort.class); + if (sortAnnotation == null) { + return org.springframework.data.domain.Sort.unsorted(); + } + + String parameterName = sortAnnotation.parameter(); + String sortExpression = webRequest.getParameter(parameterName); + + if (sortExpression == null || sortExpression.trim().isEmpty()) { + if (sortAnnotation.required()) { + throw new IllegalArgumentException( + "Required sort parameter '" + parameterName + "' is missing"); + } + + String defaultValue = sortAnnotation.defaultValue(); + if (defaultValue != null && !defaultValue.isEmpty()) { + sortExpression = defaultValue; + } else { + return org.springframework.data.domain.Sort.unsorted(); + } + } + + SortExpression parsed = sortParser.parse(sortExpression, sortAnnotation.maxFields()); + + return parsed.toSpringSort(); + + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/SortArgumentResolverConfigurer.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/SortArgumentResolverConfigurer.java new file mode 100644 index 0000000..648e18e --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/SortArgumentResolverConfigurer.java @@ -0,0 +1,26 @@ +package com.turkraft.springfilter.boot; + +import com.turkraft.springfilter.pagesort.SortParser; +import java.util.List; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Component +@Conditional(WebMvcConfigurerCondition.class) +public class SortArgumentResolverConfigurer implements WebMvcConfigurer { + + protected final SortParser sortParser; + + public SortArgumentResolverConfigurer(@Lazy SortParser sortParser) { + this.sortParser = sortParser; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new SortArgumentResolver(sortParser)); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/boot/package-info.java b/page-sort/src/main/java/com/turkraft/springfilter/boot/package-info.java new file mode 100644 index 0000000..06406d7 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/boot/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package com.turkraft.springfilter.boot; + +import org.springframework.lang.NonNullApi; diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/AntPathFilterMixin.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/AntPathFilterMixin.java new file mode 100644 index 0000000..8a4235b --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/AntPathFilterMixin.java @@ -0,0 +1,10 @@ +package com.turkraft.springfilter.pagesort; + +import com.fasterxml.jackson.annotation.JsonFilter; + +@JsonFilter(AntPathFilterMixin.FILTER) +public class AntPathFilterMixin { + + public static final String FILTER = "antPathFilter"; + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/AntPathPropertyFilter.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/AntPathPropertyFilter.java new file mode 100644 index 0000000..8cc3fe0 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/AntPathPropertyFilter.java @@ -0,0 +1,149 @@ +package com.turkraft.springfilter.pagesort; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonStreamContext; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.util.AntPathMatcher; + +public class AntPathPropertyFilter extends SimpleBeanPropertyFilter { + + private static final AntPathMatcher MATCHER = new AntPathMatcher("."); + + protected final Set propertiesToInclude; + protected final Set propertiesToExclude; + private final Map matchCache; + + public AntPathPropertyFilter(FieldsExpression fields) { + Objects.requireNonNull(fields, "fields must not be null"); + this.propertiesToInclude = fields.getIncludePatterns(); + this.propertiesToExclude = fields.getExcludePatterns(); + this.matchCache = new HashMap<>(); + } + + private String getPathToTest(PropertyWriter writer, JsonGenerator generator) { + StringBuilder nestedPath = new StringBuilder(); + nestedPath.append(writer.getName()); + JsonStreamContext sc = generator.getOutputContext(); + if (sc != null) { + sc = sc.getParent(); + } + while (sc != null) { + if (sc.getCurrentName() != null) { + if (nestedPath.length() > 0) { + nestedPath.insert(0, "."); + } + nestedPath.insert(0, sc.getCurrentName()); + } + sc = sc.getParent(); + } + return nestedPath.toString(); + } + + @Override + protected boolean include(BeanPropertyWriter writer) { + throw new UnsupportedOperationException("Cannot call include without JsonGenerator"); + } + + @Override + protected boolean include(PropertyWriter writer) { + throw new UnsupportedOperationException("Cannot call include without JsonGenerator"); + } + + protected boolean include(PropertyWriter writer, JsonGenerator generator) { + + String pathToTest = getPathToTest(writer, generator); + + if (matchCache.containsKey(pathToTest)) { + return matchCache.get(pathToTest); + } + + if (propertiesToInclude.isEmpty()) { + for (String pattern : propertiesToExclude) { + if (onlyExcludesMatchPath(pathToTest, pattern)) { + matchCache.put(pathToTest, false); + return false; + } + } + matchCache.put(pathToTest, true); + return true; + } + + boolean include = false; + for (String pattern : propertiesToInclude) { + if (includesMatchPath(pathToTest, pattern)) { + include = true; + break; + } + } + + if (include && !propertiesToExclude.isEmpty()) { + for (String pattern : propertiesToExclude) { + if (excludesMatchPath(pathToTest, pattern)) { + include = false; + break; + } + } + } + + matchCache.put(pathToTest, include); + return include; + + } + + private boolean onlyExcludesMatchPath(String pathToTest, String pattern) { + if (pattern.contains("*")) { + return MATCHER.match(pattern, pathToTest); + } else { + return pattern.equals(pathToTest); + } + } + + private boolean excludesMatchPath(String pathToTest, String pattern) { + if (pattern.contains("*")) { + return MATCHER.match(pattern, pathToTest); + } else { + return pattern.equals(pathToTest) + || pathToTest.startsWith(pattern + "."); + } + } + + private boolean includesMatchPath(String pathToTest, String pattern) { + if (pattern.equals("*")) { + return true; + } + if (pattern.contains("*")) { + if (MATCHER.match(pattern, pathToTest)) { + return true; + } + String patternPrefix = pattern.substring(0, pattern.indexOf('*')); + if (patternPrefix.endsWith(".")) { + patternPrefix = patternPrefix.substring(0, patternPrefix.length() - 1); + } + return !patternPrefix.isEmpty() && (patternPrefix.equals(pathToTest) + || patternPrefix.startsWith(pathToTest + ".")); + } else { + return pattern.equals(pathToTest) || pattern.startsWith(pathToTest + ".") + || pathToTest.startsWith(pattern + "."); + } + } + + @Override + public void serializeAsField(Object pojo, JsonGenerator jgen, + SerializerProvider provider, + PropertyWriter writer) + throws Exception { + if (include(writer, jgen)) { + writer.serializeAsField(pojo, jgen, provider); + } else if (!jgen.canOmitFields()) { + writer.serializeAsOmittedField(pojo, jgen, provider); + } + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/FieldsExpression.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/FieldsExpression.java new file mode 100644 index 0000000..4271f13 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/FieldsExpression.java @@ -0,0 +1,157 @@ +package com.turkraft.springfilter.pagesort; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class FieldsExpression { + + private static final Pattern FIELD_PATTERN = + Pattern.compile("^-?!?[a-zA-Z_*][a-zA-Z0-9_.*]*$"); + + private final String[] patterns; + private final Set includePatterns; + private final Set excludePatterns; + + public FieldsExpression(String expression) { + if (expression == null || expression.trim().isEmpty()) { + this.patterns = new String[0]; + this.includePatterns = Collections.emptySet(); + this.excludePatterns = Collections.emptySet(); + return; + } + + String[] parsed = parsePatterns(expression); + if (parsed.length == 0) { + this.patterns = new String[0]; + this.includePatterns = Collections.emptySet(); + this.excludePatterns = Collections.emptySet(); + return; + } + + this.patterns = parsed; + + Set includes = new HashSet<>(); + Set excludes = new HashSet<>(); + + for (String pattern : parsed) { + if (pattern.startsWith("-") || pattern.startsWith("!")) { + excludes.add(pattern.substring(1)); + } else { + includes.add(pattern); + } + } + + this.includePatterns = Collections.unmodifiableSet(includes); + this.excludePatterns = Collections.unmodifiableSet(excludes); + } + + public FieldsExpression(String... patterns) { + Objects.requireNonNull(patterns, "patterns must not be null"); + this.patterns = Arrays.stream(patterns) + .filter(p -> p != null && !p.trim().isEmpty()) + .map(String::trim) + .toArray(String[]::new); + + Set includes = new HashSet<>(); + Set excludes = new HashSet<>(); + + for (String pattern : this.patterns) { + validatePattern(pattern); + if (pattern.startsWith("-") || pattern.startsWith("!")) { + excludes.add(pattern.substring(1)); + } else { + includes.add(pattern); + } + } + + this.includePatterns = Collections.unmodifiableSet(includes); + this.excludePatterns = Collections.unmodifiableSet(excludes); + } + + private String[] parsePatterns(String expression) { + String[] parts = expression.split(","); + String[] result = new String[parts.length]; + int index = 0; + for (String part : parts) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + validatePattern(trimmed); + result[index++] = trimmed; + } + } + return Arrays.copyOf(result, index); + } + + private void validatePattern(String pattern) { + if (pattern == null || pattern.isEmpty()) { + throw new FieldsParseException("Pattern cannot be null or empty"); + } + + if (!FIELD_PATTERN.matcher(pattern).matches()) { + throw new FieldsParseException("Invalid field pattern: " + pattern); + } + } + + public String[] getPatterns() { + return Arrays.copyOf(patterns, patterns.length); + } + + public Set getIncludePatterns() { + return includePatterns; + } + + public Set getExcludePatterns() { + return excludePatterns; + } + + public boolean isEmpty() { + return patterns.length == 0; + } + + public int size() { + return patterns.length; + } + + public boolean hasIncludes() { + return !includePatterns.isEmpty(); + } + + public boolean hasExcludes() { + return !excludePatterns.isEmpty(); + } + + public static FieldsExpression empty() { + return new FieldsExpression((String) null); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FieldsExpression that = (FieldsExpression) o; + return Arrays.equals(patterns, that.patterns); + } + + @Override + public int hashCode() { + return Arrays.hashCode(patterns); + } + + @Override + public String toString() { + if (isEmpty()) { + return "empty"; + } + return Arrays.stream(patterns).collect(Collectors.joining(", ")); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/FieldsParseException.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/FieldsParseException.java new file mode 100644 index 0000000..7f95d4c --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/FieldsParseException.java @@ -0,0 +1,13 @@ +package com.turkraft.springfilter.pagesort; + +public class FieldsParseException extends RuntimeException { + + public FieldsParseException(String message) { + super(message); + } + + public FieldsParseException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SimpleSortParser.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SimpleSortParser.java new file mode 100644 index 0000000..fc334ad --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SimpleSortParser.java @@ -0,0 +1,115 @@ +package com.turkraft.springfilter.pagesort; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class SimpleSortParser implements SortParser { + + private static final Pattern FIELD_PATH_PATTERN = + Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)*$"); + + @Override + public SortExpression parse(String expression) + throws SortParseException { + return parse(expression, Integer.MAX_VALUE); + } + + @Override + public SortExpression parse(String expression, int maxFields) + throws SortParseException { + + if (expression == null || expression + .trim() + .isEmpty()) { + return SortExpression.unsorted(); + } + + String normalized = normalizeExpression(expression); + if (normalized.isEmpty()) { + return SortExpression.unsorted(); + } + + String[] parts = normalized.split(","); + + if (parts.length > maxFields) { + throw new SortParseException( + "Too many sort fields: " + parts.length + " (max: " + maxFields + ")"); + } + + List fields = new ArrayList<>(); + + for (int i = 0; i < parts.length; i++) { + String part = parts[i].trim(); + if (part.isEmpty()) { + throw new SortParseException("Empty sort field at position " + i); + } + fields.add(parseSingleField(part, i)); + } + + return new SortExpression(fields); + + } + + private String normalizeExpression(String expression) { + return expression + .replaceAll("\\s+", "") + .trim(); + } + + private SortField parseSingleField(String fieldExpr, int position) + throws SortParseException { + + if (fieldExpr == null || fieldExpr.isEmpty()) { + throw new SortParseException("Invalid sort field at position " + position); + } + + String fieldPath; + SortDirection direction; + + if (fieldExpr.startsWith("-")) { + direction = SortDirection.DESC; + fieldPath = fieldExpr.substring(1); + } else { + direction = SortDirection.ASC; + fieldPath = fieldExpr; + } + + validateFieldPath(fieldPath, position); + + return new SortField(fieldPath, direction); + + } + + private void validateFieldPath(String fieldPath, int position) + throws SortParseException { + + if (fieldPath == null || fieldPath.isEmpty()) { + throw new SortParseException("Empty field path at position " + position); + } + + if (fieldPath.startsWith(".")) { + throw new SortParseException( + "Field path cannot start with '.' at position " + position + ": " + fieldPath); + } + + if (fieldPath.endsWith(".")) { + throw new SortParseException( + "Field path cannot end with '.' at position " + position + ": " + fieldPath); + } + + if (fieldPath.contains("..")) { + throw new SortParseException( + "Field path cannot contain consecutive dots at position " + position + ": " + fieldPath); + } + + if (!FIELD_PATH_PATTERN + .matcher(fieldPath) + .matches()) { + throw new SortParseException( + "Invalid field path at position " + position + ": " + fieldPath); + } + + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortBuilder.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortBuilder.java new file mode 100644 index 0000000..1868ea6 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortBuilder.java @@ -0,0 +1,64 @@ +package com.turkraft.springfilter.pagesort; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class SortBuilder { + + private final List fields = new ArrayList<>(); + private String currentField; + private SortDirection currentDirection = SortDirection.ASC; + + public SortBuilder field(String fieldPath) { + Objects.requireNonNull(fieldPath, "fieldPath must not be null"); + if (currentField != null) { + fields.add(new SortField(currentField, currentDirection)); + currentDirection = SortDirection.ASC; + } + currentField = fieldPath; + return this; + } + + public SortBuilder asc() { + currentDirection = SortDirection.ASC; + return this; + } + + public SortBuilder desc() { + currentDirection = SortDirection.DESC; + return this; + } + + public SortBuilder direction(SortDirection direction) { + this.currentDirection = Objects.requireNonNull(direction, "direction must not be null"); + return this; + } + + public SortExpression build() { + + if (currentField != null) { + fields.add(new SortField(currentField, currentDirection)); + currentField = null; + currentDirection = SortDirection.ASC; + } + + if (fields.isEmpty()) { + return SortExpression.unsorted(); + } + + return new SortExpression(new ArrayList<>(fields)); + + } + + public org.springframework.data.domain.Sort buildSpringSort() { + return build().toSpringSort(); + } + + public void reset() { + fields.clear(); + currentField = null; + currentDirection = SortDirection.ASC; + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortDirection.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortDirection.java new file mode 100644 index 0000000..49143ec --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortDirection.java @@ -0,0 +1,47 @@ +package com.turkraft.springfilter.pagesort; + +public enum SortDirection { + + ASC("asc", "ascending"), + DESC("desc", "descending"); + + private final String shortName; + private final String longName; + + SortDirection(String shortName, String longName) { + this.shortName = shortName; + this.longName = longName; + } + + public String getShortName() { + return shortName; + } + + public String getLongName() { + return longName; + } + + public static SortDirection parse(String value) { + + if (value == null || value.trim().isEmpty()) { + return ASC; + } + + String normalized = value.trim().toLowerCase(); + + return switch (normalized) { + case "asc", "ascending" -> ASC; + case "desc", "descending" -> DESC; + default -> throw new IllegalArgumentException( + "Invalid sort direction: " + value + ". Expected: asc, desc, ascending, or descending"); + }; + + } + + public org.springframework.data.domain.Sort.Direction toSpringDirection() { + return this == ASC + ? org.springframework.data.domain.Sort.Direction.ASC + : org.springframework.data.domain.Sort.Direction.DESC; + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortExpression.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortExpression.java new file mode 100644 index 0000000..f62a7ef --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortExpression.java @@ -0,0 +1,82 @@ +package com.turkraft.springfilter.pagesort; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class SortExpression { + + private final List fields; + + public SortExpression(List fields) { + if (fields == null || fields.isEmpty()) { + this.fields = Collections.emptyList(); + } else { + this.fields = Collections.unmodifiableList(new ArrayList<>(fields)); + } + } + + public SortExpression(SortField... fields) { + this(fields != null ? List.of(fields) : Collections.emptyList()); + } + + public static SortExpression unsorted() { + return new SortExpression(Collections.emptyList()); + } + + public List getFields() { + return fields; + } + + public boolean isEmpty() { + return fields.isEmpty(); + } + + public int size() { + return fields.size(); + } + + public org.springframework.data.domain.Sort toSpringSort() { + + if (isEmpty()) { + return org.springframework.data.domain.Sort.unsorted(); + } + + List orders = fields.stream() + .map(SortField::toSpringOrder) + .collect(Collectors.toList()); + + return org.springframework.data.domain.Sort.by(orders); + + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SortExpression that = (SortExpression) o; + return Objects.equals(fields, that.fields); + } + + @Override + public int hashCode() { + return Objects.hash(fields); + } + + @Override + public String toString() { + if (isEmpty()) { + return "unsorted"; + } + return fields.stream() + .map(SortField::toString) + .collect(Collectors.joining(",")); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortField.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortField.java new file mode 100644 index 0000000..eafbf02 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortField.java @@ -0,0 +1,58 @@ +package com.turkraft.springfilter.pagesort; + +import java.util.Objects; + +public class SortField { + + private final String fieldPath; + private final SortDirection direction; + + public SortField(String fieldPath, SortDirection direction) { + if (fieldPath == null || fieldPath.trim().isEmpty()) { + throw new IllegalArgumentException("Field path cannot be null or empty"); + } + this.fieldPath = fieldPath.trim(); + this.direction = direction != null ? direction : SortDirection.ASC; + } + + public SortField(String fieldPath) { + this(fieldPath, SortDirection.ASC); + } + + public String getFieldPath() { + return fieldPath; + } + + public SortDirection getDirection() { + return direction; + } + + public org.springframework.data.domain.Sort.Order toSpringOrder() { + return new org.springframework.data.domain.Sort.Order( + direction.toSpringDirection(), + fieldPath); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SortField sortField = (SortField) o; + return Objects.equals(fieldPath, sortField.fieldPath) && direction == sortField.direction; + } + + @Override + public int hashCode() { + return Objects.hash(fieldPath, direction); + } + + @Override + public String toString() { + return direction == SortDirection.DESC ? "-" + fieldPath : fieldPath; + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortParseException.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortParseException.java new file mode 100644 index 0000000..3dccfc4 --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortParseException.java @@ -0,0 +1,13 @@ +package com.turkraft.springfilter.pagesort; + +public class SortParseException extends RuntimeException { + + public SortParseException(String message) { + super(message); + } + + public SortParseException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortParser.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortParser.java new file mode 100644 index 0000000..692dfff --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/SortParser.java @@ -0,0 +1,9 @@ +package com.turkraft.springfilter.pagesort; + +public interface SortParser { + + SortExpression parse(String expression) throws SortParseException; + + SortExpression parse(String expression, int maxFields) throws SortParseException; + +} diff --git a/page-sort/src/main/java/com/turkraft/springfilter/pagesort/package-info.java b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/package-info.java new file mode 100644 index 0000000..8f3a0ed --- /dev/null +++ b/page-sort/src/main/java/com/turkraft/springfilter/pagesort/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package com.turkraft.springfilter.pagesort; + +import org.springframework.lang.NonNullApi; diff --git a/page-sort/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/page-sort/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..ded56a3 --- /dev/null +++ b/page-sort/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,4 @@ +com.turkraft.springfilter.boot.PageSortAutoConfiguration +com.turkraft.springfilter.boot.SortArgumentResolverConfigurer +com.turkraft.springfilter.boot.PageArgumentResolverConfigurer +com.turkraft.springfilter.boot.FieldsFilterAdvice diff --git a/page-sort/src/test/java/com/turkraft/springfilter/boot/PageArgumentResolverTest.java b/page-sort/src/test/java/com/turkraft/springfilter/boot/PageArgumentResolverTest.java new file mode 100644 index 0000000..d7ebfb0 --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/boot/PageArgumentResolverTest.java @@ -0,0 +1,314 @@ +package com.turkraft.springfilter.boot; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.turkraft.springfilter.pagesort.SimpleSortParser; +import com.turkraft.springfilter.pagesort.SortParser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.web.context.request.NativeWebRequest; + +class PageArgumentResolverTest { + + private PageArgumentResolver resolver; + private MethodParameter parameter; + private NativeWebRequest webRequest; + + @BeforeEach + void setUp() { + SortParser sortParser = new SimpleSortParser(); + resolver = new PageArgumentResolver(sortParser); + parameter = mock(MethodParameter.class); + webRequest = mock(NativeWebRequest.class); + } + + @Test + void testSupportsParameterWithPageAnnotation() { + when(parameter.hasParameterAnnotation(Page.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) Pageable.class); + + assertTrue(resolver.supportsParameter(parameter)); + } + + @Test + void testSupportsParameterWithoutPageAnnotation() { + when(parameter.hasParameterAnnotation(Page.class)).thenReturn(false); + + assertFalse(resolver.supportsParameter(parameter)); + } + + @Test + void testSupportsParameterWithWrongType() { + when(parameter.hasParameterAnnotation(Page.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) String.class); + + assertFalse(resolver.supportsParameter(parameter)); + } + + @Test + void testResolveArgumentWithDefaults() throws Exception { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn(null); + when(webRequest.getParameter("size")).thenReturn(null); + when(webRequest.getParameter("sort")).thenReturn(null); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertEquals(0, result.getPageNumber()); + assertEquals(20, result.getPageSize()); + assertFalse(result.getSort().isSorted()); + } + + @Test + void testResolveArgumentWithCustomPageAndSize() throws Exception { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("2"); + when(webRequest.getParameter("size")).thenReturn("50"); + when(webRequest.getParameter("sort")).thenReturn(null); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertEquals(2, result.getPageNumber()); + assertEquals(50, result.getPageSize()); + assertFalse(result.getSort().isSorted()); + } + + @Test + void testResolveArgumentWithSort() throws Exception { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("0"); + when(webRequest.getParameter("size")).thenReturn("20"); + when(webRequest.getParameter("sort")).thenReturn("name"); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertEquals(0, result.getPageNumber()); + assertEquals(20, result.getPageSize()); + assertTrue(result.getSort().isSorted()); + + Sort.Order order = result.getSort().iterator().next(); + assertEquals("name", order.getProperty()); + assertEquals(Sort.Direction.ASC, order.getDirection()); + } + + @Test + void testResolveArgumentWithDescendingSort() throws Exception { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("1"); + when(webRequest.getParameter("size")).thenReturn("10"); + when(webRequest.getParameter("sort")).thenReturn("-createdAt"); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertEquals(1, result.getPageNumber()); + assertEquals(10, result.getPageSize()); + assertTrue(result.getSort().isSorted()); + + Sort.Order order = result.getSort().iterator().next(); + assertEquals("createdAt", order.getProperty()); + assertEquals(Sort.Direction.DESC, order.getDirection()); + } + + @Test + void testResolveArgumentWithMultipleSortFields() throws Exception { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("0"); + when(webRequest.getParameter("size")).thenReturn("20"); + when(webRequest.getParameter("sort")).thenReturn("-year,name"); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertTrue(result.getSort().isSorted()); + + var orders = result.getSort().toList(); + assertEquals(2, orders.size()); + + assertEquals("year", orders.get(0).getProperty()); + assertEquals(Sort.Direction.DESC, orders.get(0).getDirection()); + + assertEquals("name", orders.get(1).getProperty()); + assertEquals(Sort.Direction.ASC, orders.get(1).getDirection()); + } + + @Test + void testResolveArgumentWithCustomParameterNames() throws Exception { + Page pageAnnotation = createPageAnnotation( + "p", "s", "order", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("p")).thenReturn("3"); + when(webRequest.getParameter("s")).thenReturn("15"); + when(webRequest.getParameter("order")).thenReturn("name"); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertEquals(3, result.getPageNumber()); + assertEquals(15, result.getPageSize()); + assertTrue(result.getSort().isSorted()); + } + + @Test + void testResolveArgumentThrowsOnNegativePage() { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("-1"); + + assertThrows(IllegalArgumentException.class, + () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + + @Test + void testResolveArgumentThrowsOnZeroSize() { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("0"); + when(webRequest.getParameter("size")).thenReturn("0"); + + assertThrows(IllegalArgumentException.class, + () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + + @Test + void testResolveArgumentThrowsOnNegativeSize() { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("0"); + when(webRequest.getParameter("size")).thenReturn("-10"); + + assertThrows(IllegalArgumentException.class, + () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + + @Test + void testResolveArgumentThrowsOnSizeExceedingMax() { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("0"); + when(webRequest.getParameter("size")).thenReturn("200"); + + assertThrows(IllegalArgumentException.class, + () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + + @Test + void testResolveArgumentThrowsOnInvalidPageNumber() { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("invalid"); + + assertThrows(IllegalArgumentException.class, + () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + + @Test + void testResolveArgumentThrowsOnInvalidSize() { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("0"); + when(webRequest.getParameter("size")).thenReturn("invalid"); + + assertThrows(IllegalArgumentException.class, + () -> resolver.resolveArgument(parameter, null, webRequest, null)); + } + + @Test + void testResolveArgumentWithSortDisabled() throws Exception { + Page pageAnnotation = createPageAnnotation( + "page", "size", "sort", 0, 20, 100, 10, false); + when(parameter.getParameterAnnotation(Page.class)).thenReturn(pageAnnotation); + when(webRequest.getParameter("page")).thenReturn("1"); + when(webRequest.getParameter("size")).thenReturn("10"); + when(webRequest.getParameter("sort")).thenReturn("name"); + + Pageable result = (Pageable) resolver.resolveArgument(parameter, null, webRequest, null); + + assertNotNull(result); + assertEquals(1, result.getPageNumber()); + assertEquals(10, result.getPageSize()); + assertFalse(result.getSort().isSorted()); + } + + private Page createPageAnnotation( + String pageParam, String sizeParam, String sortParam, + int defaultPage, int defaultSize, int maxSize, int sortMaxFields) { + return createPageAnnotation(pageParam, sizeParam, sortParam, + defaultPage, defaultSize, maxSize, sortMaxFields, true); + } + + private Page createPageAnnotation( + String pageParam, String sizeParam, String sortParam, + int defaultPage, int defaultSize, int maxSize, int sortMaxFields, boolean enableSort) { + return new Page() { + @Override + public Class annotationType() { + return Page.class; + } + + @Override + public String pageParameter() { + return pageParam; + } + + @Override + public String sizeParameter() { + return sizeParam; + } + + @Override + public String sortParameter() { + return sortParam; + } + + @Override + public int defaultPage() { + return defaultPage; + } + + @Override + public int defaultSize() { + return defaultSize; + } + + @Override + public int maxSize() { + return maxSize; + } + + @Override + public int sortMaxFields() { + return sortMaxFields; + } + + @Override + public boolean enableSort() { + return enableSort; + } + }; + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/AntPathPropertyFilterTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/AntPathPropertyFilterTest.java new file mode 100644 index 0000000..df9db1c --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/AntPathPropertyFilterTest.java @@ -0,0 +1,280 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AntPathPropertyFilterTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + void testConstructorWithNullFieldsThrowsException() { + assertThrows(NullPointerException.class, () -> new AntPathPropertyFilter(null)); + } + + @Test + void testIncludeSingleField() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("name"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertTrue(json.contains("John")); + assertFalse(json.contains("email")); + assertFalse(json.contains("password")); + } + + @Test + void testIncludeMultipleFields() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("name,email"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertFalse(json.contains("password")); + } + + @Test + void testExcludeField() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("-password"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertFalse(json.contains("password")); + } + + @Test + void testIncludeAllExcludeOne() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("*,-password"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertFalse(json.contains("password")); + } + + @Test + void testNestedFieldInclusion() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("user.name"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + objectMapper.addMixIn(TestOrder.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + TestOrder order = new TestOrder(123, user); + String json = objectMapper.writeValueAsString(order); + + assertTrue(json.contains("user")); + assertTrue(json.contains("name")); + assertTrue(json.contains("John")); + assertFalse(json.contains("email")); + assertFalse(json.contains("password")); + } + + @Test + void testNestedFieldWildcard() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("user.*"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + objectMapper.addMixIn(TestOrder.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + TestOrder order = new TestOrder(123, user); + String json = objectMapper.writeValueAsString(order); + + assertTrue(json.contains("user")); + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertTrue(json.contains("password")); + assertFalse(json.contains("\"id\"")); + } + + @Test + void testExcludeNestedField() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("*,-user.password"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + objectMapper.addMixIn(TestOrder.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + TestOrder order = new TestOrder(123, user); + String json = objectMapper.writeValueAsString(order); + + assertTrue(json.contains("id")); + assertTrue(json.contains("user")); + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertFalse(json.contains("password")); + } + + @Test + void testEmptyFields() throws JsonProcessingException { + FieldsExpression fields = FieldsExpression.empty(); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertTrue(json.contains("password")); + } + + @Test + void testWildcardIncludesAll() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("*"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertTrue(json.contains("email")); + assertTrue(json.contains("password")); + } + + @Test + void testMultipleExclusions() throws JsonProcessingException { + FieldsExpression fields = new FieldsExpression("-email,-password"); + AntPathPropertyFilter filter = new AntPathPropertyFilter(fields); + + SimpleFilterProvider filterProvider = new SimpleFilterProvider() + .addFilter("testFilter", filter); + + objectMapper.setFilterProvider(filterProvider); + objectMapper.addMixIn(TestUser.class, TestFilterMixin.class); + + TestUser user = new TestUser("John", "john@example.com", "secret123"); + String json = objectMapper.writeValueAsString(user); + + assertTrue(json.contains("name")); + assertFalse(json.contains("email")); + assertFalse(json.contains("password")); + } + + @JsonFilter("testFilter") + static class TestFilterMixin { + + } + + static class TestUser { + + private String name; + private String email; + private String password; + + public TestUser(String name, String email, String password) { + this.name = name; + this.email = email; + this.password = password; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + } + + static class TestOrder { + + private int id; + private TestUser user; + + public TestOrder(int id, TestUser user) { + this.id = id; + this.user = user; + } + + public int getId() { + return id; + } + + public TestUser getUser() { + return user; + } + + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/FieldsExpressionTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/FieldsExpressionTest.java new file mode 100644 index 0000000..837b033 --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/FieldsExpressionTest.java @@ -0,0 +1,292 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +class FieldsExpressionTest { + + @Test + void testConstructorWithSingleField() { + FieldsExpression expression = new FieldsExpression("name"); + + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"name"}, expression.getPatterns()); + assertTrue(expression.hasIncludes()); + assertFalse(expression.hasExcludes()); + } + + @Test + void testConstructorWithMultipleFields() { + FieldsExpression expression = new FieldsExpression("id,name,email"); + + assertEquals(3, expression.size()); + assertArrayEquals(new String[]{"id", "name", "email"}, expression.getPatterns()); + assertTrue(expression.hasIncludes()); + assertFalse(expression.hasExcludes()); + } + + @Test + void testConstructorWithWildcard() { + FieldsExpression expression = new FieldsExpression("*"); + + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"*"}, expression.getPatterns()); + assertTrue(expression.hasIncludes()); + assertFalse(expression.hasExcludes()); + } + + @Test + void testConstructorWithNestedFields() { + FieldsExpression expression = new FieldsExpression("user.name,user.email"); + + assertEquals(2, expression.size()); + assertArrayEquals(new String[]{"user.name", "user.email"}, expression.getPatterns()); + } + + @Test + void testConstructorWithWildcardPattern() { + FieldsExpression expression = new FieldsExpression("user.*"); + + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"user.*"}, expression.getPatterns()); + } + + @Test + void testConstructorWithExclusion() { + FieldsExpression expression = new FieldsExpression("-password"); + + assertEquals(1, expression.size()); + assertFalse(expression.hasIncludes()); + assertTrue(expression.hasExcludes()); + assertTrue(expression.getExcludePatterns().contains("password")); + } + + @Test + void testConstructorWithExclusionUsingExclamation() { + FieldsExpression expression = new FieldsExpression("!password"); + + assertEquals(1, expression.size()); + assertFalse(expression.hasIncludes()); + assertTrue(expression.hasExcludes()); + assertTrue(expression.getExcludePatterns().contains("password")); + } + + @Test + void testConstructorWithIncludesAndExcludes() { + FieldsExpression expression = new FieldsExpression("*,-password,-secret"); + + assertEquals(3, expression.size()); + assertTrue(expression.hasIncludes()); + assertTrue(expression.hasExcludes()); + assertEquals(Set.of("*"), expression.getIncludePatterns()); + assertEquals(Set.of("password", "secret"), expression.getExcludePatterns()); + } + + @Test + void testConstructorWithWhitespace() { + FieldsExpression expression = new FieldsExpression(" id , name , email "); + + assertEquals(3, expression.size()); + assertArrayEquals(new String[]{"id", "name", "email"}, expression.getPatterns()); + } + + @Test + void testConstructorWithNull() { + FieldsExpression expression = new FieldsExpression((String) null); + + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + } + + @Test + void testConstructorWithEmptyString() { + FieldsExpression expression = new FieldsExpression(""); + + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + } + + @Test + void testConstructorWithOnlyWhitespace() { + FieldsExpression expression = new FieldsExpression(" "); + + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + } + + @Test + void testConstructorWithVarargs() { + FieldsExpression expression = new FieldsExpression("id", "name", "email"); + + assertEquals(3, expression.size()); + assertArrayEquals(new String[]{"id", "name", "email"}, expression.getPatterns()); + } + + @Test + void testConstructorVarargsFiltersNullAndEmpty() { + FieldsExpression expression = new FieldsExpression("id", null, "", "name", " "); + + assertEquals(2, expression.size()); + assertArrayEquals(new String[]{"id", "name"}, expression.getPatterns()); + } + + @Test + void testConstructorVarargsThrowsOnNull() { + assertThrows(NullPointerException.class, () -> new FieldsExpression((String[]) null)); + } + + @Test + void testEmpty() { + FieldsExpression expression = FieldsExpression.empty(); + + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + assertFalse(expression.hasIncludes()); + assertFalse(expression.hasExcludes()); + } + + @Test + void testGetPatternsReturnsDefensiveCopy() { + FieldsExpression expression = new FieldsExpression("id,name"); + String[] patterns = expression.getPatterns(); + patterns[0] = "modified"; + + assertArrayEquals(new String[]{"id", "name"}, expression.getPatterns()); + } + + @Test + void testGetIncludePatternsIsImmutable() { + FieldsExpression expression = new FieldsExpression("id,name"); + Set includes = expression.getIncludePatterns(); + + assertThrows(UnsupportedOperationException.class, () -> includes.add("email")); + } + + @Test + void testGetExcludePatternsIsImmutable() { + FieldsExpression expression = new FieldsExpression("*,-password"); + Set excludes = expression.getExcludePatterns(); + + assertThrows(UnsupportedOperationException.class, () -> excludes.add("secret")); + } + + @Test + void testInvalidPatternStartingWithNumber() { + assertThrows(FieldsParseException.class, () -> new FieldsExpression("123invalid")); + } + + @Test + void testInvalidPatternWithSpecialCharacters() { + assertThrows(FieldsParseException.class, () -> new FieldsExpression("field@name")); + assertThrows(FieldsParseException.class, () -> new FieldsExpression("field#name")); + assertThrows(FieldsParseException.class, () -> new FieldsExpression("field$name")); + } + + @Test + void testInvalidPatternWithSpace() { + assertThrows(FieldsParseException.class, () -> new FieldsExpression("field name")); + } + + @Test + void testValidPatternWithUnderscore() { + FieldsExpression expression = new FieldsExpression("field_name"); + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"field_name"}, expression.getPatterns()); + } + + @Test + void testValidPatternStartingWithUnderscore() { + FieldsExpression expression = new FieldsExpression("_id"); + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"_id"}, expression.getPatterns()); + } + + @Test + void testNestedFieldWithWildcard() { + FieldsExpression expression = new FieldsExpression("user.*.name"); + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"user.*.name"}, expression.getPatterns()); + } + + @Test + void testComplexPattern() { + FieldsExpression expression = new FieldsExpression( + "id,name,user.*,order.items,-user.password,-order.secret"); + + assertEquals(6, expression.size()); + assertTrue(expression.hasIncludes()); + assertTrue(expression.hasExcludes()); + assertEquals(Set.of("id", "name", "user.*", "order.items"), + expression.getIncludePatterns()); + assertEquals(Set.of("user.password", "order.secret"), expression.getExcludePatterns()); + } + + @Test + void testEquals() { + FieldsExpression expression1 = new FieldsExpression("id,name"); + FieldsExpression expression2 = new FieldsExpression("id,name"); + FieldsExpression expression3 = new FieldsExpression("id,email"); + + assertEquals(expression1, expression2); + assertNotEquals(expression1, expression3); + assertNotEquals(expression1, null); + } + + @Test + void testHashCode() { + FieldsExpression expression1 = new FieldsExpression("id,name"); + FieldsExpression expression2 = new FieldsExpression("id,name"); + + assertEquals(expression1.hashCode(), expression2.hashCode()); + } + + @Test + void testToString() { + FieldsExpression expression1 = new FieldsExpression("id,name,email"); + assertEquals("id, name, email", expression1.toString()); + + FieldsExpression expression2 = FieldsExpression.empty(); + assertEquals("empty", expression2.toString()); + } + + @Test + void testToStringWithExclusions() { + FieldsExpression expression = new FieldsExpression("*,-password"); + assertEquals("*, -password", expression.toString()); + } + + @Test + void testMultipleWildcards() { + FieldsExpression expression = new FieldsExpression("user.*,order.*,product.*"); + assertEquals(3, expression.size()); + assertTrue(expression.hasIncludes()); + assertFalse(expression.hasExcludes()); + } + + @Test + void testExclusionWithWildcard() { + FieldsExpression expression = new FieldsExpression("*,-user.*"); + assertEquals(2, expression.size()); + assertTrue(expression.hasIncludes()); + assertTrue(expression.hasExcludes()); + assertEquals(Set.of("*"), expression.getIncludePatterns()); + assertEquals(Set.of("user.*"), expression.getExcludePatterns()); + } + + @Test + void testMixedExclusionPrefixes() { + FieldsExpression expression = new FieldsExpression("*,-password,!secret"); + assertEquals(3, expression.size()); + assertEquals(Set.of("password", "secret"), expression.getExcludePatterns()); + } + + @Test + void testDeepNestedFields() { + FieldsExpression expression = new FieldsExpression("a.b.c.d.e.f"); + assertEquals(1, expression.size()); + assertArrayEquals(new String[]{"a.b.c.d.e.f"}, expression.getPatterns()); + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/FieldsFilterAdviceTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/FieldsFilterAdviceTest.java new file mode 100644 index 0000000..d3f6da9 --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/FieldsFilterAdviceTest.java @@ -0,0 +1,375 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ser.FilterProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import com.turkraft.springfilter.boot.Fields; +import com.turkraft.springfilter.boot.FieldsFilterAdvice; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Method; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +class FieldsFilterAdviceTest { + + private FieldsFilterAdvice advice; + private HttpServletRequest request; + + @BeforeEach + void setUp() { + advice = new FieldsFilterAdvice(); + request = mock(HttpServletRequest.class); + } + + @Test + void testSupportsWithFieldsAnnotationAndParameter() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn("id,name"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + boolean supports = advice.supports(returnType, null); + assertTrue(supports); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testSupportsWithFieldsAnnotationButNoParameter() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn(null); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + boolean supports = advice.supports(returnType, null); + assertFalse(supports); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testSupportsWithDefaultValue() + throws Exception { + Method method = TestController.class.getMethod("withFieldsDefault"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn(null); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + boolean supports = advice.supports(returnType, null); + assertTrue(supports); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testSupportsWithoutFieldsAnnotation() + throws Exception { + Method method = TestController.class.getMethod("withoutFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn("id,name"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + boolean supports = advice.supports(returnType, null); + assertFalse(supports); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testSupportsWithNoRequestAttributes() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + RequestContextHolder.setRequestAttributes(null); + + boolean supports = advice.supports(returnType, null); + assertFalse(supports); + } + + @Test + void testBeforeBodyWriteWrapsBody() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn("id,name"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + Object result = advice.beforeBodyWrite(body, returnType, null, null, null, null); + + assertInstanceOf(MappingJacksonValue.class, result); + MappingJacksonValue wrapper = (MappingJacksonValue) result; + assertEquals(body, wrapper.getValue()); + assertNotNull(wrapper.getFilters()); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testBeforeBodyWriteWithExistingWrapper() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn("id,name"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + MappingJacksonValue existingWrapper = new MappingJacksonValue(body); + + Object result = advice.beforeBodyWrite(existingWrapper, returnType, null, null, null, null); + + assertInstanceOf(MappingJacksonValue.class, result); + MappingJacksonValue wrapper = (MappingJacksonValue) result; + assertEquals(body, wrapper.getValue()); + assertNotNull(wrapper.getFilters()); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testBeforeBodyWriteWithEmptyParameter() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn(""); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + Object result = advice.beforeBodyWrite(body, returnType, null, null, null, null); + + assertEquals(body, result); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testBeforeBodyWriteWithDefaultValue() + throws Exception { + Method method = TestController.class.getMethod("withFieldsDefault"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn(null); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + Object result = advice.beforeBodyWrite(body, returnType, null, null, null, null); + + assertInstanceOf(MappingJacksonValue.class, result); + MappingJacksonValue wrapper = (MappingJacksonValue) result; + assertNotNull(wrapper.getFilters()); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testBeforeBodyWriteWithNoRequestAttributes() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + RequestContextHolder.setRequestAttributes(null); + + TestObject body = new TestObject(); + Object result = advice.beforeBodyWrite(body, returnType, null, null, null, null); + + assertEquals(body, result); + } + + @Test + void testBeforeBodyWriteSetsCorrectFilter() + throws Exception { + Method method = TestController.class.getMethod("withFields"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn("id,name"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + Object result = advice.beforeBodyWrite(body, returnType, null, null, null, null); + + MappingJacksonValue wrapper = (MappingJacksonValue) result; + FilterProvider filterProvider = wrapper.getFilters(); + + assertNotNull(filterProvider); + assertInstanceOf(SimpleFilterProvider.class, filterProvider); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testBeforeBodyWriteWithCustomParameter() + throws Exception { + Method method = TestController.class.getMethod("withCustomParameter"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("select")).thenReturn("id,name"); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + Object result = advice.beforeBodyWrite(body, returnType, null, null, null, null); + + assertInstanceOf(MappingJacksonValue.class, result); + MappingJacksonValue wrapper = (MappingJacksonValue) result; + assertNotNull(wrapper.getFilters()); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testSupportsReturnsTrueWhenRequiredEvenIfMissing() + throws Exception { + Method method = TestController.class.getMethod("withFieldsRequired"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn(null); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + boolean supports = advice.supports(returnType, null); + assertTrue(supports); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void testBeforeBodyWriteThrowsWhenRequiredParameterMissing() + throws Exception { + Method method = TestController.class.getMethod("withFieldsRequired"); + MethodParameter returnType = new MethodParameter(method, -1); + + when(request.getParameter("fields")).thenReturn(null); + + ServletRequestAttributes attributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(attributes); + + try { + TestObject body = new TestObject(); + assertThrows(IllegalArgumentException.class, + () -> advice.beforeBodyWrite(body, returnType, null, null, null, null)); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @RestController + static class TestController { + + @GetMapping("/fields") + @Fields + public TestObject withFields() { + return new TestObject(); + } + + @GetMapping("/fields-default") + @Fields(defaultValue = "id,name") + public TestObject withFieldsDefault() { + return new TestObject(); + } + + @GetMapping("/custom") + @Fields(parameter = "select") + public TestObject withCustomParameter() { + return new TestObject(); + } + + @GetMapping("/fields-required") + @Fields(required = true) + public TestObject withFieldsRequired() { + return new TestObject(); + } + + @GetMapping("/no-fields") + public TestObject withoutFields() { + return new TestObject(); + } + + } + + static class TestObject { + + private final String id = "123"; + private final String name = "Test"; + private final String password = "secret"; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getPassword() { + return password; + } + + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SimpleSortParserTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SimpleSortParserTest.java new file mode 100644 index 0000000..cf9f5df --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SimpleSortParserTest.java @@ -0,0 +1,254 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SimpleSortParserTest { + + private SimpleSortParser parser; + + @BeforeEach + void setUp() { + parser = new SimpleSortParser(); + } + + @Test + void testParseSingleFieldDefaultDirection() { + SortExpression result = parser.parse("year"); + + assertEquals(1, result.size()); + assertEquals("year", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseSingleFieldAscending() { + SortExpression result = parser.parse("year"); + + assertEquals(1, result.size()); + assertEquals("year", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseSingleFieldDescending() { + SortExpression result = parser.parse("-year"); + + assertEquals(1, result.size()); + assertEquals("year", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseNestedField() { + SortExpression result = parser.parse("brand.name"); + + assertEquals(1, result.size()); + assertEquals("brand.name", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseDeeplyNestedFieldDescending() { + SortExpression result = parser.parse("-owner.company.address.city"); + + assertEquals(1, result.size()); + assertEquals("owner.company.address.city", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseMultipleFields() { + SortExpression result = parser.parse("-year,brand.name"); + + assertEquals(2, result.size()); + + assertEquals("year", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + + assertEquals("brand.name", result.getFields().get(1).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(1).getDirection()); + } + + @Test + void testParseThreeFields() { + SortExpression result = parser.parse("price,-createdAt,id"); + + assertEquals(3, result.size()); + + assertEquals("price", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(0).getDirection()); + + assertEquals("createdAt", result.getFields().get(1).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(1).getDirection()); + + assertEquals("id", result.getFields().get(2).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(2).getDirection()); + } + + @Test + void testParseWithWhitespace() { + SortExpression result = parser.parse(" -year , name "); + + assertEquals(2, result.size()); + assertEquals("year", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + assertEquals("name", result.getFields().get(1).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(1).getDirection()); + } + + @Test + void testParseEmptyString() { + SortExpression result = parser.parse(""); + assertTrue(result.isEmpty()); + } + + @Test + void testParseNull() { + SortExpression result = parser.parse(null); + assertTrue(result.isEmpty()); + } + + @Test + void testParseWithUnderscore() { + SortExpression result = parser.parse("-created_at"); + assertEquals("created_at", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseFieldPathStartsWithDot() { + assertThrows(SortParseException.class, () -> parser.parse(".invalid")); + } + + @Test + void testParseFieldPathEndsWithDot() { + assertThrows(SortParseException.class, () -> parser.parse("invalid.")); + } + + @Test + void testParseFieldPathConsecutiveDots() { + assertThrows(SortParseException.class, () -> parser.parse("field..name")); + } + + @Test + void testParseEmptyFieldInList() { + assertThrows(SortParseException.class, () -> parser.parse("year,,name")); + } + + @Test + void testParseTooManyFields() { + assertThrows(SortParseException.class, + () -> parser.parse("f1,f2,f3,f4", 3)); + } + + @Test + void testConversionToSpringSort() { + SortExpression expression = parser.parse("-year,name"); + org.springframework.data.domain.Sort springSort = expression.toSpringSort(); + + assertNotNull(springSort); + assertTrue(springSort.isSorted()); + + var orders = springSort.toList(); + assertEquals(2, orders.size()); + + assertEquals("year", orders.get(0).getProperty()); + assertEquals(org.springframework.data.domain.Sort.Direction.DESC, orders.get(0).getDirection()); + + assertEquals("name", orders.get(1).getProperty()); + assertEquals(org.springframework.data.domain.Sort.Direction.ASC, orders.get(1).getDirection()); + } + + @Test + void testEmptyExpressionConvertsToUnsorted() { + SortExpression expression = parser.parse(""); + org.springframework.data.domain.Sort springSort = expression.toSpringSort(); + + assertNotNull(springSort); + assertFalse(springSort.isSorted()); + } + + @Test + void testParseVeryLongFieldPath() { + String longPath = "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p"; + SortExpression result = parser.parse(longPath); + assertEquals(longPath, result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.ASC, result.getFields().get(0).getDirection()); + } + + @Test + void testMaxFieldsDefault() { + String expression = "f1,f2,f3,f4,f5"; + SortExpression result = parser.parse(expression); + assertEquals(5, result.size()); + } + + @Test + void testMaxFieldsEnforced() { + String expression = "f1,f2,f3"; + assertThrows(SortParseException.class, () -> parser.parse(expression, 2)); + } + + @Test + void testMaxFieldsExactly() { + String expression = "f1,f2"; + SortExpression result = parser.parse(expression, 2); + assertEquals(2, result.size()); + } + + @Test + void testParseFieldStartingWithNumber() { + assertThrows(SortParseException.class, () -> parser.parse("123invalid")); + } + + @Test + void testParseOnlyWhitespace() { + SortExpression result = parser.parse(" "); + assertTrue(result.isEmpty()); + } + + @Test + void testParseFieldWithSpecialCharacters() { + assertThrows(SortParseException.class, () -> parser.parse("field@name")); + assertThrows(SortParseException.class, () -> parser.parse("field$name")); + } + + @Test + void testParseDescendingNestedField() { + SortExpression result = parser.parse("-user.name"); + assertEquals("user.name", result.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + } + + @Test + void testParseAllDescending() { + SortExpression result = parser.parse("-year,-name,-id"); + assertEquals(3, result.size()); + assertEquals(SortDirection.DESC, result.getFields().get(0).getDirection()); + assertEquals(SortDirection.DESC, result.getFields().get(1).getDirection()); + assertEquals(SortDirection.DESC, result.getFields().get(2).getDirection()); + } + + @Test + void testParseAllAscending() { + SortExpression result = parser.parse("year,name,id"); + assertEquals(3, result.size()); + assertEquals(SortDirection.ASC, result.getFields().get(0).getDirection()); + assertEquals(SortDirection.ASC, result.getFields().get(1).getDirection()); + assertEquals(SortDirection.ASC, result.getFields().get(2).getDirection()); + } + + @Test + void testParseEmptyFieldAfterMinus() { + assertThrows(SortParseException.class, () -> parser.parse("-")); + } + + @Test + void testParseMultipleMinuses() { + assertThrows(SortParseException.class, () -> parser.parse("--year")); + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortBuilderTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortBuilderTest.java new file mode 100644 index 0000000..bd6e294 --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortBuilderTest.java @@ -0,0 +1,134 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +class SortBuilderTest { + + @Test + void testBuildSingleFieldDefaultDirection() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder.field("name").build(); + + assertEquals(1, expression.size()); + assertEquals("name", expression.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.ASC, expression.getFields().get(0).getDirection()); + } + + @Test + void testBuildSingleFieldAsc() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder.field("name").asc().build(); + + assertEquals(1, expression.size()); + assertEquals(SortDirection.ASC, expression.getFields().get(0).getDirection()); + } + + @Test + void testBuildSingleFieldDesc() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder.field("name").desc().build(); + + assertEquals(1, expression.size()); + assertEquals(SortDirection.DESC, expression.getFields().get(0).getDirection()); + } + + @Test + void testBuildMultipleFields() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder + .field("year").desc() + .field("name").asc() + .build(); + + assertEquals(2, expression.size()); + + assertEquals("year", expression.getFields().get(0).getFieldPath()); + assertEquals(SortDirection.DESC, expression.getFields().get(0).getDirection()); + + assertEquals("name", expression.getFields().get(1).getFieldPath()); + assertEquals(SortDirection.ASC, expression.getFields().get(1).getDirection()); + } + + @Test + void testBuildThreeFields() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder + .field("price").asc() + .field("createdAt").desc() + .field("id").asc() + .build(); + + assertEquals(3, expression.size()); + } + + @Test + void testBuildEmpty() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder.build(); + + assertTrue(expression.isEmpty()); + } + + @Test + void testBuildSpringSort() { + SortBuilder builder = new SortBuilder(); + Sort springSort = builder + .field("year").desc() + .field("name").asc() + .buildSpringSort(); + + assertTrue(springSort.isSorted()); + assertEquals(2, springSort.toList().size()); + } + + @Test + void testDirectionMethod() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder + .field("name").direction(SortDirection.DESC) + .build(); + + assertEquals(SortDirection.DESC, expression.getFields().get(0).getDirection()); + } + + @Test + void testReset() { + SortBuilder builder = new SortBuilder(); + builder.field("name").desc(); + builder.reset(); + + SortExpression expression = builder.build(); + assertTrue(expression.isEmpty()); + } + + @Test + void testReusableBuilder() { + SortBuilder builder = new SortBuilder(); + + SortExpression expression1 = builder.field("name").desc().build(); + assertEquals(1, expression1.size()); + + builder.reset(); + + SortExpression expression2 = builder.field("price").asc().build(); + assertEquals(1, expression2.size()); + assertEquals("price", expression2.getFields().get(0).getFieldPath()); + } + + @Test + void testNestedFieldPath() { + SortBuilder builder = new SortBuilder(); + SortExpression expression = builder + .field("brand.name").asc() + .field("owner.company.name").desc() + .build(); + + assertEquals(2, expression.size()); + assertEquals("brand.name", expression.getFields().get(0).getFieldPath()); + assertEquals("owner.company.name", expression.getFields().get(1).getFieldPath()); + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortDirectionTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortDirectionTest.java new file mode 100644 index 0000000..efa97e6 --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortDirectionTest.java @@ -0,0 +1,60 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +class SortDirectionTest { + + @Test + void testParseAsc() { + assertEquals(SortDirection.ASC, SortDirection.parse("asc")); + assertEquals(SortDirection.ASC, SortDirection.parse("ASC")); + assertEquals(SortDirection.ASC, SortDirection.parse("Asc")); + assertEquals(SortDirection.ASC, SortDirection.parse("ascending")); + assertEquals(SortDirection.ASC, SortDirection.parse("ASCENDING")); + } + + @Test + void testParseDesc() { + assertEquals(SortDirection.DESC, SortDirection.parse("desc")); + assertEquals(SortDirection.DESC, SortDirection.parse("DESC")); + assertEquals(SortDirection.DESC, SortDirection.parse("Desc")); + assertEquals(SortDirection.DESC, SortDirection.parse("descending")); + assertEquals(SortDirection.DESC, SortDirection.parse("DESCENDING")); + } + + @Test + void testParseNullDefaultsToAsc() { + assertEquals(SortDirection.ASC, SortDirection.parse(null)); + } + + @Test + void testParseEmptyDefaultsToAsc() { + assertEquals(SortDirection.ASC, SortDirection.parse("")); + assertEquals(SortDirection.ASC, SortDirection.parse(" ")); + } + + @Test + void testParseInvalidThrowsException() { + assertThrows(IllegalArgumentException.class, () -> SortDirection.parse("invalid")); + assertThrows(IllegalArgumentException.class, () -> SortDirection.parse("up")); + assertThrows(IllegalArgumentException.class, () -> SortDirection.parse("down")); + } + + @Test + void testToSpringDirection() { + assertEquals(Sort.Direction.ASC, SortDirection.ASC.toSpringDirection()); + assertEquals(Sort.Direction.DESC, SortDirection.DESC.toSpringDirection()); + } + + @Test + void testGetters() { + assertEquals("asc", SortDirection.ASC.getShortName()); + assertEquals("ascending", SortDirection.ASC.getLongName()); + assertEquals("desc", SortDirection.DESC.getShortName()); + assertEquals("descending", SortDirection.DESC.getLongName()); + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortExpressionTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortExpressionTest.java new file mode 100644 index 0000000..4a8131f --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortExpressionTest.java @@ -0,0 +1,123 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +class SortExpressionTest { + + @Test + void testConstructorWithList() { + List fields = List.of( + new SortField("name", SortDirection.ASC), + new SortField("price", SortDirection.DESC) + ); + + SortExpression expression = new SortExpression(fields); + + assertEquals(2, expression.size()); + assertEquals(fields, expression.getFields()); + } + + @Test + void testConstructorWithVarargs() { + SortExpression expression = new SortExpression( + new SortField("name", SortDirection.ASC), + new SortField("price", SortDirection.DESC) + ); + + assertEquals(2, expression.size()); + } + + @Test + void testConstructorWithNullList() { + SortExpression expression = new SortExpression((List) null); + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + } + + @Test + void testConstructorWithEmptyList() { + SortExpression expression = new SortExpression(Collections.emptyList()); + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + } + + @Test + void testUnsorted() { + SortExpression expression = SortExpression.unsorted(); + assertTrue(expression.isEmpty()); + assertEquals(0, expression.size()); + } + + @Test + void testToSpringSort() { + SortExpression expression = new SortExpression( + new SortField("name", SortDirection.ASC), + new SortField("price", SortDirection.DESC) + ); + + Sort springSort = expression.toSpringSort(); + + assertTrue(springSort.isSorted()); + List orders = springSort.toList(); + assertEquals(2, orders.size()); + + assertEquals("name", orders.get(0).getProperty()); + assertEquals(Sort.Direction.ASC, orders.get(0).getDirection()); + + assertEquals("price", orders.get(1).getProperty()); + assertEquals(Sort.Direction.DESC, orders.get(1).getDirection()); + } + + @Test + void testToSpringSortEmpty() { + SortExpression expression = SortExpression.unsorted(); + Sort springSort = expression.toSpringSort(); + + assertFalse(springSort.isSorted()); + } + + @Test + void testToString() { + SortExpression expression1 = new SortExpression( + new SortField("name", SortDirection.ASC), + new SortField("price", SortDirection.DESC) + ); + assertEquals("name,-price", expression1.toString()); + + SortExpression expression2 = SortExpression.unsorted(); + assertEquals("unsorted", expression2.toString()); + } + + @Test + void testEquals() { + SortExpression expression1 = new SortExpression(new SortField("name", SortDirection.ASC)); + SortExpression expression2 = new SortExpression(new SortField("name", SortDirection.ASC)); + SortExpression expression3 = new SortExpression(new SortField("price", SortDirection.ASC)); + + assertEquals(expression1, expression2); + assertNotEquals(expression1, expression3); + assertNotEquals(expression1, null); + } + + @Test + void testHashCode() { + SortExpression expression1 = new SortExpression(new SortField("name", SortDirection.ASC)); + SortExpression expression2 = new SortExpression(new SortField("name", SortDirection.ASC)); + + assertEquals(expression1.hashCode(), expression2.hashCode()); + } + + @Test + void testFieldsAreImmutable() { + List fields = List.of(new SortField("name", SortDirection.ASC)); + SortExpression expression = new SortExpression(fields); + + assertThrows(UnsupportedOperationException.class, () -> expression.getFields().clear()); + } + +} diff --git a/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortFieldTest.java b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortFieldTest.java new file mode 100644 index 0000000..922c9d9 --- /dev/null +++ b/page-sort/src/test/java/com/turkraft/springfilter/pagesort/SortFieldTest.java @@ -0,0 +1,87 @@ +package com.turkraft.springfilter.pagesort; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +class SortFieldTest { + + @Test + void testConstructorWithDirection() { + SortField field = new SortField("name", SortDirection.DESC); + assertEquals("name", field.getFieldPath()); + assertEquals(SortDirection.DESC, field.getDirection()); + } + + @Test + void testConstructorWithoutDirection() { + SortField field = new SortField("name"); + assertEquals("name", field.getFieldPath()); + assertEquals(SortDirection.ASC, field.getDirection()); + } + + @Test + void testConstructorNullDirectionDefaultsToAsc() { + SortField field = new SortField("name", null); + assertEquals(SortDirection.ASC, field.getDirection()); + } + + @Test + void testConstructorNullFieldPathThrows() { + assertThrows(IllegalArgumentException.class, () -> new SortField(null, SortDirection.ASC)); + } + + @Test + void testConstructorEmptyFieldPathThrows() { + assertThrows(IllegalArgumentException.class, () -> new SortField("", SortDirection.ASC)); + assertThrows(IllegalArgumentException.class, () -> new SortField(" ", SortDirection.ASC)); + } + + @Test + void testToSpringOrder() { + SortField field = new SortField("name", SortDirection.DESC); + Sort.Order order = field.toSpringOrder(); + + assertEquals("name", order.getProperty()); + assertEquals(Sort.Direction.DESC, order.getDirection()); + } + + @Test + void testToString() { + SortField field1 = new SortField("name", SortDirection.ASC); + assertEquals("name", field1.toString()); + + SortField field2 = new SortField("price", SortDirection.DESC); + assertEquals("-price", field2.toString()); + } + + @Test + void testEquals() { + SortField field1 = new SortField("name", SortDirection.ASC); + SortField field2 = new SortField("name", SortDirection.ASC); + SortField field3 = new SortField("name", SortDirection.DESC); + SortField field4 = new SortField("price", SortDirection.ASC); + + assertEquals(field1, field2); + assertNotEquals(field1, field3); + assertNotEquals(field1, field4); + assertNotEquals(field1, null); + assertNotEquals(field1, "string"); + } + + @Test + void testHashCode() { + SortField field1 = new SortField("name", SortDirection.ASC); + SortField field2 = new SortField("name", SortDirection.ASC); + + assertEquals(field1.hashCode(), field2.hashCode()); + } + + @Test + void testTrimFieldPath() { + SortField field = new SortField(" name ", SortDirection.ASC); + assertEquals("name", field.getFieldPath()); + } + +} diff --git a/pom.xml b/pom.xml index 1df8378..77e7343 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ predicate-language predicate openapi + page-sort