This is an automated email from the ASF dual-hosted git repository.

matrei pushed a commit to branch feat/http-client-testing-support
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 93c0e5e00e7310557f24696ef0d746d0553d80df
Author: Mattias Reichel <[email protected]>
AuthorDate: Mon Mar 23 13:06:42 2026 +0100

    docs: Add documentation for `grails-testing-support-http-client`
---
 grails-doc/src/en/guide/introduction/whatsNew.adoc |  15 +-
 .../src/en/guide/testing/integrationTesting.adoc   | 305 +++++++++++++++++++++
 grails-testing-support-http-client/README.md       |   5 +-
 3 files changed, 319 insertions(+), 6 deletions(-)

diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc 
b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index 97287f0a08..d8733bff6a 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -89,8 +89,19 @@ With the removal of Micronaut, and the fixes to the asset 
pipeline plugin, Grail
 
 The `g:form` tag now automatically provides csrf protection when Spring 
Security CSRF is enabled.
 
-==== Grails Banner versions and customization (Grails 7.1+)
+==== Banner versions and customization (Grails 7.1+)
 
-A Grails banner was introduced in Grails 7 that is displayed on application 
startup.
+A Grails banner was introduced in Grails 7 that is displayed in the console on 
application startup.
 From Grails 7.1 onwards, this banner now shows versions of foundational 
dependencies
 and can be xref:conf.adoc#customizing-the-banner[customized].
+
+==== HTTP Client Testing Support (Grails 7.1+)
+
+Grails 7.1 introduces a new `grails-testing-support-http-client` module that 
simplifies HTTP-based
+integration and functional tests.
+
+The module provides an `HttpClientSupport` trait with request helpers for 
common use cases,
+and fluent response assertions via returning a TestHttpResponse wrapper of the 
actual HttpResponse.
+
+See the xref:testing.adoc#httpClientTestingSupport[HTTP Client Testing 
Support] section in the Testing chapter
+for setup and examples.
diff --git a/grails-doc/src/en/guide/testing/integrationTesting.adoc 
b/grails-doc/src/en/guide/testing/integrationTesting.adoc
index 3f5ec55ab5..13eb1c14af 100644
--- a/grails-doc/src/en/guide/testing/integrationTesting.adoc
+++ b/grails-doc/src/en/guide/testing/integrationTesting.adoc
@@ -322,6 +322,311 @@ Example usage:
 ----
 
 
+[[httpClientTestingSupport]]
+==== HTTP Client Testing Support
+
+For integration and functional tests that need to interact with live HTTP 
endpoints, Grails provides the
+`grails-testing-support-http-client` module.
+
+Add the dependency to your integration test configuration:
+
+[source,groovy]
+----
+dependencies {
+    integrationTestImplementation 
'org.apache.grails:grails-testing-support-http-client'
+}
+----
+
+You can then implement the `HttpClientSupport` trait in your Spock 
specification and use helper methods for
+request building and response assertions:
+
+[source,groovy]
+----
+import java.net.http.HttpClient
+import java.net.http.HttpTimeoutException
+import java.time.Duration
+
+import spock.lang.Specification
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+import org.apache.grails.testing.http.client.MultipartBody
+
+@Integration
+class DemoSpec extends Specification implements HttpClientSupport {
+
+    void 'simple GET with status and body assertions'() {
+        when: 'invoking a GET request to the /health endpoint'
+        def response = http('/health')
+
+        then: 'verifies status, headers and body contents via fluent API'
+        response.assertStatus(200)
+                .assertHeaders('Content-Type': 'application/json')
+                .assertContains('UP')
+
+
+        and: 'can also verify all these under the hood in one go'
+        response.assertContains(200, 'Content-Type': 'application/json', 'UP')
+    }
+
+    void 'GET with headers and header assertions'() {
+        when:
+        def response = http('/api/info', 'Accept': 'application/json')
+
+        then:
+        response.assertHeadersIgnoreCase(200, 'content-type': 
'application/json;charset=UTF-8')
+    }
+
+    void 'Posting and verifying JSON'() {
+        when: 'you post a map it is turned into a JSON payload'
+        def response = httpPostJson('/api/books', [title: 'Grails in Action', 
pages: 500])
+
+        then: 'you can also verify JSON response bodies with maps'
+        response.assertJson(201, [id: 1, title: 'Grails in Action', pages: 
500])
+
+        and: 'you can also verify against a JSON string'
+        response.assertJson('''
+            {
+                "id": 1,
+                "title": "Grails in Action",
+                "pages": 500
+            }
+        ''')
+
+        and: 'the canonicalized JSON tree structure is compared, not strings'
+        response.assertJson('{ "pages": 500, "id": 1, "title": "Grails in 
Action" }')
+
+        and: 'if you want to compare strings, use 
assertContains/assertNotContains'
+        response.assertNotContains('{ "id": 1, "title": "Grails in Action", 
"pages": 500 }')
+
+        and: 'you can also use assertJsonContains to verify sub trees in the 
response json'
+        response.assertJsonContains([title: 'Grails in Action'])
+    }
+
+    void 'Posting and verifying XML'() {
+        when: 'you can post XML using a builder'
+        def response = httpPostXml('/api/books') {
+            book {
+                title('Grails in Action')
+                pages(500)
+            }
+        }
+
+        then: 'and you can verify XML responses with assertions on GPathResult'
+        with(response.xml()) {
+            title.text() == 'Grails in Action'
+            pages.text().toInteger() == 500
+        }
+    }
+
+    void 'custom request and custom client configuration'() {
+        when: 'you need a custom configured client'
+        def noRedirectClient = newHttpClientWith {
+            followRedirects(HttpClient.Redirect.NEVER)
+        }
+
+        and: 'or a custom configured request'
+        def request = newHttpRequestWith('/api/slow') {
+            timeout(Duration.ofSeconds(1))
+        }
+
+        when:
+        sendHttpRequest(request, noRedirectClient)
+
+        then:
+        thrown(HttpTimeoutException)
+    }
+
+    void 'multipart upload'() {
+        given:
+        def body = MultipartBody.builder()
+                .addPart('description', 'test file')
+                .addPart('file', 'hello.txt', 'text/plain', 'hello world')
+                .build()
+
+        when:
+        def response = httpPostMultipart('/api/upload', body)
+
+        then:
+        response.assertStatus(200)
+    }
+}
+----
+
+`HttpClientSupport` uses the JDK `HttpClient` under the hood and applies 
default request and connect timeouts of
+60 seconds and a redirect policy of always following redirects. You can use 
helpers `newHttpRequestWith(Closure configurer)`
+and `newHttpClientWith(Closure configurer)` to customize your own client and 
requests starting from the defaults.
+
+Additional request helper methods available include `httpDelete()`, 
`httpHead()`, `httpOptions()`, `httpPatch()`,
+`httpPut()`, `httpTrace()`, and many assertion methods on responses.
+
+===== Custom JSON Parsing
+
+When response JSON needs non-default `JsonSlurper` behavior, configure it 
fluently on the response wrapper:
+
+[source,groovy]
+----
+import groovy.json.JsonParserType
+
+given:
+def response = http('/config')
+
+expect:
+response
+    .withJsonSlurper(parserType: JsonParserType.LAX, checkDates: true)
+    .assertJsonContains('{featureFlag:true}')
+----
+
+Supported named options mirror the `JsonSlurper` settings exposed by 
`JsonUtils.SlurperConfig`, including
+`parserType`, `checkDates`, `chop`, `lazyChop`, and `maxSizeForInMemory`.
+
+===== Custom XML Parsing
+
+Response XML parsing uses a secure default `XmlSlurper` configuration. It is 
namespace-aware, non-validating,
+allows inline `DOCTYPE` declarations, and disables external entity expansion 
plus external DTD loading.
+
+When a test needs different XML parsing behavior, override it fluently on the 
response wrapper:
+
+[source,groovy]
+----
+import groovy.xml.XmlSlurper
+
+given:
+def response = http('/feed')
+
+when:
+def xml = response.withXmlSlurper(factory: { new XmlSlurper(false, false) 
}).xml()
+
+then:
+xml.channel.item.size() == 10
+----
+
+`withXmlSlurper(...)` accepts an `XmlUtils.SlurperConfig`. The default 
configuration is secure; the
+`factory` hook is available for tests that need a fully custom parser instance.
+
+===== JSON payload formatting
+
+JSON request bodies can be sent in three ways depending on how much formatting 
control you need.
+
+For exact control over the payload text, pass a pre-rendered JSON string to 
the request helper:
+
+[source,groovy]
+----
+httpPostJson('/products', '{"name":"Widget","tags":["new","sale"]}')
+----
+
+For the common case, use the `http[Post|Put|Patch]Json` helpers with a `Map`. 
They serialize the payload with
+Groovy's default `JsonOutput.toJson(...)` behavior:
+
+[source,groovy]
+----
+httpPostJson('/products', [name: 'Widget', qty: 2])
+----
+
+When you want custom JSON formatting, pass a `JsonGenerator` alongside the 
`Map` payload. This lets you control
+rendering details such as null handling, field exclusions, converters, and 
date formatting:
+
+[source,groovy]
+----
+import groovy.json.JsonGenerator
+
+def jsonGenerator = new JsonGenerator.Options()
+    .excludeNulls()
+    .dateFormat('yyyy-MM-dd')
+    .build()
+
+httpPutJson('/products/1', jsonGenerator, [
+    name: 'Widget',
+    discontinuedAt: null,
+    releaseDate: new Date()
+])
+----
+
+The same `JsonGenerator` overload pattern is available on `httpPostJson(...)`, 
`httpPutJson(...)`, and
+`httpPatchJson(...)`, including the variants that accept request headers and 
an explicit `HttpClient`.
+
+===== XML formatting
+
+XML request bodies can be generated with Groovy MarkupBuilder DSL. For the 
`http[Post|Patch|Put]Xml` methods,
+`HttpClientSupport` accepts an optional `XmlUtils.Format` instance, which 
constructor takes named params to
+configure the format:
+
+[source,groovy]
+----
+import org.apache.grails.testing.http.client.utils.XmlUtils
+
+when:
+def response = httpPostXml('/products', new XmlUtils.Format(
+    omitDeclaration: false,
+    prettyPrint: true,
+    indent: '  ',
+    lineSeparator: '\n',
+    doctype: '<!DOCTYPE product SYSTEM "product.dtd">',
+    omitNullAttributes: true,
+    omitEmptyAttributes: true,
+    spaceInEmptyElements: false,
+    escapeAttributes: true
+)) {
+    product(id: '1', description: null, sku: '') {
+        name('Widget')
+        empty()
+    }
+}
+
+then:
+response.assertStatus(201)
+
+----
+
+When you need the same formatting control for other request helpers, render 
the XML first and then pass the
+result to `httpPost`, `httpPut`, or `httpPatch` with `application/xml`:
+
+[source,groovy]
+----
+import org.apache.grails.testing.http.client.utils.XmlUtils
+
+def payload = XmlUtils.toXml(
+    omitDeclaration: false,
+    prettyPrint: true,
+    indent: '    ',
+    lineSeparator: '\n',
+    doctype: '<!DOCTYPE product SYSTEM "product.dtd">',
+    omitNullAttributes: true,
+    omitEmptyAttributes: true,
+    spaceInEmptyElements: false,
+    escapeAttributes: true
+) {
+    product(id: '1', description: null, sku: '') {
+        name('Widget')
+        empty()
+    }
+}
+
+httpPut('/products/1', payload, 'application/xml')
+----
+
+Supported `XmlUtils.Format` options include:
+
+- Declaration and document metadata: `omitDeclaration`, `xmlVersion`, 
`charset`, `doubleQuotes`, `doctype`
+- Pretty-printing layout: `prettyPrint`, `indent`, `lineSeparator`
+- Empty elements and attribute handling: `expandEmptyElements`, 
`spaceInEmptyElements`,
+`omitEmptyAttributes`, `omitNullAttributes`
+- Attribute escaping: `escapeAttributes`
+
+`XmlUtils.toXml(...)` also supports inline named options, so shorter cases can 
be written as:
+
+[source,groovy]
+----
+
+def payload = XmlUtils.toXml(omitNullAttributes: true, spaceInEmptyElements: 
false) {
+    product(name: 'Widget', description: null) {
+        empty()
+    }
+}
+
+httpPost('/products', payload, 'application/xml')
+----
+
 ==== Autowiring
 
 
diff --git a/grails-testing-support-http-client/README.md 
b/grails-testing-support-http-client/README.md
index 369fd00edb..b30fffec10 100644
--- a/grails-testing-support-http-client/README.md
+++ b/grails-testing-support-http-client/README.md
@@ -107,9 +107,6 @@ rendering details such as null handling, field exclusions, 
converters, and date
 
 ```groovy
 import groovy.json.JsonGenerator
-import java.time.LocalDate
-import java.time.ZoneOffset
-import java.util.Date
 
 def jsonGenerator = new JsonGenerator.Options()
     .excludeNulls()
@@ -119,7 +116,7 @@ def jsonGenerator = new JsonGenerator.Options()
 httpPutJson('/products/1', jsonGenerator, [
     name: 'Widget',
     discontinuedAt: null,
-    releaseDate: 
Date.from(LocalDate.parse('2026-03-18').atStartOfDay(ZoneOffset.UTC).toInstant())
+    releaseDate: new Date()
 ])
 ```
 

Reply via email to