From 249078928cd5382320668175df37864cdf82054d Mon Sep 17 00:00:00 2001 From: Piotr Rzysko Date: Mon, 23 Dec 2024 11:11:28 +0100 Subject: [PATCH] Ensure queries returned via REST API are redacted @JsonConstructor for TrimmedBasicQueryInfo was introduced to facilitate the deserialization of server responses in tests. --- .../server/ui/TrimmedBasicQueryInfo.java | 40 +++++ .../io/trino/server/TestQueryResource.java | 48 +++++ .../server/TestQueryStateInfoResource.java | 58 +++++- .../TestResourceGroupStateInfoResource.java | 133 ++++++++++++++ .../trino/server/ui/TestUiQueryResource.java | 166 ++++++++++++++++++ 5 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 core/trino-main/src/test/java/io/trino/server/TestResourceGroupStateInfoResource.java create mode 100644 core/trino-main/src/test/java/io/trino/server/ui/TestUiQueryResource.java diff --git a/core/trino-main/src/main/java/io/trino/server/ui/TrimmedBasicQueryInfo.java b/core/trino-main/src/main/java/io/trino/server/ui/TrimmedBasicQueryInfo.java index ab2cff5e9300..2982740949e5 100644 --- a/core/trino-main/src/main/java/io/trino/server/ui/TrimmedBasicQueryInfo.java +++ b/core/trino-main/src/main/java/io/trino/server/ui/TrimmedBasicQueryInfo.java @@ -13,6 +13,7 @@ */ package io.trino.server.ui; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.errorprone.annotations.Immutable; import io.trino.execution.QueryState; @@ -54,6 +55,45 @@ public class TrimmedBasicQueryInfo private final Optional queryType; private final RetryPolicy retryPolicy; + @JsonCreator + public TrimmedBasicQueryInfo( + @JsonProperty("queryId") QueryId queryId, + @JsonProperty("sessionUser") String sessionUser, + @JsonProperty("sessionPrincipal") Optional sessionPrincipal, + @JsonProperty("sessionSource") Optional sessionSource, + @JsonProperty("resourceGroupId") Optional resourceGroupId, + @JsonProperty("queryDataEncoding") Optional queryDataEncoding, + @JsonProperty("state") QueryState state, + @JsonProperty("scheduled") boolean scheduled, + @JsonProperty("self") URI self, + @JsonProperty("queryTextPreview") String queryTextPreview, + @JsonProperty("updateType") Optional updateType, + @JsonProperty("preparedQuery") Optional preparedQuery, + @JsonProperty("queryStats") BasicQueryStats queryStats, + @JsonProperty("errorType") Optional errorType, + @JsonProperty("errorCode") Optional errorCode, + @JsonProperty("queryType") Optional queryType, + @JsonProperty("retryPolicy") RetryPolicy retryPolicy) + { + this.queryId = requireNonNull(queryId, "queryId is null"); + this.sessionUser = requireNonNull(sessionUser, "sessionUser is null"); + this.sessionPrincipal = requireNonNull(sessionPrincipal, "sessionPrincipal is null"); + this.sessionSource = requireNonNull(sessionSource, "sessionSource is null"); + this.resourceGroupId = requireNonNull(resourceGroupId, "resourceGroupId is null"); + this.queryDataEncoding = requireNonNull(queryDataEncoding, "queryDataEncoding is null"); + this.state = requireNonNull(state, "state is null"); + this.scheduled = scheduled; + this.self = requireNonNull(self, "self is null"); + this.queryTextPreview = requireNonNull(queryTextPreview, "queryTextPreview is null"); + this.updateType = requireNonNull(updateType, "updateType is null"); + this.preparedQuery = requireNonNull(preparedQuery, "preparedQuery is null"); + this.queryStats = requireNonNull(queryStats, "queryStats is null"); + this.errorType = requireNonNull(errorType, "errorType is null"); + this.errorCode = requireNonNull(errorCode, "errorCode is null"); + this.queryType = requireNonNull(queryType, "queryType is null"); + this.retryPolicy = requireNonNull(retryPolicy, "retryPolicy is null"); + } + public TrimmedBasicQueryInfo(BasicQueryInfo queryInfo) { this.queryId = requireNonNull(queryInfo.getQueryId(), "queryId is null"); diff --git a/core/trino-main/src/test/java/io/trino/server/TestQueryResource.java b/core/trino-main/src/test/java/io/trino/server/TestQueryResource.java index d837b0624b07..8384330782b0 100644 --- a/core/trino-main/src/test/java/io/trino/server/TestQueryResource.java +++ b/core/trino-main/src/test/java/io/trino/server/TestQueryResource.java @@ -13,6 +13,7 @@ */ package io.trino.server; +import com.google.common.collect.ImmutableSet; import com.google.inject.Key; import io.airlift.http.client.HttpClient; import io.airlift.http.client.HttpUriBuilder; @@ -29,6 +30,8 @@ import io.trino.client.QueryDataClientJacksonModule; import io.trino.client.QueryResults; import io.trino.client.ResultRowsDecoder; +import io.trino.connector.MockConnectorFactory; +import io.trino.connector.MockConnectorPlugin; import io.trino.execution.QueryInfo; import io.trino.plugin.tpch.TpchPlugin; import io.trino.server.testing.TestingTrinoServer; @@ -65,6 +68,7 @@ import static io.trino.testing.TestingAccessControlManager.TestingPrivilegeType.KILL_QUERY; import static io.trino.testing.TestingAccessControlManager.TestingPrivilegeType.VIEW_QUERY; import static io.trino.testing.TestingAccessControlManager.privilege; +import static io.trino.testing.TestingNames.randomNameSuffix; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -94,6 +98,9 @@ public void setup() { client = new JettyHttpClient(); server = TestingTrinoServer.create(); + server.installPlugin(new MockConnectorPlugin(MockConnectorFactory.builder() + .withSecuritySensitivePropertyNames(ImmutableSet.of("password")) + .build())); server.installPlugin(new TpchPlugin()); server.createCatalog("tpch", "tpch"); } @@ -226,6 +233,47 @@ public void testGetQueryInfoExecutionFailure() assertThat(info.getFailureInfo().getErrorCode()).isEqualTo(DIVISION_BY_ZERO.toErrorCode()); } + @Test + public void testGetQueryInfosWithRedactedSecrets() + { + String catalog = "catalog_" + randomNameSuffix(); + runToCompletion(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '1234' + )""".formatted(catalog)); + + List infos = getQueryInfos("/v1/query"); + assertThat(infos.size()).isEqualTo(1); + assertThat(infos.getFirst().getQuery()).isEqualTo(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '***' + )""".formatted(catalog)); + } + + @Test + public void testGetQueryInfoWithRedactedSecrets() + { + String catalog = "catalog_" + randomNameSuffix(); + String queryId = runToCompletion(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '1234' + )""".formatted(catalog)); + + QueryInfo queryInfo = getQueryInfo(queryId); + assertThat(queryInfo.getQuery()).isEqualTo(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '***' + )""".formatted(catalog)); + } + @Test public void testCancel() { diff --git a/core/trino-main/src/test/java/io/trino/server/TestQueryStateInfoResource.java b/core/trino-main/src/test/java/io/trino/server/TestQueryStateInfoResource.java index 09bc72e3b61f..00fe95501904 100644 --- a/core/trino-main/src/test/java/io/trino/server/TestQueryStateInfoResource.java +++ b/core/trino-main/src/test/java/io/trino/server/TestQueryStateInfoResource.java @@ -13,6 +13,7 @@ */ package io.trino.server; +import com.google.common.collect.ImmutableSet; import com.google.common.io.Closer; import io.airlift.http.client.HttpClient; import io.airlift.http.client.Request; @@ -23,6 +24,8 @@ import io.airlift.json.ObjectMapperProvider; import io.airlift.units.Duration; import io.trino.client.QueryResults; +import io.trino.connector.MockConnectorFactory; +import io.trino.connector.MockConnectorPlugin; import io.trino.plugin.tpch.TpchPlugin; import io.trino.server.protocol.spooling.QueryDataJacksonModule; import io.trino.server.testing.TestingTrinoServer; @@ -47,6 +50,7 @@ import static io.airlift.json.JsonCodec.listJsonCodec; import static io.trino.client.ProtocolHeaders.TRINO_HEADERS; import static io.trino.execution.QueryState.FAILED; +import static io.trino.execution.QueryState.FINISHING; import static io.trino.execution.QueryState.RUNNING; import static io.trino.server.TestQueryResource.BASIC_QUERY_INFO_CODEC; import static io.trino.testing.TestingAccessControlManager.TestingPrivilegeType.VIEW_QUERY; @@ -71,11 +75,15 @@ public class TestQueryStateInfoResource private TestingTrinoServer server; private HttpClient client; private QueryResults queryResults; + private QueryResults createCatalogResults; @BeforeAll public void setUp() { server = TestingTrinoServer.create(); + server.installPlugin(new MockConnectorPlugin(MockConnectorFactory.builder() + .withSecuritySensitivePropertyNames(ImmutableSet.of("password")) + .build())); server.installPlugin(new TpchPlugin()); server.createCatalog("tpch", "tpch"); client = new JettyHttpClient(); @@ -96,6 +104,19 @@ public void setUp() QueryResults queryResults2 = client.execute(request2, createJsonResponseHandler(QUERY_RESULTS_JSON_CODEC)); client.execute(prepareGet().setUri(queryResults2.getNextUri()).build(), createJsonResponseHandler(QUERY_RESULTS_JSON_CODEC)); + Request createCatalogRequest = preparePost() + .setUri(uriBuilderFrom(server.getBaseUrl()).replacePath("/v1/statement").build()) + .setBodyGenerator(createStaticBodyGenerator(""" + CREATE CATALOG test_catalog USING mock + WITH ( + "user" = 'bob', + "password" = '1234' + )""", UTF_8)) + .setHeader(TRINO_HEADERS.requestUser(), "catalogCreator") + .build(); + createCatalogResults = client.execute(createCatalogRequest, createJsonResponseHandler(jsonCodec(QueryResults.class))); + client.execute(prepareGet().setUri(createCatalogResults.getNextUri()).build(), createJsonResponseHandler(QUERY_RESULTS_JSON_CODEC)); + // queries are started in the background, so they may not all be immediately visible long start = System.nanoTime(); while (Duration.nanosSince(start).compareTo(new Duration(5, MINUTES)) < 0) { @@ -105,8 +126,8 @@ public void setUp() .setHeader(TRINO_HEADERS.requestUser(), "unknown") .build(), createJsonResponseHandler(BASIC_QUERY_INFO_CODEC)); - if (queryInfos.size() == 2) { - if (queryInfos.stream().allMatch(info -> info.getState() == RUNNING)) { + if (queryInfos.size() == 3) { + if (queryInfos.stream().allMatch(info -> info.getState() == RUNNING || info.getState() == FINISHING)) { break; } @@ -143,7 +164,12 @@ public void testGetAllQueryStateInfos() .build(), createJsonResponseHandler(listJsonCodec(QueryStateInfo.class))); - assertThat(infos).hasSize(2); + assertThat(infos.size()).isEqualTo(3); + QueryStateInfo createCatalogInfo = infos.stream() + .filter(info -> info.getQueryId().getId().equals(createCatalogResults.getId())) + .findFirst() + .orElse(null); + assertCreateCatalogQueryIsRedacted(createCatalogInfo); } @Test @@ -185,6 +211,19 @@ public void testGetQueryStateInfo() assertThat(info).isNotNull(); } + @Test + public void testGetQueryStateInfoWithRedactedSecrets() + { + QueryStateInfo info = client.execute( + prepareGet() + .setUri(server.resolve("/v1/queryState/" + createCatalogResults.getId())) + .setHeader(TRINO_HEADERS.requestUser(), "unknown") + .build(), + createJsonResponseHandler(jsonCodec(QueryStateInfo.class))); + + assertCreateCatalogQueryIsRedacted(info); + } + @Test public void testGetAllQueryStateInfosDenied() { @@ -194,7 +233,7 @@ public void testGetAllQueryStateInfosDenied() .setHeader(TRINO_HEADERS.requestUser(), "any-other-user") .build(), createJsonResponseHandler(listJsonCodec(QueryStateInfo.class))); - assertThat(infos).hasSize(2); + assertThat(infos).hasSize(3); testGetAllQueryStateInfosDenied("user1", 1); testGetAllQueryStateInfosDenied("any-other-user", 0); @@ -249,4 +288,15 @@ public void testGetQueryStateInfoNo() .isInstanceOf(UnexpectedResponseException.class) .hasMessageMatching("Expected response code .*, but was 404"); } + + private static void assertCreateCatalogQueryIsRedacted(QueryStateInfo info) + { + assertThat(info).isNotNull(); + assertThat(info.getQuery()).isEqualTo(""" + CREATE CATALOG test_catalog USING mock + WITH ( + "user" = 'bob', + "password" = '***' + )"""); + } } diff --git a/core/trino-main/src/test/java/io/trino/server/TestResourceGroupStateInfoResource.java b/core/trino-main/src/test/java/io/trino/server/TestResourceGroupStateInfoResource.java new file mode 100644 index 000000000000..6d0b4be77bd9 --- /dev/null +++ b/core/trino-main/src/test/java/io/trino/server/TestResourceGroupStateInfoResource.java @@ -0,0 +1,133 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import io.airlift.http.client.HttpClient; +import io.airlift.http.client.Request; +import io.airlift.http.client.jetty.JettyHttpClient; +import io.trino.client.QueryResults; +import io.trino.connector.MockConnectorFactory; +import io.trino.connector.MockConnectorPlugin; +import io.trino.server.testing.TestingTrinoServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.Execution; + +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkState; +import static io.airlift.http.client.HttpUriBuilder.uriBuilderFrom; +import static io.airlift.http.client.JsonResponseHandler.createJsonResponseHandler; +import static io.airlift.http.client.Request.Builder.prepareGet; +import static io.airlift.http.client.Request.Builder.preparePost; +import static io.airlift.http.client.StaticBodyGenerator.createStaticBodyGenerator; +import static io.airlift.json.JsonCodec.jsonCodec; +import static io.airlift.testing.Closeables.closeAll; +import static io.trino.client.ProtocolHeaders.TRINO_HEADERS; +import static io.trino.testing.TestingNames.randomNameSuffix; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.junit.jupiter.api.parallel.ExecutionMode.CONCURRENT; + +@TestInstance(PER_CLASS) +@Execution(CONCURRENT) +final class TestResourceGroupStateInfoResource +{ + private TestingTrinoServer server; + private HttpClient client; + + @BeforeAll + public void setup() + { + client = new JettyHttpClient(); + server = TestingTrinoServer.builder() + .setProperties(ImmutableMap.builder() + .put("web-ui.authentication.type", "fixed") + .put("web-ui.user", "test-user") + .buildOrThrow()) + .build(); + server.installPlugin(new MockConnectorPlugin(MockConnectorFactory.builder() + .withSecuritySensitivePropertyNames(ImmutableSet.of("password")) + .build())); + } + + @AfterAll + public void teardown() + throws Exception + { + closeAll(server, client); + server = null; + client = null; + } + + @Test + void testGetResourceGroupInfoWithRedactedSecrets() + { + String catalog = "catalog_" + randomNameSuffix(); + startQuery(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '1234' + )""".formatted(catalog)); + + ResourceGroupInfo resourceGroupInfo = getResourceGroupInfo("global"); + Optional> queryStateInfos = resourceGroupInfo.runningQueries(); + assertThat(queryStateInfos.isPresent()).isTrue(); + List queryStates = queryStateInfos.get(); + assertThat(queryStates.size()).isEqualTo(1); + assertThat(queryStates.getFirst().getQuery()).isEqualTo(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '***' + )""".formatted(catalog)); + } + + private void startQuery(String sql) + { + Request request = preparePost() + .setUri(server.resolve("/v1/statement")) + .setBodyGenerator(createStaticBodyGenerator(sql, UTF_8)) + .setHeader(TRINO_HEADERS.requestUser(), "unknown") + .build(); + QueryResults queryResults = client.execute(request, createJsonResponseHandler(jsonCodec(QueryResults.class))); + checkState(queryResults.getNextUri() != null && queryResults.getNextUri().toString().contains("/v1/statement/queued/"), "nextUri should point to /v1/statement/queued/"); + request = prepareGet() + .setHeader(TRINO_HEADERS.requestUser(), "unknown") + .setUri(queryResults.getNextUri()) + .build(); + client.execute(request, createJsonResponseHandler(jsonCodec(QueryResults.class))); + } + + private ResourceGroupInfo getResourceGroupInfo(String resourceGroupId) + { + URI uri = uriBuilderFrom(server.getBaseUrl()) + .replacePath("/v1/resourceGroupState") + .appendPath(resourceGroupId) + .build(); + Request request = prepareGet() + .setUri(uri) + .setHeader(TRINO_HEADERS.requestUser(), "unknown") + .build(); + return client.execute(request, createJsonResponseHandler(jsonCodec(ResourceGroupInfo.class))); + } +} diff --git a/core/trino-main/src/test/java/io/trino/server/ui/TestUiQueryResource.java b/core/trino-main/src/test/java/io/trino/server/ui/TestUiQueryResource.java new file mode 100644 index 000000000000..736b27d5263d --- /dev/null +++ b/core/trino-main/src/test/java/io/trino/server/ui/TestUiQueryResource.java @@ -0,0 +1,166 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.server.ui; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.inject.Key; +import io.airlift.http.client.HttpClient; +import io.airlift.http.client.Request; +import io.airlift.http.client.jetty.JettyHttpClient; +import io.airlift.json.JsonCodec; +import io.airlift.json.JsonCodecFactory; +import io.trino.client.QueryResults; +import io.trino.connector.MockConnectorFactory; +import io.trino.connector.MockConnectorPlugin; +import io.trino.execution.QueryInfo; +import io.trino.server.testing.TestingTrinoServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.Execution; + +import java.net.URI; +import java.util.List; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static io.airlift.http.client.HttpUriBuilder.uriBuilderFrom; +import static io.airlift.http.client.JsonResponseHandler.createJsonResponseHandler; +import static io.airlift.http.client.Request.Builder.prepareGet; +import static io.airlift.http.client.Request.Builder.preparePost; +import static io.airlift.http.client.StaticBodyGenerator.createStaticBodyGenerator; +import static io.airlift.json.JsonCodec.jsonCodec; +import static io.airlift.json.JsonCodec.listJsonCodec; +import static io.airlift.testing.Closeables.closeAll; +import static io.trino.client.ProtocolHeaders.TRINO_HEADERS; +import static io.trino.testing.TestingNames.randomNameSuffix; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.junit.jupiter.api.parallel.ExecutionMode.CONCURRENT; + +@TestInstance(PER_CLASS) +@Execution(CONCURRENT) +final class TestUiQueryResource +{ + private TestingTrinoServer server; + private HttpClient client; + + @BeforeAll + public void setup() + { + client = new JettyHttpClient(); + server = TestingTrinoServer.builder() + .setProperties(ImmutableMap.builder() + .put("web-ui.authentication.type", "fixed") + .put("web-ui.user", "test-user") + .buildOrThrow()) + .build(); + server.installPlugin(new MockConnectorPlugin(MockConnectorFactory.builder() + .withSecuritySensitivePropertyNames(ImmutableSet.of("password")) + .build())); + } + + @AfterAll + public void teardown() + throws Exception + { + closeAll(server, client); + server = null; + client = null; + } + + @Test + void testGetQueryInfosWithRedactedSecrets() + { + String catalog = "catalog_" + randomNameSuffix(); + String queryId = runToCompletion(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '1234' + )""".formatted(catalog)); + + List infos = getQueryInfos().stream() + .filter(info -> info.getQueryId().getId().equals(queryId)) + .collect(toImmutableList()); + assertThat(infos.size()).isEqualTo(1); + assertThat(infos.getFirst().getQueryTextPreview()).isEqualTo(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '***' + )""".formatted(catalog)); + } + + @Test + void testGetQueryInfoWithRedactedSecrets() + { + String catalog = "catalog_" + randomNameSuffix(); + String queryId = runToCompletion(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '1234' + )""".formatted(catalog)); + + QueryInfo queryInfo = getQueryInfo(queryId); + assertThat(queryInfo.getQuery()).isEqualTo(""" + CREATE CATALOG %s USING mock + WITH ( + "user" = 'bob', + "password" = '***' + )""".formatted(catalog)); + } + + private String runToCompletion(String sql) + { + Request request = preparePost() + .setHeader(TRINO_HEADERS.requestUser(), "unknown") + .setUri(server.getBaseUrl().resolve("/v1/statement")) + .setBodyGenerator(createStaticBodyGenerator(sql, UTF_8)) + .build(); + QueryResults queryResults = client.execute(request, createJsonResponseHandler(jsonCodec(QueryResults.class))); + while (queryResults.getNextUri() != null) { + request = prepareGet() + .setHeader(TRINO_HEADERS.requestUser(), "unknown") + .setUri(queryResults.getNextUri()) + .build(); + queryResults = client.execute(request, createJsonResponseHandler(jsonCodec(QueryResults.class))); + } + return queryResults.getId(); + } + + private List getQueryInfos() + { + Request request = prepareGet() + .setUri(server.resolve("/ui/api/query")) + .build(); + return client.execute(request, createJsonResponseHandler(listJsonCodec(TrimmedBasicQueryInfo.class))); + } + + private QueryInfo getQueryInfo(String queryId) + { + URI uri = uriBuilderFrom(server.getBaseUrl()) + .replacePath("/ui/api/query") + .appendPath(queryId) + .build(); + Request request = prepareGet() + .setUri(uri) + .build(); + JsonCodec codec = server.getInstance(Key.get(JsonCodecFactory.class)).jsonCodec(QueryInfo.class); + return client.execute(request, createJsonResponseHandler(codec)); + } +}