본문 바로가기
IT/JAVA

Rest API QueryString Parsing for MyBatis

by 최고영회 2019. 9. 24.
728x90
반응형
SMALL

REST API 를 만들다 보면 상세한 검색조건을 처리해야 하는 일이 발생하는데 

URI 에 포함된 querystring 을 어떻게 처리해야 좋을지 고민 된다. 

JPA, QueryDSL 등과 같은 ORM 에 대해서는 찾아보면 많은 라이브러리, Framework 들이 존재하기 때문에 

적당한 녀석을 찾아서 사용하면 된다.

ex. https://docs.spring.io/spring-data/rest/docs/current/reference/html/#repository-resources.query-method-resource

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 을 호출 해 보면 

잘된다. 

728x90
반응형
LIST