diff --git a/settings.gradle b/settings.gradle index d101f1f799f..cdee3bbaec0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -63,6 +63,7 @@ def subprojects = [ 'groovy-jsr223', 'groovy-logging-test', 'groovy-ginq', + 'groovy-http-builder', 'groovy-macro', 'groovy-macro-library', 'groovy-nio', diff --git a/subprojects/groovy-binary/src/spec/doc/index.adoc b/subprojects/groovy-binary/src/spec/doc/index.adoc index 60c53814493..220f2454f85 100644 --- a/subprojects/groovy-binary/src/spec/doc/index.adoc +++ b/subprojects/groovy-binary/src/spec/doc/index.adoc @@ -118,6 +118,8 @@ include::../../../../../subprojects/groovy-typecheckers/src/spec/doc/typechecker include::../../../../../subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc[leveloffset=+2] +include::../../../../../subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc[leveloffset=+2] + === Scripting Ant tasks Groovy integrates very well with http://ant.apache.org[Apache Ant] thanks to <>. diff --git a/subprojects/groovy-http-builder/build.gradle b/subprojects/groovy-http-builder/build.gradle new file mode 100644 index 00000000000..2839f8f5eea --- /dev/null +++ b/subprojects/groovy-http-builder/build.gradle @@ -0,0 +1,39 @@ +/* + * 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 + * + * http://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. + */ +plugins { + id 'org.apache.groovy-library' +} + +dependencies { + api rootProject + implementation projects.groovyJson + implementation projects.groovyXml + testRuntimeOnly "org.jsoup:jsoup:1.22.1" + testImplementation projects.groovyTest +} + +groovyLibrary { + optionalModule() + withoutBinaryCompatibilityChecks() +} + + + + + diff --git a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy new file mode 100644 index 00000000000..8e8ba1e009b --- /dev/null +++ b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy @@ -0,0 +1,312 @@ +/* + * 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 + * + * http://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 groovy.http + +import groovy.lang.DelegatesTo +import groovy.lang.Closure +import groovy.json.JsonOutput +import org.apache.groovy.lang.annotation.Incubating + +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.time.Duration + +/** + * Tiny DSL over JDK {@link HttpClient}. + */ +@Incubating +final class HttpBuilder { + private final HttpClient client + private final URI baseUri + private final Map defaultHeaders + private final Duration defaultRequestTimeout + + private HttpBuilder(final Config config) { + HttpClient.Builder clientBuilder = HttpClient.newBuilder() + if (config.connectTimeout != null) { + clientBuilder.connectTimeout(config.connectTimeout) + } + if (config.followRedirects) { + clientBuilder.followRedirects(HttpClient.Redirect.NORMAL) + } + client = clientBuilder.build() + baseUri = config.baseUri + defaultHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(config.headers)) + defaultRequestTimeout = config.requestTimeout + } + + static HttpBuilder http( + @DelegatesTo(value = Config, strategy = Closure.DELEGATE_FIRST) + final Closure spec + ) { + Config config = new Config() + Closure code = (Closure) spec.clone() + code.resolveStrategy = Closure.DELEGATE_FIRST + code.delegate = config + code.call() + return new HttpBuilder(config) + } + + static HttpBuilder http(final String baseUri) { + Config config = new Config() + config.baseUri(baseUri) + return new HttpBuilder(config) + } + + HttpResult get(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure spec = null) { + return request('GET', uri, spec) + } + + HttpResult post(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure spec = null) { + return request('POST', uri, spec) + } + + HttpResult put(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure spec = null) { + return request('PUT', uri, spec) + } + + HttpResult delete(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure spec = null) { + return request('DELETE', uri, spec) + } + + HttpResult request(final String method, + final Object uri, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure spec = null) { + RequestSpec requestSpec = new RequestSpec() + if (spec != null) { + Closure code = (Closure) spec.clone() + code.resolveStrategy = Closure.DELEGATE_FIRST + code.delegate = requestSpec + code.call() + } + + URI resolvedUri = resolveUri(uri, requestSpec.queryParameters) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(resolvedUri) + + Duration timeout = requestSpec.timeout ?: defaultRequestTimeout + if (timeout != null) { + requestBuilder.timeout(timeout) + } + + defaultHeaders.each { String name, String value -> + requestBuilder.header(name, value) + } + requestSpec.headers.each { String name, String value -> + requestBuilder.setHeader(name, value) + } + + requestBuilder.method(method, bodyPublisher(method, requestSpec.body)) + + HttpResponse response + try { + response = client.send(requestBuilder.build(), requestSpec.bodyHandler) + } catch (InterruptedException e) { + Thread.currentThread().interrupt() + throw new RuntimeException("HTTP request " + method + " " + resolvedUri + " was interrupted", e) + } catch (IOException e) { + throw new RuntimeException("I/O error during HTTP request " + method + " " + resolvedUri, e) + } + return new HttpResult(response) + } + + private URI resolveUri(final Object uri, final Map query) { + URI target = toUri(uri) + if (!target.isAbsolute()) { + if (baseUri == null) { + throw new IllegalArgumentException('Request URI must be absolute when no baseUri is configured') + } + target = baseUri.resolve(target.toString()) + } + return appendQuery(target, query) + } + + private static URI requireAbsoluteUriWithHost(final URI uri, final String name) { + if (uri == null || !uri.isAbsolute() || uri.host == null) { + throw new IllegalArgumentException(name + ' must be an absolute URI with scheme and host') + } + return uri + } + + private URI toUri(final Object value) { + if (value == null) { + if (baseUri == null) { + throw new IllegalArgumentException('URI must be provided when no baseUri is configured') + } + return baseUri + } + if (value instanceof URI) { + return (URI) value + } + return URI.create(value.toString()) + } + + private static URI appendQuery(final URI uri, final Map queryValues) { + if (queryValues.isEmpty()) { + return uri + } + + List pairs = new ArrayList<>() + if (uri.query != null && !uri.query.isEmpty()) { + pairs.add(uri.query) + } + + queryValues.each { String key, Object value -> + String encodedKey = encodeQueryComponent(key) + String encodedValue = value == null ? '' : encodeQueryComponent(value.toString()) + pairs.add(encodedKey + '=' + encodedValue) + } + + String query = pairs.join('&') + return new URI(uri.scheme, uri.authority, uri.path, query, uri.fragment) + } + + private static String encodeQueryComponent(final String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8) + .replace('+', '%20') + .replace('*', '%2A') + .replace('%7E', '~') + } + + private static HttpRequest.BodyPublisher bodyPublisher(final String method, final Object body) { + if (body == null) { + return HttpRequest.BodyPublishers.noBody() + } + if ('GET'.equalsIgnoreCase(method)) { + throw new IllegalArgumentException('GET requests do not support a body in this DSL') + } + if (body instanceof byte[]) { + return HttpRequest.BodyPublishers.ofByteArray((byte[]) body) + } + return HttpRequest.BodyPublishers.ofString(body.toString()) + } + + static final class Config { + URI baseUri + Duration connectTimeout + Duration requestTimeout + boolean followRedirects + final Map headers = [:] + + void baseUri(final Object value) { + URI candidate = value instanceof URI ? (URI) value : URI.create(value.toString()) + baseUri = requireAbsoluteUriWithHost(candidate, 'baseUri') + } + + void connectTimeout(final Duration value) { + connectTimeout = value + } + + void requestTimeout(final Duration value) { + requestTimeout = value + } + + void followRedirects(final boolean value) { + followRedirects = value + } + + void header(final String name, final Object value) { + headers.put(name, String.valueOf(value)) + } + + void headers(final Map values) { + values.each { String name, Object value -> header(name, value) } + } + } + + static final class RequestSpec { + Duration timeout + Object body + HttpResponse.BodyHandler bodyHandler = HttpResponse.BodyHandlers.ofString() + final Map headers = new LinkedHashMap<>() + final Map queryParameters = new LinkedHashMap<>() + + void timeout(final Duration value) { + timeout = value + } + + void header(final String name, final Object value) { + headers.put(name, String.valueOf(value)) + } + + void headers(final Map values) { + values.each { String name, Object value -> header(name, value) } + } + + void query(final String name, final Object value) { + queryParameters.put(name, value) + } + + void query(final Map values) { + values.each { String name, Object value -> query(name, value) } + } + + void text(final Object value) { + body = value == null ? null : value.toString() + } + + void bytes(final byte[] value) { + body = value + } + + void body(final Object value) { + body = value + } + + /** + * Encodes map entries as application/x-www-form-urlencoded and sets a default content type. + */ + void form(final Map values) { + if (!headers.find{ it.key.equalsIgnoreCase('Content-Type') }) { + header('Content-Type', 'application/x-www-form-urlencoded') + } + body = values.collect { String name, Object value -> + String encodedName = URLEncoder.encode(name, StandardCharsets.UTF_8) + String encodedValue = value == null ? '' : URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) + encodedName + '=' + encodedValue + }.join('&') + } + + /** + * Serializes the given value as JSON and sets a default content type. + */ + void json(final Object value) { + if (!headers.find{ it.key.equalsIgnoreCase('Content-Type') }) { + header('Content-Type', 'application/json') + } + body = JsonOutput.toJson(value) + } + + void asString() { + bodyHandler = HttpResponse.BodyHandlers.ofString() + } + } +} diff --git a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpResult.groovy b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpResult.groovy new file mode 100644 index 00000000000..a37a0baed37 --- /dev/null +++ b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpResult.groovy @@ -0,0 +1,97 @@ +/* + * 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 + * + * http://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 groovy.http + +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +import org.apache.groovy.lang.annotation.Incubating + +import java.net.http.HttpHeaders +import java.net.http.HttpResponse +import java.util.Locale + +/** + * Simple response wrapper for the {@link HttpBuilder} DSL. + */ +@Incubating +record HttpResult(int status, String body, HttpHeaders headers, HttpResponse raw) { + + HttpResult(final HttpResponse response) { + this(response.statusCode(), response.body(), response.headers(), response) + } + + Object getJson() { + return new JsonSlurper().parseText(body) + } + + Object getXml() { + return new XmlSlurper().parseText(body) + } + + Object getHtml() { + try { + Class jsoup = loadOptionalClass('org.jsoup.Jsoup') + if (jsoup == null) { + throw new ClassNotFoundException('org.jsoup.Jsoup') + } + return jsoup.getMethod('parse', String).invoke(null, body) + } catch (ClassNotFoundException e) { + throw new IllegalStateException("HTML parsing requires jsoup on the classpath", e) + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to parse HTML via jsoup", e) + } + } + + private static Class loadOptionalClass(final String className) { + List classLoaders = [ + Thread.currentThread().contextClassLoader, + HttpResult.class.classLoader, + ClassLoader.systemClassLoader + ].findAll { it != null }.unique() + + for (ClassLoader classLoader : classLoaders) { + try { + return Class.forName(className, false, classLoader) + } catch (ClassNotFoundException ignore) { + // try next class loader + } + } + return null + } + + Object getParsed() { + String contentType = headers.firstValue('Content-Type').orElse('') + String mediaType = contentType.split(';', 2)[0].trim().toLowerCase(Locale.ROOT) + + if (mediaType == 'application/json' || mediaType.endsWith('+json')) { + return getJson() + } + if (mediaType == 'application/xml' || mediaType == 'text/xml' || mediaType.endsWith('+xml')) { + return getXml() + } + if (mediaType == 'text/html') { + try { + return getHtml() + } catch (IllegalStateException ignored) { + System.err.println "HttpResult unable to parse HTML: $ignored.message" + } + } + return body + } +} diff --git a/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc new file mode 100644 index 00000000000..36a883a7598 --- /dev/null +++ b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc @@ -0,0 +1,183 @@ +////////////////////////////////////////// + + 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 + + http://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. + +////////////////////////////////////////// + += HttpBuilder – lightweight HTTP client DSL (incubating) + +The `groovy-http-builder` module provides a tiny declarative DSL over +the JDK `java.net.http.HttpClient`. +It is designed for scripting and simple automation tasks where a full-blown +HTTP library would be overkill. + +== Goals + +* Keep the implementation small and easy to maintain. +* Use only JDK HTTP client primitives (Jsoup is optionally supported for HTML parsing). +* Make common request setup declarative with Groovy closures. +* Handle only the simple cases that often pop up in scripting — not the full + use cases that Apache Geb covers. +* Include JSON/XML/HTML response parsing hooks while intentionally keeping + request hooks minimal. + +== Basic Usage + +Create a client with `HttpBuilder.http`, configure shared settings in the +closure, and issue requests: + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=basic_get_with_query,indent=0] +---- + +`query(...)` encodes keys and values as URI query components using RFC 3986 +style percent-encoding — for example, spaces become `%20`. + +=== Non-DSL Equivalent (JDK HttpClient) + +The snippet above is equivalent to the following plain JDK code: + +[source,groovy] +---- +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets + +def encodeQueryComponent = { Object value -> + URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) + .replace('+', '%20') + .replace('*', '%2A') + .replace('%7E', '~') +} + +def baseUri = 'https://example.com/' +def query = [page: 1, size: 10] + .collect { k, v -> + "${encodeQueryComponent(k)}=${encodeQueryComponent(v)}" + } + .join('&') + +def target = URI.create(baseUri).resolve("/api/items?${query}") + +def client = HttpClient.newHttpClient() +def request = HttpRequest.newBuilder(target) + .header('User-Agent', 'my-app/1.0') + .GET() + .build() + +def response = client.send(request, HttpResponse.BodyHandlers.ofString()) + +assert response.statusCode() == 200 +println response.body() +---- + +== JSON + +=== GET + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=json_get,indent=0] +---- + +`res.json` lazily parses the response body as JSON. +`res.parsed` auto-dispatches by the response `Content-Type` header, so for +`application/json` it behaves identically to `res.json`. + +=== POST + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=json_post,indent=0] +---- + +The `json(...)` helper serialises the supplied object as JSON and sets +`Content-Type: application/json` automatically. + +== XML + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=xml_get,indent=0] +---- + +`result.xml` parses the response body with `XmlSlurper`. +`result.parsed` dispatches to `xml` for XML content types. + +== HTML (jsoup) + +If https://jsoup.org[jsoup] is on the classpath, `result.html` returns a +jsoup `Document`: + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=html_jsoup,indent=0] +---- + +`result.parsed` dispatches to jsoup for `text/html` content types when jsoup +is available, otherwise it falls back to the raw string body. + +== Form URL-Encoding + +The `form(...)` helper sends `application/x-www-form-urlencoded` POST bodies: + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=form_post,indent=0] +---- + +`form(...)` encodes values as `application/x-www-form-urlencoded` and sets +`Content-Type` automatically (unless you override it with `header`). + +Unlike `query(...)`, `form(...)` uses _form semantics_, so spaces become `+`. + +== HTML Login Example + +Combining `form(...)` with HTML parsing enables simple login flows: + +[source,groovy] +---- +include::../test/HttpBuilderSpecTest.groovy[tags=html_login,indent=0] +---- + +== Content-Type Auto-Parsing + +`result.parsed` dispatches by the response `Content-Type`: + +[cols="1,1"] +|=== +| Content-Type | Parsed as + +| `application/json`, `application/\*+json` +| JSON object (`JsonSlurper`) + +| `application/xml`, `text/xml`, `application/*+xml` +| XML object (`XmlSlurper`) + +| `text/html` +| jsoup `Document` (if jsoup is on the classpath, otherwise raw string) + +| anything else +| raw string body +|=== + + diff --git a/subprojects/groovy-http-builder/src/spec/test/HttpBuilderSpecTest.groovy b/subprojects/groovy-http-builder/src/spec/test/HttpBuilderSpecTest.groovy new file mode 100644 index 00000000000..0f76340b23b --- /dev/null +++ b/subprojects/groovy-http-builder/src/spec/test/HttpBuilderSpecTest.groovy @@ -0,0 +1,240 @@ +/* + * 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 + * + * http://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. + */ +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import java.nio.charset.StandardCharsets + +import static org.junit.jupiter.api.Assumptions.assumeFalse + +class HttpBuilderSpecTest { + + private HttpServer server + private URI rootUri + + @BeforeEach + void setup() { + server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + server.createContext('/api/items') { exchange -> + String query = exchange.requestURI.query ?: '' + String method = exchange.requestMethod + String contentType = exchange.requestHeaders.getFirst('Content-Type') ?: '' + String requestBody = exchange.requestBody.getText(StandardCharsets.UTF_8.name()) + String ua = exchange.requestHeaders.getFirst('User-Agent') ?: '' + String body + if (method == 'GET') { + body = """{"items":[{"name":"book","qty":2}],"query":"${query}","ua":"${ua}"}""" + } else if (method == 'POST' && contentType.contains('application/json')) { + body = """{"ok":true,"received":${requestBody}}""" + } else { + body = """{"ok":true}""" + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'application/json') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/api/repo.xml') { exchange -> + String body = 'groovyApache License 2.0' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'application/xml') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/login') { exchange -> + String method = exchange.requestMethod + if (method == 'GET') { + String body = '

Please Login

' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'text/html; charset=UTF-8') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } else { + String requestBody = exchange.requestBody.getText(StandardCharsets.UTF_8.name()) + String body + if (requestBody.contains('username=admin')) { + body = '

Admin Section

' + } else { + body = '

Login Failed

' + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'text/html; charset=UTF-8') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + } + server.start() + rootUri = URI.create("http://127.0.0.1:${server.address.port}") + } + + @AfterEach + void cleanup() { + server?.stop(0) + } + + @Test + void testBasicGetWithQuery() { + assertScript """ + // tag::basic_get_with_query[] + import groovy.http.HttpBuilder + + def http = HttpBuilder.http { + baseUri '${rootUri}/' + header 'User-Agent', 'my-app/1.0' + } + + def res = http.get('/api/items') { + query page: 1, size: 10 + } + + assert res.status == 200 + // end::basic_get_with_query[] + assert res.json.ua == 'my-app/1.0' + """ + } + + @Test + void testJsonGet() { + assertScript """ + // tag::json_get[] + import static groovy.http.HttpBuilder.http + + def client = http '${rootUri}' + def res = client.get('/api/items') + + assert res.status == 200 + assert res.json.items[0].name == 'book' + assert res.parsed.items[0].name == 'book' // auto-parsed from Content-Type + // end::json_get[] + """ + } + + @Test + void testJsonPost() { + assertScript """ + import static groovy.http.HttpBuilder.http + + def http = http '${rootUri}' + // tag::json_post[] + def result = http.post('/api/items') { + json([name: 'book', qty: 2]) + } + + assert result.status == 200 + assert result.json.ok + // end::json_post[] + """ + } + + @Test + void testXmlGet() { + assertScript """ + import static groovy.http.HttpBuilder.http + + def http = http '${rootUri}' + // tag::xml_get[] + def result = http.get('/api/repo.xml') + + assert result.status == 200 + assert result.xml.license.text() == 'Apache License 2.0' + assert result.parsed.license.text() == 'Apache License 2.0' // auto-parsed from Content-Type + // end::xml_get[] + """ + } + + @Test + void testHtmlLogin() { + assertScript """ + import static groovy.http.HttpBuilder.http + + // tag::html_login[] + def app = http { + baseUri '${rootUri}' + followRedirects true + header 'User-Agent', 'Mozilla/5.0 (Macintosh)' + } + + def loginPage = app.get('/login') + assert loginPage.status == 200 + assert loginPage.html.select('h1').text() == 'Please Login' + + def afterLogin = app.post('/login') { + form(username: 'admin', password: 'p@ssw0rd') + } + + assert afterLogin.status == 200 + assert afterLogin.html.select('h1').text() == 'Admin Section' + // end::html_login[] + """ + } + + @Test + void testHtmlJsoup() { + // Skip on JDKs with TLS fingerprints that trigger Cloudflare bot detection + def jdkVersion = Runtime.version().feature() + assumeFalse(jdkVersion in [18, 19, 20, 22], + "Skipping on JDK ${jdkVersion} due to Cloudflare TLS fingerprinting") + + assertScript ''' + import static groovy.http.HttpBuilder.http + + // tag::html_jsoup[] + // @Grab('org.jsoup:jsoup:1.22.1') // needed if running as standalone script + def client = http('https://mvnrepository.com') + def res = client.get('/artifact/org.codehaus.groovy/groovy-all') { + header 'User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' + } + + assert res.status == 200 + + def license = res.parsed.select('span.badge.badge-license')*.text().join(', ') + assert license == 'Apache 2.0' + // end::html_jsoup[] + ''' + } + + @Test + void testFormPost() { + assertScript """ + import static groovy.http.HttpBuilder.http + + def http = http '${rootUri}' + // tag::form_post[] + def result = http.post('/login') { + form(username: 'admin', password: 'p@ssw0rd') + } + + assert result.status == 200 + // end::form_post[] + """ + } + + private static void assertScript(String script) { + new GroovyShell(HttpBuilderSpecTest.classLoader).evaluate(script) + } +} + + + + + + + diff --git a/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderTest.groovy b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderTest.groovy new file mode 100644 index 00000000000..afee7861ae1 --- /dev/null +++ b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderTest.groovy @@ -0,0 +1,320 @@ +/* + * 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 + * + * http://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 groovy.http + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertThrows + +import java.nio.charset.StandardCharsets +import java.time.Duration + +class HttpBuilderTest { + + private HttpServer server + private URI rootUri + + @BeforeEach + void setup() { + server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + server.createContext('/hello') { exchange -> + String body = "method=${exchange.requestMethod};query=${exchange.requestURI.query};ua=${exchange.requestHeaders.getFirst('User-Agent')}" + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/echo') { exchange -> + String requestBody = exchange.requestBody.getText(StandardCharsets.UTF_8.name()) + String body = "method=${exchange.requestMethod};header=${exchange.requestHeaders.getFirst('X-Trace')};body=${requestBody}" + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.sendResponseHeaders(201, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/json') { exchange -> + String requestBody = exchange.requestBody.getText(StandardCharsets.UTF_8.name()) + String contentType = exchange.requestHeaders.getFirst('Content-Type') + String body = /{"ok":true,"contentType":"${contentType}","requestBody":${requestBody}}/ + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'application/json') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/xml') { exchange -> + String body = 'groovyApache License 2.0' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'application/xml') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/plain') { exchange -> + String body = 'just text' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'text/plain') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/form') { exchange -> + String requestBody = exchange.requestBody.getText(StandardCharsets.UTF_8.name()) + String contentType = exchange.requestHeaders.getFirst('Content-Type') + String body = "method=${exchange.requestMethod};contentType=${contentType};body=${requestBody}" + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/html') { exchange -> + String body = 'Apache License 2.0' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'text/html; charset=UTF-8') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/redirect-target') { exchange -> + String body = 'redirect reached' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.createContext('/redirect-me') { exchange -> + exchange.responseHeaders.add('Location', '/redirect-target') + exchange.sendResponseHeaders(302, -1) + exchange.close() + } + server.start() + rootUri = URI.create("http://127.0.0.1:${server.address.port}/") + } + + @AfterEach + void cleanup() { + server?.stop(0) + } + + @Test + void getsWithBaseUriDefaultHeadersAndQueryDsl() { + HttpBuilder http = HttpBuilder.http { + baseUri rootUri + connectTimeout Duration.ofSeconds(2) + requestTimeout Duration.ofSeconds(2) + header 'User-Agent', 'groovy-http-builder-test' + } + + HttpResult result = http.get('/hello') { + query lang: 'groovy', page: 1 + } + + assert result.status == 200 + assert result.body.contains('method=GET') + assert result.body.contains('lang=groovy') + assert result.body.contains('page=1') + assert result.body.contains('ua=groovy-http-builder-test') + } + + @Test + void getsUsingStringBaseUriFactoryWithoutClosureConfig() { + HttpBuilder http = HttpBuilder.http(rootUri.toString()) + + HttpResult result = http.get('/hello') { + query page: 1 + } + + assert result.status == 200 + assert result.body.contains('method=GET') + assert result.body.contains('page=1') + } + + @Test + void relativeUriWithoutBaseUriConfiguredThrows() { + HttpBuilder http = HttpBuilder.http { + header 'User-Agent', 'groovy-http-builder-test' + } + + IllegalArgumentException error = assertThrows(IllegalArgumentException) { + http.get('/hello') + } + + assert error.message == 'Request URI must be absolute when no baseUri is configured' + } + + @Test + void omittedUriWithoutBaseUriConfiguredThrows() { + HttpBuilder http = HttpBuilder.http { + header 'User-Agent', 'groovy-http-builder-test' + } + + IllegalArgumentException error = assertThrows(IllegalArgumentException) { + http.get() + } + + assert error.message == 'URI must be provided when no baseUri is configured' + } + + @Test + void relativeBaseUriConfiguredInClosureThrows() { + IllegalArgumentException error = assertThrows(IllegalArgumentException) { + HttpBuilder.http { + baseUri '/api' + } + } + + assert error.message == 'baseUri must be an absolute URI with scheme and host' + } + + @Test + void relativeBaseUriConfiguredViaStringFactoryThrows() { + IllegalArgumentException error = assertThrows(IllegalArgumentException) { + HttpBuilder.http('/api') + } + + assert error.message == 'baseUri must be an absolute URI with scheme and host' + } + + @Test + void queryDslUsesRfc3986StyleEncoding() { + HttpBuilder http = HttpBuilder.http(rootUri.toString()) + + HttpResult result = http.get('/hello') { + query 'sp ace', 'a b' + query 'plus', 'c+d' + query 'marks', '~*' + query 'empty', null + } + + assert result.status == 200 + assert result.body.contains('query=sp%20ace=a%20b&plus=c%2Bd&marks=~%2A&empty=') + } + + @Test + void postsWithBodyAndPerRequestHeader() { + HttpBuilder http = HttpBuilder.http { + baseUri rootUri + } + + HttpResult result = http.post('/echo') { + header 'X-Trace', 'trace-42' + text 'hello from DSL' + } + + assert result.status == 201 + assert result.body == 'method=POST;header=trace-42;body=hello from DSL' + } + + @Test + void formHookEncodesBodyAndSetsDefaultContentType() { + HttpBuilder http = HttpBuilder.http(rootUri.toString()) + + HttpResult result = http.post('/form') { + form([username: 'admin', password: 'p@ss word']) + } + + assert result.status == 200 + assert result.body == 'method=POST;contentType=application/x-www-form-urlencoded;body=username=admin&password=p%40ss+word' + } + + @Test + void perRequestHeaderOverridesDefaultHeader() { + HttpBuilder http = HttpBuilder.http { + baseUri rootUri + connectTimeout Duration.ofSeconds(2) + requestTimeout Duration.ofSeconds(2) + header 'User-Agent', 'default-ua' + } + HttpResult result = http.get('/hello') { + header 'User-Agent', 'overridden-ua' + } + assert result.status == 200 + assert result.body.contains('ua=overridden-ua') + assert !result.body.contains('ua=default-ua') + } + + @Test + void jsonHookSerializesRequestAndParsesResponse() { + HttpBuilder http = HttpBuilder.http { + baseUri rootUri + } + + HttpResult result = http.post('/json') { + json([name: 'Groovy', version: 6]) + } + + assert result.status == 200 + Map payload = (Map) result.getJson() + assert payload.ok == true + assert payload.contentType == 'application/json' + assert payload.requestBody.name == 'Groovy' + assert payload.requestBody.version == 6 + + Map parsed = (Map) result.parsed + assert parsed.ok == true + } + + @Test + void xmlHookParsesResponseBody() { + HttpBuilder http = HttpBuilder.http(rootUri.toString()) + + HttpResult result = http.get('/xml') + + assert result.status == 200 + def xml = result.xml + assert xml.name.text() == 'groovy' + assert xml.license.text() == 'Apache License 2.0' + + def parsed = result.parsed + assert parsed.name.text() == 'groovy' + } + + @Test + void parsedFallsBackToRawBodyForUnsupportedContentType() { + HttpBuilder http = HttpBuilder.http(rootUri.toString()) + + HttpResult result = http.get('/plain') + + assert result.status == 200 + assert result.parsed == 'just text' + } + + @Test + void htmlHookParsesMalformedHtmlViaJsoup() { + HttpBuilder http = HttpBuilder.http(rootUri.toString()) + + HttpResult result = http.get('/html') + + assert result.status == 200 + assert result.html.select('span.b.lic').text() == 'Apache License 2.0' + assert result.parsed.select('span.b.lic').text() == 'Apache License 2.0' + } + + @Test + void followsRedirectsWhenFlagEnabled() { + HttpBuilder noRedirectClient = HttpBuilder.http { + baseUri rootUri + } + HttpResult noRedirect = noRedirectClient.get('/redirect-me') + assert noRedirect.status == 302 + + HttpBuilder redirectClient = HttpBuilder.http { + baseUri rootUri + followRedirects true + } + HttpResult redirected = redirectClient.get('/redirect-me') + assert redirected.status == 200 + assert redirected.body == 'redirect reached' + } +}