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() ]) ```
