This is an automated email from the ASF dual-hosted git repository. pmouawad pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/jmeter.git
The following commit(s) were added to refs/heads/master by this push: new 82e56be BZ 64752 - Add GraphQL/HTTP Request Sampler (#627) 82e56be is described below commit 82e56beb04505af96d1e9ce327f67aa8eba99e06 Author: Woonsan Ko <woon...@users.noreply.github.com> AuthorDate: Sun Oct 4 05:06:56 2020 -0400 BZ 64752 - Add GraphQL/HTTP Request Sampler (#627) * BZ-64752: adding GraphQL HTTP Sampler GUI components * BZ-64752: javadocs * BZ-64752: fixing tab selection problem; disable tab validation in graphql ui * BZ-64752: (de)serializing test element with graphql query and vars * BZ-64752: (de)serializing using gson * BZ-64752: removing unnecessary GraphQLHTTPSampler * BZ-64752: adding operationName input field * BZ-64752: support GET method * BZ-64752: init operationName from test elem * BZ-64752: adding a simple graphql test plan demo * BZ-64752: show advanced pane * BZ-64752: add gson info to lib/aareadme.txt * BZ-64752: screenshot and default constructor * BZ-64752: documentation on GraphQLHTTPRequest * BZ-64752: record in changes.xml * BZ-64752: add gson.jar to expected_release_jars.csv * BZ-64752: removing unnecessary, untranslated messages * BZ-64752: utility for graphql param serialization and unit test * BZ-64752: replace gson with jackson for graphql (de)serialization * BZ-64752: remove gson jar from expected release jars * BZ-64752: correcting French translation, thanks to pmouawad * BZ-64752: graphql http recording support * BZ-64752: checkbox option to switch on/off auto graphql req detection, true by default * BZ-64752: precise json content type checking; encode in GET * BZ-64752: French translation for graphql recording option, thanks to @ubikloadpack Co-authored-by: Woonsan Ko <woonsan...@bloomreach.com> --- bin/saveservice.properties | 1 + .../java/org/apache/jmeter/save/SaveService.java | 2 +- .../org/apache/jmeter/gui/util/textarea.properties | 1 + .../apache/jmeter/resources/messages.properties | 7 + .../apache/jmeter/resources/messages_fr.properties | 7 + src/protocol/build.gradle.kts | 2 + .../protocol/http/config/GraphQLRequestParams.java | 67 +++++ .../config/gui/AbstractValidationTabbedPane.java | 86 +++++++ .../http/config/gui/GraphQLUrlConfigGui.java | 190 ++++++++++++++ .../http/config/gui/UrlConfigDefaults.java | 272 +++++++++++++++++++++ .../protocol/http/config/gui/UrlConfigGui.java | 129 +++++----- .../protocol/http/control/HeaderManager.java | 20 ++ .../http/control/gui/GraphQLHTTPSamplerGui.java | 75 ++++++ .../http/control/gui/HttpTestSampleGui.java | 62 +++-- .../protocol/http/proxy/DefaultSamplerCreator.java | 44 ++++ .../jmeter/protocol/http/proxy/HttpRequestHdr.java | 18 ++ .../apache/jmeter/protocol/http/proxy/Proxy.java | 2 + .../jmeter/protocol/http/proxy/ProxyControl.java | 12 + .../protocol/http/proxy/gui/ProxyControlGui.java | 22 ++ .../http/util/GraphQLRequestParamUtils.java | 254 +++++++++++++++++++ .../sampler/GraphQLHTTPSamplerResources.properties | 27 ++ .../http/util/TestGraphQLRequestParamUtils.java | 212 ++++++++++++++++ xdocs/changes.xml | 1 + xdocs/demos/SimpleGraphQLTestPlan.jmx | 135 ++++++++++ .../screenshots/graphql-http-request-vars.png | Bin 0 -> 110286 bytes xdocs/images/screenshots/graphql-http-request.png | Bin 0 -> 115587 bytes xdocs/usermanual/component_reference.xml | 28 ++- 27 files changed, 1596 insertions(+), 80 deletions(-) diff --git a/bin/saveservice.properties b/bin/saveservice.properties index 6f0b44b..b2ec167 100644 --- a/bin/saveservice.properties +++ b/bin/saveservice.properties @@ -150,6 +150,7 @@ GaussianRandomTimerGui=org.apache.jmeter.timers.gui.GaussianRandomTimerGui GenericController=org.apache.jmeter.control.GenericController GraphAccumVisualizer=org.apache.jmeter.visualizers.GraphAccumVisualizer GraphVisualizer=org.apache.jmeter.visualizers.GraphVisualizer +GraphQLHTTPSamplerGui=org.apache.jmeter.protocol.http.control.gui.GraphQLHTTPSamplerGui Header=org.apache.jmeter.protocol.http.control.Header HeaderManager=org.apache.jmeter.protocol.http.control.HeaderManager HeaderPanel=org.apache.jmeter.protocol.http.gui.HeaderPanel diff --git a/src/core/src/main/java/org/apache/jmeter/save/SaveService.java b/src/core/src/main/java/org/apache/jmeter/save/SaveService.java index 0d7cf07..2955c38 100644 --- a/src/core/src/main/java/org/apache/jmeter/save/SaveService.java +++ b/src/core/src/main/java/org/apache/jmeter/save/SaveService.java @@ -155,7 +155,7 @@ public class SaveService { private static String fileVersion = ""; // computed from saveservice.properties file// $NON-NLS-1$ // Must match the sha1 checksum of the file saveservice.properties (without newline character), // used to ensure saveservice.properties and SaveService are updated simultaneously - static final String FILEVERSION = "56ae8319b2b02d33eb1028c4460db770cf246b5c"; // Expected value $NON-NLS-1$ + static final String FILEVERSION = "66ea47f7da884dff1c42ccede75113971c5c11f3"; // Expected value $NON-NLS-1$ private static String fileEncoding = ""; // read from properties file// $NON-NLS-1$ diff --git a/src/core/src/main/resources/org/apache/jmeter/gui/util/textarea.properties b/src/core/src/main/resources/org/apache/jmeter/gui/util/textarea.properties index 76b5365..f587f3b 100644 --- a/src/core/src/main/resources/org/apache/jmeter/gui/util/textarea.properties +++ b/src/core/src/main/resources/org/apache/jmeter/gui/util/textarea.properties @@ -37,6 +37,7 @@ jexl3 = text/java jpython = text/python js = text/javascript jscript = text/javascript +json = text/json judoscript = text/plain jython = text/python lisp = text/lisp diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties index 9013ed1..903e9db 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties @@ -293,6 +293,7 @@ deltest=Deletion test deref=Dereference aliases description=Description detail=Detail +detect_graphql_request=Detect GraphQL Request directory_field_title=Working directory: disable=Disable dn=DN @@ -449,6 +450,11 @@ graph_results_ms=ms graph_results_no_samples=No of Samples graph_results_throughput=Throughput graph_results_title=Graph Results +graphql_http_sampler_title=GraphQL HTTP Request +graphql_request_info=GraphQL Request +graphql_operation_name=Operation Name +graphql_query=Query +graphql_variables=Variables groovy_function_expression=Expression to evaluate grouping_add_separators=Add separators between groups grouping_in_controllers=Put each group in a new controller @@ -864,6 +870,7 @@ proxy_headers=Capture HTTP Headers proxy_pause_http_sampler=Create new transaction after request (ms)\: proxy_recorder_dialog=Recorder\: Transactions Control proxy_regex=Regex matching +proxy_sampler_graphql_settings=GraphQL HTTP Sampler settings proxy_sampler_settings=HTTP Sampler settings proxy_sampler_type=Type\: proxy_separators=Add Separators diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties index 0dc5426..ddb10a2 100644 --- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties +++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties @@ -288,6 +288,7 @@ deltest=Suppression deref=Déréférencement des alias description=Description detail=Détail +detect_graphql_request=Détecter les requêtes GraphQL directory_field_title=Répertoire d'exécution \: disable=Désactiver dn=Racine DN \: @@ -443,6 +444,11 @@ graph_results_ms=ms graph_results_no_samples=Nombre d'échantillons graph_results_throughput=Débit graph_results_title=Graphique de résultats +graphql_http_sampler_title=Requête HTTP GraphQL +graphql_request_info=Requête GraphQL +graphql_operation_name=Nom de l'opération +graphql_query=Requête +graphql_variables=Variables groovy_function_expression=Expression à évaluer grouping_add_separators=Ajouter des séparateurs entre les groupes grouping_in_controllers=Mettre chaque groupe dans un nouveau contrôleur @@ -853,6 +859,7 @@ proxy_headers=Capturer les entêtes HTTP proxy_pause_http_sampler=Créer une nouvelle transaction après la requête (ms) \: proxy_recorder_dialog=Enregistreur\: Contrôle des transactions proxy_regex=Correspondance des variables par regex ? +proxy_sampler_graphql_settings=Configuration de la requête GraphQL proxy_sampler_settings=Paramètres Echantillon HTTP proxy_sampler_type=Type \: proxy_separators=Ajouter des séparateurs diff --git a/src/protocol/build.gradle.kts b/src/protocol/build.gradle.kts index aca2843..d0855cf 100644 --- a/src/protocol/build.gradle.kts +++ b/src/protocol/build.gradle.kts @@ -85,6 +85,8 @@ project("http") { implementation("org.apache.httpcomponents:httpcore") implementation("org.brotli:dec") implementation("com.miglayout:miglayout-swing") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-databind") testImplementation(testFixtures(project(":src:testkit-wiremock"))) testImplementation("com.github.tomakehurst:wiremock-jre8") } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/GraphQLRequestParams.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/GraphQLRequestParams.java new file mode 100644 index 0000000..6edabbf --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/GraphQLRequestParams.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.config; + +import java.io.Serializable; + +/** + * Represents GraphQL request parameter input data for Query, Variables and Operation Name. + */ +public class GraphQLRequestParams implements Serializable { + + private static final long serialVersionUID = 1L; + + private String operationName; + + private String query; + + private String variables; + + public GraphQLRequestParams() { + } + + public GraphQLRequestParams(final String operationName, final String query, final String variables) { + this.operationName = operationName; + this.query = query; + this.variables = variables; + } + + public String getOperationName() { + return operationName; + } + + public void setOperationName(String operationName) { + this.operationName = operationName; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getVariables() { + return variables; + } + + public void setVariables(String variables) { + this.variables = variables; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/AbstractValidationTabbedPane.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/AbstractValidationTabbedPane.java new file mode 100644 index 0000000..bc8c942 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/AbstractValidationTabbedPane.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.config.gui; + +import javax.swing.JTabbedPane; + +/** + * Abstract {@link JTabbedPane} to allow validating the requested tab index, updating states and changing the tab index + * after the validation if necessary. + */ +abstract class AbstractValidationTabbedPane extends JTabbedPane { + + private static final long serialVersionUID = 7014311238367882880L; + + /** + * Flag whether the validation feature should be enabled or not, {@code true} by default. + */ + private boolean validationEnabled = true; + + /** + * {@inheritDoc} + * <P> + * Overridden to delegate to {@link #setSelectedIndex(int, boolean)} in order to validate the requested tab index by default. + */ + @Override + public void setSelectedIndex(int index) { + setSelectedIndex(index, true); + } + + /** + * Apply some check rules by invoking {@link #getValidatedTabIndex(int, int)} + * if {@link #isValidationEnabled()} returns true and the {@code check} input is true. + * + * @param index index to select + * @param check flag whether to perform checks before setting the selected index + */ + public void setSelectedIndex(int index, boolean check) { + final int curIndex = super.getSelectedIndex(); + + if (!isValidationEnabled() || !check || curIndex == -1) { + super.setSelectedIndex(index); + return; + } + + super.setSelectedIndex(getValidatedTabIndex(curIndex, index)); + } + + /** + * Validate the requested tab index ({@code newTabIndex}) and return a validated tab index after applying some check rules. + * @param currentTabIndex current tab index + * @param newTabIndex new requested tab index to validate + * @return the validated tab index + */ + abstract protected int getValidatedTabIndex(final int currentTabIndex, final int newTabIndex); + + /** + * Return true if the validation feature should be enabled, {@code true} by default. + * @return true if the validation feature should be enabled, {@code true} by default + */ + protected boolean isValidationEnabled() { + return validationEnabled; + } + + /** + * Set the flag whether the validation feature should be enabled or not. + * @param validationEnabled flag whether the validation feature should be enabled or not + */ + protected void setValidationEnabled(boolean validationEnabled) { + this.validationEnabled = validationEnabled; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/GraphQLUrlConfigGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/GraphQLUrlConfigGui.java new file mode 100644 index 0000000..38b3824 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/GraphQLUrlConfigGui.java @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.config.gui; + +import java.awt.Component; + +import javax.swing.BorderFactory; +import javax.swing.JPanel; +import javax.swing.JTabbedPane; + +import org.apache.commons.lang3.StringUtils; +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.gui.util.HorizontalPanel; +import org.apache.jmeter.gui.util.JSyntaxTextArea; +import org.apache.jmeter.gui.util.JTextScrollPane; +import org.apache.jmeter.protocol.http.config.GraphQLRequestParams; +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; +import org.apache.jmeter.protocol.http.util.GraphQLRequestParamUtils; +import org.apache.jmeter.protocol.http.util.HTTPArgument; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.testelement.TestElement; +import org.apache.jmeter.testelement.property.TestElementProperty; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.gui.JLabeledTextField; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Extending {@link UrlConfigGui}, GraphQL over HTTP Request configuration GUI, providing more convenient UI elements + * for GraphQL query, variables and operationName. + */ +public class GraphQLUrlConfigGui extends UrlConfigGui { + + private static final long serialVersionUID = 1L; + + private static Logger log = LoggerFactory.getLogger(GraphQLUrlConfigGui.class); + + public static final String OPERATION_NAME = "GraphQLHTTPSampler.operationName"; + + public static final String QUERY = "GraphQLHTTPSampler.query"; + + public static final String VARIABLES = "GraphQLHTTPSampler.variables"; + + /** + * Default value settings for GraphQL URL Configuration GUI elements. + */ + private static final UrlConfigDefaults URL_CONFIG_DEFAULTS = new UrlConfigDefaults(); + static { + URL_CONFIG_DEFAULTS.setValidMethods(new String[] { HTTPConstants.POST, HTTPConstants.GET }); + URL_CONFIG_DEFAULTS.setDefaultMethod(HTTPConstants.POST); + URL_CONFIG_DEFAULTS.setAutoRedirects(false); + URL_CONFIG_DEFAULTS.setFollowRedirects(false); + URL_CONFIG_DEFAULTS.setUseBrowserCompatibleMultipartMode(false); + URL_CONFIG_DEFAULTS.setUseKeepAlive(true); + URL_CONFIG_DEFAULTS.setUseMultipart(false); + URL_CONFIG_DEFAULTS.setUseMultipartVisible(false); + } + + private JLabeledTextField operationNameText; + + private JSyntaxTextArea queryContent; + + private JSyntaxTextArea variablesContent; + + /** + * Constructor which is setup to show the sampler fields for GraphQL over HTTP request. + */ + public GraphQLUrlConfigGui() { + super(true, false, false); + } + + @Override + public void configure(TestElement element) { + super.configure(element); + final String operationName = element.getPropertyAsString(OPERATION_NAME, ""); + operationNameText.setText(operationName); + final String query = element.getPropertyAsString(QUERY, ""); + queryContent.setText(query); + final String variables = element.getPropertyAsString(VARIABLES, ""); + variablesContent.setText(variables); + } + + @Override + public void modifyTestElement(TestElement element) { + super.modifyTestElement(element); + + final String method = element.getPropertyAsString(HTTPSamplerBase.METHOD); + final GraphQLRequestParams params = new GraphQLRequestParams(operationNameText.getText(), + queryContent.getText(), variablesContent.getText()); + + element.setProperty(OPERATION_NAME, params.getOperationName()); + element.setProperty(QUERY, params.getQuery()); + element.setProperty(VARIABLES, params.getVariables()); + element.setProperty(HTTPSamplerBase.POST_BODY_RAW, !HTTPConstants.GET.equals(method)); + + final Arguments args; + + if (HTTPConstants.GET.equals(method)) { + args = createHTTPArgumentsTestElement(); + + if (StringUtils.isNotBlank(params.getOperationName())) { + args.addArgument(createHTTPArgument("operationName", params.getOperationName().trim(), true)); + } + + args.addArgument(createHTTPArgument("query", + GraphQLRequestParamUtils.queryToGetParamValue(params.getQuery()), true)); + + if (StringUtils.isNotBlank(params.getVariables())) { + final String variablesParamValue = GraphQLRequestParamUtils + .variablesToGetParamValue(params.getVariables()); + if (variablesParamValue != null) { + args.addArgument(createHTTPArgument("variables", variablesParamValue, true)); + } + } + } else { + args = new Arguments(); + args.addArgument(createHTTPArgument("", GraphQLRequestParamUtils.toPostBodyString(params), false)); + } + + element.setProperty(new TestElementProperty(HTTPSamplerBase.ARGUMENTS, args)); + } + + @Override + protected UrlConfigDefaults getUrlConfigDefaults() { + return URL_CONFIG_DEFAULTS; + } + + /** + * {@inheritDoc} + * <P> + * Overridden to add the extra GraphQL Request Information section including 'operationName' text field. + */ + @Override + protected Component getPathPanel() { + final JPanel panel = (JPanel) super.getPathPanel(); + JPanel graphQLReqInfoPane = new HorizontalPanel(); + graphQLReqInfoPane + .setBorder(BorderFactory.createTitledBorder(JMeterUtils.getResString("graphql_request_info"))); + operationNameText = new JLabeledTextField(JMeterUtils.getResString("graphql_operation_name"), 40); + graphQLReqInfoPane.add(operationNameText); + panel.add(graphQLReqInfoPane); + return panel; + } + + /** + * {@inheritDoc} + * <P> + * Overridden to remove the existing tab for parameter arguments and GraphQL variables content pane. + */ + @Override + protected JTabbedPane getParameterPanel() { + final AbstractValidationTabbedPane paramPanel = (AbstractValidationTabbedPane) super.getParameterPanel(); + paramPanel.removeAll(); + paramPanel.setValidationEnabled(false); + + queryContent = JSyntaxTextArea.getInstance(26, 50); + queryContent.setInitialText(""); + paramPanel.add(JMeterUtils.getResString("graphql_query"), JTextScrollPane.getInstance(queryContent)); + + variablesContent = JSyntaxTextArea.getInstance(26, 50); + variablesContent.setLanguage("json"); + variablesContent.setInitialText(""); + paramPanel.add(JMeterUtils.getResString("graphql_variables"), JTextScrollPane.getInstance(variablesContent)); + + return paramPanel; + } + + private HTTPArgument createHTTPArgument(final String name, final String value, final boolean alwaysEncoded) { + final HTTPArgument arg = new HTTPArgument(name, value); + arg.setUseEquals(true); + arg.setEnabled(true); + arg.setAlwaysEncoded(alwaysEncoded); + return arg; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigDefaults.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigDefaults.java new file mode 100644 index 0000000..e830628 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigDefaults.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.config.gui; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.List; + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; + +/** + * Default option value settings for {@link UrlConfigGui}. + */ +public class UrlConfigDefaults implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Available HTTP methods to be shown in the {@link UrlConfigGui}. + */ + private List<String> validMethodList; + + /** + * The default HTTP method to be selected in the {@link UrlConfigGui}. + */ + private String defaultMethod = HTTPSamplerBase.DEFAULT_METHOD; + + /** + * The default value to be set for the followRedirect checkbox in the {@link UrlConfigGui}. + */ + private boolean followRedirects = true; + + /** + * The default value to be set for the autoRedirects checkbox in the {@link UrlConfigGui}. + */ + private boolean autoRedirects; + + /** + * The default value to be set for the useKeepAlive checkbox in the {@link UrlConfigGui}. + */ + private boolean useKeepAlive = true; + + /** + * The default value to be set for the useMultipart checkbox in the {@link UrlConfigGui}. + */ + private boolean useMultipart; + + /** + * The default value to be set for the useBrowserCompatibleMultipartMode checkbox in the {@link UrlConfigGui}. + */ + private boolean useBrowserCompatibleMultipartMode = HTTPSamplerBase.BROWSER_COMPATIBLE_MULTIPART_MODE_DEFAULT; + + /** + * Flag whether to show the followRedirect checkbox in the {@link UrlConfigGui}. + */ + private boolean followRedirectsVisible = true; + + /** + * Flag whether to show the autoRedirectsVisible checkbox in the {@link UrlConfigGui}. + */ + private boolean autoRedirectsVisible = true; + + /** + * Flag whether to show the useKeepAliveVisible checkbox in the {@link UrlConfigGui}. + */ + private boolean useKeepAliveVisible = true; + + /** + * Flag whether to show the useMultipartVisible checkbox in the {@link UrlConfigGui}. + */ + private boolean useMultipartVisible = true; + + /** + * Flag whether to show the useBrowserCompatibleMultipartModeVisible checkbox in the {@link UrlConfigGui}. + */ + private boolean useBrowserCompatibleMultipartModeVisible = true; + + /** + * Return available HTTP methods to be shown in the {@link UrlConfigGui}, returning {@link HTTPSamplerBase#getValidMethodsAsArray()} + * by default if not reset. + * @return available HTTP methods to be shown in the {@link UrlConfigGui} + */ + public String[] getValidMethods() { + if (validMethodList != null) { + return validMethodList.toArray(new String[validMethodList.size()]); + } + return HTTPSamplerBase.getValidMethodsAsArray(); + } + + /** + * Set available HTTP methods to be shown in the {@link UrlConfigGui}. + * @param validMethods available HTTP methods + * @throws IllegalArgumentException if the input array is empty + */ + public void setValidMethods(String[] validMethods) { + if (validMethods == null || validMethods.length == 0) { + throw new IllegalArgumentException("HTTP methods array is empty."); + } + this.validMethodList = Arrays.asList(validMethods); + } + + /** + * Return the default HTTP method to be selected in the {@link UrlConfigGui}. + * @return the default HTTP method to be selected in the {@link UrlConfigGui} + */ + public String getDefaultMethod() { + return defaultMethod; + } + + /** + * Set the default HTTP method to be selected in the {@link UrlConfigGui}. + * @param defaultMethod the default HTTP method to be selected in the {@link UrlConfigGui} + */ + public void setDefaultMethod(String defaultMethod) { + this.defaultMethod = defaultMethod; + } + + /** + * Return the default value to be set for the followRedirect checkbox in the {@link UrlConfigGui}. + */ + public boolean isFollowRedirects() { + return followRedirects; + } + + /** + * Set the default value to be set for the followRedirect checkbox in the {@link UrlConfigGui}. + */ + public void setFollowRedirects(boolean followRedirects) { + this.followRedirects = followRedirects; + } + + /** + * Return the default value to be set for the autoRedirects checkbox in the {@link UrlConfigGui}. + */ + public boolean isAutoRedirects() { + return autoRedirects; + } + + /** + * Set the default value to be set for the autoRedirects checkbox in the {@link UrlConfigGui}. + */ + public void setAutoRedirects(boolean autoRedirects) { + this.autoRedirects = autoRedirects; + } + + /** + * Return the default value to be set for the useKeepAlive checkbox in the {@link UrlConfigGui}. + */ + public boolean isUseKeepAlive() { + return useKeepAlive; + } + + /** + * Set the default value to be set for the useKeepAlive checkbox in the {@link UrlConfigGui}. + */ + public void setUseKeepAlive(boolean useKeepAlive) { + this.useKeepAlive = useKeepAlive; + } + + /** + * Return the default value to be set for the useMultipart checkbox in the {@link UrlConfigGui}. + */ + public boolean isUseMultipart() { + return useMultipart; + } + + /** + * Set the default value to be set for the useMultipart checkbox in the {@link UrlConfigGui}. + */ + public void setUseMultipart(boolean useMultipart) { + this.useMultipart = useMultipart; + } + + /** + * Return the default value to be set for the useBrowserCompatibleMultipartMode checkbox in the {@link UrlConfigGui}. + */ + public boolean isUseBrowserCompatibleMultipartMode() { + return useBrowserCompatibleMultipartMode; + } + + /** + * Set the default value to be set for the useBrowserCompatibleMultipartMode checkbox in the {@link UrlConfigGui}. + */ + public void setUseBrowserCompatibleMultipartMode(boolean useBrowserCompatibleMultipartMode) { + this.useBrowserCompatibleMultipartMode = useBrowserCompatibleMultipartMode; + } + + /** + * Return true if the followRedirect checkbox should be visible in the {@link UrlConfigGui}. + */ + public boolean isFollowRedirectsVisible() { + return followRedirectsVisible; + } + + /** + * Set the visibility of the followRedirect checkbox in the {@link UrlConfigGui}. + */ + public void setFollowRedirectsVisible(boolean followRedirectsVisible) { + this.followRedirectsVisible = followRedirectsVisible; + } + + /** + * Return true if the autoRedirectsVisible checkbox should be visible in the {@link UrlConfigGui}. + */ + public boolean isAutoRedirectsVisible() { + return autoRedirectsVisible; + } + + /** + * Set the visibility of the autoRedirectsVisible checkbox in the {@link UrlConfigGui}. + */ + public void setAutoRedirectsVisible(boolean autoRedirectsVisible) { + this.autoRedirectsVisible = autoRedirectsVisible; + } + + /** + * Return true if the useKeepAliveVisible checkbox should be visible in the {@link UrlConfigGui}. + */ + public boolean isUseKeepAliveVisible() { + return useKeepAliveVisible; + } + + /** + * Set the visibility of the useKeepAliveVisible checkbox in the {@link UrlConfigGui}. + */ + public void setUseKeepAliveVisible(boolean useKeepAliveVisible) { + this.useKeepAliveVisible = useKeepAliveVisible; + } + + /** + * Return true if the useMultipartVisible checkbox should by default in the {@link UrlConfigGui}. + */ + public boolean isUseMultipartVisible() { + return useMultipartVisible; + } + + /** + * Set the visibility of the useMultipartVisible checkbox in the {@link UrlConfigGui}. + */ + public void setUseMultipartVisible(boolean useMultipartVisible) { + this.useMultipartVisible = useMultipartVisible; + } + + /** + * Return true if the useBrowserCompatibleMultipartModeVisible checkbox should be visible in the {@link UrlConfigGui}. + */ + public boolean isUseBrowserCompatibleMultipartModeVisible() { + return useBrowserCompatibleMultipartModeVisible; + } + + /** + * Set the visibility of the useBrowserCompatibleMultipartModeVisible checkbox in the {@link UrlConfigGui}. + */ + public void setUseBrowserCompatibleMultipartModeVisible(boolean useBrowserCompatibleMultipartModeVisible) { + this.useBrowserCompatibleMultipartModeVisible = useBrowserCompatibleMultipartModeVisible; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java index 2fd4a69..4f29b4b 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/UrlConfigGui.java @@ -63,6 +63,11 @@ public class UrlConfigGui extends JPanel implements ChangeListener { private static final long serialVersionUID = 240L; + /** + * Default value settings for URL Configuration GUI elements. + */ + private static final UrlConfigDefaults URL_CONFIG_DEFAULTS = new UrlConfigDefaults(); + private static final int TAB_PARAMETERS = 0; private int tabRawBodyIndex = 1; @@ -106,7 +111,7 @@ public class UrlConfigGui extends JPanel implements ChangeListener { private JSyntaxTextArea postBodyContent; // Tabbed pane that contains parameters and raw body - private ValidationTabbedPane postContentTabbedPane; + private AbstractValidationTabbedPane postContentTabbedPane; private boolean showRawBodyPane; private boolean showFileUploadPane; @@ -156,12 +161,12 @@ public class UrlConfigGui extends JPanel implements ChangeListener { public void clear() { domain.setText(""); // $NON-NLS-1$ if (notConfigOnly){ - followRedirects.setSelected(true); - autoRedirects.setSelected(false); - method.setText(HTTPSamplerBase.DEFAULT_METHOD); - useKeepAlive.setSelected(true); - useMultipart.setSelected(false); - useBrowserCompatibleMultipartMode.setSelected(HTTPSamplerBase.BROWSER_COMPATIBLE_MULTIPART_MODE_DEFAULT); + followRedirects.setSelected(getUrlConfigDefaults().isFollowRedirects()); + autoRedirects.setSelected(getUrlConfigDefaults().isAutoRedirects()); + method.setText(getUrlConfigDefaults().getDefaultMethod()); + useKeepAlive.setSelected(getUrlConfigDefaults().isUseKeepAlive()); + useMultipart.setSelected(getUrlConfigDefaults().isUseMultipart()); + useBrowserCompatibleMultipartMode.setSelected(getUrlConfigDefaults().isUseBrowserCompatibleMultipartMode()); } path.setText(""); // $NON-NLS-1$ port.setText(""); // $NON-NLS-1$ @@ -193,7 +198,7 @@ public class UrlConfigGui extends JPanel implements ChangeListener { * @param element {@link TestElement} to modify */ public void modifyTestElement(TestElement element) { - boolean useRaw = !postBodyContent.getText().isEmpty(); + boolean useRaw = showRawBodyPane && !postBodyContent.getText().isEmpty(); Arguments args; if(useRaw) { args = new Arguments(); @@ -273,18 +278,21 @@ public class UrlConfigGui extends JPanel implements ChangeListener { setName(el.getName()); Arguments arguments = (Arguments) el.getProperty(HTTPSamplerBase.ARGUMENTS).getObjectValue(); - boolean useRaw = el.getPropertyAsBoolean(HTTPSamplerBase.POST_BODY_RAW, HTTPSamplerBase.POST_BODY_RAW_DEFAULT); - if(useRaw) { - String postBody = computePostBody(arguments, true); // Convert CRLF to CR, see modifyTestElement - postBodyContent.setInitialText(postBody); - postBodyContent.setCaretPosition(0); - argsPanel.clear(); - postContentTabbedPane.setSelectedIndex(tabRawBodyIndex, false); - } else { - postBodyContent.setInitialText(""); - argsPanel.configure(arguments); - postContentTabbedPane.setSelectedIndex(TAB_PARAMETERS, false); + if (showRawBodyPane) { + boolean useRaw = el.getPropertyAsBoolean(HTTPSamplerBase.POST_BODY_RAW, HTTPSamplerBase.POST_BODY_RAW_DEFAULT); + if(useRaw) { + String postBody = computePostBody(arguments, true); // Convert CRLF to CR, see modifyTestElement + postBodyContent.setInitialText(postBody); + postBodyContent.setCaretPosition(0); + argsPanel.clear(); + postContentTabbedPane.setSelectedIndex(tabRawBodyIndex, false); + } else { + postBodyContent.setInitialText(""); + argsPanel.configure(arguments); + postContentTabbedPane.setSelectedIndex(TAB_PARAMETERS, false); + } } + if(showFileUploadPane) { filesPanel.configure(el); } @@ -349,6 +357,13 @@ public class UrlConfigGui extends JPanel implements ChangeListener { return webServerPanel; } + /** + * Return the {@link UrlConfigDefaults} instance to be used when configuring the UI elements and default values. + * @return the {@link UrlConfigDefaults} instance to be used when configuring the UI elements and default values + */ + protected UrlConfigDefaults getUrlConfigDefaults() { + return URL_CONFIG_DEFAULTS; + } /** * This method defines the Panel for: @@ -365,33 +380,37 @@ public class UrlConfigGui extends JPanel implements ChangeListener { if (notConfigOnly){ method = new JLabeledChoice(JMeterUtils.getResString("method"), // $NON-NLS-1$ - HTTPSamplerBase.getValidMethodsAsArray(), true, false); + getUrlConfigDefaults().getValidMethods(), true, false); method.addChangeListener(this); } if (notConfigOnly){ followRedirects = new JCheckBox(JMeterUtils.getResString("follow_redirects")); // $NON-NLS-1$ JFactory.small(followRedirects); - followRedirects.setSelected(true); + followRedirects.setSelected(getUrlConfigDefaults().isFollowRedirects()); followRedirects.addChangeListener(this); + followRedirects.setVisible(getUrlConfigDefaults().isFollowRedirectsVisible()); autoRedirects = new JCheckBox(JMeterUtils.getResString("follow_redirects_auto")); //$NON-NLS-1$ JFactory.small(autoRedirects); autoRedirects.addChangeListener(this); - autoRedirects.setSelected(false);// Default changed in 2.3 and again in 2.4 + autoRedirects.setSelected(getUrlConfigDefaults().isAutoRedirects());// Default changed in 2.3 and again in 2.4 + autoRedirects.setVisible(getUrlConfigDefaults().isAutoRedirectsVisible()); useKeepAlive = new JCheckBox(JMeterUtils.getResString("use_keepalive")); // $NON-NLS-1$ JFactory.small(useKeepAlive); - useKeepAlive.setSelected(true); + useKeepAlive.setSelected(getUrlConfigDefaults().isUseKeepAlive()); + useKeepAlive.setVisible(getUrlConfigDefaults().isUseKeepAliveVisible()); useMultipart = new JCheckBox(JMeterUtils.getResString("use_multipart_for_http_post")); // $NON-NLS-1$ JFactory.small(useMultipart); - useMultipart.setSelected(false); + useMultipart.setSelected(getUrlConfigDefaults().isUseMultipart()); + useMultipart.setVisible(getUrlConfigDefaults().isUseMultipartVisible()); useBrowserCompatibleMultipartMode = new JCheckBox(JMeterUtils.getResString("use_multipart_mode_browser")); // $NON-NLS-1$ JFactory.small(useBrowserCompatibleMultipartMode); - useBrowserCompatibleMultipartMode.setSelected(HTTPSamplerBase.BROWSER_COMPATIBLE_MULTIPART_MODE_DEFAULT); - + useBrowserCompatibleMultipartMode.setSelected(getUrlConfigDefaults().isUseBrowserCompatibleMultipartMode()); + useBrowserCompatibleMultipartMode.setVisible(getUrlConfigDefaults().isUseBrowserCompatibleMultipartModeVisible()); } JPanel pathPanel = new HorizontalPanel(); @@ -438,59 +457,47 @@ public class UrlConfigGui extends JPanel implements ChangeListener { } /** - * + * Create a new {@link Arguments} instance associated with the specific GUI used in this component. + * @return a new {@link Arguments} instance associated with the specific GUI used in this component */ - class ValidationTabbedPane extends JTabbedPane { + protected Arguments createHTTPArgumentsTestElement() { + return (Arguments) argsPanel.createTestElement(); + } - /** - * - */ - private static final long serialVersionUID = 7014311238367882880L; + class ValidationTabbedPane extends AbstractValidationTabbedPane { + private static final long serialVersionUID = 7014311238367882881L; @Override - public void setSelectedIndex(int index) { - setSelectedIndex(index, true); - } - - /** - * Apply some check rules if check is true - * - * @param index - * index to select - * @param check - * flag whether to perform checks before setting the selected - * index - */ - public void setSelectedIndex(int index, boolean check) { - int oldSelectedIndex = this.getSelectedIndex(); - if(!check || oldSelectedIndex == -1) { - super.setSelectedIndex(index); - } else if(index == tabFileUploadIndex) { // We're going to File, no problem - super.setSelectedIndex(index); + protected int getValidatedTabIndex(int currentTabIndex, int newTabIndex) { + if (newTabIndex == tabFileUploadIndex) { // We're going to File, no problem + return newTabIndex; } + // We're moving to Raw or Parameters - else if(index != oldSelectedIndex) { + if (newTabIndex != currentTabIndex) { // If the Parameter data can be converted (i.e. no names) // we switch - if(index == tabRawBodyIndex) { - if(canSwitchToRawBodyPane()) { + if (newTabIndex == tabRawBodyIndex) { + if (canSwitchToRawBodyPane()) { convertParametersToRaw(); - super.setSelectedIndex(index); + return newTabIndex; } else { - super.setSelectedIndex(TAB_PARAMETERS); + return TAB_PARAMETERS; } } else { // If the Parameter data cannot be converted to Raw, then the user should be // prevented from doing so raise an error dialog - if(canSwitchToParametersTab()) { - super.setSelectedIndex(index); + if (canSwitchToParametersTab()) { + return newTabIndex; } else { - super.setSelectedIndex(tabRawBodyIndex); + return tabRawBodyIndex; } } } + + return newTabIndex; } /** @@ -510,7 +517,7 @@ public class UrlConfigGui extends JPanel implements ChangeListener { * @return true if postBodyContent is empty */ private boolean canSwitchToParametersTab() { - return postBodyContent.getText().isEmpty(); + return showRawBodyPane && postBodyContent.getText().isEmpty(); } } @@ -531,7 +538,7 @@ public class UrlConfigGui extends JPanel implements ChangeListener { * Convert Parameters to Raw Body */ void convertParametersToRaw() { - if(postBodyContent.getText().isEmpty()) { + if (showRawBodyPane && postBodyContent.getText().isEmpty()) { postBodyContent.setInitialText(computePostBody((Arguments)argsPanel.createTestElement())); postBodyContent.setCaretPosition(0); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/HeaderManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/HeaderManager.java index aaf675a..02d5b39 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/HeaderManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/HeaderManager.java @@ -200,6 +200,26 @@ public class HeaderManager extends ConfigTestElement implements Serializable, Re } /** + * Get the first header from Headers by the header name, or {@code null} if not found. + * @param name header name + * @return the first header from Headers by the header name, or {@code null} if not found + */ + public Header getFirstHeaderNamed(final String name) { + final CollectionProperty headers = getHeaders(); + final int size = headers.size(); + for (int i = 0; i < size; i++) { + Header header = (Header) headers.get(i).getObjectValue(); + if (header == null) { + continue; + } + if (header.getName().equalsIgnoreCase(name)) { + return header; + } + } + return null; + } + + /** * Remove from Headers the header named name * @param name header name */ diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/GraphQLHTTPSamplerGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/GraphQLHTTPSamplerGui.java new file mode 100644 index 0000000..c393e77 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/GraphQLHTTPSamplerGui.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.control.gui; + +import javax.swing.JPanel; + +import org.apache.jmeter.gui.TestElementMetadata; +import org.apache.jmeter.protocol.http.config.gui.GraphQLUrlConfigGui; +import org.apache.jmeter.protocol.http.config.gui.UrlConfigGui; +import org.apache.jmeter.util.JMeterUtils; + +/** + * GraphQL HTTP Sampler GUI which extends {@link HttpTestSampleGui} in order to provide more convenient UI elements for + * GraphQL query, variables and operationName. + */ +@TestElementMetadata(labelResource = "graphql_http_sampler_title") +public class GraphQLHTTPSamplerGui extends HttpTestSampleGui { + + private static final long serialVersionUID = 1L; + + public GraphQLHTTPSamplerGui() { + super(); + } + + // Use this instead of getLabelResource() otherwise getDocAnchor() below does not work + @Override + public String getStaticLabel() { + return JMeterUtils.getResString("graphql_http_sampler_title"); // $NON-NLS-1$ + } + + @Override + public String getDocAnchor() {// reuse documentation + return super.getStaticLabel().replace(' ', '_'); //$NON-NLS-1$ //$NON-NLS-2$ + } + + /** + * {@inheritDoc} + * <P> + * Overridden to hide the HTML embedded resource handling section as GraphQL responses are always in JSON. + */ + @Override + protected JPanel createEmbeddedRsrcPanel() { + final JPanel panel = super.createEmbeddedRsrcPanel(); + // No need to consider embedded resources in HTML as the GraphQL responses are always in JSON. + panel.setVisible(false); + return panel; + } + + /** + * {@inheritDoc} + * <P> + * Overridden to create a {@link GraphQLUrlConfigGui} which extends {@link UrlConfigGui} for GraphQL specific UI elements. + */ + @Override + protected UrlConfigGui createUrlConfigGui() { + final GraphQLUrlConfigGui configGui = new GraphQLUrlConfigGui(); + configGui.setBorder(makeBorder()); + return configGui; + } +} diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java index f7acf2a..0464568 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java @@ -168,10 +168,52 @@ public class HttpTestSampleGui extends AbstractSamplerGui { setLayout(new BorderLayout(0, 5)); setBorder(BorderFactory.createEmptyBorder()); + JTabbedPane tabbedPane = createTabbedConfigPane(); + + JPanel wrapper = new JPanel(new BorderLayout()); + wrapper.setBorder(makeBorder()); + wrapper.add(makeTitlePanel(), BorderLayout.CENTER); + + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, wrapper, tabbedPane); + splitPane.setBorder(BorderFactory.createEmptyBorder()); + splitPane.setOneTouchExpandable(true); + add(splitPane); + } + + /** + * Create the parameters configuration tabstrip which includes the Basic tab ({@link UrlConfigGui}) + * and the Advanced tab by default. + * @return the parameters configuration tabstrip which includes the Basic tab ({@link UrlConfigGui}) + * and the Advanced tab by default + */ + protected JTabbedPane createTabbedConfigPane() { + final JTabbedPane tabbedPane = new JTabbedPane(); + // URL CONFIG - urlConfigGui = new UrlConfigGui(true, true, true); - urlConfigGui.setBorder(makeBorder()); + urlConfigGui = createUrlConfigGui(); + + tabbedPane.add(JMeterUtils + .getResString("web_testing_basic"), urlConfigGui); + + // AdvancedPanel (embedded resources, source address and optional tasks) + final JPanel advancedPanel = createAdvancedConfigPanel(); + tabbedPane.add(JMeterUtils + .getResString("web_testing_advanced"), advancedPanel); + return tabbedPane; + } + + /** + * Create a {@link UrlConfigGui} which is used as the Basic tab in the parameters configuration tabstrip. + * @return a {@link UrlConfigGui} which is used as the Basic tab + */ + protected UrlConfigGui createUrlConfigGui() { + final UrlConfigGui configGui = new UrlConfigGui(true, true, true); + configGui.setBorder(makeBorder()); + return configGui; + } + + private JPanel createAdvancedConfigPanel() { // HTTP request options JPanel httpOptions = new HorizontalPanel(); httpOptions.add(getImplementationPanel()); @@ -190,21 +232,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui { } advancedPanel.add(createOptionalTasksPanel()); - - JTabbedPane tabbedPane = new JTabbedPane(); - tabbedPane.add(JMeterUtils - .getResString("web_testing_basic"), urlConfigGui); - tabbedPane.add(JMeterUtils - .getResString("web_testing_advanced"), advancedPanel); - - JPanel wrapper = new JPanel(new BorderLayout()); - wrapper.setBorder(makeBorder()); - wrapper.add(makeTitlePanel(), BorderLayout.CENTER); - - JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, wrapper, tabbedPane); - splitPane.setBorder(BorderFactory.createEmptyBorder()); - splitPane.setOneTouchExpandable(true); - add(splitPane); + return advancedPanel; } private JPanel getTimeOutPanel() { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/DefaultSamplerCreator.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/DefaultSamplerCreator.java index 2881a3f..61d1af5 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/DefaultSamplerCreator.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/DefaultSamplerCreator.java @@ -33,12 +33,17 @@ import javax.xml.parsers.SAXParserFactory; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.protocol.http.config.GraphQLRequestParams; import org.apache.jmeter.protocol.http.config.MultipartUrlConfig; +import org.apache.jmeter.protocol.http.config.gui.GraphQLUrlConfigGui; +import org.apache.jmeter.protocol.http.control.Header; +import org.apache.jmeter.protocol.http.control.gui.GraphQLHTTPSamplerGui; import org.apache.jmeter.protocol.http.control.gui.HttpTestSampleGui; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; import org.apache.jmeter.protocol.http.sampler.PostWriter; import org.apache.jmeter.protocol.http.util.ConversionUtils; +import org.apache.jmeter.protocol.http.util.GraphQLRequestParamUtils; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.protocol.http.util.HTTPFileArg; import org.apache.jmeter.testelement.TestElement; @@ -121,6 +126,45 @@ public class DefaultSamplerCreator extends AbstractSamplerCreator { if(arguments.getArgumentCount() == 1 && arguments.getArgument(0).getName().length()==0) { sampler.setPostBodyRaw(true); } + + if (request.isDetectGraphQLRequest()) { + detectAndModifySamplerOnGraphQLRequest(sampler, request); + } + } + + private void detectAndModifySamplerOnGraphQLRequest(final HTTPSamplerBase sampler, final HttpRequestHdr request) { + final String method = request.getMethod(); + final Header header = request.getHeaderManager().getFirstHeaderNamed("Content-Type"); + final boolean graphQLContentType = header != null + && GraphQLRequestParamUtils.isGraphQLContentType(header.getValue()); + + GraphQLRequestParams params = null; + + if (HTTPConstants.POST.equals(method) && graphQLContentType) { + try { + byte[] postData = request.getRawPostData(); + if (postData != null && postData.length > 0) { + params = GraphQLRequestParamUtils.toGraphQLRequestParams(request.getRawPostData(), + sampler.getContentEncoding()); + } + } catch (Exception e) { + log.debug("Ignoring request, '{}' as it's not a valid GraphQL post data."); + } + } else if (HTTPConstants.GET.equals(method)) { + try { + params = GraphQLRequestParamUtils.toGraphQLRequestParams(sampler.getArguments(), + sampler.getContentEncoding()); + } catch (Exception e) { + log.debug("Ignoring request, '{}' as it does not valid GraphQL arguments."); + } + } + + if (params != null) { + sampler.setProperty(TestElement.GUI_CLASS, GraphQLHTTPSamplerGui.class.getName()); + sampler.setProperty(GraphQLUrlConfigGui.OPERATION_NAME, params.getOperationName()); + sampler.setProperty(GraphQLUrlConfigGui.QUERY, params.getQuery()); + sampler.setProperty(GraphQLUrlConfigGui.VARIABLES, params.getVariables()); + } } /** diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/HttpRequestHdr.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/HttpRequestHdr.java index 961627b..abd69a8 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/HttpRequestHdr.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/HttpRequestHdr.java @@ -86,6 +86,8 @@ public class HttpRequestHdr { private String httpSampleNameFormat; + private boolean detectGraphQLRequest; + public HttpRequestHdr() { this("", ""); } @@ -120,6 +122,22 @@ public class HttpRequestHdr { } /** + * Return true if automatic GraphQL Request detection is enabled. + * @return true if automatic GraphQL Request detection is enabled + */ + public boolean isDetectGraphQLRequest() { + return detectGraphQLRequest; + } + + /** + * Sets whether automatic GraphQL Request detection is enabled. + * @param detectGraphQLRequest whether automatic GraphQL Request detection is enabled + */ + public void setDetectGraphQLRequest(boolean detectGraphQLRequest) { + this.detectGraphQLRequest = detectGraphQLRequest; + } + + /** * Parses a http header from a stream. * * @param in diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/Proxy.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/Proxy.java index 5faf29b..37c1f47 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/Proxy.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/Proxy.java @@ -163,6 +163,8 @@ public class Proxy extends Thread { HttpRequestHdr request = new HttpRequestHdr(target.getPrefixHTTPSampleName(), httpSamplerName, target.getHTTPSampleNamingMode(), target.getHttpSampleNameFormat()); + request.setDetectGraphQLRequest(target.getDetectGraphQLRequest()); + SampleResult result = null; HeaderManager headers = null; HTTPSamplerBase sampler = null; diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/ProxyControl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/ProxyControl.java index 4341da2..642b943 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/ProxyControl.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/ProxyControl.java @@ -136,6 +136,7 @@ public class ProxyControl extends GenericController implements NonTestElement { private static final String SAMPLER_REDIRECT_AUTOMATICALLY = "ProxyControlGui.sampler_redirect_automatically"; // $NON-NLS-1$ private static final String SAMPLER_FOLLOW_REDIRECTS = "ProxyControlGui.sampler_follow_redirects"; // $NON-NLS-1$ private static final String USE_KEEPALIVE = "ProxyControlGui.use_keepalive"; // $NON-NLS-1$ + private static final String DETECT_GRAPHQL_REQUEST = "ProxyControlGui.detect_graphql_request"; // $NON-NLS-1$ private static final String SAMPLER_DOWNLOAD_IMAGES = "ProxyControlGui.sampler_download_images"; // $NON-NLS-1$ private static final String HTTP_SAMPLER_NAMING_MODE = "ProxyControlGui.proxy_http_sampler_naming_mode"; // $NON-NLS-1$ private static final String HTTP_SAMPLER_FORMAT = "ProxyControlGui.proxy_http_sampler_format"; // $NON-NLS-1$ @@ -268,6 +269,8 @@ public class ProxyControl extends GenericController implements NonTestElement { private volatile boolean regexMatch = false; + private volatile boolean detectGraphQLRequest = false; + private Set<Class<?>> addableInterfaces = new HashSet<>( Arrays.asList(Visualizer.class, ConfigElement.class, Assertion.class, Timer.class, PreProcessor.class, @@ -360,6 +363,11 @@ public class ProxyControl extends GenericController implements NonTestElement { setProperty(new BooleanProperty(USE_KEEPALIVE, b)); } + public void setDetectGraphQLRequest(boolean b) { + detectGraphQLRequest = b; + setProperty(new BooleanProperty(DETECT_GRAPHQL_REQUEST, b)); + } + public void setSamplerDownloadImages(boolean b) { samplerDownloadImages = b; setProperty(new BooleanProperty(SAMPLER_DOWNLOAD_IMAGES, b)); @@ -460,6 +468,10 @@ public class ProxyControl extends GenericController implements NonTestElement { return getPropertyAsBoolean(USE_KEEPALIVE, true); } + public boolean getDetectGraphQLRequest() { + return getPropertyAsBoolean(DETECT_GRAPHQL_REQUEST, true); + } + public boolean getSamplerDownloadImages() { return getPropertyAsBoolean(SAMPLER_DOWNLOAD_IMAGES, false); } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/gui/ProxyControlGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/gui/ProxyControlGui.java index a01dd58..d79428c 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/gui/ProxyControlGui.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/proxy/gui/ProxyControlGui.java @@ -148,6 +148,11 @@ public class ProxyControlGui extends LogicControllerGui implements JMeterGUIComp */ private JCheckBox useKeepAlive; + /** + * Set/clear the Detect GraphQL Request box on the samplers (default is true) + */ + private JCheckBox detectGraphQLRequest; + /* * Use regexes to match the source data */ @@ -324,6 +329,7 @@ public class ProxyControlGui extends LogicControllerGui implements JMeterGUIComp model.setSamplerRedirectAutomatically(samplerRedirectAutomatically.isSelected()); model.setSamplerFollowRedirects(samplerFollowRedirects.isSelected()); model.setUseKeepAlive(useKeepAlive.isSelected()); + model.setDetectGraphQLRequest(detectGraphQLRequest.isSelected()); model.setSamplerDownloadImages(samplerDownloadImages.isSelected()); model.setHTTPSampleNamingMode(httpSampleNamingMode.getSelectedIndex()); model.setDefaultEncoding(defaultEncoding.getText()); @@ -392,6 +398,7 @@ public class ProxyControlGui extends LogicControllerGui implements JMeterGUIComp samplerRedirectAutomatically.setSelected(model.getSamplerRedirectAutomatically()); samplerFollowRedirects.setSelected(model.getSamplerFollowRedirects()); useKeepAlive.setSelected(model.getUseKeepalive()); + detectGraphQLRequest.setSelected(model.getDetectGraphQLRequest()); samplerDownloadImages.setSelected(model.getSamplerDownloadImages()); httpSampleNamingMode.setSelectedIndex(model.getHTTPSampleNamingMode()); prefixHTTPSampleName.setText(model.getPrefixHTTPSampleName()); @@ -757,6 +764,7 @@ public class ProxyControlGui extends LogicControllerGui implements JMeterGUIComp testPlanPanel.add(createTestPlanContentPanel()); testPlanPanel.add(Box.createVerticalStrut(5)); testPlanPanel.add(createHTTPSamplerPanel()); + testPlanPanel.add(createGraphQLHTTPSamplerPanel()); tabbedPane.add(JMeterUtils .getResString("proxy_test_plan_creation"), testPlanPanel); @@ -993,6 +1001,20 @@ public class ProxyControlGui extends LogicControllerGui implements JMeterGUIComp panel.add(labelSamplerType); panel.add(samplerTypeName, "growx, span"); + + return panel; + } + + private JPanel createGraphQLHTTPSamplerPanel() { + detectGraphQLRequest = new JCheckBox(JMeterUtils.getResString("detect_graphql_request")); // $NON-NLS-1$ + detectGraphQLRequest.setSelected(true); + detectGraphQLRequest.addActionListener(this); + detectGraphQLRequest.setActionCommand(ENABLE_RESTART); + + JPanel panel = new JPanel(new MigLayout("fillx, wrap 3")); + panel.setBorder(BorderFactory.createTitledBorder(JMeterUtils.getResString("proxy_sampler_graphql_settings"))); // $NON-NLS-1$ + panel.add(detectGraphQLRequest); + return panel; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/GraphQLRequestParamUtils.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/GraphQLRequestParamUtils.java new file mode 100644 index 0000000..6844722 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/GraphQLRequestParamUtils.java @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.util; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.RegExUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.entity.ContentType; +import org.apache.jmeter.config.Argument; +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.protocol.http.config.GraphQLRequestParams; +import org.apache.jmeter.testelement.property.JMeterProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Utilities to (de)serialize GraphQL request parameters. + */ +public final class GraphQLRequestParamUtils { + + private static Logger log = LoggerFactory.getLogger(GraphQLRequestParamUtils.class); + + private static Pattern WHITESPACES_PATTERN = Pattern.compile("\\p{Space}+"); + + private GraphQLRequestParamUtils() { + } + + /** + * Return true if the content type is GraphQL content type (i.e. 'application/json'). + * @param contentType Content-Type value + * @return true if the content type is GraphQL content type + */ + public static boolean isGraphQLContentType(final String contentType) { + if (StringUtils.isEmpty(contentType)) { + return false; + } + final ContentType type = ContentType.parse(contentType); + return ContentType.APPLICATION_JSON.getMimeType().equals(type.getMimeType()); + } + + /** + * Convert the GraphQL request parameters input data to an HTTP POST body string. + * @param params GraphQL request parameter input data + * @return an HTTP POST body string converted from the GraphQL request parameters input data + * @throws RuntimeException if JSON serialization fails for some reason due to any runtime environment issues + */ + public static String toPostBodyString(final GraphQLRequestParams params) { + final ObjectMapper mapper = new ObjectMapper(); + final ObjectNode postBodyJson = mapper.createObjectNode(); + postBodyJson.put("operationName", StringUtils.trimToNull(params.getOperationName())); + + if (StringUtils.isNotBlank(params.getVariables())) { + try { + final ObjectNode variablesJson = mapper.readValue(params.getVariables(), ObjectNode.class); + postBodyJson.set("variables", variablesJson); + } catch (JsonProcessingException e) { + log.error("Ignoring the GraphQL query variables content due to the syntax error: {}", + e.getLocalizedMessage()); + } + } + + postBodyJson.put("query", StringUtils.trim(params.getQuery())); + + try { + return mapper.writeValueAsString(postBodyJson); + } catch (JsonProcessingException e) { + throw new RuntimeException("Cannot serialize JSON for POST body string", e); + } + } + + /** + * Convert the GraphQL Query input string into an HTTP GET request parameter value. + * @param query the GraphQL Query input string + * @return an HTTP GET request parameter value converted from the GraphQL Query input string + */ + public static String queryToGetParamValue(final String query) { + return RegExUtils.replaceAll(StringUtils.trim(query), WHITESPACES_PATTERN, " "); + } + + /** + * Convert the GraphQL Variables JSON input string into an HTTP GET request parameter value. + * @param variables the GraphQL Variables JSON input string + * @return an HTTP GET request parameter value converted from the GraphQL Variables JSON input string + */ + public static String variablesToGetParamValue(final String variables) { + final ObjectMapper mapper = new ObjectMapper(); + + try { + final ObjectNode variablesJson = mapper.readValue(variables, ObjectNode.class); + return mapper.writeValueAsString(variablesJson); + } catch (JsonProcessingException e) { + log.error("Ignoring the GraphQL query variables content due to the syntax error: {}", + e.getLocalizedMessage()); + } + + return null; + } + + /** + * Parse {@code postData} and convert it to a {@link GraphQLRequestParams} object if it is a valid GraphQL post data. + * @param postData post data + * @param contentEncoding content encoding + * @return a converted {@link GraphQLRequestParams} object form the {@code postData} + * @throws IllegalArgumentException if {@code postData} is not a GraphQL post JSON data or not a valid JSON + * @throws JsonProcessingException if it fails to serialize a parsed JSON object to string + * @throws UnsupportedEncodingException if it fails to decode parameter value + */ + public static GraphQLRequestParams toGraphQLRequestParams(byte[] postData, final String contentEncoding) + throws IllegalArgumentException, JsonProcessingException, UnsupportedEncodingException { + final String encoding = StringUtils.isNotEmpty(contentEncoding) ? contentEncoding + : EncoderCache.URL_ARGUMENT_ENCODING; + + final ObjectMapper mapper = new ObjectMapper(); + ObjectNode data; + + try (InputStreamReader reader = new InputStreamReader(new ByteArrayInputStream(postData), encoding)) { + data = mapper.readValue(reader, ObjectNode.class); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid json data: " + e.getLocalizedMessage()); + } + + String operationName = null; + String query = null; + String variables = null; + + final JsonNode operationNameNode = data.has("operationName") ? data.get("operationName") : null; + if (operationNameNode != null) { + operationName = getJsonNodeTextContent(operationNameNode, true); + } + + if (!data.has("query")) { + throw new IllegalArgumentException("Not a valid GraphQL query."); + } + final JsonNode queryNode = data.get("query"); + query = getJsonNodeTextContent(queryNode, false); + final String trimmedQuery = StringUtils.trim(query); + if (!StringUtils.startsWith(trimmedQuery, "query") && !StringUtils.startsWith(trimmedQuery, "mutation")) { + throw new IllegalArgumentException("Not a valid GraphQL query."); + } + + final JsonNode variablesNode = data.has("variables") ? data.get("variables") : null; + if (variablesNode != null) { + final JsonNodeType nodeType = variablesNode.getNodeType(); + if (nodeType != JsonNodeType.NULL) { + if (nodeType == JsonNodeType.OBJECT) { + variables = mapper.writeValueAsString(variablesNode); + } else { + throw new IllegalArgumentException("Not a valid object node for GraphQL variables."); + } + } + } + + return new GraphQLRequestParams(operationName, query, variables); + } + + /** + * Parse {@code arguments} and convert it to a {@link GraphQLRequestParams} object if it has valid GraphQL HTTP arguments. + * @param arguments arguments + * @param contentEncoding content encoding + * @return a converted {@link GraphQLRequestParams} object form the {@code arguments} + * @throws IllegalArgumentException if {@code arguments} does not contain valid GraphQL request arguments + * @throws UnsupportedEncodingException if it fails to decode parameter value + */ + public static GraphQLRequestParams toGraphQLRequestParams(final Arguments arguments, final String contentEncoding) + throws IllegalArgumentException, UnsupportedEncodingException { + final String encoding = StringUtils.isNotEmpty(contentEncoding) ? contentEncoding + : EncoderCache.URL_ARGUMENT_ENCODING; + + String operationName = null; + String query = null; + String variables = null; + + for (JMeterProperty prop : arguments) { + final Argument arg = (Argument) prop.getObjectValue(); + if (!(arg instanceof HTTPArgument)) { + continue; + } + + final String name = arg.getName(); + final String metadata = arg.getMetaData(); + final String value = StringUtils.trimToNull(arg.getValue()); + + if ("=".equals(metadata) && value != null) { + final boolean alwaysEncoded = ((HTTPArgument) arg).isAlwaysEncoded(); + + if ("operationName".equals(name)) { + operationName = alwaysEncoded ? value : URLDecoder.decode(value, encoding); + } else if ("query".equals(name)) { + query = alwaysEncoded ? value : URLDecoder.decode(value, encoding); + } else if ("variables".equals(name)) { + variables = alwaysEncoded ? value : URLDecoder.decode(value, encoding); + } + } + } + + if (StringUtils.isEmpty(query) + || (!StringUtils.startsWith(query, "query") && !StringUtils.startsWith(query, "mutation"))) { + throw new IllegalArgumentException("Not a valid GraphQL query."); + } + + if (StringUtils.isNotEmpty(variables)) { + if (!StringUtils.startsWith(variables, "{") || !StringUtils.endsWith(variables, "}")) { + throw new IllegalArgumentException("Not a valid object node for GraphQL variables."); + } + } + + return new GraphQLRequestParams(operationName, query, variables); + } + + private static String getJsonNodeTextContent(final JsonNode jsonNode, final boolean nullable) throws IllegalArgumentException { + final JsonNodeType nodeType = jsonNode.getNodeType(); + + if (nodeType == JsonNodeType.NULL) { + if (nullable) { + return null; + } + + throw new IllegalArgumentException("Not a non-null value node."); + } + + if (nodeType == JsonNodeType.STRING) { + return jsonNode.asText(); + } + + throw new IllegalArgumentException("Not a string value node."); + } +} diff --git a/src/protocol/http/src/main/resources/org/apache/jmeter/protocol/http/sampler/GraphQLHTTPSamplerResources.properties b/src/protocol/http/src/main/resources/org/apache/jmeter/protocol/http/sampler/GraphQLHTTPSamplerResources.properties new file mode 100644 index 0000000..27951e8 --- /dev/null +++ b/src/protocol/http/src/main/resources/org/apache/jmeter/protocol/http/sampler/GraphQLHTTPSamplerResources.properties @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you 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. +# + +displayName=GraphQL HTTP Request +defaults.displayName=Default Test Values +method.displayName=Method +method.shortDescription=Method +operationName.displayName=Operation Name +operationName.shortDescription=Operation Name +query.displayName=Query +query.shortDescription=Query or Mutation +variables.displayName=Variables +variables.shortDescription=Variables diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/util/TestGraphQLRequestParamUtils.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/util/TestGraphQLRequestParamUtils.java new file mode 100644 index 0000000..8316e68 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/util/TestGraphQLRequestParamUtils.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 org.apache.jmeter.protocol.http.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.nio.charset.StandardCharsets; + +import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.protocol.http.config.GraphQLRequestParams; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.jknack.handlebars.internal.lang3.StringUtils; + +public class TestGraphQLRequestParamUtils { + + private static final String OPERATION_NAME = ""; + + private static final String QUERY = + "query($id: ID!) {\n" + + " droid(id: $id) {\n" + + " id\n" + + " name\n" + + " friends {\n" + + " id\n" + + " name\n" + + " appearsIn\n" + + " }\n" + + " }\n" + + "}\n"; + + private static final String VARIABLES = + "{\n" + + " \"id\": \"2001\"\n" + + "}\n"; + + private static final String EXPECTED_QUERY_GET_PARAM_VALUE = + "query($id: ID!) { droid(id: $id) { id name friends { id name appearsIn } } }"; + + private static final String EXPECTED_VARIABLES_GET_PARAM_VALUE = "{\"id\":\"2001\"}"; + + private static final String EXPECTED_POST_BODY = + "{" + + "\"operationName\":null," + + "\"variables\":" + EXPECTED_VARIABLES_GET_PARAM_VALUE + "," + + "\"query\":\"" + StringUtils.replace(QUERY.trim(), "\n", "\\n") + "\"" + + "}"; + + private GraphQLRequestParams params; + + @BeforeEach + public void setUp() { + params = new GraphQLRequestParams(OPERATION_NAME, QUERY, VARIABLES); + } + + @Test + public void testIsGraphQLContentType() throws Exception { + assertTrue(GraphQLRequestParamUtils.isGraphQLContentType("application/json")); + assertTrue(GraphQLRequestParamUtils.isGraphQLContentType("application/json;charset=utf-8")); + assertTrue(GraphQLRequestParamUtils.isGraphQLContentType("application/json; charset=utf-8")); + + assertFalse(GraphQLRequestParamUtils.isGraphQLContentType("application/vnd.api+json")); + assertFalse(GraphQLRequestParamUtils.isGraphQLContentType("application/json-patch+json")); + assertFalse(GraphQLRequestParamUtils.isGraphQLContentType("")); + assertFalse(GraphQLRequestParamUtils.isGraphQLContentType(null)); + } + + @Test + public void testToPostBodyString() throws Exception { + assertEquals(EXPECTED_POST_BODY, GraphQLRequestParamUtils.toPostBodyString(params)); + } + + @Test + public void testQueryToGetParamValue() throws Exception { + assertEquals(EXPECTED_QUERY_GET_PARAM_VALUE, GraphQLRequestParamUtils.queryToGetParamValue(params.getQuery())); + } + + @Test + public void testVariablesToGetParamValue() throws Exception { + assertEquals(EXPECTED_VARIABLES_GET_PARAM_VALUE, + GraphQLRequestParamUtils.variablesToGetParamValue(params.getVariables())); + } + + @Test + public void testToGraphQLRequestParamsWithPostData() throws Exception { + GraphQLRequestParams params = GraphQLRequestParamUtils + .toGraphQLRequestParams(EXPECTED_POST_BODY.getBytes(StandardCharsets.UTF_8), null); + assertNull(params.getOperationName()); + assertEquals(QUERY.trim(), params.getQuery()); + assertEquals(EXPECTED_VARIABLES_GET_PARAM_VALUE, params.getVariables()); + + params = GraphQLRequestParamUtils.toGraphQLRequestParams( + "{\"operationName\":\"op1\",\"variables\":{\"id\":123},\"query\":\"query { droid { id }}\"}" + .getBytes(StandardCharsets.UTF_8), + null); + assertEquals("op1", params.getOperationName()); + assertEquals("query { droid { id }}", params.getQuery()); + assertEquals("{\"id\":123}", params.getVariables()); + + try { + params = GraphQLRequestParamUtils.toGraphQLRequestParams("".getBytes(StandardCharsets.UTF_8), null); + fail("Should have failed due to invalid json data."); + } catch (IllegalArgumentException ignore) { + } + + try { + params = GraphQLRequestParamUtils.toGraphQLRequestParams("{}".getBytes(StandardCharsets.UTF_8), null); + fail("Should have failed due to invalid json data."); + } catch (IllegalArgumentException ignore) { + } + + try { + params = GraphQLRequestParamUtils + .toGraphQLRequestParams("{\"query\":\"select * from emp\"}".getBytes(StandardCharsets.UTF_8), null); + fail("Should have failed due to invalid graph query param."); + } catch (IllegalArgumentException ignore) { + } + + try { + params = GraphQLRequestParamUtils + .toGraphQLRequestParams("{\"operationName\":{\"id\":123},\"query\":\"query { droid { id }}\"}" + .getBytes(StandardCharsets.UTF_8), null); + fail("Should have failed due to invalid graph operationName type."); + } catch (IllegalArgumentException ignore) { + } + + try { + params = GraphQLRequestParamUtils.toGraphQLRequestParams( + "{\"variables\":\"r2d2\",\"query\":\"query { droid { id }}\"}".getBytes(StandardCharsets.UTF_8), + null); + fail("Should have failed due to invalid graph variables type."); + } catch (IllegalArgumentException ignore) { + } + } + + @Test + public void testToGraphQLRequestParamsWithHttpArguments() throws Exception { + Arguments args = new Arguments(); + args.addArgument(new HTTPArgument("query", "query { droid { id }}", "=", false)); + GraphQLRequestParams params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + assertNull(params.getOperationName()); + assertEquals("query { droid { id }}", params.getQuery()); + assertNull(params.getVariables()); + + args = new Arguments(); + args.addArgument(new HTTPArgument("operationName", "op1", "=", false)); + args.addArgument(new HTTPArgument("query", "query { droid { id }}", "=", false)); + args.addArgument(new HTTPArgument("variables", "{\"id\":123}", "=", false)); + params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + assertEquals("op1", params.getOperationName()); + assertEquals("query { droid { id }}", params.getQuery()); + assertEquals("{\"id\":123}", params.getVariables()); + + args = new Arguments(); + args.addArgument(new HTTPArgument("query", "query+%7B+droid+%7B+id+%7D%7D", "=", true)); + params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + assertNull(params.getOperationName()); + assertEquals("query { droid { id }}", params.getQuery()); + assertNull(params.getVariables()); + + args = new Arguments(); + args.addArgument(new HTTPArgument("query", "query%20%7B%20droid%20%7B%20id%20%7D%7D", "=", true)); + params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + assertNull(params.getOperationName()); + assertEquals("query { droid { id }}", params.getQuery()); + assertNull(params.getVariables()); + + try { + args = new Arguments(); + params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + fail("Should have failed due to missing GraphQL parameters."); + } catch (IllegalArgumentException ignore) { + } + + try { + args = new Arguments(); + args.addArgument(new HTTPArgument("query", "select * from emp", "=", false)); + params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + fail("Should have failed due to invalid graph query param."); + } catch (IllegalArgumentException ignore) { + } + + try { + args = new Arguments(); + args.addArgument(new HTTPArgument("query", "query { droid { id }}", "=", false)); + args.addArgument(new HTTPArgument("variables", "r2d2", "=", false)); + params = GraphQLRequestParamUtils.toGraphQLRequestParams(args, null); + fail("Should have failed due to invalid graph query param."); + } catch (IllegalArgumentException ignore) { + } + } +} diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 4fb8339..d6a24f4 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -83,6 +83,7 @@ applications when JMeter is starting up.</p> <ul> <li><bug>53848</bug><bug>63527</bug>Implement a new setting to allow the exclusion of embedded URLs</li> <li><bug>64696</bug><pr>571</pr><pr>595</pr>Freestyle format for names in (Default)SamplerCreater. Based on a patch by Vincent Daburon (vdaburon at gmail.com)</li> + <li><bug>64752</bug>Add GraphQL/HTTP Request Sampler. Contributed by woonsan.</li> </ul> <h3>Other samplers</h3> diff --git a/xdocs/demos/SimpleGraphQLTestPlan.jmx b/xdocs/demos/SimpleGraphQLTestPlan.jmx new file mode 100644 index 0000000..7d3d49f --- /dev/null +++ b/xdocs/demos/SimpleGraphQLTestPlan.jmx @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="UTF-8"?> +<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.3.1-SNAPSHOT 790e46c"> + <hashTree> + <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Simple GraphQL HTTP Request Test Plan" enabled="true"> + <stringProp name="TestPlan.comments">Simple GraphQL HTTP Request Test Plan for demonstration purpose, querying data from a demo GraphQL server</stringProp> + <boolProp name="TestPlan.functional_mode">false</boolProp> + <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp> + <boolProp name="TestPlan.serialize_threadgroups">false</boolProp> + <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + <stringProp name="TestPlan.user_define_classpath"></stringProp> + </TestPlan> + <hashTree> + <ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP Request Defaults" enabled="true"> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true"> + <collectionProp name="Arguments.arguments"/> + </elementProp> + <stringProp name="HTTPSampler.domain">localhost</stringProp> + <stringProp name="HTTPSampler.port">8080</stringProp> + <stringProp name="HTTPSampler.protocol">http</stringProp> + <stringProp name="HTTPSampler.contentEncoding"></stringProp> + <stringProp name="HTTPSampler.path"></stringProp> + <stringProp name="HTTPSampler.concurrentPool">6</stringProp> + <stringProp name="HTTPSampler.connect_timeout"></stringProp> + <stringProp name="HTTPSampler.response_timeout"></stringProp> + </ConfigTestElement> + <hashTree/> + <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true"> + <collectionProp name="HeaderManager.headers"> + <elementProp name="" elementType="Header"> + <stringProp name="Header.name">Content-Type</stringProp> + <stringProp name="Header.value">application/json</stringProp> + </elementProp> + </collectionProp> + </HeaderManager> + <hashTree/> + <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Thread Group" enabled="true"> + <stringProp name="ThreadGroup.on_sample_error">continue</stringProp> + <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true"> + <boolProp name="LoopController.continue_forever">false</boolProp> + <stringProp name="LoopController.loops">1</stringProp> + </elementProp> + <stringProp name="ThreadGroup.num_threads">1</stringProp> + <stringProp name="ThreadGroup.ramp_time">1</stringProp> + <boolProp name="ThreadGroup.scheduler">false</boolProp> + <stringProp name="ThreadGroup.duration"></stringProp> + <stringProp name="ThreadGroup.delay"></stringProp> + <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp> + </ThreadGroup> + <hashTree> + <HTTPSamplerProxy guiclass="GraphQLHTTPSamplerGui" testclass="HTTPSamplerProxy" testname="GraphQL HTTP Request to get the favorite droid" enabled="true"> + <elementProp name="HTTPsampler.Arguments" elementType="Arguments"> + <collectionProp name="Arguments.arguments"> + <elementProp name="" elementType="HTTPArgument" enabled="true"> + <boolProp name="HTTPArgument.always_encode">false</boolProp> + <stringProp name="Argument.value">{"operationName":null,"variables":{"id":"2001"},"query":"query($id: ID!) {\n droid(id: $id) {\n id\n name\n friends {\n id\n name\n appearsIn\n }\n }\n}"}</stringProp> + <stringProp name="Argument.metadata">=</stringProp> + <boolProp name="HTTPArgument.use_equals">true</boolProp> + </elementProp> + </collectionProp> + </elementProp> + <stringProp name="HTTPSampler.domain"></stringProp> + <stringProp name="HTTPSampler.port"></stringProp> + <stringProp name="HTTPSampler.protocol"></stringProp> + <stringProp name="HTTPSampler.contentEncoding"></stringProp> + <stringProp name="HTTPSampler.path">/graphql</stringProp> + <stringProp name="HTTPSampler.method">POST</stringProp> + <boolProp name="HTTPSampler.follow_redirects">false</boolProp> + <boolProp name="HTTPSampler.auto_redirects">false</boolProp> + <boolProp name="HTTPSampler.use_keepalive">true</boolProp> + <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp> + <stringProp name="GraphQLHTTPSampler.operationName"></stringProp> + <stringProp name="GraphQLHTTPSampler.query">query($id: ID!) { + droid(id: $id) { + id + name + friends { + id + name + appearsIn + } + } +}</stringProp> + <stringProp name="GraphQLHTTPSampler.variables">{ + "id": "2001" +}</stringProp> + <boolProp name="HTTPSampler.postBodyRaw">true</boolProp> + <stringProp name="HTTPSampler.embedded_url_re"></stringProp> + <stringProp name="HTTPSampler.embedded_url_exclude_re"></stringProp> + <stringProp name="HTTPSampler.connect_timeout"></stringProp> + <stringProp name="HTTPSampler.response_timeout"></stringProp> + </HTTPSamplerProxy> + <hashTree/> + </hashTree> + <ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="View Results Tree" enabled="true"> + <boolProp name="ResultCollector.error_logging">false</boolProp> + <objProp> + <name>saveConfig</name> + <value class="SampleSaveConfiguration"> + <time>true</time> + <latency>true</latency> + <timestamp>true</timestamp> + <success>true</success> + <label>true</label> + <code>true</code> + <message>true</message> + <threadName>true</threadName> + <dataType>true</dataType> + <encoding>false</encoding> + <assertions>true</assertions> + <subresults>true</subresults> + <responseData>false</responseData> + <samplerData>false</samplerData> + <xml>false</xml> + <fieldNames>true</fieldNames> + <responseHeaders>false</responseHeaders> + <requestHeaders>false</requestHeaders> + <responseDataOnError>false</responseDataOnError> + <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage> + <assertionsResultsToSave>0</assertionsResultsToSave> + <bytes>true</bytes> + <sentBytes>true</sentBytes> + <url>true</url> + <threadCounts>true</threadCounts> + <idleTime>true</idleTime> + <connectTime>true</connectTime> + </value> + </objProp> + <stringProp name="filename"></stringProp> + </ResultCollector> + <hashTree/> + </hashTree> + </hashTree> +</jmeterTestPlan> diff --git a/xdocs/images/screenshots/graphql-http-request-vars.png b/xdocs/images/screenshots/graphql-http-request-vars.png new file mode 100644 index 0000000..b6e0d9e Binary files /dev/null and b/xdocs/images/screenshots/graphql-http-request-vars.png differ diff --git a/xdocs/images/screenshots/graphql-http-request.png b/xdocs/images/screenshots/graphql-http-request.png new file mode 100644 index 0000000..50a3609 Binary files /dev/null and b/xdocs/images/screenshots/graphql-http-request.png differ diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 9e8cf07..b241ac5 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -130,7 +130,7 @@ Latency is set to the time it takes to login. them. This can save you time if you have a lot of HTTP requests or requests with many parameters.</p> - <p><b>There are two different test elements used to define the samplers:</b></p> + <p><b>There are three different test elements used to define the samplers:</b></p> <dl> <dt>AJP/1.3 Sampler</dt><dd>uses the Tomcat mod_jk protocol (allows testing of Tomcat in AJP mode without needing Apache httpd) The AJP Sampler does not support multiple file upload; only the first file will be used. @@ -143,6 +143,17 @@ Latency is set to the time it takes to login. <dt>Blank Value</dt><dd>does not set implementation on HTTP Samplers, so relies on HTTP Request Defaults if present or on <code>jmeter.httpsampler</code> property defined in <code>jmeter.properties</code></dd> </dl> </dd> + <dt>GraphQL HTTP Request</dt><dd>this is a GUI variation of the <b>HTTP Request</b> to provide more convenient UI elements + to view or edit GraphQL <b>Query</b>, <b>Variables</b> and <b>Operation Name</b>, while converting them into HTTP Arguments automatically under the hood + using the same sampler. + This hides or customizes the following UI elements as they are less convenient for or irrelevant to GraphQL over HTTP/HTTPS requests: + <ul> + <li><b>Method</b>: Only POST and GET methods are available conforming the GraphQL over HTTP specification. POST method is selected by default.</li> + <li><b>Parameters</b> and <b>Post Body</b> tabs: you may view or edit parameter content through Query, Variables and Operation Name UI elements instead.</li> + <li><b>File Upload</b> tab: irrelevant to GraphQL queries.</li> + <li><b>Embedded Resources from HTML Files</b> section in the Advanced tab: irrelevant in GraphQL JSON responses.</li> + </ul> + </dd> </dl> <p>The Java HTTP implementation has some limitations:</p> <ul> @@ -201,6 +212,8 @@ https.default.protocol=SSLv3 for additional configuration steps.</p> </description> <figure width="951" height="284" image="http-request-advanced-tab.png">HTTP Request Advanced config fields</figure> +<figure width="950" height="618" image="graphql-http-request.png">Screenshot of Control-Panel of GraphQL HTTP Request</figure> +<figure width="950" height="618" image="graphql-http-request-vars.png">Variables field for GraphQL HTTP Request</figure> <properties> <property name="Name" required="No">Descriptive name for this sampler that is shown in the tree.</property> @@ -363,6 +376,19 @@ and send HTTP/HTTPS requests for all images, Java applets, JavaScript files, CSS If the property <code>httpclient.localaddress</code> is defined, that is used for all HttpClient requests. </property> </properties> + <p>The following parameters are available only for <b>GraphQL HTTP Request</b>:</p> + <properties> + <property name="Query" required="Yes"> + GraphQL query (or mutation) statement. + </property> + <property name="Variables" required="No"> + GraphQL query (or mutation) variables in a valid JSON string. + <b>Note</b>: If the input string is not a valid JSON string, this will be ignored with an ERROR log. + </property> + <property name="Operation Name" required="No"> + Optional GraphQL operation name when making a request for multi-operation documents. + </property> + </properties> <note> When using Automatic Redirection, cookies are only sent for the initial URL. This can cause unexpected behaviour for web-sites that redirect to a local server.