REST API 를 만들다 보면 상세한 검색조건을 처리해야 하는 일이 발생하는데
URI 에 포함된 querystring 을 어떻게 처리해야 좋을지 고민 된다.
JPA, QueryDSL 등과 같은 ORM 에 대해서는 찾아보면 많은 라이브러리, Framework 들이 존재하기 때문에
적당한 녀석을 찾아서 사용하면 된다.
ex. https://www.baeldung.com/rest-api-search-querydsl-web-in-spring-data-jpa
ex. https://www.baeldung.com/spring-rest-api-query-search-language-tutorial
ex) https://www.baeldung.com/rest-api-search-language-rsql-fiql
사실 REST API 에 QueryString 을 Design 하는 것은 자칫 Client 에게 Server 의 Model 을 그대로 노출할 수도 있으며
예기치 못한 오류 상황을 만들어낼 확률이 높아질 수 있으니 그리 좋은 방법은 아니다.
하지만 다양한 검색조건을 만족시키는 API 가 필요하다면 불가피하다.
MyBatis 와 같은 Mapper 를 사용할 경우 QueryString 을 Parsing 하여 처리하는 방법을 정리 해 본다.
요구사항
1. eq, like, not eq, gt, lt 와 같은 기본적인 operator와 and, or condition 을 제공한다.
2. query 에서 ${sql} 과 같은 형태로 사용하기 때문에 parsing 된 sql 이 실제 컬럼과 매핑되어야 함.
즉, querystring 에 잘못된 key (column) 가 있으면 예외를 반환
3. model 정보를 client 에 노출하지 않아야 함
구현
요구사항 3번을 대응하기 위해 QueryString Parsing 시 사용할 Key 에 대한 실제 Column 이름(실제 쿼리에서 사용하는) 매핑 정보를 담고 있는 Enum 정의
public interface Columns {
String getColumn();
}
@Getter
public enum UserColumn implements Columns {
ID ("user_id"),
NAME ("user_name"),
DEPT ("dept_name"),
POSITION ("post_name"),
LOCK ("is_lock"),
MAIL ("email_address"),
CELLPHONE ("cellphone"),
USER_TYPE ("user_type_str");
private String column;
UserColumn(String column) {
this.column = column;
}
}
실제로 querystring을 받아서 parsing 하는 부분
@Description("url parameter `search`를 parsing 하여 MyBatis 에서 사용할 수 있도록 SQL 형태로 변환해 주는 Class")
public class QueryStringParser {
private String queryString;
private StringBuilder result;
private Columns[] columnType = UserColumn.values();
public QueryStringParser(Columns[] columnType, String queryString) {
this.columnType = columnType;
this.queryString = queryString;
}
public String toSql() throws Exception {
if (isNull(this.columnType) || isNull(this.queryString)) {
System.out.println("column type or query is null");
return null;
}
this.result = new StringBuilder();
parsing();
return this.result.toString().trim();
}
private String parsing() throws Exception {
StringTokenizer tokenizer = new StringTokenizer(queryString, "|&()", true);
while( tokenizer.hasMoreTokens() ) {
String token = tokenizer.nextToken();
switch (token) {
case "|":
result.append("OR ");
break;
case "&":
result.append("AND ");
break;
case "(":
result.append("( ");
break;
case ")":
result.append(") ");
break;
default:
tokenProcess(token);
break;
}
}
return this.result.toString();
}
private void tokenProcess(String command) throws InvalidParameterException {
command = command.trim();
if (CommonUtils.isNull(command)) {
return;
}
String[] tmpStr = command.split(":");
if (tmpStr.length != 2) {
throw new InvalidParameterException("empty string");
}
String target = tmpStr[0];
String token = tmpStr[1];
String column = null;
for (Columns c: this.columnType) {
if (c.toString().equalsIgnoreCase(target)) {
column = c.getColumn();
}
}
if (CommonUtils.isNull(column)) {
throw new InvalidParameterException(target);
}
target = column;
result.append(target).append(" ");
if( token.contains("*") ) {
if( token.charAt(0) == '!' ) {
result.append("NOT LIKE ");
token = token.substring(1, token.length());
} else {
result.append("LIKE ");
}
token = token.replaceAll("\\*", "%").replaceAll("\\\\", "\\\\\\\\");
} else {
switch (token.charAt(0)) {
case '!':
result.append("!= ");
token = token.substring(1, token.length());
break;
case '>':
if (token.charAt(1) == '=') {
result.append(">= ");
token = token.substring(2, token.length());
} else {
result.append("> ");
token = token.substring(1, token.length());
}
break;
case '<':
if (token.charAt(1) == '=') {
result.append("<= ");
token = token.substring(2, token.length());
} else {
result.append("< ");
token = token.substring(1, token.length());
}
break;
default:
result.append("= ");
break;
}
}
result.append("\'").append(token).append("\'").append(" ");
}
@Override
public String toString() {
return result.toString().trim();
}
}
사용
Controller
@ApiOperation(value = "Search users")
@ApiResponses({@ApiResponse(code = 200, message = "success")})
@GetMapping("search")
public ResponseEntity<List<User>> searchUsers(
@ApiParam(value = "key:(operator)val (conditions) key:(operator)val ... \n"
+ "ex) support key: ID, NAME, DEPT, MAIL, CELLPHONE, POSITION, LOCK, USER_TYPE\n"
+ "ex) support operator: default(equals), !(not equals), *(like), >(greater than), <(less than)\n"
+ "ex) support conditions: &(and),|(or)\n"
) @RequestParam("search") String search) {
return new ResponseEntity<List<User>>(service.searchUser(search), HttpStatus.OK);
}
Service
public List<User> searchUser(String qs){
String sql = null;
try {
sql = new QueryStringParser(UserColumn.values(), qs).toSql();
} catch (Exception e) {
e.printStackTrace();
throw new InvalidParameterException(e.getMessage());
}
return mapper.searchUserList(UserSearch.builder().sql(sql).build());
}
XML
<select id="searchUserList" parameterType="usersearch" resultType="user">
SELECT
<include refid="selectUser"/>
<include refid="selectUserFrom"/>
${sql}
</select>
TEST
(ID:yhkim | ID:crpark) & mail:yhkim@* & position:사원
으로 입력하고 GET 을 호출 해 보면
잘된다.
'IT > JAVA' 카테고리의 다른 글
Java 13 특징 (feat. Java 10 특징) (0) | 2019.12.20 |
---|---|
Gson Serialize 시 예외 시키기 (@Expose) (0) | 2019.11.28 |
Gson LocalDateTime 처리(BEGIN_OBJECT but was STRING) (2) | 2019.09.18 |
ConcurrentModificationException (0) | 2019.05.21 |
Ehcache 옵션 정리 (0) | 2018.09.12 |