Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions http/src/main/java/io/micronaut/http/uri/DefaultUriBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -385,11 +385,14 @@ private String buildQueryParams(Map<String, ? super Object> values) {
while (nameIterator.hasNext()) {
Map.Entry<String, List<String>> entry = nameIterator.next();
String rawName = entry.getKey();
String name = expandOrEncode(rawName, values);
// Query parameter names should be encoded using RFC3986 rules (spaces as %20)
String name = isTemplate(rawName, values) ? UriTemplate.of(rawName).expand(values) : encodeQueryParam(rawName);

final Iterator<String> i = entry.getValue().iterator();
while (i.hasNext()) {
String v = expandOrEncode(i.next(), values);
String rawValue = i.next();
// Query parameter values should be encoded using RFC3986 rules (spaces as %20)
String v = isTemplate(rawValue, values) ? UriTemplate.of(rawValue).expand(values) : encodeQueryParam(rawValue);
builder.append(name).append('=').append(v);
if (i.hasNext()) {
builder.append('&');
Expand All @@ -414,7 +417,24 @@ private String expandOrEncode(String value, Map<String, ? super Object> values)
return value;
}

private String encode(String userInfo) {
return URLEncoder.encode(userInfo, StandardCharsets.UTF_8);
private String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}

/**
* Encodes a URI component for use in a query string, adhering to RFC3986.
* Spaces are encoded as '%20', not '+'.
*
* @param value The value to encode.
* @return The encoded value.
*/
private String encodeQueryParam(String value) {
// URLEncoder.encode encodes spaces as '+', which is not RFC3986 compliant for query parameters.
// We need to replace '+' with '%20' after encoding.
// Also, URLEncoder.encode doesn't encode '~', which is unreserved in RFC3986 but often encoded.
// However, for query parameters, it's generally safe to leave '~' unencoded.
// The primary concern here is space encoding.
String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
return encoded.replace("+", "%20");
}
}
106 changes: 106 additions & 0 deletions reproduce_issue.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import groovy.transform.Field
import java.io.ByteArrayOutputStream

@Field
String testSource = """
package io.micronaut.reproduce

import io.micronaut.http.uri.UriBuilder
import spock.lang.Specification

class ReproduceIssueSpec extends Specification {

def "test UriBuilder encodes spaces as plus not percent20"() {
println "DEBUG: ReproduceIssueSpec test method entered (UriBuilder direct test)."

given: "a query parameter with a space"
String paramValue = "value with spaces"

when: "a UriBuilder encodes the parameter"
// The issue states DefaultUriBuilder uses java.net.URLEncoder.
// UriBuilder.of("/").queryParam() eventually uses DefaultUriBuilder.
String encodedQuery = UriBuilder.of("/").queryParam("myParam", paramValue).build().getRawQuery()

then: "the encoded parameter should use %20 for spaces as per RFC3986"
String expectedEncodedPlus = "myParam=value+with+spaces" // As reported in the bug (W3C HTML 4 / java.net.URLEncoder default)
String expectedEncodedPercent20 = "myParam=value%20with%20spaces" // Expected correct encoding (RFC 3986)

println "DEBUG: Encoded query: '\$encodedQuery'"

if (encodedQuery == expectedEncodedPlus) {
println ">>> BUG REPRODUCED: UriBuilder encoded spaces as '+' as per the issue description."
System.exit(129) // Indicate bug reproduced
} else if (encodedQuery == expectedEncodedPercent20) {
println ">>> BUG NOT REPRODUCED: UriBuilder encoded spaces as '%20', meaning the bug is fixed."
System.exit(0) // Indicate bug not reproduced
} else {
println ">>> UNEXPECTED RESULT: Encoded query: '\$encodedQuery'. Neither '+' nor '%20' encoding found."
System.exit(1) // General error, unexpected encoding
}
}
}
"""

def testDir = new File("test-suite/src/test/groovy/io/micronaut/reproduce")
def testFile = new File(testDir, "ReproduceIssueSpec.groovy")

try {
testDir.mkdirs()
testFile.write(testSource)
System.out.println "Created test file: ${testFile.absolutePath}"

// For this simple test, we don't need EmbeddedServer or HttpClient injection.
// Removed -Dmicronaut.test.server.port=-1 as @MicronautTest is no longer used.
// The test should run as a plain Spock test within the existing test-suite.
def command = "./gradlew :test-suite:cleanTest :test-suite:test --tests io.micronaut.reproduce.ReproduceIssueSpec --rerun-tasks --info"
System.out.println "Executing Gradle command: ${command}"

ByteArrayOutputStream stdout = new ByteArrayOutputStream()
ByteArrayOutputStream stderr = new ByteArrayOutputStream()

def process = command.execute()
process.waitForProcessOutput(stdout, stderr) // Capture output

System.out.println "--- Gradle Standard Output ---"
System.out.println stdout.toString()
System.out.println "--- Gradle Standard Error ---"
System.err.println stderr.toString()
System.out.println "-----------------------------"

def gradleExitCode = process.exitValue()
System.out.println "Gradle command finished with exit code: ${gradleExitCode}"

String fullOutput = stdout.toString() + stderr.toString()

// Check for specific messages from the ReproduceIssueSpec to determine if the test ran and what it found.
if (fullOutput.contains(">>> BUG REPRODUCED:")) {
System.out.println "Script wrapper: Test explicitly reported 'BUG REPRODUCED'."
System.exit(129) // Indicate bug reproduced
} else if (fullOutput.contains(">>> BUG NOT REPRODUCED:")) {
System.out.println "Script wrapper: Test explicitly reported 'BUG NOT REPRODUCED'."
System.exit(0) // Indicate bug not reproduced
} else if (fullOutput.contains(">>> UNEXPECTED RESULT:")) {
System.out.println "Script wrapper: Test reported 'UNEXPECTED RESULT'."
System.exit(1) // General error, unexpected encoding
} else if (fullOutput.contains("NO TESTS WERE EXECUTED")) {
System.err.println "Script wrapper: No tests were executed by Gradle. Check test path and build configuration."
System.exit(1)
} else if (gradleExitCode != 0) {
System.err.println "Script wrapper: Gradle command failed with non-zero exit code (${gradleExitCode}). Review Gradle logs for compilation or test execution errors."
System.exit(1)
} else {
// Gradle exited with 0, but no specific test outcome message was found. This indicates the test did not run correctly.
System.err.println "Script wrapper: Gradle command completed with exit code 0, but no expected test outcome or diagnostic messages found. The test might not have run correctly."
System.exit(1)
}
} catch (Exception e) {
System.err.println "Error in Groovy reproduction script wrapper itself:"
e.printStackTrace(System.err)
System.exit(1) // Script error
} finally {
// Clean up the created test file.
if (testFile.exists()) {
testFile.delete()
System.out.println "Deleted test file: ${testFile.absolutePath}"
}
}