diff --git a/docs/api-reference/sql-api.md b/docs/api-reference/sql-api.md index 2b25bdd02f0b..ccba5b154355 100644 --- a/docs/api-reference/sql-api.md +++ b/docs/api-reference/sql-api.md @@ -1286,6 +1286,9 @@ Getting the query results for an ingestion query returns an empty response. * `resultFormat` (optional) * Type: String * Defines the format in which the results are presented. The following options are supported `arrayLines`,`objectLines`,`array`,`object`, and `csv`. The default is `object`. +* `filename` (optional) + * Type: String + * If set, attaches a `Content-Disposition` header to the response with the value of `attachment; filename={filename}`. The filename must not be longer than 255 characters and must not contain the characters `/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, `\0`, `\n`, or `\r`. #### Responses diff --git a/extensions-core/multi-stage-query/src/main/java/org/apache/druid/msq/sql/resources/SqlStatementResource.java b/extensions-core/multi-stage-query/src/main/java/org/apache/druid/msq/sql/resources/SqlStatementResource.java index dcaa447539b1..f0a800c246ab 100644 --- a/extensions-core/multi-stage-query/src/main/java/org/apache/druid/msq/sql/resources/SqlStatementResource.java +++ b/extensions-core/multi-stage-query/src/main/java/org/apache/druid/msq/sql/resources/SqlStatementResource.java @@ -95,6 +95,7 @@ import org.jboss.netty.handler.codec.http.HttpResponseStatus; import javax.servlet.http.HttpServletRequest; +import javax.validation.constraints.NotNull; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; @@ -114,6 +115,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -122,6 +124,8 @@ public class SqlStatementResource { public static final String RESULT_FORMAT = "__resultFormat"; + public static final String CONTENT_DISPOSITION_RESPONSE_HEADER = "Content-Disposition"; + private static final Pattern FILENAME_PATTERN = Pattern.compile("^[^/:*?><\\\\\"|\0\n\r]*$"); private static final Logger log = new Logger(SqlStatementResource.class); private final SqlStatementFactory msqSqlStatementFactory; private final ObjectMapper jsonMapper; @@ -277,6 +281,7 @@ public Response doGetResults( @PathParam("id") final String queryId, @QueryParam("page") Long page, @QueryParam("resultFormat") String resultFormat, + @QueryParam("filename") String filename, @Context final HttpServletRequest req ) { @@ -309,10 +314,12 @@ public Response doGetResults( ); throwIfQueryIsNotSuccessful(queryId, statusPlus); + final String contentDispositionHeaderValue = filename != null ? StringUtils.format("attachment; filename=\"%s\"", validateFilename(filename)) : null; + Optional> signature = SqlStatementResourceHelper.getSignature(msqControllerTask); if (!signature.isPresent() || MSQControllerTask.isIngestion(msqControllerTask.getQuerySpec())) { // Since it's not a select query, nothing to return. - return Response.ok().build(); + return addContentDisposition(Response.ok(), contentDispositionHeaderValue).build(); } // returning results @@ -321,18 +328,20 @@ public Response doGetResults( results = getResultYielder(queryId, page, msqControllerTask, closer); if (!results.isPresent()) { // no results, return empty - return Response.ok().build(); + return addContentDisposition(Response.ok(), contentDispositionHeaderValue).build(); } ResultFormat preferredFormat = getPreferredResultFormat(resultFormat, msqControllerTask.getQuerySpec()); - return Response.ok((StreamingOutput) outputStream -> resultPusher( + final Response.ResponseBuilder responseBuilder = Response.ok((StreamingOutput) outputStream -> resultPusher( queryId, signature, closer, results, new CountingOutputStream(outputStream), preferredFormat - )).build(); + )); + + return addContentDisposition(responseBuilder, contentDispositionHeaderValue).build(); } @@ -976,6 +985,42 @@ private void checkForDurableStorageConnectorImpl() } } + private static Response.ResponseBuilder addContentDisposition( + Response.ResponseBuilder responseBuilder, + String contentDisposition + ) + { + if (contentDisposition != null) { + responseBuilder.header(CONTENT_DISPOSITION_RESPONSE_HEADER, contentDisposition); + } + return responseBuilder; + } + + /** + * Validates that a filename is valid. Filenames are considered to be valid if it is: + * + */ + @VisibleForTesting + static String validateFilename(@NotNull String filename) + { + if (filename.isEmpty()) { + throw InvalidInput.exception("Filename cannot be empty."); + } + + if (filename.length() > 255) { + throw InvalidInput.exception("Filename cannot be longer than 255 characters."); + } + + if (!FILENAME_PATTERN.matcher(filename).matches()) { + throw InvalidInput.exception("Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + } + return filename; + } + private T contactOverlord(final ListenableFuture future, String queryId) { try { diff --git a/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlMSQStatementResourcePostTest.java b/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlMSQStatementResourcePostTest.java index e810de56f4e1..0437fbccda25 100644 --- a/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlMSQStatementResourcePostTest.java +++ b/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlMSQStatementResourcePostTest.java @@ -393,6 +393,7 @@ public void testWithDurableStorage() throws IOException sqlStatementResult.getQueryId(), null, ResultFormat.OBJECTLINES.name(), + null, SqlStatementResourceTest.makeOkRequest() ), objectMapper @@ -406,6 +407,7 @@ public void testWithDurableStorage() throws IOException sqlStatementResult.getQueryId(), 0L, ResultFormat.OBJECTLINES.name(), + null, SqlStatementResourceTest.makeOkRequest() ), objectMapper @@ -419,6 +421,7 @@ public void testWithDurableStorage() throws IOException sqlStatementResult.getQueryId(), 2L, ResultFormat.OBJECTLINES.name(), + null, SqlStatementResourceTest.makeOkRequest() ), objectMapper @@ -485,6 +488,7 @@ public void testMultipleWorkersWithPageSizeLimiting() throws IOException sqlStatementResult.getQueryId(), null, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); @@ -492,6 +496,7 @@ public void testMultipleWorkersWithPageSizeLimiting() throws IOException sqlStatementResult.getQueryId(), 0L, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); @@ -499,6 +504,7 @@ public void testMultipleWorkersWithPageSizeLimiting() throws IOException sqlStatementResult.getQueryId(), 1L, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); @@ -506,6 +512,7 @@ public void testMultipleWorkersWithPageSizeLimiting() throws IOException sqlStatementResult.getQueryId(), 2L, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); @@ -513,6 +520,7 @@ public void testMultipleWorkersWithPageSizeLimiting() throws IOException sqlStatementResult.getQueryId(), 3L, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); @@ -520,6 +528,7 @@ public void testMultipleWorkersWithPageSizeLimiting() throws IOException sqlStatementResult.getQueryId(), 4L, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); } @@ -565,6 +574,7 @@ public void testResultFormat() throws Exception sqlStatementResult.getQueryId(), null, resultFormat.name(), + null, SqlStatementResourceTest.makeOkRequest() ), objectMapper ) @@ -577,6 +587,7 @@ public void testResultFormat() throws Exception sqlStatementResult.getQueryId(), 0L, resultFormat.name(), + null, SqlStatementResourceTest.makeOkRequest() ), objectMapper ) @@ -616,6 +627,7 @@ public void testResultFormatWithParamInSelect() throws IOException sqlStatementResult.getQueryId(), null, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); @@ -623,6 +635,7 @@ public void testResultFormatWithParamInSelect() throws IOException sqlStatementResult.getQueryId(), 0L, ResultFormat.ARRAY.name(), + null, SqlStatementResourceTest.makeOkRequest() ))); } @@ -695,6 +708,7 @@ public void testInsert() actual.getQueryId(), 0L, null, + null, SqlStatementResourceTest.makeOkRequest() ); Assert.assertEquals(Response.Status.OK.getStatusCode(), resultsResponse.getStatus()); @@ -738,6 +752,7 @@ public void testReplaceAll() actual.getQueryId(), 0L, null, + null, SqlStatementResourceTest.makeOkRequest() ); Assert.assertEquals(Response.Status.OK.getStatusCode(), resultsResponse.getStatus()); diff --git a/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlStatementResourceTest.java b/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlStatementResourceTest.java index 40dcb303b1dc..83ae1de7f9d3 100644 --- a/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlStatementResourceTest.java +++ b/extensions-core/multi-stage-query/src/test/java/org/apache/druid/msq/sql/resources/SqlStatementResourceTest.java @@ -22,11 +22,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; import org.apache.calcite.sql.type.SqlTypeName; import org.apache.druid.client.indexing.TaskPayloadResponse; import org.apache.druid.client.indexing.TaskStatusResponse; +import org.apache.druid.error.DruidException; +import org.apache.druid.error.DruidExceptionMatcher; import org.apache.druid.error.ErrorResponse; import org.apache.druid.indexer.TaskLocation; import org.apache.druid.indexer.TaskState; @@ -92,6 +95,7 @@ import org.mockito.Mock; import org.mockito.Mockito; +import javax.annotation.Nullable; import javax.ws.rs.core.Response; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -102,6 +106,8 @@ import java.util.List; import java.util.function.Supplier; +import static org.hamcrest.MatcherAssert.assertThat; + public class SqlStatementResourceTest extends MSQTestBase { public static final DateTime CREATED_TIME = DateTimes.of("2023-05-31T12:00Z"); @@ -683,6 +689,16 @@ private static AuthenticationResult makeAuthResultForUser(String user) ); } + @Nullable + private Object getHeader(Response resp, String header) + { + final List objects = resp.getMetadata().get(header); + if (objects == null) { + return null; + } + return Iterables.getOnlyElement(objects); + } + @BeforeEach public void init() { @@ -716,7 +732,7 @@ public void testMSQSelectAcceptedQuery() ); assertExceptionMessage( - resource.doGetResults(ACCEPTED_SELECT_MSQ_QUERY, 0L, null, makeOkRequest()), + resource.doGetResults(ACCEPTED_SELECT_MSQ_QUERY, 0L, null, null, makeOkRequest()), StringUtils.format( "Query[%s] is currently in [%s] state. Please wait for it to complete.", ACCEPTED_SELECT_MSQ_QUERY, @@ -750,7 +766,7 @@ public void testMSQSelectRunningQuery() ); assertExceptionMessage( - resource.doGetResults(RUNNING_SELECT_MSQ_QUERY, 0L, null, makeOkRequest()), + resource.doGetResults(RUNNING_SELECT_MSQ_QUERY, 0L, null, null, makeOkRequest()), StringUtils.format( "Query[%s] is currently in [%s] state. Please wait for it to complete.", RUNNING_SELECT_MSQ_QUERY, @@ -816,7 +832,7 @@ public void testFinishedSelectMSQQuery() throws Exception null )), objectMapper.writeValueAsString(response.getEntity())); - Response resultsResponse = resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), makeOkRequest()); + Response resultsResponse = resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), null, makeOkRequest()); Assert.assertEquals(Response.Status.OK.getStatusCode(), resultsResponse.getStatus()); String expectedResult = "{\"_time\":123,\"alias\":\"foo\",\"market\":\"bar\"}\n" @@ -824,6 +840,8 @@ public void testFinishedSelectMSQQuery() throws Exception assertExpectedResults(expectedResult, resultsResponse); + Assert.assertNull(getHeader(resultsResponse, SqlStatementResource.CONTENT_DISPOSITION_RESPONSE_HEADER)); + Assert.assertEquals( Response.Status.OK.getStatusCode(), resource.deleteQuery(FINISHED_SELECT_MSQ_QUERY, makeOkRequest()).getStatus() @@ -835,6 +853,7 @@ public void testFinishedSelectMSQQuery() throws Exception FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), + null, makeOkRequest() ) ); @@ -845,14 +864,50 @@ public void testFinishedSelectMSQQuery() throws Exception FINISHED_SELECT_MSQ_QUERY, null, ResultFormat.OBJECTLINES.name(), + null, makeOkRequest() ) ); Assert.assertEquals( Response.Status.BAD_REQUEST.getStatusCode(), - resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, -1L, null, makeOkRequest()).getStatus() + resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, -1L, null, null, makeOkRequest()).getStatus() + ); + + Assert.assertEquals( + "attachment; filename=\"my-file.ndjson\"", + getHeader( + resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), "my-file.ndjson", makeOkRequest()), + SqlStatementResource.CONTENT_DISPOSITION_RESPONSE_HEADER + ) + ); + } + + @Test + public void testDownloadResultsAsFile() throws Exception + { + final String expectedResult = "{\"_time\":123,\"alias\":\"foo\",\"market\":\"bar\"}\n" + + "{\"_time\":234,\"alias\":\"foo1\",\"market\":\"bar1\"}\n\n"; + + Response resultsResponse1 = resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), "results.txt", makeOkRequest()); + Assert.assertEquals(Response.Status.OK.getStatusCode(), resultsResponse1.getStatus()); + Assert.assertEquals( + "attachment; filename=\"results.txt\"", + getHeader(resultsResponse1, "Content-Disposition") + ); + assertExpectedResults(expectedResult, resultsResponse1); + + Response resultsResponse2 = resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), "final results.txt", makeOkRequest()); + Assert.assertEquals(Response.Status.OK.getStatusCode(), resultsResponse2.getStatus()); + Assert.assertEquals( + "attachment; filename=\"final results.txt\"", + getHeader(resultsResponse2, "Content-Disposition") ); + assertExpectedResults(expectedResult, resultsResponse2); + + Response resultsResponse3 = resource.doGetResults(FINISHED_SELECT_MSQ_QUERY, 0L, ResultFormat.OBJECTLINES.name(), "/Users/Name/final.txt", makeOkRequest()); + Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resultsResponse3.getStatus()); + Assert.assertNull(resultsResponse3.getMetadata().get("Content-Disposition")); } private void assertExpectedResults(String expectedResult, Response resultsResponse) throws IOException @@ -867,7 +922,7 @@ public void testFailedMSQQuery() for (String queryID : ImmutableList.of(ERRORED_SELECT_MSQ_QUERY, ERRORED_INSERT_MSQ_QUERY)) { assertExceptionMessage(resource.doGetStatus(queryID, false, makeOkRequest()), FAILURE_MSG, Response.Status.OK); assertExceptionMessage( - resource.doGetResults(queryID, 0L, null, makeOkRequest()), + resource.doGetResults(queryID, 0L, null, null, makeOkRequest()), StringUtils.format( "Query[%s] failed. Check the status api for more details.", queryID @@ -897,18 +952,29 @@ public void testFinishedInsertMSQQuery() null ), (SqlStatementResult) response.getEntity()); + final Response resultResponse = resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, 0L, null, null, makeOkRequest()); + Assert.assertEquals( Response.Status.OK.getStatusCode(), - resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, 0L, null, makeOkRequest()).getStatus() + resultResponse.getStatus() ); + Assert.assertNull(getHeader(resultResponse, SqlStatementResource.CONTENT_DISPOSITION_RESPONSE_HEADER)); Assert.assertEquals( Response.Status.OK.getStatusCode(), - resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, null, null, makeOkRequest()).getStatus() + resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, null, null, null, makeOkRequest()).getStatus() ); Assert.assertEquals( Response.Status.BAD_REQUEST.getStatusCode(), - resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, -1L, null, makeOkRequest()).getStatus() + resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, -1L, null, null, makeOkRequest()).getStatus() + ); + + Assert.assertEquals( + "attachment; filename=\"my-file.ndjson\"", + getHeader( + resource.doGetResults(FINISHED_INSERT_MSQ_QUERY, 0L, null, "my-file.ndjson", makeOkRequest()), + SqlStatementResource.CONTENT_DISPOSITION_RESPONSE_HEADER + ) ); } @@ -917,7 +983,7 @@ public void testNonMSQTasks() { for (String queryID : ImmutableList.of(RUNNING_NON_MSQ_TASK, FAILED_NON_MSQ_TASK, FINISHED_NON_MSQ_TASK)) { assertNotFound(resource.doGetStatus(queryID, false, makeOkRequest()), queryID); - assertNotFound(resource.doGetResults(queryID, 0L, null, makeOkRequest()), queryID); + assertNotFound(resource.doGetResults(queryID, 0L, null, null, makeOkRequest()), queryID); assertNotFound(resource.deleteQuery(queryID, makeOkRequest()), queryID); } } @@ -941,7 +1007,7 @@ public void testMSQInsertAcceptedQuery() ); assertExceptionMessage( - resource.doGetResults(ACCEPTED_INSERT_MSQ_TASK, 0L, null, makeOkRequest()), + resource.doGetResults(ACCEPTED_INSERT_MSQ_TASK, 0L, null, null, makeOkRequest()), StringUtils.format( "Query[%s] is currently in [%s] state. Please wait for it to complete.", ACCEPTED_INSERT_MSQ_TASK, @@ -974,7 +1040,7 @@ public void testMSQInsertRunningQuery() ); assertExceptionMessage( - resource.doGetResults(RUNNING_INSERT_MSQ_QUERY, 0L, null, makeOkRequest()), + resource.doGetResults(RUNNING_INSERT_MSQ_QUERY, 0L, null, null, makeOkRequest()), StringUtils.format( "Query[%s] is currently in [%s] state. Please wait for it to complete.", RUNNING_INSERT_MSQ_QUERY, @@ -1005,6 +1071,7 @@ public void testAPIBehaviourWithSuperUsers() RUNNING_SELECT_MSQ_QUERY, 1L, null, + null, makeExpectedReq(makeAuthResultForUser(SUPERUSER)) ).getStatus() ); @@ -1035,6 +1102,7 @@ public void testAPIBehaviourWithDifferentUserAndNoStatePermission() RUNNING_SELECT_MSQ_QUERY, 1L, null, + null, makeExpectedReq(differentUserAuthResult) ).getStatus() ); @@ -1065,6 +1133,7 @@ public void testAPIBehaviourWithDifferentUserAndStateRPermission() RUNNING_SELECT_MSQ_QUERY, 1L, null, + null, makeExpectedReq(differentUserAuthResult) ).getStatus() ); @@ -1095,6 +1164,7 @@ public void testAPIBehaviourWithDifferentUserAndStateWPermission() RUNNING_SELECT_MSQ_QUERY, 1L, null, + null, makeExpectedReq(differentUserAuthResult) ).getStatus() ); @@ -1125,6 +1195,7 @@ public void testAPIBehaviourWithDifferentUserAndStateRWPermission() RUNNING_SELECT_MSQ_QUERY, 1L, null, + null, makeExpectedReq(differentUserAuthResult) ).getStatus() ); @@ -1156,7 +1227,7 @@ public void testTaskIdNotFound() ); Assert.assertEquals( Response.Status.NOT_FOUND.getStatusCode(), - resource.doGetResults(taskIdNotFound, null, null, makeOkRequest()).getStatus() + resource.doGetResults(taskIdNotFound, null, null, null, makeOkRequest()).getStatus() ); Assert.assertEquals( Response.Status.NOT_FOUND.getStatusCode(), @@ -1193,4 +1264,44 @@ private void assertSqlStatementResult(SqlStatementResult expected, SqlStatementR Assert.assertEquals(expected.getErrorResponse().getAsMap(), actual.getErrorResponse().getAsMap()); } } + + @Test + public void testValidFilename() + { + // Valid cases + SqlStatementResource.validateFilename("testname"); + SqlStatementResource.validateFilename("A.txt"); + SqlStatementResource.validateFilename("final-results.txt"); + SqlStatementResource.validateFilename("final results.txt"); + SqlStatementResource.validateFilename("final;results.txt"); + SqlStatementResource.validateFilename("final@results.txt"); + + // Empty + assertInvalidFileName("", "Filename cannot be empty."); + + // Too long + assertInvalidFileName(StringUtils.repeat("A", 300), "Filename cannot be longer than 255 characters."); + + // Special characters + assertInvalidFileName("He\\llo", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("Hello/Name", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("C:/Users/Name", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("username:password", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("A>ValueB", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("A, |, \0, \n, or \r)"); + assertInvalidFileName("A\0a11", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("\rrB", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("\nAnB", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("A|B", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("A\"B", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + assertInvalidFileName("A?B", "Filename contains invalid characters. (/, \\, :, *, ?, \", <, >, |, \0, \n, or \r)"); + } + + private void assertInvalidFileName(String filename, String errorMessage) + { + assertThat( + Assert.assertThrows(DruidException.class, () -> SqlStatementResource.validateFilename(filename)), + DruidExceptionMatcher.invalidInput().expectMessageIs(errorMessage) + ); + } }