eslint 커스텀 룰 만들기

Role Base Access Control(RBAC)을 구현하다 보니 Role 체크 함수를 항상 컨트롤러에서 호출하도록 강제하는 방법은 없을까... 고민했다.

이게 뭔말인지 코드를 예로 보여주자면

kafka-ui/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/KafkaConnectController.java at master · provectus/kafka-ui
Open-Source Web UI for Apache Kafka Management. Contribute to provectus/kafka-ui development by creating an account on GitHub.

Kafka UI 프로젝트의 일부인데 이 프로젝트도 RBAC을 지원한다.

@RestController
public class KafkaConnectController {
...
...
	@Override
  public Mono<ResponseEntity<ConnectorDTO>> createConnector(String clusterName, String connectName,
                                                            @Valid Mono<NewConnectorDTO> connector,
                                                            ServerWebExchange exchange) {

    var context = AccessContext.builder()
        .cluster(clusterName)
        .connect(connectName)
        .connectActions(ConnectAction.VIEW, ConnectAction.CREATE)
        .operationName("createConnector")
        .build();

    return accessControlService.validateAccess(context).then(
        kafkaConnectService.createConnector(getCluster(clusterName), connectName, connector)
            .map(ResponseEntity::ok)
    ).doOnEach(sig -> auditService.audit(context, sig));
  }

  @Override
  public Mono<ResponseEntity<ConnectorDTO>> getConnector(String clusterName, String connectName,
                                                         String connectorName,
                                                         ServerWebExchange exchange) {

    var context = AccessContext.builder()
        .cluster(clusterName)
        .connect(connectName)
        .connectActions(ConnectAction.VIEW)
        .connector(connectorName)
        .operationName("getConnector")
        .build();

    return accessControlService.validateAccess(context).then(
        kafkaConnectService.getConnector(getCluster(clusterName), connectName, connectorName)
            .map(ResponseEntity::ok)
    ).doOnEach(sig -> auditService.audit(context, sig));
  }
...
...
}

계속 같은 패턴으로 accessControlService.validateAccess 함수를 호출하고 Accessible이면 비즈니스 로직을 수행한다.

만약 다른 개발자가 그냥 함수 호출을 잊고 비즈니스 로직을 수행했다면? 어디서 에러를 잡아줘야 할지... 하는 고민을 하다가 eslint로 커스텀 룰을 세워 강제하면 어떨까 했다.

eslint는 typescript, javascript를 위한 정적 코드 분석 도구다.
eslint를 어떻게 적용하는지는 알고 있다는 가정하에 바로 custom rule 만들기로 시작하겠다.

준비물은 딱 2개다.

@typescript-eslint/experimental-utils 패키지와 아래 사이트

AST explorer
An online AST explorer.
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

위와 같은 Controller 코드를 그대로 AST explorer 사이트에 넣으면

상단에 @typescript-eslint/parser 설정을 주의. 파서마다 다른 결과가 나온다.

코드 텍스트에서 트리로 데이터를 만들어낸다. 해당 데이터에서 커서가 위치한 곳으로 자동 하이라이트 되어 있는걸 볼 수 있다.

그럼 AST를 따라서 Logger를 무조건 호출하도록 룰을 만들어보면

custom eslint rule
custom eslint rule. GitHub Gist: instantly share code, notes, and snippets.
const ANY_CONTROLLER =
  'ClassDeclaration > Decorator:matches(' +
  '[expression.callee.name="Controller"])';

...

create: (context) => {
    return {
      [ANY_CONTROLLER](node: TSESTree.Decorator) {
        const classDeclaration = getClassDeclarationFromDecorator(node);
        if (classDeclaration === null) return;
        const apis = getAllAPIsFromClassDeclaration(classDeclaration);
        if (!apis || apis.length === 0) return;
        apis.forEach((api) => {
          if (!isFunctionExpression(api.value)) return;
          resolveAPI(api.value, context);
        });
      },
    };
  }

...


export function isCallLogger(
  expressionStatement: TSESTree.ExpressionStatement,
) {
  const callExpression = expressionStatement.expression;
  if (!isCallExpression(callExpression)) return false;
  const memberExpression = callExpression.callee;
  if (!isMemberExpression(memberExpression)) return false;
  const identifier = memberExpression.object;
  if (!isIdentifier(identifier)) return false;
  return identifier.name === 'Logger';
}

결국 AST tree의 구조를 따라가 코드 분석 했을 때 원하는 패턴이 있냐 없냐 검사하는 구조다.

그 이후에는 eslint 설정에 커스텀 룰을 적용하면

위와 같이 에디터가 잡아주기도 하고

원하는 적용 시점에 따라 적용하여 CI 완성도를 높일 수 있다.

  • 커밋 직전 : husky, pre-commit 으로 lint 실행
  • PR 요청시 : github actions, jenkins 와 연동하여 lint 실행 (대부분 오픈소스들이 사용)