You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/01/26 03:08:46 UTC
(grails-core) 03/12: Add content negotiation and URL mappings tests
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch test/expand-integration-test-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit d665e49f199113ae30fa12a4828fc0acb73a5f36
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sun Jan 25 22:04:33 2026 -0500
Add content negotiation and URL mappings tests
- Add ContentNegotiationSpec with 21 tests for format handling
- Tests JSON, XML, HTML responses based on Accept headers
- Tests format parameter, extension-based negotiation
- Add UrlMappingsSpec with 20 tests for routing
- Tests path variables, constraints, HTTP methods, redirects
- Includes optional parameters and format extension support
---
.../contentneg/ContentNegotiationController.groovy | 168 +++++++
.../urlmappings/UrlMappingsTestController.groovy | 167 +++++++
.../grails-app/views/contentNegotiation/error.gsp | 11 +
.../grails-app/views/contentNegotiation/index.gsp | 16 +
.../contentneg/ContentNegotiationSpec.groovy | 530 +++++++++++++++++++++
.../urlmappings/UrlMappingsSpec.groovy | 466 ++++++++++++++++++
6 files changed, 1358 insertions(+)
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/contentneg/ContentNegotiationController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/contentneg/ContentNegotiationController.groovy
new file mode 100644
index 0000000000..349a2ef33e
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/contentneg/ContentNegotiationController.groovy
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package functionaltests.contentneg
+
+import grails.converters.JSON
+import grails.converters.XML
+
+/**
+ * Controller demonstrating content negotiation features.
+ */
+class ContentNegotiationController {
+
+ static responseFormats = ['json', 'xml', 'html']
+
+ /**
+ * Returns data in format based on Accept header or extension.
+ */
+ def index() {
+ def data = [
+ message: 'Hello World',
+ timestamp: new Date().format('yyyy-MM-dd'),
+ items: ['one', 'two', 'three']
+ ]
+
+ withFormat {
+ json { render data as JSON }
+ xml { render data as XML }
+ html { render view: 'index', model: [data: data] }
+ }
+ }
+
+ /**
+ * Demonstrates respond method for automatic content negotiation.
+ */
+ def respond() {
+ def data = [
+ status: 'success',
+ code: 200,
+ data: [
+ id: 1,
+ name: 'Test Item',
+ active: true
+ ]
+ ]
+ respond data
+ }
+
+ /**
+ * Returns different status codes based on format.
+ */
+ def statusByFormat() {
+ withFormat {
+ json { render([status: 'ok'] as JSON) }
+ xml { render([status: 'ok'] as XML) }
+ html {
+ response.status = 200
+ render '<html><body><p>OK</p></body></html>'
+ }
+ }
+ }
+
+ /**
+ * Demonstrates format parameter override.
+ */
+ def formatParam() {
+ def data = [format: params.format ?: 'unknown', value: 42]
+
+ withFormat {
+ json { render data as JSON }
+ xml { render data as XML }
+ '*' { render data as JSON } // Default fallback
+ }
+ }
+
+ /**
+ * Returns a list for collection content negotiation.
+ */
+ def list() {
+ def items = [
+ [id: 1, name: 'Item 1'],
+ [id: 2, name: 'Item 2'],
+ [id: 3, name: 'Item 3']
+ ]
+ respond items
+ }
+
+ /**
+ * Demonstrates explicit content type setting.
+ */
+ def explicitContentType() {
+ response.contentType = 'application/json'
+ render '{"explicit": true}'
+ }
+
+ /**
+ * Returns data with custom JSON rendering.
+ */
+ def customJson() {
+ def data = [
+ nested: [
+ deep: [
+ value: 'found'
+ ]
+ ]
+ ]
+ render(contentType: 'application/json') {
+ result(data.nested.deep.value)
+ }
+ }
+
+ /**
+ * Demonstrates error response formatting.
+ */
+ def error() {
+ def error = [
+ error: true,
+ message: 'Something went wrong',
+ code: 'ERR_001'
+ ]
+
+ withFormat {
+ json {
+ response.status = 400
+ render error as JSON
+ }
+ xml {
+ response.status = 400
+ render error as XML
+ }
+ html {
+ response.status = 400
+ render view: 'error', model: [error: error]
+ }
+ }
+ }
+
+ /**
+ * Demonstrates multiple accept types in request.
+ */
+ def multiAccept() {
+ // Request can have multiple Accept types with quality values
+ def acceptHeader = request.getHeader('Accept') ?: 'none'
+ def data = [acceptHeader: acceptHeader, negotiated: response.format ?: 'unknown']
+
+ withFormat {
+ json { render data as JSON }
+ xml { render data as XML }
+ '*' { render data as JSON }
+ }
+ }
+}
diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/urlmappings/UrlMappingsTestController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/urlmappings/UrlMappingsTestController.groovy
new file mode 100644
index 0000000000..9ff7aa4f39
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/urlmappings/UrlMappingsTestController.groovy
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package functionaltests.urlmappings
+
+import grails.converters.JSON
+
+/**
+ * Controller for testing URL mapping features.
+ */
+class UrlMappingsTestController {
+
+ static responseFormats = ['json']
+
+ /**
+ * Returns information about the requested parameters.
+ */
+ def index() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'index',
+ params: params.findAll { k, v -> !['controller', 'action'].contains(k) }
+ ] as JSON)
+ }
+
+ /**
+ * Show action with ID parameter.
+ */
+ def show() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'show',
+ id: params.id
+ ] as JSON)
+ }
+
+ /**
+ * Action with multiple path variables.
+ */
+ def pathVars() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'pathVars',
+ year: params.year,
+ month: params.month,
+ day: params.day
+ ] as JSON)
+ }
+
+ /**
+ * Action for testing named URL mappings.
+ */
+ def named() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'named',
+ name: params.name
+ ] as JSON)
+ }
+
+ /**
+ * Action for testing constrained parameters.
+ */
+ def constrained() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'constrained',
+ code: params.code
+ ] as JSON)
+ }
+
+ /**
+ * Action that demonstrates wildcard capture.
+ */
+ def wildcard() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'wildcard',
+ path: params.path
+ ] as JSON)
+ }
+
+ /**
+ * Action for REST-style resource.
+ */
+ def list() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'list',
+ format: params.format ?: 'json'
+ ] as JSON)
+ }
+
+ /**
+ * Create action for REST resource.
+ */
+ def save() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'save',
+ method: 'POST'
+ ] as JSON)
+ }
+
+ /**
+ * Update action for REST resource.
+ */
+ def update() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'update',
+ id: params.id,
+ method: 'PUT'
+ ] as JSON)
+ }
+
+ /**
+ * Delete action for REST resource.
+ */
+ def delete() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'delete',
+ id: params.id,
+ method: 'DELETE'
+ ] as JSON)
+ }
+
+ /**
+ * Action for optional parameters.
+ */
+ def optional() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'optional',
+ required: params.required,
+ optional: params.optional ?: 'default'
+ ] as JSON)
+ }
+
+ /**
+ * Action that returns HTTP method info.
+ */
+ def httpMethod() {
+ render([
+ controller: 'urlMappingsTest',
+ action: 'httpMethod',
+ method: request.method
+ ] as JSON)
+ }
+}
diff --git a/grails-test-examples/app1/grails-app/views/contentNegotiation/error.gsp b/grails-test-examples/app1/grails-app/views/contentNegotiation/error.gsp
new file mode 100644
index 0000000000..fda47db970
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/views/contentNegotiation/error.gsp
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Error</title>
+</head>
+<body>
+ <h1>Error</h1>
+ <p>Message: ${error.message}</p>
+ <p>Code: ${error.code}</p>
+</body>
+</html>
diff --git a/grails-test-examples/app1/grails-app/views/contentNegotiation/index.gsp b/grails-test-examples/app1/grails-app/views/contentNegotiation/index.gsp
new file mode 100644
index 0000000000..72b762563c
--- /dev/null
+++ b/grails-test-examples/app1/grails-app/views/contentNegotiation/index.gsp
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Content Negotiation Demo</title>
+</head>
+<body>
+ <h1>Content Negotiation</h1>
+ <p>Message: ${data.message}</p>
+ <p>Timestamp: ${data.timestamp}</p>
+ <ul>
+ <g:each in="${data.items}" var="item">
+ <li>${item}</li>
+ </g:each>
+ </ul>
+</body>
+</html>
diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/contentneg/ContentNegotiationSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/contentneg/ContentNegotiationSpec.groovy
new file mode 100644
index 0000000000..018eab6bdb
--- /dev/null
+++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/contentneg/ContentNegotiationSpec.groovy
@@ -0,0 +1,530 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package functionaltests.contentneg
+
+import functionaltests.Application
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonSlurper
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.MediaType
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import spock.lang.Narrative
+import spock.lang.Specification
+
+/**
+ * Integration tests for Grails content negotiation features.
+ *
+ * Tests Accept header-based content negotiation, URL extension-based
+ * format selection, respond method, withFormat, and various
+ * content type handling scenarios.
+ */
+@Integration(applicationClass = Application)
+@Narrative('''
+Grails provides content negotiation that allows the same controller action
+to return different response formats (JSON, XML, HTML) based on the client's
+Accept header or URL extension.
+''')
+class ContentNegotiationSpec extends Specification {
+
+ private HttpClient createClient() {
+ HttpClient.create(new URL("http://localhost:$serverPort"))
+ }
+
+ // ========== Accept Header-Based Negotiation ==========
+
+ def "JSON response via Accept header application/json"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with Accept: application/json"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/index')
+ .accept(MediaType.APPLICATION_JSON),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('application/json')
+
+ and: "content is valid JSON"
+ def json = new JsonSlurper().parseText(response.body())
+ json.message == 'Hello World'
+ json.items.size() == 3
+
+ cleanup:
+ client.close()
+ }
+
+ def "XML response via Accept header application/xml"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with Accept: application/xml"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/index')
+ .accept(MediaType.APPLICATION_XML),
+ String
+ )
+
+ then: "response is XML"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('xml')
+
+ and: "content contains expected XML elements (Grails XML converter uses entry key format for maps)"
+ response.body().contains('<entry key="message">Hello World</entry>')
+
+ cleanup:
+ client.close()
+ }
+
+ def "HTML response via Accept header text/html"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with Accept: text/html"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/index')
+ .accept(MediaType.TEXT_HTML),
+ String
+ )
+
+ then: "response is HTML"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('text/html')
+
+ and: "content is HTML page"
+ response.body().contains('<h1>Content Negotiation</h1>')
+ response.body().contains('Hello World')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== URL Extension-Based Negotiation ==========
+
+ def "JSON response via .json extension"() {
+ given:
+ def client = createClient()
+
+ when: "requesting URL with .json extension"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/index.json'),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+
+ and: "content is valid JSON with expected data"
+ def json = new JsonSlurper().parseText(response.body())
+ json.message == 'Hello World'
+
+ cleanup:
+ client.close()
+ }
+
+ def "XML response via .xml extension"() {
+ given:
+ def client = createClient()
+
+ when: "requesting URL with .xml extension"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/index.xml'),
+ String
+ )
+
+ then: "response is XML"
+ response.status == HttpStatus.OK
+
+ and: "content is XML (Grails converter uses map/entry format)"
+ response.body().contains('<entry key="message">')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Respond Method Tests ==========
+
+ def "respond method returns JSON for Accept application/json"() {
+ given:
+ def client = createClient()
+
+ when: "calling respond action with Accept: application/json"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/respond')
+ .accept(MediaType.APPLICATION_JSON),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+
+ and: "content is valid JSON"
+ def json = new JsonSlurper().parseText(response.body())
+ json.status == 'success'
+ json.data.id == 1
+ json.data.name == 'Test Item'
+
+ cleanup:
+ client.close()
+ }
+
+ def "respond method returns XML for Accept application/xml"() {
+ given:
+ def client = createClient()
+
+ when: "calling respond action with Accept: application/xml"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/respond')
+ .accept(MediaType.APPLICATION_XML),
+ String
+ )
+
+ then: "response is XML"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('xml')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== List/Collection Content Negotiation ==========
+
+ def "list action returns JSON array"() {
+ given:
+ def client = createClient()
+
+ when: "requesting list with Accept: application/json"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/list')
+ .accept(MediaType.APPLICATION_JSON),
+ String
+ )
+
+ then: "response is JSON array"
+ response.status == HttpStatus.OK
+
+ and: "content is array with 3 items"
+ def json = new JsonSlurper().parseText(response.body())
+ json instanceof List
+ json.size() == 3
+ json[0].id == 1
+ json[0].name == 'Item 1'
+
+ cleanup:
+ client.close()
+ }
+
+ def "list action returns XML for XML accept"() {
+ given:
+ def client = createClient()
+
+ when: "requesting list with Accept: application/xml"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/list')
+ .accept(MediaType.APPLICATION_XML),
+ String
+ )
+
+ then: "response is XML"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('xml')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Explicit Content Type ==========
+
+ def "explicit content type overrides negotiation"() {
+ given:
+ def client = createClient()
+
+ when: "calling action with explicit content type"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/explicitContentType'),
+ String
+ )
+
+ then: "response has explicit content type"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('application/json')
+
+ and: "content is as specified"
+ def json = new JsonSlurper().parseText(response.body())
+ json.explicit == true
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Error Response Formatting ==========
+
+ def "error response in JSON format"() {
+ given:
+ def client = createClient()
+
+ when: "requesting error action with Accept: application/json"
+ client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/error')
+ .accept(MediaType.APPLICATION_JSON),
+ String
+ )
+
+ then: "error response is returned"
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.BAD_REQUEST
+
+ and: "error body is JSON"
+ def json = new JsonSlurper().parseText(e.response.body().toString())
+ json.error == true
+ json.message == 'Something went wrong'
+ json.code == 'ERR_001'
+
+ cleanup:
+ client.close()
+ }
+
+ def "error response in XML format"() {
+ given:
+ def client = createClient()
+
+ when: "requesting error action with Accept: application/xml"
+ client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/error')
+ .accept(MediaType.APPLICATION_XML),
+ String
+ )
+
+ then: "error response is returned"
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.BAD_REQUEST
+
+ and: "error body contains XML elements (Grails converter uses entry key format)"
+ e.response.body().toString().contains('<entry key="error">true</entry>')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Format Parameter Override ==========
+
+ def "format parameter can specify JSON"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with format=json parameter"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/formatParam?format=json'),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+
+ and: "format is recorded in response"
+ def json = new JsonSlurper().parseText(response.body())
+ json.format == 'json'
+ json.value == 42
+
+ cleanup:
+ client.close()
+ }
+
+ def "format parameter can specify XML"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with format=xml parameter"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/formatParam?format=xml'),
+ String
+ )
+
+ then: "response is XML"
+ response.status == HttpStatus.OK
+ response.body().contains('<entry key="format">xml</entry>')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Status Code Tests ==========
+
+ def "status by format returns correct status for JSON"() {
+ given:
+ def client = createClient()
+
+ when: "requesting statusByFormat with Accept: application/json"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/statusByFormat')
+ .accept(MediaType.APPLICATION_JSON),
+ String
+ )
+
+ then: "response status is OK"
+ response.status == HttpStatus.OK
+
+ and: "body is JSON"
+ def json = new JsonSlurper().parseText(response.body())
+ json.status == 'ok'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Accept Header Quality Values ==========
+
+ def "multiAccept action handles Accept header"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with complex Accept header"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/multiAccept')
+ .accept(MediaType.APPLICATION_JSON),
+ String
+ )
+
+ then: "response is successful"
+ response.status == HttpStatus.OK
+
+ and: "response contains accept header info"
+ def json = new JsonSlurper().parseText(response.body())
+ json.acceptHeader != null
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Wildcard/Default Format ==========
+
+ def "formatParam falls back to default for unknown format"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with unknown format"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/formatParam?format=unknown'),
+ String
+ )
+
+ then: "response uses default format (JSON)"
+ response.status == HttpStatus.OK
+
+ and: "response is valid JSON"
+ def json = new JsonSlurper().parseText(response.body())
+ json.format == 'unknown'
+ json.value == 42
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Content-Type Header Variations ==========
+
+ def "JSON with charset in Accept header"() {
+ given:
+ def client = createClient()
+
+ when: "requesting with Accept including charset"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/respond')
+ .header('Accept', 'application/json; charset=utf-8'),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('json')
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Multiple Format Extensions ==========
+
+ def "respond action with .json extension"() {
+ given:
+ def client = createClient()
+
+ when: "requesting respond with .json extension"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/respond.json'),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+
+ and: "content is valid"
+ def json = new JsonSlurper().parseText(response.body())
+ json.status == 'success'
+
+ cleanup:
+ client.close()
+ }
+
+ def "list action with .json extension"() {
+ given:
+ def client = createClient()
+
+ when: "requesting list with .json extension"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/list.json'),
+ String
+ )
+
+ then: "response is JSON array"
+ response.status == HttpStatus.OK
+
+ and: "content is array"
+ def json = new JsonSlurper().parseText(response.body())
+ json instanceof List
+ json.size() == 3
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Custom JSON Rendering ==========
+
+ def "custom JSON rendering produces valid output"() {
+ given:
+ def client = createClient()
+
+ when: "requesting custom JSON action"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/contentNegotiation/customJson'),
+ String
+ )
+
+ then: "response is JSON"
+ response.status == HttpStatus.OK
+ response.contentType.get().toString().contains('application/json')
+
+ cleanup:
+ client.close()
+ }
+}
diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/urlmappings/UrlMappingsSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/urlmappings/UrlMappingsSpec.groovy
new file mode 100644
index 0000000000..894214b069
--- /dev/null
+++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/urlmappings/UrlMappingsSpec.groovy
@@ -0,0 +1,466 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package functionaltests.urlmappings
+
+import functionaltests.Application
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonSlurper
+import io.micronaut.http.HttpMethod
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import spock.lang.Narrative
+import spock.lang.Specification
+
+/**
+ * Integration tests for Grails URL mappings features.
+ *
+ * Tests static paths, path variables, constraints, HTTP method mappings,
+ * redirects, and various URL mapping patterns.
+ */
+@Integration(applicationClass = Application)
+@Narrative('''
+Grails URL mappings provide flexible routing of HTTP requests to controller actions.
+This includes path variables, constraints, HTTP method-based routing, and redirects.
+''')
+class UrlMappingsSpec extends Specification {
+
+ private HttpClient createClient() {
+ HttpClient.create(new URL("http://localhost:$serverPort"))
+ }
+
+ // ========== Static Path Mappings ==========
+
+ def "static path mapping routes to correct action"() {
+ given:
+ def client = createClient()
+
+ when: "accessing static path"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/test'),
+ String
+ )
+
+ then: "routes to index action"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.controller == 'urlMappingsTest'
+ json.action == 'index'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Path Variable Mappings ==========
+
+ def "single path variable is captured"() {
+ given:
+ def client = createClient()
+
+ when: "accessing path with variable"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/items/123'),
+ String
+ )
+
+ then: "variable is captured"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'show'
+ json.id == '123'
+
+ cleanup:
+ client.close()
+ }
+
+ def "path variable accepts alphanumeric values"() {
+ given:
+ def client = createClient()
+
+ when: "accessing path with alphanumeric id"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/items/abc-123'),
+ String
+ )
+
+ then: "variable is captured correctly"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.id == 'abc-123'
+
+ cleanup:
+ client.close()
+ }
+
+ def "multiple path variables are captured"() {
+ given:
+ def client = createClient()
+
+ when: "accessing path with multiple variables"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/archive/2024/03/15'),
+ String
+ )
+
+ then: "all variables are captured"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'pathVars'
+ json.year == '2024'
+ json.month == '03'
+ json.day == '15'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Named URL Mappings ==========
+
+ def "named mapping routes correctly"() {
+ given:
+ def client = createClient()
+
+ when: "accessing named mapping"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/named/test-name'),
+ String
+ )
+
+ then: "routes to correct action with variable"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'named'
+ json.name == 'test-name'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Constrained Path Variables ==========
+
+ def "constrained path accepts valid values"() {
+ given:
+ def client = createClient()
+
+ when: "accessing with valid constrained value"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/codes/ABC'),
+ String
+ )
+
+ then: "request succeeds"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'constrained'
+ json.code == 'ABC'
+
+ cleanup:
+ client.close()
+ }
+
+ def "constrained path rejects invalid values"() {
+ given:
+ def client = createClient()
+
+ when: "accessing with invalid constrained value (lowercase)"
+ client.toBlocking().exchange(
+ HttpRequest.GET('/api/codes/abc'),
+ String
+ )
+
+ then: "request is rejected with 404"
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+
+ cleanup:
+ client.close()
+ }
+
+ def "constrained path rejects numeric values"() {
+ given:
+ def client = createClient()
+
+ when: "accessing with numeric value"
+ client.toBlocking().exchange(
+ HttpRequest.GET('/api/codes/123'),
+ String
+ )
+
+ then: "request is rejected with 404"
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== HTTP Method Mappings ==========
+
+ def "GET request routes to list action"() {
+ given:
+ def client = createClient()
+
+ when: "making GET request to resources"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/resources'),
+ String
+ )
+
+ then: "routes to list action"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'list'
+
+ cleanup:
+ client.close()
+ }
+
+ def "POST request routes to save action"() {
+ given:
+ def client = createClient()
+
+ when: "making POST request to resources"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.POST('/api/resources', '{}'),
+ String
+ )
+
+ then: "routes to save action"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'save'
+ json.method == 'POST'
+
+ cleanup:
+ client.close()
+ }
+
+ def "PUT request routes to update action"() {
+ given:
+ def client = createClient()
+
+ when: "making PUT request to resources with id"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.PUT('/api/resources/42', '{}'),
+ String
+ )
+
+ then: "routes to update action"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'update'
+ json.id == '42'
+ json.method == 'PUT'
+
+ cleanup:
+ client.close()
+ }
+
+ def "DELETE request routes to delete action"() {
+ given:
+ def client = createClient()
+
+ when: "making DELETE request to resources with id"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.DELETE('/api/resources/42'),
+ String
+ )
+
+ then: "routes to delete action"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'delete'
+ json.id == '42'
+ json.method == 'DELETE'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Optional Path Variables ==========
+
+ def "optional path variable with value"() {
+ given:
+ def client = createClient()
+
+ when: "accessing with optional variable provided"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/optional/required-value/optional-value'),
+ String
+ )
+
+ then: "both values captured"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.required == 'required-value'
+ json.optional == 'optional-value'
+
+ cleanup:
+ client.close()
+ }
+
+ def "optional path variable without value uses default"() {
+ given:
+ def client = createClient()
+
+ when: "accessing without optional variable"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/optional/required-value'),
+ String
+ )
+
+ then: "required captured, optional uses default"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.required == 'required-value'
+ json.optional == 'default'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Redirect Mappings ==========
+
+ def "redirect mapping performs redirect"() {
+ given:
+ def client = HttpClient.create(new URL("http://localhost:$serverPort"))
+
+ when: "accessing redirect mapping"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/old-endpoint').header('Accept', '*/*'),
+ String
+ )
+
+ then: "redirect is performed and final response received"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'index'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Default Controller/Action Mapping ==========
+
+ def "default mapping with controller and action"() {
+ given:
+ def client = createClient()
+
+ when: "using default mapping pattern"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/urlMappingsTest/show/99'),
+ String
+ )
+
+ then: "routes correctly"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.controller == 'urlMappingsTest'
+ json.action == 'show'
+ json.id == '99'
+
+ cleanup:
+ client.close()
+ }
+
+ def "default mapping with format extension"() {
+ given:
+ def client = createClient()
+
+ when: "using default mapping with format"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/urlMappingsTest/list.json'),
+ String
+ )
+
+ then: "routes correctly with format"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.action == 'list'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== Query Parameter Handling ==========
+
+ def "query parameters are accessible in action"() {
+ given:
+ def client = createClient()
+
+ when: "accessing with query parameters"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/test?param1=value1¶m2=value2'),
+ String
+ )
+
+ then: "query params are accessible"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.params.param1 == 'value1'
+ json.params.param2 == 'value2'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== HTTP Method Detection ==========
+
+ def "request method is correctly detected"() {
+ given:
+ def client = createClient()
+
+ when: "making request"
+ HttpResponse<String> response = client.toBlocking().exchange(
+ HttpRequest.GET('/api/method-test'),
+ String
+ )
+
+ then: "method is correctly detected"
+ response.status == HttpStatus.OK
+ def json = new JsonSlurper().parseText(response.body())
+ json.method == 'GET'
+
+ cleanup:
+ client.close()
+ }
+
+ // ========== 404 Not Found ==========
+
+ def "non-existent path returns 404"() {
+ given:
+ def client = createClient()
+
+ when: "accessing non-existent path"
+ client.toBlocking().exchange(
+ HttpRequest.GET('/api/does-not-exist'),
+ String
+ )
+
+ then: "returns 404"
+ HttpClientResponseException e = thrown()
+ e.status == HttpStatus.NOT_FOUND
+
+ cleanup:
+ client.close()
+ }
+}