From fff09d6096a7692d4c4400e01ecacc1392c1017c Mon Sep 17 00:00:00 2001 From: ewalsh Date: Tue, 7 Jul 2026 15:33:51 +0100 Subject: [PATCH 1/3] Add HTTP QUERY method support (RFC 10008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUERY is a safe, idempotent method that carries a request body — semantically like GET but with POST-style body support. - Add query[T: BodyWritable](body: T) to Scala StandaloneWSRequest trait - Add query(BodyWritable body) to Java StandaloneWSRequest interface - Add AHC implementations for both Scala and Java - Add MiMa exclusions for new abstract method - Add unit tests (Scala + Java) and integration test - Add README documentation for both Scala and Java - Housekeeping: add .metals/ and .vscode/ to .gitignore --- .gitignore | 2 + README.md | 25 +++++++++++++ build.sbt | 3 ++ .../api/libs/ws/ahc/AhcWSClientSpec.scala | 12 ++++++ .../libs/ws/ahc/StandaloneAhcWSRequest.java | 5 +++ .../libs/ws/ahc/StandaloneAhcWSRequest.scala | 6 +++ .../api/libs/ws/ahc/AhcWSRequestSpec.scala | 37 +++++++++++++++++++ .../play/libs/ws/ahc/AhcWSRequestSpec.scala | 28 ++++++++++++++ .../play/libs/ws/StandaloneWSRequest.java | 12 ++++++ .../api/libs/ws/StandaloneWSRequest.scala | 8 ++++ 10 files changed, 138 insertions(+) diff --git a/.gitignore b/.gitignore index edce82ea..b2b304db 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ generated.keystore generated.truststore *.log .bsp/ +.metals/ +.vscode/ # Scala-IDE specific .scala_dependencies diff --git a/README.md b/README.md index 4d70ca06..a1925847 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,19 @@ def postExampleString(ws: play.api.libs.ws.StandaloneWSClient)( } ``` +To perform a QUERY request ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)), which is safe and idempotent like GET but carries a request body: + +```scala +import scala.concurrent.ExecutionContext +import play.api.libs.ws.DefaultBodyWritables._ // required + +def queryExample(ws: play.api.libs.ws.StandaloneWSClient)( + implicit ec: ExecutionContext) = { + val queryBody = """{"search": "play framework"}""" + ws.url("...").query(queryBody).map { response => /* do something */ } +} +``` + You can also define your own custom BodyReadable: ```scala @@ -222,6 +235,18 @@ class MyClass { } ``` +To perform a QUERY request ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)), which is safe and idempotent like GET but carries a request body: + +```java +public class MyClient implements DefaultBodyWritables, DefaultBodyReadables { + public CompletionStage doQuery() { + return client.url("http://example.com").query(body("{\"search\": \"play framework\"}")).thenApply(response -> + response.body(string()) + ); + } +} +``` + You can define a custom `BodyReadable`: ```java diff --git a/build.sbt b/build.sbt index ad247e3e..2f1db18a 100644 --- a/build.sbt +++ b/build.sbt @@ -62,6 +62,9 @@ lazy val mimaSettings = Seq( ProblemFilters.exclude[FinalClassProblem]("play.libs.ws.DefaultObjectMapper"), // instance is now a method, not "just" a field anymore, so it always returns the current mapper ProblemFilters.exclude[MissingFieldProblem]("play.libs.ws.DefaultObjectMapper.instance"), + // Add HTTP QUERY method support (RFC 10008) + ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.ws.StandaloneWSRequest.query"), + ProblemFilters.exclude[ReversedMissingMethodProblem]("play.libs.ws.StandaloneWSRequest.query"), ) ) diff --git a/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala b/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala index a74b0258..9faeb705 100644 --- a/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala +++ b/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala @@ -52,6 +52,8 @@ class AhcWSClientSpec(implicit val executionEnv: ExecutionEnv) Results.Ok("Say hello to play") case POST(_) => Results.Ok(s"POST: ${request.body.asText.getOrElse("")}") + case _ if request.method == "QUERY" => + Results.Ok(s"QUERY: ${request.body.asText.getOrElse("")}") case _ => Results.NotFound } @@ -129,6 +131,16 @@ class AhcWSClientSpec(implicit val executionEnv: ExecutionEnv) } } + "perform a QUERY request with body" in { + withClient() { client => + val result = Await.result( + client.url(s"http://localhost:$testServerPort/index").query("query body").map(res => res.body[String]), + defaultTimeout + ) + result must beEqualTo("QUERY: query body") + } + } + "request a url as a Foo" in { case class Foo(body: String) diff --git a/play-ahc-ws-standalone/src/main/java/play/libs/ws/ahc/StandaloneAhcWSRequest.java b/play-ahc-ws-standalone/src/main/java/play/libs/ws/ahc/StandaloneAhcWSRequest.java index 591aafe9..fee79794 100644 --- a/play-ahc-ws-standalone/src/main/java/play/libs/ws/ahc/StandaloneAhcWSRequest.java +++ b/play-ahc-ws-standalone/src/main/java/play/libs/ws/ahc/StandaloneAhcWSRequest.java @@ -369,6 +369,11 @@ public CompletionStage put(BodyWritable body) { return setMethod("PUT").setBody(body).execute(); } + @Override + public CompletionStage query(BodyWritable body) { + return setMethod("QUERY").setBody(body).execute(); + } + @Override public CompletionStage delete() { return setMethod("DELETE").execute(); diff --git a/play-ahc-ws-standalone/src/main/scala/play/api/libs/ws/ahc/StandaloneAhcWSRequest.scala b/play-ahc-ws-standalone/src/main/scala/play/api/libs/ws/ahc/StandaloneAhcWSRequest.scala index fc1250f0..806bde1a 100644 --- a/play-ahc-ws-standalone/src/main/scala/play/api/libs/ws/ahc/StandaloneAhcWSRequest.scala +++ b/play-ahc-ws-standalone/src/main/scala/play/api/libs/ws/ahc/StandaloneAhcWSRequest.scala @@ -177,6 +177,12 @@ case class StandaloneAhcWSRequest( withBody(body).execute("PUT") } + /** + */ + override def query[T: BodyWritable](body: T): Future[Response] = { + withBody(body).execute("QUERY") + } + /** * Sets the body for this request. */ diff --git a/play-ahc-ws-standalone/src/test/scala/play/api/libs/ws/ahc/AhcWSRequestSpec.scala b/play-ahc-ws-standalone/src/test/scala/play/api/libs/ws/ahc/AhcWSRequestSpec.scala index 6dc7d9b4..d07c1454 100644 --- a/play-ahc-ws-standalone/src/test/scala/play/api/libs/ws/ahc/AhcWSRequestSpec.scala +++ b/play-ahc-ws-standalone/src/test/scala/play/api/libs/ws/ahc/AhcWSRequestSpec.scala @@ -684,4 +684,41 @@ class AhcWSRequestSpec extends Specification with AfterAll with DefaultBodyReada req.getHeaders.getAll(HttpHeaderNames.CONTENT_TYPE.toString()).asScala must_== Seq("text/plain; charset=US-ASCII") } + "For QUERY requests" in { + + "set the method to QUERY and attach the body" in withClient { client => + val req: AHCRequest = client + .url("http://playframework.com/") + .withBody("HELLO WORLD") + .withMethod("QUERY") + .asInstanceOf[StandaloneAhcWSRequest] + .buildRequest() + req.getMethod must beEqualTo("QUERY") + (new String(req.getByteData, "UTF-8")) must be_==("HELLO WORLD") + } + + "set content-type for the body" in withClient { client => + val req: AHCRequest = client + .url("http://playframework.com/") + .withBody("HELLO WORLD") + .withMethod("QUERY") + .asInstanceOf[StandaloneAhcWSRequest] + .buildRequest() + req.getHeaders.get(HttpHeaderNames.CONTENT_TYPE.toString()) must beEqualTo("text/plain; charset=UTF-8") + } + + "send binary data as body" in withClient { client => + val binData = ByteString((0 to 127).map(_.toByte).toArray) + val req: AHCRequest = client + .url("http://playframework.com/") + .addHttpHeaders(HttpHeaderNames.CONTENT_TYPE.toString() -> "application/octet-stream") + .withBody(binData) + .withMethod("QUERY") + .asInstanceOf[StandaloneAhcWSRequest] + .buildRequest() + req.getMethod must beEqualTo("QUERY") + ByteString(req.getByteData) must_== binData + } + } + } diff --git a/play-ahc-ws-standalone/src/test/scala/play/libs/ws/ahc/AhcWSRequestSpec.scala b/play-ahc-ws-standalone/src/test/scala/play/libs/ws/ahc/AhcWSRequestSpec.scala index fa27a44d..a067d529 100644 --- a/play-ahc-ws-standalone/src/test/scala/play/libs/ws/ahc/AhcWSRequestSpec.scala +++ b/play-ahc-ws-standalone/src/test/scala/play/libs/ws/ahc/AhcWSRequestSpec.scala @@ -261,6 +261,34 @@ class AhcWSRequestSpec extends Specification with DefaultBodyReadables with Defa req.getRealm.isUsePreemptiveAuth must beFalse } + "For QUERY requests" in { + + "set method to QUERY and attach the body" in { + val client = StandaloneAhcWSClient.create( + AhcWSClientConfigFactory.forConfig(ConfigFactory.load(), this.getClass.getClassLoader), /*materializer*/ null + ) + val req = new StandaloneAhcWSRequest(client, "http://playframework.com/", null) + .setBody(body("HELLO WORLD")) + .setMethod("QUERY") + .asInstanceOf[StandaloneAhcWSRequest] + .buildRequest() + req.getMethod must be_==("QUERY") + req.getStringData must be_==("HELLO WORLD") + } + + "set content-type for the body" in { + val client = StandaloneAhcWSClient.create( + AhcWSClientConfigFactory.forConfig(ConfigFactory.load(), this.getClass.getClassLoader), /*materializer*/ null + ) + val req = new StandaloneAhcWSRequest(client, "http://playframework.com/", null) + .setBody(body("HELLO WORLD")) + .setMethod("QUERY") + .asInstanceOf[StandaloneAhcWSRequest] + .buildRequest() + req.getHeaders.get(HttpHeaderNames.CONTENT_TYPE) must be_==("text/plain; charset=UTF-8") + } + } + "Set Realm.UsePreemptiveAuth to true when WSAuthScheme.DIGEST not being used" in { val client = StandaloneAhcWSClient.create( AhcWSClientConfigFactory.forConfig(ConfigFactory.load(), this.getClass.getClassLoader), /*materializer*/ null diff --git a/play-ws-standalone/src/main/java/play/libs/ws/StandaloneWSRequest.java b/play-ws-standalone/src/main/java/play/libs/ws/StandaloneWSRequest.java index 7e06af07..63bb65bb 100644 --- a/play-ws-standalone/src/main/java/play/libs/ws/StandaloneWSRequest.java +++ b/play-ws-standalone/src/main/java/play/libs/ws/StandaloneWSRequest.java @@ -82,6 +82,18 @@ public interface StandaloneWSRequest { */ CompletionStage put(BodyWritable body); + //------------------------------------------------------------------------- + // "QUERY" + //------------------------------------------------------------------------- + + /** + * Perform a QUERY on the request asynchronously. + * + * @param body the BodyWritable + * @return a promise to the response + */ + CompletionStage query(BodyWritable body); + //------------------------------------------------------------------------- // Miscellaneous execution methods //------------------------------------------------------------------------- diff --git a/play-ws-standalone/src/main/scala/play/api/libs/ws/StandaloneWSRequest.scala b/play-ws-standalone/src/main/scala/play/api/libs/ws/StandaloneWSRequest.scala index 2f835956..0fc6d7d4 100644 --- a/play-ws-standalone/src/main/scala/play/api/libs/ws/StandaloneWSRequest.scala +++ b/play-ws-standalone/src/main/scala/play/api/libs/ws/StandaloneWSRequest.scala @@ -248,6 +248,14 @@ trait StandaloneWSRequest { */ def put[T: BodyWritable](body: T): Future[Response] + /** + * Performs a QUERY request. + * + * @param body the payload body submitted with this request + * @return a future with the response for the QUERY request + */ + def query[T: BodyWritable](body: T): Future[Response] + /** * Perform a DELETE on the request asynchronously. */ From bff188b6db2461cf514979dbf75e54fc873b9377 Mon Sep 17 00:00:00 2001 From: ewalsh Date: Sun, 12 Jul 2026 14:39:41 +0100 Subject: [PATCH 2/3] Remove .metals/ and .vscode/ from .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index b2b304db..edce82ea 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,6 @@ generated.keystore generated.truststore *.log .bsp/ -.metals/ -.vscode/ # Scala-IDE specific .scala_dependencies From 3f6320b52e14f91dd70f09501994bf1d7efe2b81 Mon Sep 17 00:00:00 2001 From: ewalsh Date: Mon, 13 Jul 2026 10:23:10 +0100 Subject: [PATCH 3/3] Use must_=== in QUERY integration test --- .../src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala b/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala index 9faeb705..e58b381b 100644 --- a/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala +++ b/integration-tests/src/test/scala/play/api/libs/ws/ahc/AhcWSClientSpec.scala @@ -137,7 +137,7 @@ class AhcWSClientSpec(implicit val executionEnv: ExecutionEnv) client.url(s"http://localhost:$testServerPort/index").query("query body").map(res => res.body[String]), defaultTimeout ) - result must beEqualTo("QUERY: query body") + result must_=== "QUERY: query body" } }