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}