Repository: kestra-io/kestra (java)

Issue:
http.Request doesn't allow URL with spaces inside of it
### Describe the issue

This user wanted to make a HTTP Request using the variable `api_url` which contains spaces. 

```yaml
id: nyc-ems-calls-ingestion
namespace: nyc-ems-calls-namespace

variables:
  start_date: "{{ now() | dateAdd(-10, 'YEARS') | date('yyyy-MM-dd') }}"
  api_url: "https://data.cityofnewyork.us/resource/76xm-jjuj?$where=incident_datetime between '{{ render(vars.start_date) }}T00:00:00' and '{{ render(vars.start_date)}}T12:00:00'&$limit=50000" 

tasks:
  - id: check_url
    type: io.kestra.plugin.core.log.Log
    message:
      - "{{render(vars.api_url)}}"
  
  - id: python
    type: io.kestra.plugin.scripts.python.Script
    script: |
      import pandas as pd
      import requests
      import json 

      url = "{{render(vars.api_url)}}"
      r = requests.get(url)
      df = pd.DataFrame(json.loads(r.text))
      print(df.shape)
    beforeCommands:
      - pip install requests pandas
  
  - id: api
    type: io.kestra.plugin.core.http.Request
    uri: "{{ render(vars.api_url) }}"
```

Now this is a valid request when put into a browser and works with the `python` task however Kestra threw an error because of the space:

```
Illegal character in query at index 73: https://data.cityofnewyork.us/resource/76xm-jjuj?$where=incident_datetime between '2015-01-24T00:00:00' and '2015-01-24T12:00:00'&$limit=50000
```

It works if you replace the spaces with `%20` instead. Can we have the plugin do something like this automatically if the spaces exist like a browser does?

### Environment

- Kestra Version: latest


Pull request:
fix(core): handle space in HTTP request URI
Fixes #6936

Patch:
diff --git a/core/src/main/java/io/kestra/core/http/HttpRequest.java b/core/src/main/java/io/kestra/core/http/HttpRequest.java
index 8bc902ab4..4c41b8e01 100644
--- a/core/src/main/java/io/kestra/core/http/HttpRequest.java
+++ b/core/src/main/java/io/kestra/core/http/HttpRequest.java
@@ -148,6 +148,10 @@ public class HttpRequest {
 
         public abstract String getContentType() throws IOException;
 
+        protected ContentType entityContentType() throws IOException {
+            return this.getCharset() != null ? ContentType.create(this.getContentType(), this.getCharset()) : ContentType.create(this.getContentType());
+        }
+
         public static RequestBody from(HttpEntity entity) throws IOException {
             if (entity == null) {
                 return null;
@@ -193,13 +197,12 @@ public class HttpRequest {
         @Builder.Default
         private String contentType = ContentType.APPLICATION_OCTET_STREAM.getMimeType();
 
-        @Builder.Default
-        private Charset charset = StandardCharsets.UTF_8;
+        private Charset charset;
 
         private InputStream content;
 
-        public HttpEntity to() {
-            return new InputStreamEntity(content, ContentType.create(contentType, charset));
+        public HttpEntity to() throws IOException {
+            return new InputStreamEntity(content, this.entityContentType());
         }
     }
 
@@ -210,13 +213,12 @@ public class HttpRequest {
         @Builder.Default
         private String contentType = ContentType.TEXT_PLAIN.getMimeType();
 
-        @Builder.Default
-        private Charset charset = StandardCharsets.UTF_8;
+        private Charset charset;
 
         private String content;
 
-        public HttpEntity to() {
-            return new StringEntity(this.content, ContentType.create(contentType, charset));
+        public HttpEntity to() throws IOException {
+            return new StringEntity(this.content, this.entityContentType());
         }
     }
 
@@ -227,13 +229,12 @@ public class HttpRequest {
         @Builder.Default
         private String contentType = ContentType.APPLICATION_OCTET_STREAM.getMimeType();
 
-        @Builder.Default
-        private Charset charset = StandardCharsets.UTF_8;
+        private Charset charset;
 
         private byte[] content;
 
-        public HttpEntity to() {
-            return new ByteArrayEntity(content, ContentType.create(contentType, charset));
+        public HttpEntity to() throws IOException {
+            return new ByteArrayEntity(content, this.entityContentType());
         }
     }
 
@@ -241,8 +242,7 @@ public class HttpRequest {
     @AllArgsConstructor
     @SuperBuilder
     public static class JsonRequestBody extends RequestBody {
-        @Builder.Default
-        private Charset charset = StandardCharsets.UTF_8;
+        private Charset charset;
 
         private Object content;
 
@@ -255,7 +255,7 @@ public class HttpRequest {
             try {
                 return new StringEntity(
                     JacksonMapper.ofJson().writeValueAsString(content),
-                    ContentType.APPLICATION_JSON.withCharset(this.charset)
+                    this.charset != null ? ContentType.APPLICATION_JSON.withCharset(this.charset) : ContentType.APPLICATION_JSON
                 );
             } catch (JsonProcessingException e) {
                 throw new IOException(e);
@@ -267,8 +267,7 @@ public class HttpRequest {
     @AllArgsConstructor
     @SuperBuilder
     public static class UrlEncodedRequestBody extends RequestBody {
-        @Builder.Default
-        private Charset charset = StandardCharsets.UTF_8;
+        private Charset charset;
 
         private Map<String, Object> content;
 
@@ -278,13 +277,12 @@ public class HttpRequest {
         }
 
         public HttpEntity to() throws IOException {
-            return new UrlEncodedFormEntity(
-                this.content .entrySet()
-                    .stream()
-                    .map(e -> new BasicNameValuePair(e.getKey(), e.getValue().toString()))
-                    .toList(),
-                this.charset
-            );
+            List<BasicNameValuePair> list = this.content.entrySet()
+                .stream()
+                .map(e -> new BasicNameValuePair(e.getKey(), e.getValue().toString()))
+                .toList();
+
+            return this.charset != null ? new UrlEncodedFormEntity(list, this.charset) : new UrlEncodedFormEntity(list);
         }
     }
 
@@ -292,8 +290,7 @@ public class HttpRequest {
     @AllArgsConstructor
     @SuperBuilder
     public static class MultipartRequestBody extends RequestBody {
-        @Builder.Default
-        private Charset charset = StandardCharsets.UTF_8;
+        private Charset charset;
 
         private Map<String, Object> content;
 
@@ -304,8 +301,11 @@ public class HttpRequest {
 
         public HttpEntity to() throws IOException {
             MultipartEntityBuilder builder = MultipartEntityBuilder
-                .create()
-                .setCharset(this.charset);
+                .create();
+
+            if (this.charset != null) {
+                builder.setCharset(this.charset);
+            }
 
             content.forEach((key, value) -> {
                 switch (value) {
diff --git a/core/src/main/java/io/kestra/core/plugins/PluginClassLoader.java b/core/src/main/java/io/kestra/core/plugins/PluginClassLoader.java
index b1109260d..aac94463d 100644
--- a/core/src/main/java/io/kestra/core/plugins/PluginClassLoader.java
+++ b/core/src/main/java/io/kestra/core/plugins/PluginClassLoader.java
@@ -43,6 +43,7 @@ public class PluginClassLoader extends URLClassLoader {
         + "|com.fasterxml.jackson.dataformat.xml"
         + "|org.reactivestreams"
         + "|dev.failsafe"
+        + "|reactor"
         + ")\\..*$");
 
     private final ClassLoader parent;
diff --git a/core/src/main/java/io/kestra/plugin/core/http/AbstractHttp.java b/core/src/main/java/io/kestra/plugin/core/http/AbstractHttp.java
index c53953bd3..363808486 100644
--- a/core/src/main/java/io/kestra/plugin/core/http/AbstractHttp.java
+++ b/core/src/main/java/io/kestra/plugin/core/http/AbstractHttp.java
@@ -25,6 +25,7 @@ import java.io.OutputStream;
 import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.net.URLEncoder;
 import java.net.http.HttpHeaders;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
@@ -104,9 +105,11 @@ public abstract class AbstractHttp extends Task implements HttpInterface {
 
     @SuppressWarnings({"unchecked", "rawtypes"})
     protected HttpRequest request(RunContext runContext) throws IllegalVariableEvaluationException, URISyntaxException, IOException {
+        // ideally we should URLEncode the path of the UI, but as we cannot URLEncode everything, we handle the common case of space in the URI.
+        String renderedUri = runContext.render(this.uri).as(String.class).map(s -> s.replace(" ", "%20")).orElseThrow();
         HttpRequest.HttpRequestBuilder request = HttpRequest.builder()
             .method(runContext.render(this.method).as(String.class).orElse(null))
-            .uri(new URI(runContext.render(this.uri).as(String.class).orElseThrow()));
+            .uri(new URI(renderedUri));
 
         var renderedFormData = runContext.render(this.formData).asMap(String.class, Object.class);
         if (!renderedFormData.isEmpty()) {
@@ -158,7 +161,7 @@ public abstract class AbstractHttp extends Task implements HttpInterface {
             request.body(HttpRequest.StringRequestBody.builder()
                 .content(runContext.render(body).as(String.class).orElseThrow())
                 .contentType(runContext.render(this.contentType).as(String.class).orElse(null))
-                .charset(this.options != null ? runContext.render(this.options.getDefaultCharset()).as(Charset.class).orElse(null) : StandardCharsets.UTF_8)
+                .charset(this.options != null && this.options.getDefaultCharset() != null ? runContext.render(this.options.getDefaultCharset()).as(Charset.class).orElse(null) : null)
                 .build()
             );
         } else if (this.contentType != null) {
diff --git a/ui/src/components/filter/utils/helpers.ts b/ui/src/components/filter/utils/helpers.ts
index 3780148bc..cd7f05416 100644
--- a/ui/src/components/filter/utils/helpers.ts
+++ b/ui/src/components/filter/utils/helpers.ts
@@ -104,7 +104,9 @@ export const decodeParams = (route, query, include, OPTIONS) => {
 
 export const encodeSearchParams = (filters, OPTIONS) => {
     const encode = (values, key, operation) => {
-        return values.reduce((acc, v) => {
+        const valuesArray = Array.isArray(values) ? values : [values];
+
+        return valuesArray.reduce((acc, v) => {
             if (key === "childFilter" && v === "ALL") return acc;
 
             const encoded = encodeURIComponent(v);
diff --git a/ui/src/components/logs/LogsWrapper.vue b/ui/src/components/logs/LogsWrapper.vue
index 160c3aacf..8120f4a8c 100644
--- a/ui/src/components/logs/LogsWrapper.vue
+++ b/ui/src/components/logs/LogsWrapper.vue
@@ -116,7 +116,9 @@
                 return this.$route.name === "namespaces/update"
             },
             selectedLogLevel() {
-                const decoded = [decodeSearchParams(this.$route.query, ["level"], [])?.[0]?.value].flat()[0] ?? "INFO"
+                const decodedParams = decodeSearchParams(this.$route.query, ["level"], []);
+                const levelFilters = decodedParams.filter(item => item.label === "level");
+                const decoded = levelFilters.length > 0 ? levelFilters[0].value : "INFO";
                 return this.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
             },
             endDate() {


Test patch:
diff --git a/core/src/test/java/io/kestra/plugin/core/http/RequestTest.java b/core/src/test/java/io/kestra/plugin/core/http/RequestTest.java
index 81feaf446..0645d8522 100644
--- a/core/src/test/java/io/kestra/plugin/core/http/RequestTest.java
+++ b/core/src/test/java/io/kestra/plugin/core/http/RequestTest.java
@@ -19,6 +19,7 @@ import io.micronaut.http.*;
 import io.micronaut.http.annotation.*;
 import io.micronaut.http.multipart.StreamingFileUpload;
 import io.micronaut.runtime.server.EmbeddedServer;
+import jakarta.annotation.Nullable;
 import jakarta.inject.Inject;
 import org.apache.commons.io.IOUtils;
 import org.junit.jupiter.api.Test;
@@ -554,7 +555,10 @@ class RequestTest {
                 .id(RequestTest.class.getSimpleName())
                 .type(RequestTest.class.getName())
                 .uri(Property.of(server.getURL().toString() + "/content-type"))
+                .method(Property.of("POST"))
+                .body(Property.of("{}"))
                 .contentType(Property.of("application/vnd.campaignsexport.v1+json"))
+                .options(HttpConfiguration.builder().logs(HttpConfiguration.LoggingType.values()).defaultCharset(null).build())
                 .build();
 
             RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, task, ImmutableMap.of());
@@ -566,6 +570,28 @@ class RequestTest {
         }
     }
 
+    @Test
+    void spaceInURI() throws Exception {
+        try (
+            ApplicationContext applicationContext = ApplicationContext.run();
+            EmbeddedServer server = applicationContext.getBean(EmbeddedServer.class).start();
+
+        ) {
+            Request task = Request.builder()
+                .id(RequestTest.class.getSimpleName())
+                .type(RequestTest.class.getName())
+                .uri(Property.of(server.getURL().toString() + "/uri with space"))
+                .build();
+
+            RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, task, ImmutableMap.of());
+
+            Request.Output output = task.run(runContext);
+
+            assertThat(output.getBody(), is("Hello World"));
+            assertThat(output.getCode(), is(200));
+        }
+    }
+
     @Controller
     static class MockController {
         @Get("/hello")
@@ -573,10 +599,10 @@ class RequestTest {
             return HttpResponse.ok("{ \"hello\": \"world\" }");
         }
 
-
-        @Get("content-type")
+        @Post("content-type")
+        @Consumes("application/vnd.campaignsexport.v1+json")
         @Produces(MediaType.TEXT_PLAIN)
-        public io.micronaut.http.HttpResponse<String> contentType(io.micronaut.http.HttpRequest<?> request) {
+        public io.micronaut.http.HttpResponse<String> contentType(io.micronaut.http.HttpRequest<?> request, @Nullable @Body Map<String, String> body) {
             return io.micronaut.http.HttpResponse.ok(request.getContentType().orElseThrow().toString());
         }
 
@@ -653,5 +679,10 @@ class RequestTest {
                     }
                 }));
         }
+
+        @Get("/uri%20with%20space")
+        HttpResponse<String> uriWithSpace() {
+            return HttpResponse.ok("Hello World");
+        }
     }
 }