io.github.microcks.web.GraphQLController Maven / Gradle / Ivy
The newest version!
/*
* Copyright The Microcks Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.microcks.web;
import io.github.microcks.domain.Header;
import io.github.microcks.domain.Operation;
import io.github.microcks.domain.ParameterConstraint;
import io.github.microcks.domain.Resource;
import io.github.microcks.domain.ResourceType;
import io.github.microcks.domain.Response;
import io.github.microcks.domain.Service;
import io.github.microcks.repository.ResourceRepository;
import io.github.microcks.repository.ResponseRepository;
import io.github.microcks.repository.ServiceRepository;
import io.github.microcks.repository.ServiceStateRepository;
import io.github.microcks.service.ProxyService;
import io.github.microcks.util.DispatchCriteriaHelper;
import io.github.microcks.util.DispatchStyles;
import io.github.microcks.util.IdBuilder;
import io.github.microcks.util.ParameterConstraintUtil;
import io.github.microcks.util.SafeLogger;
import io.github.microcks.util.dispatcher.FallbackSpecification;
import io.github.microcks.util.dispatcher.JsonEvaluationSpecification;
import io.github.microcks.util.dispatcher.JsonExpressionEvaluator;
import io.github.microcks.util.dispatcher.JsonMappingException;
import io.github.microcks.util.dispatcher.ProxyFallbackSpecification;
import io.github.microcks.util.graphql.GraphQLHttpRequest;
import io.github.microcks.util.script.ScriptEngineBinder;
import io.github.microcks.service.ServiceStateStore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.language.Argument;
import graphql.language.Definition;
import graphql.language.Document;
import graphql.language.Field;
import graphql.language.FragmentDefinition;
import graphql.language.FragmentSpread;
import graphql.language.OperationDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import graphql.language.StringValue;
import graphql.language.VariableReference;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import graphql.parser.Parser;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpServletRequest;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* A controller for mocking GraphQL responses.
* @author laurent
*/
@org.springframework.web.bind.annotation.RestController
@RequestMapping("/graphql")
public class GraphQLController {
/** A safe logger for filtering user-controlled data in diagnostic messages. */
private static final SafeLogger log = SafeLogger.getLogger(GraphQLController.class);
private static final String INTROSPECTION_SELECTION = "__schema";
private static final String TYPENAME_SELECTION = "__typename";
private static final Set IGNORED_HEADERS = Set.of("transfer-encoding", "content-length");
private final ServiceRepository serviceRepository;
private final ServiceStateRepository serviceStateRepository;
private final ResponseRepository responseRepository;
private final ResourceRepository resourceRepository;
private final ApplicationContext applicationContext;
private final ProxyService proxyService;
@Value("${mocks.enable-invocation-stats}")
private Boolean enableInvocationStats;
private final Parser requestParser = new Parser();
private final SchemaParser schemaParser = new SchemaParser();
private final SchemaGenerator schemaGenerator = new SchemaGenerator();
private final ObjectMapper mapper = new ObjectMapper();
/**
* Build a GraphQLController with required dependencies.
* @param serviceRepository The repository to access services definitions
* @param serviceStateRepository The repository to access service state
* @param responseRepository The repository to access responses definitions
* @param resourceRepository The repository to access resources artifacts
* @param applicationContext The Spring application context
* @param proxyService The proxy to external URLs or services
*/
public GraphQLController(ServiceRepository serviceRepository, ServiceStateRepository serviceStateRepository,
ResponseRepository responseRepository, ResourceRepository resourceRepository,
ApplicationContext applicationContext, ProxyService proxyService) {
this.serviceRepository = serviceRepository;
this.serviceStateRepository = serviceStateRepository;
this.responseRepository = responseRepository;
this.resourceRepository = resourceRepository;
this.applicationContext = applicationContext;
this.proxyService = proxyService;
}
@RequestMapping(value = "/{service}/{version}/**", method = { RequestMethod.GET, RequestMethod.POST })
public ResponseEntity> execute(@PathVariable("service") String serviceName,
@PathVariable("version") String version, @RequestParam(value = "delay", required = false) Long delay,
@RequestBody(required = false) String body, @RequestHeader HttpHeaders headers, HttpServletRequest request,
HttpMethod method) {
log.info("Servicing GraphQL mock response for service [{}, {}]", serviceName, version);
log.debug("Request body: {}", body);
long startTime = System.currentTimeMillis();
// Setup serviceAndVersion for proxy dispatchers
String serviceAndVersion = MockControllerCommons.composeServiceAndVersion(serviceName, version);
// If serviceName was encoded with '+' instead of '%20', remove them.
if (serviceName.contains("+")) {
serviceName = serviceName.replace('+', ' ');
}
Service service = serviceRepository.findByNameAndVersion(serviceName, version);
if (service == null) {
return new ResponseEntity<>(
String.format("The service %s with version %s does not exist!", serviceName, version),
HttpStatus.NOT_FOUND);
}
GraphQLHttpRequest graphqlHttpReq;
Document graphqlRequest;
try {
graphqlHttpReq = GraphQLHttpRequest.from(body, request);
graphqlRequest = requestParser.parseDocument(graphqlHttpReq.getQuery());
} catch (Exception e) {
log.error("Error parsing GraphQL request: {}", e.getMessage());
return new ResponseEntity<>("Error parsing GraphQL request: " + e.getMessage(), HttpStatus.BAD_REQUEST);
}
Definition> graphqlDef = graphqlRequest.getDefinitions().get(0);
OperationDefinition graphqlOperation = (OperationDefinition) graphqlDef;
log.debug("Got this graphqlOperation: {}", graphqlOperation);
// Operation type is direct but name depends on syntax (it can be a composite one). Better to use the names of selections...
String operationType = graphqlOperation.getOperation().toString();
// Check is it's an introspection query to handle first.
if ("QUERY".equals(operationType) && INTROSPECTION_SELECTION
.equals(((Field) graphqlOperation.getSelectionSet().getSelections().get(0)).getName())) {
log.info("Handling GraphQL schema introspection query...");
Resource graphqlSchema = resourceRepository
.findByServiceIdAndType(service.getId(), ResourceType.GRAPHQL_SCHEMA).get(0);
TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(graphqlSchema.getContent());
GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry,
RuntimeWiring.MOCKED_WIRING);
GraphQL graphQL = GraphQL.newGraphQL(graphQLSchema).build();
ExecutionResult executionResult = graphQL.execute(graphqlHttpReq.getQuery());
String responseContent = null;
try {
responseContent = mapper.writeValueAsString(executionResult);
} catch (JsonProcessingException jpe) {
log.error("Unknown Json processing exception", jpe);
return new ResponseEntity<>(jpe.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
return new ResponseEntity<>(responseContent, HttpStatus.OK);
}
// Then deal with one or many regular GraphQL selection queries.
List graphqlResponses = new ArrayList<>();
final Long[] maxDelay = { (delay == null ? 0L : delay) };
for (Selection> selection : graphqlOperation.getSelectionSet().getSelections()) {
try {
GraphQLQueryResponse graphqlResponse = processGraphQLQuery(service, operationType, (Field) selection,
graphqlRequest.getDefinitionsOfType(FragmentDefinition.class), body, graphqlHttpReq, request,
serviceAndVersion);
if (graphqlResponse.getProxyUrl() != null) {
// If we've got a proxyUrl, that's the moment!
return proxyService.callExternal(graphqlResponse.getProxyUrl(), method, headers, body);
}
graphqlResponses.add(graphqlResponse);
if (graphqlResponse.getOperationDelay() != null && graphqlResponse.getOperationDelay() > maxDelay[0]) {
maxDelay[0] = graphqlResponse.getOperationDelay();
}
} catch (GraphQLQueryProcessingException e) {
log.error("Caught a GraphQL processing exception", e);
return new ResponseEntity<>(e.getMessage(), e.getStatus());
}
}
/*
* Optimized parallel version but need to better handle exception. graphqlResponses =
* graphqlOperation.getSelectionSet().getSelections().stream().parallel().map(selection -> { try {
* GraphQLQueryResponse graphqlResponse = processGraphQLQuery(service, operationType, (Field) selection,
* graphqlRequest.getDefinitionsOfType(FragmentDefinition.class), body, graphqlHttpReq, request); if
* (graphqlResponse.getOperationDelay() != null && graphqlResponse.getOperationDelay() > maxDelay[0]) {
* maxDelay[0] = graphqlResponse.getOperationDelay(); } return graphqlResponse; } catch
* (GraphQLQueryProcessingException e) { log.error("Caught a GraphQL processing exception", e); return null; }
* }).collect(Collectors.toList());
*/
// Deal with response headers.
HttpHeaders responseHeaders = new HttpHeaders();
for (GraphQLQueryResponse response : graphqlResponses) {
if (response.getResponse() != null && response.getResponse().getHeaders() != null) {
for (Header header : response.getResponse().getHeaders()) {
if (!IGNORED_HEADERS.contains(header.getName().toLowerCase())) {
responseHeaders.put(header.getName(), new ArrayList<>(header.getValues()));
}
}
}
}
if (!responseHeaders.containsKey("Content-Type") && !responseHeaders.containsKey("content-type")) {
responseHeaders.put("Content-Type", List.of("application/json"));
}
// Waiting for delay if any.
MockControllerCommons.waitForDelay(startTime, maxDelay[0]);
// Publish an invocation event before returning if enabled.
if (Boolean.TRUE.equals(enableInvocationStats)) {
for (GraphQLQueryResponse response : graphqlResponses) {
// If it's not a __typename query, we might have a response, publish the invocation.
if (response.getResponse() != null) {
MockControllerCommons.publishMockInvocation(applicationContext, this, service, response.getResponse(),
startTime);
}
}
}
String responseContent = null;
JsonNode responseNode = graphqlResponses.get(0).getJsonResponse();
// If multi-queries and aliases were used, recompose an aggregated result.
if (graphqlResponses.size() > 1) {
ObjectNode aggregated = mapper.createObjectNode();
ObjectNode dataNode = aggregated.putObject("data");
for (GraphQLQueryResponse response : graphqlResponses) {
dataNode.set(StringUtils.defaultIfBlank(response.getAlias(), response.getOperationName()),
response.getJsonResponse().path("data").path(response.getOperationName()).deepCopy());
}
responseNode = aggregated;
}
try {
responseContent = mapper.writeValueAsString(responseNode);
} catch (JsonProcessingException e) {
log.error("Unknown Json processing exception", e);
return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
return new ResponseEntity<>(responseContent, responseHeaders, HttpStatus.OK);
}
/**
* Process a GraphQL field selection query (an Http query may contain many field selection queries).
* @param service The Service this query is targeting
* @param operationType The type of GraphQL operation (QUERY or MUTATION)
* @param graphqlField The Field selection we should apply
* @param fragmentDefinitions A list of fragment field selection
* @param body The Http request body
* @param graphqlHttpReq The Http GraphQL request wrapper
* @param request The bare Http Servlet request
* @param serviceAndVersion The composed serviceAndVersion string for the proxy checking
* @return A GraphQL query response wrapper with some elements from the Microcks domain matching Response
* @throws GraphQLQueryProcessingException if incoming field selection query cannot be processed
*/
protected GraphQLQueryResponse processGraphQLQuery(Service service, String operationType, Field graphqlField,
List fragmentDefinitions, String body, GraphQLHttpRequest graphqlHttpReq,
HttpServletRequest request, String serviceAndVersion) throws GraphQLQueryProcessingException {
GraphQLQueryResponse result = new GraphQLQueryResponse();
String operationName = graphqlField.getName();
result.setAlias(graphqlField.getAlias());
result.setOperationName(operationName);
log.debug("Processing a '{}' operation with name '{}'", operationType, operationName);
if (TYPENAME_SELECTION.equals(operationName)) {
log.debug("Handling GraphQL __typename query...");
ObjectNode typenameResponse = mapper.createObjectNode();
ObjectNode dataNode = typenameResponse.putObject("data");
dataNode.put(TYPENAME_SELECTION, "QUERY".equalsIgnoreCase(operationType) ? "Query" : "Mutation");
result.setOperationName(TYPENAME_SELECTION);
result.setJsonResponse(typenameResponse);
return result;
}
Operation rOperation = null;
for (Operation operation : service.getOperations()) {
// Select operation based on type (QUERY or MUTATION)...
// ... then check the operation name itself.
if (operation.getMethod().equals(operationType) && operation.getName().equals(operationName)) {
rOperation = operation;
break;
}
}
if (rOperation != null) {
log.debug("Found a valid operation {} with rules: {}", rOperation.getName(), rOperation.getDispatcherRules());
String violationMsg = validateParameterConstraintsIfAny(rOperation, request);
if (violationMsg != null) {
throw new GraphQLQueryProcessingException(violationMsg + ". Check parameter constraints.",
HttpStatus.BAD_REQUEST);
}
// We must find dispatcher and its rules. Default to operation ones but
// if we have a Fallback this is the one who is holding the first pass rules.
String dispatcher = rOperation.getDispatcher();
String dispatcherRules = rOperation.getDispatcherRules();
FallbackSpecification fallback = MockControllerCommons.getFallbackIfAny(rOperation);
if (fallback != null) {
dispatcher = fallback.getDispatcher();
dispatcherRules = fallback.getDispatcherRules();
}
ProxyFallbackSpecification proxyFallback = MockControllerCommons.getProxyFallbackIfAny(rOperation);
if (proxyFallback != null) {
dispatcher = proxyFallback.getDispatcher();
dispatcherRules = proxyFallback.getDispatcherRules();
}
//
DispatchContext dispatchContext = computeDispatchCriteria(service, dispatcher, dispatcherRules, graphqlField,
graphqlHttpReq.getVariables(), request, body);
log.debug("Dispatch criteria for finding response is {}", dispatchContext.dispatchCriteria());
// First try: using computed dispatchCriteria on main dispatcher.
Response response = null;
List responses = responseRepository.findByOperationIdAndDispatchCriteria(
IdBuilder.buildOperationId(service, rOperation), dispatchContext.dispatchCriteria());
if (!responses.isEmpty()) {
response = responses.get(0);
}
if (response == null) {
// When using the SCRIPT dispatcher, return of evaluation may be the name of response.
log.debug("No responses with dispatch criteria, trying the name...");
responses = responseRepository.findByOperationIdAndName(IdBuilder.buildOperationId(service, rOperation),
dispatchContext.dispatchCriteria());
if (!responses.isEmpty()) {
response = responses.get(0);
}
}
if (response == null && fallback != null) {
// If we've found nothing and got a fallback, that's the moment!
log.debug("No responses till now so far, applying the fallback...");
responses = responseRepository.findByOperationIdAndName(IdBuilder.buildOperationId(service, rOperation),
fallback.getFallback());
if (!responses.isEmpty()) {
response = responses.get(0);
}
}
Optional proxyUrl = MockControllerCommons.getProxyUrlIfProxyIsNeeded(dispatcher, dispatcherRules,
MockControllerCommons.extractResourcePath(request, serviceAndVersion), proxyFallback, request, response);
if (proxyUrl.isPresent()) {
// If we've got a proxyUrl, that's the moment to tell about it!
result.setProxyUrl(proxyUrl.get());
return result;
}
if (response == null) {
// In case no response found (because dispatcher is null for example), just get one for the operation.
// This will allow also OPTIONS operations (like pre-flight requests) with no dispatch criteria to work.
log.debug("No responses found so far, tempting with just bare operationId...");
responses = responseRepository.findByOperationId(IdBuilder.buildOperationId(service, rOperation));
if (!responses.isEmpty()) {
response = responses.get(0);
}
}
if (response != null) {
result.setResponse(response);
result.setOperationDelay(rOperation.getDefaultDelay());
// Prepare headers for evaluation.
Map evaluableHeaders = new HashMap<>();
if (response.getHeaders() != null) {
for (Header header : response.getHeaders()) {
evaluableHeaders.put(header.getName(), request.getHeader(header.getName()));
}
}
// Render response content before waiting and returning.
String responseContent = MockControllerCommons.renderResponseContent(body, null, evaluableHeaders,
dispatchContext.requestContext(), response);
try {
JsonNode responseJson = mapper.readTree(responseContent);
filterFieldSelection(graphqlField.getSelectionSet(), fragmentDefinitions,
responseJson.get("data").get(operationName));
result.setJsonResponse(responseJson);
} catch (JsonProcessingException pe) {
log.error("JsonProcessingException while filtering response according GraphQL field selection", pe);
throw new GraphQLQueryProcessingException("Exception while filtering response JSON",
HttpStatus.INTERNAL_SERVER_ERROR);
}
return result;
}
log.debug("No response found. Throwing a BAD_REQUEST exception...");
throw new GraphQLQueryProcessingException("No matching response found", HttpStatus.BAD_REQUEST);
}
log.debug("No valid operation found. Throwing a NOT_FOUND exception...");
throw new GraphQLQueryProcessingException("No '" + operationName + "' operation found", HttpStatus.NOT_FOUND);
}
/** Validate the parameter constraints and return a single string with violation message if any. */
private String validateParameterConstraintsIfAny(Operation rOperation, HttpServletRequest request) {
if (rOperation.getParameterConstraints() != null) {
for (ParameterConstraint constraint : rOperation.getParameterConstraints()) {
String violationMsg = ParameterConstraintUtil.validateConstraint(request, constraint);
if (violationMsg != null) {
return violationMsg;
}
}
}
return null;
}
/** Compute a dispatch context with a dispatchCriteria string from type, rules and request elements. */
private DispatchContext computeDispatchCriteria(Service service, String dispatcher, String dispatcherRules,
Selection> graphqlSelection, JsonNode requestVariables, HttpServletRequest request, String body) {
String dispatchCriteria = null;
Map requestContext = null;
// Depending on dispatcher, evaluate request with rules.
if (dispatcher != null) {
switch (dispatcher) {
case DispatchStyles.SCRIPT:
ScriptEngineManager sem = new ScriptEngineManager();
requestContext = new HashMap<>();
try {
// Evaluating request with script coming from operation dispatcher rules.
ScriptEngine se = sem.getEngineByExtension("groovy");
ScriptEngineBinder.bindEnvironment(se, body, requestContext,
new ServiceStateStore(serviceStateRepository, service.getId()), request);
dispatchCriteria = (String) se.eval(dispatcherRules);
} catch (Exception e) {
log.error("Error during Script evaluation", e);
}
break;
case DispatchStyles.QUERY_ARGS:
Field field = (Field) graphqlSelection;
Map queryParams = new HashMap<>();
for (Argument arg : field.getArguments()) {
if (arg.getValue() instanceof StringValue stringArg) {
queryParams.put(arg.getName(), stringArg.getValue());
} else if (arg.getValue() instanceof VariableReference varRef && requestVariables != null) {
queryParams.put(arg.getName(), requestVariables.path(varRef.getName()).asText(""));
}
}
dispatchCriteria = DispatchCriteriaHelper.extractFromParamMap(dispatcherRules, queryParams);
break;
case DispatchStyles.JSON_BODY:
try {
JsonEvaluationSpecification specification = JsonEvaluationSpecification
.buildFromJsonString(dispatcherRules);
dispatchCriteria = JsonExpressionEvaluator.evaluate(mapper.writeValueAsString(requestVariables),
specification);
} catch (JsonMappingException jme) {
log.error("Dispatching rules of operation cannot be interpreted as JsonEvaluationSpecification", jme);
} catch (JsonProcessingException jpe) {
log.error("Request variables cannot be serialized as Json for evaluation", jpe);
}
break;
}
}
return new DispatchContext(dispatchCriteria, requestContext);
}
/**
* Apply a FieldSelection filter on Json node.
* @param selectionSet The set of selections to apply
* @param fragmentDefinitions A list of fragment field selection
* @param node The Json node to apply on
*/
protected void filterFieldSelection(SelectionSet selectionSet, List fragmentDefinitions,
JsonNode node) {
// Stop condition: no more selection to apply.
if (selectionSet == null || selectionSet.getSelections() == null || selectionSet.getSelections().isEmpty()) {
return;
}
switch (node.getNodeType()) {
case OBJECT:
// We must retain properties corresponding to field selection
// and recurse on each retrained object property.
List properties = new ArrayList<>();
for (Selection> selection : selectionSet.getSelections()) {
if (selection instanceof Field fieldSelection) {
filterFieldSelection(fieldSelection.getSelectionSet(), fragmentDefinitions,
node.get(fieldSelection.getName()));
properties.add(fieldSelection.getName());
} else if (selection instanceof FragmentSpread fragmentSpread) {
// FragmentSpread is an indirection to selection find in definitions.
FragmentDefinition fragmentDef = fragmentDefinitions.stream()
.filter(def -> def.getName().equals(fragmentSpread.getName())).findFirst().orElse(null);
if (fragmentDef != null) {
filterFieldSelection(fragmentDef.getSelectionSet(), fragmentDefinitions, node);
}
}
}
// Only filter if properties to retain.
if (!properties.isEmpty()) {
((ObjectNode) node).retain(properties);
}
break;
case ARRAY:
// We must apply selection on each element of the array.
Iterator children = node.elements();
while (children.hasNext()) {
filterFieldSelection(selectionSet, fragmentDefinitions, children.next());
}
break;
default:
break;
}
}
/** Simple wrapper around a GraphQL query response. */
protected class GraphQLQueryResponse {
String operationName;
String alias;
Long operationDelay;
Response response;
JsonNode jsonResponse;
URI proxyUrl;
public String getOperationName() {
return operationName;
}
public void setOperationName(String operationName) {
this.operationName = operationName;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public Long getOperationDelay() {
return operationDelay;
}
public void setOperationDelay(Long operationDelay) {
this.operationDelay = operationDelay;
}
public JsonNode getJsonResponse() {
return jsonResponse;
}
public void setJsonResponse(JsonNode jsonResponse) {
this.jsonResponse = jsonResponse;
}
public Response getResponse() {
return response;
}
public void setResponse(Response response) {
this.response = response;
}
public URI getProxyUrl() {
return proxyUrl;
}
public void setProxyUrl(URI proxyUrl) {
this.proxyUrl = proxyUrl;
}
}
/** Simple exception wrapping a processing error. */
protected static class GraphQLQueryProcessingException extends Exception {
final HttpStatus status;
public HttpStatus getStatus() {
return status;
}
public GraphQLQueryProcessingException(String message, HttpStatus status) {
super(message);
this.status = status;
}
}
}