Перейти к основному содержимому

Язык запросов REST с RSQL

· 6 мин. чтения

1. Обзор

В этой пятой статье серии мы проиллюстрируем создание языка REST API Query с помощью классной библиотеки — rsql-parser .

RSQL — это расширенный набор языка запросов к элементам веб-каналов ( FIQL ) — чистый и простой синтаксис фильтров для веб-каналов; поэтому он вполне естественно вписывается в REST API. **

**

2. Подготовка

Во-первых, давайте добавим в библиотеку зависимость Maven:

<dependency>
<groupId>cz.jirutka.rsql</groupId>
<artifactId>rsql-parser</artifactId>
<version>2.1.0</version>
</dependency>

А также определите основной объект , с которым мы будем работать в примерах — User :

@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

private String firstName;
private String lastName;
private String email;

private int age;
}

3. Разберите запрос

Внутреннее представление выражений RSQL осуществляется в виде узлов, а шаблон посетителя используется для разбора входных данных.

Имея это в виду, мы реализуем интерфейс RSQLVisitor и создадим собственную реализацию посетителя — CustomRsqlVisitor :

public class CustomRsqlVisitor<T> implements RSQLVisitor<Specification<T>, Void> {

private GenericRsqlSpecBuilder<T> builder;

public CustomRsqlVisitor() {
builder = new GenericRsqlSpecBuilder<T>();
}

@Override
public Specification<T> visit(AndNode node, Void param) {
return builder.createSpecification(node);
}

@Override
public Specification<T> visit(OrNode node, Void param) {
return builder.createSpecification(node);
}

@Override
public Specification<T> visit(ComparisonNode node, Void params) {
return builder.createSecification(node);
}
}

Теперь нам нужно разобраться с персистентностью и построить наш запрос из каждого из этих узлов.

Мы собираемся использовать спецификации Spring Data JPA , которые мы использовали ранее , и мы собираемся реализовать построитель спецификаций для создания спецификаций из каждого из этих узлов, которые мы посещаем :

public class GenericRsqlSpecBuilder<T> {

public Specification<T> createSpecification(Node node) {
if (node instanceof LogicalNode) {
return createSpecification((LogicalNode) node);
}
if (node instanceof ComparisonNode) {
return createSpecification((ComparisonNode) node);
}
return null;
}

public Specification<T> createSpecification(LogicalNode logicalNode) {
List<Specification> specs = logicalNode.getChildren()
.stream()
.map(node -> createSpecification(node))
.filter(Objects::nonNull)
.collect(Collectors.toList());

Specification<T> result = specs.get(0);
if (logicalNode.getOperator() == LogicalOperator.AND) {
for (int i = 1; i < specs.size(); i++) {
result = Specification.where(result).and(specs.get(i));
}
} else if (logicalNode.getOperator() == LogicalOperator.OR) {
for (int i = 1; i < specs.size(); i++) {
result = Specification.where(result).or(specs.get(i));
}
}

return result;
}

public Specification<T> createSpecification(ComparisonNode comparisonNode) {
Specification<T> result = Specification.where(
new GenericRsqlSpecification<T>(
comparisonNode.getSelector(),
comparisonNode.getOperator(),
comparisonNode.getArguments()
)
);
return result;
}
}

Обратите внимание, как:

  • LogicalNode является узлом AND / OR и имеет несколько дочерних элементов. ``
  • ComparisonNode не имеет дочерних элементов и содержит селектор, оператор и аргументы.

Например, для запроса « name==john » — имеем:

  1. Селектор : «имя»
  2. Оператор : «==»
  3. Аргументы : [Джон]

4. Создайте пользовательскую спецификацию

При построении запроса мы использовали спецификацию:

public class GenericRsqlSpecification<T> implements Specification<T> {

private String property;
private ComparisonOperator operator;
private List<String> arguments;

@Override
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
List<Object> args = castArguments(root);
Object argument = args.get(0);
switch (RsqlSearchOperation.getSimpleOperator(operator)) {

case EQUAL: {
if (argument instanceof String) {
return builder.like(root.get(property), argument.toString().replace('*', '%'));
} else if (argument == null) {
return builder.isNull(root.get(property));
} else {
return builder.equal(root.get(property), argument);
}
}
case NOT_EQUAL: {
if (argument instanceof String) {
return builder.notLike(root.<String> get(property), argument.toString().replace('*', '%'));
} else if (argument == null) {
return builder.isNotNull(root.get(property));
} else {
return builder.notEqual(root.get(property), argument);
}
}
case GREATER_THAN: {
return builder.greaterThan(root.<String> get(property), argument.toString());
}
case GREATER_THAN_OR_EQUAL: {
return builder.greaterThanOrEqualTo(root.<String> get(property), argument.toString());
}
case LESS_THAN: {
return builder.lessThan(root.<String> get(property), argument.toString());
}
case LESS_THAN_OR_EQUAL: {
return builder.lessThanOrEqualTo(root.<String> get(property), argument.toString());
}
case IN:
return root.get(property).in(args);
case NOT_IN:
return builder.not(root.get(property).in(args));
}

return null;
}

private List<Object> castArguments(final Root<T> root) {

Class<? extends Object> type = root.get(property).getJavaType();

List<Object> args = arguments.stream().map(arg -> {
if (type.equals(Integer.class)) {
return Integer.parseInt(arg);
} else if (type.equals(Long.class)) {
return Long.parseLong(arg);
} else {
return arg;
}
}).collect(Collectors.toList());

return args;
}

// standard constructor, getter, setter
}

Обратите внимание, что спецификация использует дженерики и не привязана к какому-либо конкретному объекту (например, пользователю).

Далее — вот наше перечисление « RsqlSearchOperation » , которое содержит операторы rsql-parser по умолчанию:

public enum RsqlSearchOperation {
EQUAL(RSQLOperators.EQUAL),
NOT_EQUAL(RSQLOperators.NOT_EQUAL),
GREATER_THAN(RSQLOperators.GREATER_THAN),
GREATER_THAN_OR_EQUAL(RSQLOperators.GREATER_THAN_OR_EQUAL),
LESS_THAN(RSQLOperators.LESS_THAN),
LESS_THAN_OR_EQUAL(RSQLOperators.LESS_THAN_OR_EQUAL),
IN(RSQLOperators.IN),
NOT_IN(RSQLOperators.NOT_IN);

private ComparisonOperator operator;

private RsqlSearchOperation(ComparisonOperator operator) {
this.operator = operator;
}

public static RsqlSearchOperation getSimpleOperator(ComparisonOperator operator) {
for (RsqlSearchOperation operation : values()) {
if (operation.getOperator() == operator) {
return operation;
}
}
return null;
}
}

5. Тестовые поисковые запросы

Давайте теперь начнем тестировать наши новые и гибкие операции в некоторых реальных сценариях:

Сначала — инициализируем данные:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { PersistenceConfig.class })
@Transactional
@TransactionConfiguration
public class RsqlTest {

@Autowired
private UserRepository repository;

private User userJohn;

private User userTom;

@Before
public void init() {
userJohn = new User();
userJohn.setFirstName("john");
userJohn.setLastName("doe");
userJohn.setEmail("john@doe.com");
userJohn.setAge(22);
repository.save(userJohn);

userTom = new User();
userTom.setFirstName("tom");
userTom.setLastName("doe");
userTom.setEmail("tom@doe.com");
userTom.setAge(26);
repository.save(userTom);
}
}

Теперь давайте проверим различные операции:

5.1. Проверка равенства

В следующем примере мы будем искать пользователей по их имени и фамилии :

@Test
public void givenFirstAndLastName_whenGettingListOfUsers_thenCorrect() {
Node rootNode = new RSQLParser().parse("firstName==john;lastName==doe");
Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>());
List<User> results = repository.findAll(spec);

assertThat(userJohn, isIn(results));
assertThat(userTom, not(isIn(results)));
}

5.2. Отрицание теста

Далее давайте найдем пользователей, которые по имени не «john»:

@Test
public void givenFirstNameInverse_whenGettingListOfUsers_thenCorrect() {
Node rootNode = new RSQLParser().parse("firstName!=john");
Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>());
List<User> results = repository.findAll(spec);

assertThat(userTom, isIn(results));
assertThat(userJohn, not(isIn(results)));
}

5.3. Тест больше, чем

Далее — будем искать пользователей с возрастом больше « 25 »:

@Test
public void givenMinAge_whenGettingListOfUsers_thenCorrect() {
Node rootNode = new RSQLParser().parse("age>25");
Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>());
List<User> results = repository.findAll(spec);

assertThat(userTom, isIn(results));
assertThat(userJohn, not(isIn(results)));
}

5.4. Тест Нравится

Далее — мы будем искать пользователей по имени , начинающемуся с « jo »:

@Test
public void givenFirstNamePrefix_whenGettingListOfUsers_thenCorrect() {
Node rootNode = new RSQLParser().parse("firstName==jo*");
Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>());
List<User> results = repository.findAll(spec);

assertThat(userJohn, isIn(results));
assertThat(userTom, not(isIn(results)));
}

5.5. Тест В

Далее — мы будем искать пользователей, их имя « john » или « jack »:

@Test
public void givenListOfFirstName_whenGettingListOfUsers_thenCorrect() {
Node rootNode = new RSQLParser().parse("firstName=in=(john,jack)");
Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>());
List<User> results = repository.findAll(spec);

assertThat(userJohn, isIn(results));
assertThat(userTom, not(isIn(results)));
}

6. Пользовательский контроллер

Наконец — давайте свяжем все это с контроллером:

@RequestMapping(method = RequestMethod.GET, value = "/users")
@ResponseBody
public List<User> findAllByRsql(@RequestParam(value = "search") String search) {
Node rootNode = new RSQLParser().parse(search);
Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>());
return dao.findAll(spec);
}

Вот пример URL:

http://localhost:8080/users?search=firstName==jo*;age<25

И ответ:

[{
"id":1,
"firstName":"john",
"lastName":"doe",
"email":"john@doe.com",
"age":24
}]

7. Заключение

В этом руководстве показано, как создать язык запросов/поиска для REST API без необходимости заново изобретать синтаксис и вместо этого использовать FIQL/RSQL.

Полную реализацию этой статьи можно найти в проекте GitHub — это проект на основе Maven, поэтому его легко импортировать и запускать как есть.