001// -------------------------------------------------------------------------------- 002// Copyright 2002-2026 Echo Three, LLC 003// 004// Licensed under the Apache License, Version 2.0 (the "License"); 005// you may not use this file except in compliance with the License. 006// You may obtain a copy of the License at 007// 008// http://www.apache.org/licenses/LICENSE-2.0 009// 010// Unless required by applicable law or agreed to in writing, software 011// distributed under the License is distributed on an "AS IS" BASIS, 012// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013// See the License for the specific language governing permissions and 014// limitations under the License. 015// -------------------------------------------------------------------------------- 016 017package com.echothree.control.user.graphql.server.command; 018 019import com.echothree.control.user.graphql.common.form.ExecuteGraphQlForm; 020import com.echothree.control.user.graphql.common.result.GraphQlResultFactory; 021import com.echothree.control.user.graphql.server.cache.GraphQlDocumentCache; 022import com.echothree.control.user.graphql.server.schema.util.GraphQlSchemaUtils; 023import com.echothree.control.user.graphql.server.strategy.GraphQlExecutionStrategy; 024import com.echothree.model.control.graphql.server.control.IdempotentResponseControl; 025import com.echothree.model.control.graphql.server.util.BaseGraphQl; 026import com.echothree.model.control.graphql.server.util.GraphQlExecutionContext; 027import com.echothree.model.data.graphql.server.entity.IdempotentResponse; 028import com.echothree.util.common.command.BaseResult; 029import com.echothree.util.common.message.ExecutionErrors; 030import com.echothree.util.common.string.GraphQlUtils; 031import com.echothree.util.common.validation.FieldDefinition; 032import com.echothree.util.common.validation.FieldType; 033import com.echothree.util.server.control.BaseSimpleCommand; 034import com.echothree.util.server.persistence.Sha256Utils; 035import com.google.gson.JsonParseException; 036import graphql.ExecutionInput; 037import graphql.ExecutionResult; 038import graphql.GraphQL; 039import graphql.GraphQLException; 040import graphql.execution.AbortExecutionException; 041import graphql.execution.instrumentation.InstrumentationContext; 042import graphql.execution.instrumentation.InstrumentationState; 043import graphql.execution.instrumentation.SimplePerformantInstrumentation; 044import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; 045import java.util.List; 046import java.util.Map; 047import java.util.Objects; 048import java.util.TreeMap; 049import javax.enterprise.context.Dependent; 050import javax.inject.Inject; 051 052@Dependent 053public class ExecuteGraphQlCommand 054 extends BaseSimpleCommand<ExecuteGraphQlForm> { 055 056 private final static List<FieldDefinition> FORM_FIELD_DEFINITIONS; 057 058 static { 059 FORM_FIELD_DEFINITIONS = List.of( 060 new FieldDefinition("ReadOnly", FieldType.BOOLEAN, true, null, null), 061 new FieldDefinition("IdempotencyKey", FieldType.STRING, false, null, null), 062 new FieldDefinition("Query", FieldType.STRING, false, 1L, null), 063 new FieldDefinition("Variables", FieldType.STRING, false, 1L, null), 064 new FieldDefinition("OperationName", FieldType.STRING, false, 1L, null), 065 new FieldDefinition("Json", FieldType.STRING, false, 1L, null), 066 // RemoteInet4Address is purposefully validated only as a string, as it's passed 067 // to other UCs that will validate it as an IPv4 address and format as necessary 068 // for use. 069 new FieldDefinition("RemoteInet4Address", FieldType.STRING, false, 1L, null) 070 ); 071 } 072 073 @Inject 074 protected IdempotentResponseControl idempotentResponseControl; 075 076 @Inject 077 Sha256Utils sha256Utils; 078 079 /** Creates a new instance of ExecuteGraphQlCommand */ 080 public ExecuteGraphQlCommand() { 081 super(null, FORM_FIELD_DEFINITIONS, false); 082 } 083 084 private static final String GRAPHQL_QUERY = "query"; 085 private static final String GRAPHQL_OPERATION_NAME = "operationName"; 086 private static final String GRAPHQL_VARIABLES = "variables"; 087 088 private static final long IDEMPOTENCY_RETENTION_MILLIS = 24 * 60 * 60 * 1000; // 24 Hours 089 090 protected static class IdempotencyState { 091 IdempotentResponse idempotentResponse; 092 String replayResponse; 093 } 094 095 // Object member order is insignificant; array order and explicit nulls are preserved. 096 private static Object canonicalize(Object value) { 097 Object result = value; 098 099 if(value instanceof Map<?, ?> map) { 100 var sorted = new TreeMap<String, Object>(); 101 102 map.forEach((key, member) -> sorted.put((String)key, canonicalize(member))); 103 result = sorted; 104 } else if(value instanceof List<?> list) { 105 result = list.stream().map(ExecuteGraphQlCommand::canonicalize).toList(); 106 } 107 108 return result; 109 } 110 111 protected String hashVariables(Map<String, Object> variables) { 112 String result = null; 113 114 if(variables != null && !variables.isEmpty()) { 115 @SuppressWarnings("unchecked") 116 var canonicalVariables = (Map<String, Object>)canonicalize(variables); 117 118 result = sha256Utils.hash(GraphQlUtils.getInstance().toJson(canonicalVariables)); 119 } 120 121 return result; 122 } 123 124 protected void beginIdempotency(final String idempotencyKey, final ExecutionInput input, final IdempotencyState idempotencyState) { 125 // This existing parent row provides a lock even when the idempotencyKey does not exist yet. 126 // The database holds it through transaction completion, including response storage. 127 var userVisit = getUserVisitForUpdate(); 128 129 var keyHash = sha256Utils.hash(idempotencyKey); 130 var queryHash = sha256Utils.hash(input.getQuery()); 131 var variablesHash = hashVariables(input.getVariables()); 132 var operationNameHash = input.getOperationName() == null ? null : sha256Utils.hash(input.getOperationName()); 133 var existingIdempotentResponse = idempotentResponseControl.getIdempotentResponseForUpdate(userVisit, keyHash); 134 135 if(existingIdempotentResponse != null) { 136 if(!Objects.equals(existingIdempotentResponse.getQueryHash(), queryHash) 137 || !Objects.equals(existingIdempotentResponse.getVariablesHash(), variablesHash) 138 || !Objects.equals(existingIdempotentResponse.getOperationNameHash(), operationNameHash)) { 139 throw new AbortExecutionException("Idempotency-Key was already used with a different request."); 140 } 141 142 if(existingIdempotentResponse.getCompletedTime() == null || existingIdempotentResponse.getResponse() == null) { 143 throw new AbortExecutionException("Idempotency-Key request has not completed. Retry later."); 144 } 145 146 idempotencyState.replayResponse = idempotentResponseControl.decodeIdempotentResponse(existingIdempotentResponse); 147 148 // GraphQL catches this before invoking any resolver. finishExecution replaces 149 // its synthetic error result with the original response verbatim. 150 throw new AbortExecutionException("Replaying completed idempotent request."); 151 } 152 153 idempotencyState.idempotentResponse = idempotentResponseControl.createIdempotentResponse(userVisit, keyHash, queryHash, variablesHash, 154 operationNameHash, session.getStartTime(), null, null, null); 155 156 // create() returns a read-only entity; retain a writable one for completion. 157 idempotencyState.idempotentResponse = Objects.requireNonNull(idempotentResponseControl.getIdempotentResponseForUpdate(userVisit, keyHash), 158 "Newly created idempotency claim was not found"); 159 } 160 161 protected String finishExecution(final ExecutionResult executionResult, final IdempotencyState idempotencyState) { 162 var replayResponse = idempotencyState.replayResponse; 163 164 if(replayResponse == null) { 165 var idempotentResponse = idempotencyState.idempotentResponse; 166 replayResponse = toJson(executionResult); 167 168 if(idempotentResponse != null) { 169 var completedTime = System.currentTimeMillis(); 170 171 idempotentResponse.setResponse(idempotentResponseControl.encodeIdempotentResponse(idempotentResponse, replayResponse)); 172 idempotentResponse.setCompletedTime(completedTime); 173 idempotentResponse.setRetainUntilTime(completedTime + IDEMPOTENCY_RETENTION_MILLIS); 174 idempotentResponse.store(); 175 } 176 } 177 178 return replayResponse; 179 } 180 181 public String toJson(final ExecutionResult executionResult) { 182 return GraphQlUtils.getInstance().toJson(executionResult.toSpecification()); 183 } 184 185 protected SimplePerformantInstrumentation getInstrumentation(final String idempotencyKey, final IdempotencyState idempotencyState) { 186 return new SimplePerformantInstrumentation() { 187 @Override 188 public InstrumentationContext<ExecutionResult> beginExecuteOperation( 189 InstrumentationExecuteOperationParameters parameters, 190 InstrumentationState state) { 191 var operation = parameters.getExecutionContext() 192 .getOperationDefinition() 193 .getOperation(); 194 195 switch(operation) { 196 case QUERY, SUBSCRIPTION -> { 197 // Selected operation is a query or subscription. 198 if(idempotencyKey != null) { 199 throw new AbortExecutionException("Idempotency-Key is supported only for mutation operations."); 200 } 201 } 202 case MUTATION -> { 203 // Selected operation is a mutation. 204 // Idempotency claim must succeed before execution continues. 205 if(idempotencyKey != null) { 206 beginIdempotency(idempotencyKey, parameters.getExecutionContext().getExecutionInput(), idempotencyState); 207 } 208 } 209 } 210 211 return super.beginExecuteOperation(parameters, state); 212 } 213 }; 214 } 215 216 @Override 217 protected BaseResult execute() { 218 var result = GraphQlResultFactory.getExecuteGraphQlResult(); 219 var idempotencyState = new IdempotencyState(); 220 221 try { 222 var readOnly = Boolean.parseBoolean(form.getReadOnly()); 223 var idempotencyKey = form.getIdempotencyKey(); 224 var query = form.getQuery(); 225 var variables = form.getVariables(); 226 var operationName = form.getOperationName(); 227 var json = form.getJson(); 228 229 Map<String, Object> parsedVariables = null; 230 if(variables != null) { 231 parsedVariables = GraphQlUtils.getInstance().toMap(variables); 232 } 233 234 if(json != null) { 235 var body = GraphQlUtils.getInstance().toMap(json); 236 var possibleQuery = body.get(GRAPHQL_QUERY); 237 var possibleOperationName = body.get(GRAPHQL_OPERATION_NAME); 238 var possibleVariables = body.get(GRAPHQL_VARIABLES); 239 240 // Validate supplied fields even when a form field takes precedence. 241 if(possibleQuery != null && !(possibleQuery instanceof String)) { 242 throw new GraphQLException("The query field must be a string."); 243 } 244 245 if(possibleOperationName != null && !(possibleOperationName instanceof String)) { 246 throw new GraphQLException("The operationName field must be a string or null."); 247 } 248 249 if(possibleVariables != null && !(possibleVariables instanceof Map)) { 250 throw new GraphQLException("The variables field must be a JSON object or null."); 251 } 252 253 // Query form field takes priority of Json's query. 254 if(possibleQuery != null && query == null) { 255 query = (String)possibleQuery; 256 } 257 258 // OperationName form field takes priority of Json's operationName. 259 if(possibleOperationName != null && operationName == null) { 260 operationName = (String)possibleOperationName; 261 } 262 263 // Variables form field takes priority of Json's variables. 264 if(possibleVariables != null && variables == null) { 265 parsedVariables = (Map<String, Object>)possibleVariables; 266 } 267 } 268 269 // query MUST be present. 270 if(query != null) { 271 var graphQL = GraphQL 272 .newGraphQL(readOnly? GraphQlSchemaUtils.getInstance().getReadOnlySchema() : GraphQlSchemaUtils.getInstance().getSchema()) 273 .queryExecutionStrategy(new GraphQlExecutionStrategy()) 274 .preparsedDocumentProvider(GraphQlDocumentCache.getInstance()) 275 .instrumentation(getInstrumentation(idempotencyKey, idempotencyState)) 276 .build(); 277 var graphQlExecutionContext = new GraphQlExecutionContext(getUserVisitPK(), getUserVisit(), 278 getUserSession(), form.getRemoteInet4Address()); 279 var builder = ExecutionInput.newExecutionInput() 280 .query(query) 281 .operationName(operationName) 282 .graphQLContext(Map.of(BaseGraphQl.GRAPHQL_EXECUTION_CONTEXT, graphQlExecutionContext)) 283 .root(new Object()); 284 285 if(parsedVariables != null) { 286 builder.variables(parsedVariables); 287 } 288 289 var executionResult = graphQL.execute(builder.build()); 290 291 result.setExecutionResult(finishExecution(executionResult, idempotencyState)); 292 } else { 293 addExecutionError(ExecutionErrors.InvalidParameterCount.name()); 294 } 295 } catch (JsonParseException jpe) { 296 if(idempotencyState.idempotentResponse != null) { 297 // Let the EJB container roll back the claim and any participating mutation 298 // changes together. Removing only the claim could commit changes that a retry repeats. 299 throw jpe; 300 } 301 302 addExecutionError(ExecutionErrors.JsonParseError.name(), jpe.getMessage()); 303 } catch (GraphQLException gqle) { 304 if(idempotencyState.idempotentResponse != null) { 305 // Let the EJB container roll back the claim and any participating mutation 306 // changes together. Returning an error normally could commit an incomplete claim. 307 throw gqle; 308 } 309 310 addExecutionError(ExecutionErrors.GraphQlError.name(), gqle.getMessage()); 311 } 312 313 return result; 314 } 315 316}