在Trabe,我们一直在使用GraphQL。我们开始使用纯JavaScript项目来 实现这项技术,但在意识到GraphQL可和React一起运行之后,我们决定采用它来处理涉及React UI的所有新项目。
由于我们的许多项目都基于服务器端Java堆栈,因此我们花了一些时间对Java生态系统的可用GraphQL库进行基准测试。我们最终决定采用GraphQL SPQR,因为我们认为它更适合我们的工作流程:它易于使用和配置,基于注释并可以很好地与Spring Boot配合使用。
GraphQL SPQR为我们提供了一组注释,可以轻松生成我们的模式,但是,作为项目的早期阶段,它不支持我们可能拥有的所有授权和身份验证需求。
授权与身份验证
这是不同的关注点,一个经典的问题,很多时候在互联网上得到了回答,但仍需要一个简短的提醒,以避免误解:
身份验证是确定某人确实是他们声称的那个人的过程。
授权是指确定允许谁做什么的规则。例如,Adam可能被授权创建和删除数据库,而Usama仅被授权阅读。
在我们的场景中,使用Spring Security处理身份验证,SPQR的Spring Boot程序创建了一个常规控制器,因此我们可以使用常规的Spring Security来管理身份验证。
我们也可以考虑使用 spring安全支持进行授权,但它不能满足我们的需求。我们需要向GraphQL客户端发送有关错误的大量信息,如果不使用自定义解决方案,则无法轻松完成。
GraphQL响应的解剖
每个GraphQL响应都是一个Map映射,包含规范中描述的一组键key。发生错误时,映射Map应包含一个键,指明是哪个Key发生错误,错误errors包含一些尝试完成请求时发生的错误列表。错误中可能存在的键也已经在规范中定义。
典型错误如下所示:
{ "errors": [ { "message": "Name for character with ID 1002 could not be fetched.", "locations": [ { "line": 6, "column": 7 } ], "path": [ "hero", "heroFriends", 1, "name" ], "extensions": { "code": "CAN_NOT_FETCH_BY_ID", "timestamp": "Fri Feb 9 14:33:09 UTC 2018" } } ] } |
GraphQL规范为我们提供了一种包含附加信息的机制:我们可以添加extensions键并在错误中包含自定义值的映射Map。
将GraphQL错误发送到客户端
现在我们知道规范允许服务器向GraphQL错误添加自定义字段。我们只需要知道如何使用SPQR来做到这一点。
SPQR使用GraphQL Java。SPQR为我们生成模式,允许我们使用代码优先方法并快速开发GraphQL API,但查询和变更的执行由GraphQL Java直接处理。因此,GraphQL Java也可以执行错误处理。
在解析器方法中抛出异常时,该异常将由ExecutionStrategy(GraphQL Java的一部分)处理。您可以完全自定义您的ExecutionStrategy任务以完成异常需要完成的任务,但GraphQL Java的作者在默认实现方面做得非常好,并且提供了以简单方式提供自定义错误的支持。
GraphQL Java提供了一个可以通过我们的异常实现的GraphQLError接口。
ExecutionStrategy(同步,异步和批处理)的所有默认实现都用SimpleDataFetcherExceptionHandler处理异常。
该类最终创建一个ExceptionWhileDataFetching,使用您包含在GraphQLError的信息来编写错误响应:
package graphql; public class ExceptionWhileDataFetching implements GraphQLError { private final String message; private final List<Object> path; private final Throwable exception; private final List<SourceLocation> locations; private final Map<String, Object> extensions; public ExceptionWhileDataFetching(ExecutionPath path, Throwable exception, SourceLocation sourceLocation) { this.path = assertNotNull(path).toList(); this.exception = assertNotNull(exception); this.locations = Collections.singletonList(sourceLocation); this.extensions = mkExtensions(exception); this.message = mkMessage(path, exception); } private String mkMessage(ExecutionPath path, Throwable exception) { return format("Exception while fetching data (%s) : %s", path, exception.getMessage()); } /* * This allows a DataFetcher to throw a graphql error and have "extension data" * be transferred from that exception into the ExceptionWhileDataFetching error * and hence have custom "extension attributes" per error message. */ private Map<String, Object> mkExtensions(Throwable exception) { Map<String, Object> extensions = null; if (exception instanceof GraphQLError) { Map<String, Object> map = ((GraphQLError) exception).getExtensions(); if (map != null) { extensions = new LinkedHashMap<>(); extensions.putAll(map); } } return extensions; } public Throwable getException() { return exception; } @Override public String getMessage() { return message; } @Override public List<SourceLocation> getLocations() { return locations; } @Override public List<Object> getPath() { return path; } @Override public Map<String, Object> getExtensions() { return extensions; } @Override public ErrorType getErrorType() { return ErrorType.DataFetchingException; } @Override public String toString() { return "ExceptionWhileDataFetching{" + "path=" + path + "exception=" + exception + "locations=" + locations + '}'; } @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") @Override public boolean equals(Object o) { return GraphqlErrorHelper.equals(this, o); } @Override public int hashCode() { return GraphqlErrorHelper.hashCode(this); } } |
这种行为允许我们生成自定义的,格式正确的GraphQL错误,你可能以为只是抛出某些异常,似乎不是什么大不了的事,但如果结合一些AOP魔术,它打开了一个错误管理可能性的世界。
用于授权的自定义注释
让我们从典型SPQR解析器的示例开始:
@GraphQLQuery public SomeObject someQuery(@GraphQLId String someId, @GraphQLRootContext ContextObject context) { return someService.getSomeObject(someId); } |
我们在解析器中包含一个上下文对象。我们假设在项目中的ServletContextFactory为每个请求创建了一个上下文对象。此上下文对象包含有关应用程序当前用户的信息(例如,可以使用JWT令牌中包含的信息创建上下文对象)。
我们需要定义一个自定义注释,以便在需要执行授权逻辑时使用:
package io.trabe.blog.graphqldemo.aop.annotations; @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) public @interface CheckPrivileges { String value() default ""; } |
并注释我们的示例解析器:
@GraphQLQuery @CheckPrivileges public SomeObject someQuery(@GraphQLId String someId, @GraphQLRootContext ContextObject context) { return someService.getSomeObject(someId); } |
我们的解析器完成了。这个难题中唯一缺失的部分是AOP方面本身。
AOP授权
授权是一个复杂的主题。授权逻辑可以根据场景而变化。让我们假设这个故事在我们的场景中,我们需要检查使用服务方法检索的对象的某个字段的值。对于这种情况,我们可以使用Around建议执行授权:
package io.trabe.blog.graphqldemo.aop; @Aspect @Component public class CheckPrivilegesAspect { private final ParameterNameDiscoverer parameterNameDiscoverer; @Autowired public CheckPrivilegesAspect(ParameterNameDiscoverer parameterNameDiscoverer) { this.parameterNameDiscoverer = parameterNameDiscoverer; } @Pointcut("execution(@io.trabe.blog.graphqldemo.aop.annotations.CheckPrivileges * *(..))") public void methodAnnotatedWithCheckPrivileges() { } @Pointcut("execution(public * *(..))") public void publicMethod() { } @Pointcut("execution(@io.leangen.graphql.annotations.GraphQLQuery * *(..))") public void methodAnnotatedWithGraphQLQuery() { } @Pointcut("execution(@io.leangen.graphql.annotations.GraphQLMutation * *(..))") public void methodAnnotatedWithGraphQLMutation() { } @Around("publicMethod() && methodAnnotatedWithCheckPrivileges() &&" + "(methodAnnotatedWithGraphQLQuery() || methodAnnotatedWithGraphQLMutation())") public Object requirePermission(ProceedingJoinPoint joinPoint) throws Throwable { Optional<ContextObject> contextOptional = getParamByType(joinPoint, ContextObject.class); if (!contextOptional.isPresent()) { throw new RuntimeException("Cannot check privileges: missing parameters!!"); } // Execute the advised method SomeObject someObject = (SomeObject) joinPoint.proceed(); // Check the required condition if (someObject.getSomeField().equals(contextOptional.get().getExpectedValue())) { return someObject; } // Unauthorized Query, throw graphql error throw new GraphQLExceptionWithErrorInformation( ErrorInformation.UNAUTHORIZED_ACCESS, "Error message"); } private <T> Optional<T> getParamByType(JoinPoint joinPoint, Class<T> paramClass) { try { for (Map.Entry<String, Object> param : getAllParams(joinPoint).entrySet()) { if (paramClass.isInstance(param.getValue())) { return Optional.of((T) param.getValue()); } } return Optional.empty(); } catch (NoSuchMethodException e) { return Optional.empty(); } } private Map<String, Object> getAllParams(JoinPoint joinPoint) throws NoSuchMethodException { Map<String, Object> params = new HashMap<>(); for (String param : parameterNameDiscoverer.getParameterNames( getMethod(joinPoint))) { params.put(param, getParam(joinPoint, param).orElse(null)); } return params; } private Optional<Object> getParam(JoinPoint joinPoint, String paramName) { try { String[] paramNames = parameterNameDiscoverer.getParameterNames(getMethod(joinPoint)); Object[] values = joinPoint.getArgs(); for (int i = 0; i < paramNames.length; i++) { if (paramName.equals(paramNames[i])) { if (values[i] != null) { return Optional.of(values[i]); } else { return Optional.empty(); } } } return Optional.empty(); } catch (NoSuchMethodException e) { return Optional.empty(); } } } |
让我们来看看关键点:
- 我们首先定义切入点Pointcuts,这是用于AOP方面的。
- 然后,我们使用这些切入点Pointcuts来定义的Around,将建议标注的每一个公共方法@CheckPrivileges和@GraphQLQuery或@GraphQLMutation。
- 在这种情况下,授权逻辑本身很简单:我们执行建议的方法,然后检查给定字段的值。
当然,我们仍然缺少一些。在我们的示例中,我们使用自定义异常:
package io.trabe.blog.graphqldemo.exceptions; public class GraphQLExceptionWithErrorInformation extends RuntimeException implements GraphQLError { private final ErrorInformation errorInformation; public GraphQLExceptionWithErrorInformation(ErrorInformation errorInformation, String message) { super(message); this.errorInformation = errorInformation; } @Override public List<SourceLocation> getLocations() { return null; } @Override public ErrorType getErrorType() { return null; } @Override public Map<String, Object> getExtensions() { Map<String, Object> errorsMap = new HashMap<>(); errorsMap.put("errorType", errorInformation.toString()); errorsMap.put("message", errorInformation.getMessageKey()); return errorsMap; } } |
GraphQLError异常实现是允许我们使用自定义扩展自定义GraphQL错误的关键点。构造函数接收ErrorInformation枚举。这种设计允许我们对不同类型的错误使用相同的异常。在这种情况下,枚举的实现将是:
package io.trabe.blog.graphqldemo.exceptions ; public enum ErrorInformation { UNAUTHORIZED_ACCESS(“ errors.unauthorizedAccess ”); private String messageKey; ErrorInformation(String messageKey){ 这个。messageKey = messageKey; } public String getMessageKey(){ return messageKey; } } |
有了所有部分,生成的错误响应SimpleDataFetcherExceptionHandler将包括extensions Map映射内的两个键:
- 具有错误类型的字段。
- 一个字段,其中包含我们的UI将在错误发生时向用户显示的消息的键。
总结
我们开发了一个基于AOP的解决方案,为SPQR解析器生成自定义GraphQL错误。此解决方案不仅限于授权,还可用于各种错误生成场景,使我们能够将代码集中在主要关注点上,并将错误生成提取到特定于目的的方面。