From 1e1b8469b2c7e524242a86d34937629bf19980bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=CC=88rg=20Prante?= Date: Tue, 2 May 2017 00:52:09 +0200 Subject: [PATCH] initial commit --- .gitignore | 12 + LICENSE.txt | 202 ++++++ build.gradle | 121 ++++ config/checkstyle/checkstyle.xml | 323 +++++++++ gradle.properties | 10 + gradle/ext.gradle | 9 + gradle/publish.gradle | 70 ++ gradle/sonarqube.gradle | 41 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54783 bytes gradle/wrapper/gradle-wrapper.properties | 6 + src/docs/asciidoc/css/foundation.css | 684 ++++++++++++++++++ src/docs/asciidoc/index.adoc | 11 + src/docs/asciidoclet/overview.adoc | 4 + .../netty/http/client/ExceptionListener.java | 28 + .../xbib/netty/http/client/Http1Handler.java | 105 +++ .../xbib/netty/http/client/Http2Handler.java | 161 +++++ .../xbib/netty/http/client/HttpClient.java | 363 ++++++++++ .../netty/http/client/HttpClientBuilder.java | 326 +++++++++ .../http/client/HttpClientChannelContext.java | 206 ++++++ .../HttpClientChannelContextDefaults.java | 122 ++++ .../client/HttpClientChannelInitializer.java | 379 ++++++++++ .../client/HttpClientChannelPoolHandler.java | 74 ++ .../http/client/HttpClientChannelPoolMap.java | 65 ++ .../http/client/HttpClientRequestBuilder.java | 352 +++++++++ .../http/client/HttpClientThreadFactory.java | 30 + .../http/client/HttpClientUserAgent.java | 58 ++ .../netty/http/client/HttpRequestBuilder.java | 73 ++ .../netty/http/client/HttpRequestContext.java | 185 +++++ .../http/client/HttpRequestDefaults.java | 35 + .../http/client/HttpResponseListener.java | 26 + .../netty/http/client/InetAddressKey.java | 78 ++ .../netty/http/client/SslClientAuthMode.java | 23 + .../http/client/TrafficLoggingHandler.java | 56 ++ .../xbib/netty/http/client/package-info.java | 4 + .../netty/http/client/test/AkamaiTest.java | 71 ++ .../http/client/test/ElasticsearchTest.java | 127 ++++ .../netty/http/client/test/ExceptionTest.java | 68 ++ .../netty/http/client/test/GoogleTest.java | 140 ++++ .../http/client/test/Http2PushioTest.java | 52 ++ .../netty/http/client/test/Http2Test.java | 209 ++++++ .../netty/http/client/test/IndexHbzTest.java | 188 +++++ .../xbib/netty/http/client/test/XbibTest.java | 161 +++++ .../netty/http/client/test/package-info.java | 4 + 43 files changed, 5262 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 build.gradle create mode 100644 config/checkstyle/checkstyle.xml create mode 100644 gradle.properties create mode 100644 gradle/ext.gradle create mode 100644 gradle/publish.gradle create mode 100644 gradle/sonarqube.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 src/docs/asciidoc/css/foundation.css create mode 100644 src/docs/asciidoc/index.adoc create mode 100644 src/docs/asciidoclet/overview.adoc create mode 100644 src/main/java/org/xbib/netty/http/client/ExceptionListener.java create mode 100755 src/main/java/org/xbib/netty/http/client/Http1Handler.java create mode 100644 src/main/java/org/xbib/netty/http/client/Http2Handler.java create mode 100755 src/main/java/org/xbib/netty/http/client/HttpClient.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientBuilder.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientChannelContext.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientChannelContextDefaults.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientChannelInitializer.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolHandler.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolMap.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientRequestBuilder.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientThreadFactory.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpClientUserAgent.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpRequestBuilder.java create mode 100755 src/main/java/org/xbib/netty/http/client/HttpRequestContext.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpRequestDefaults.java create mode 100644 src/main/java/org/xbib/netty/http/client/HttpResponseListener.java create mode 100644 src/main/java/org/xbib/netty/http/client/InetAddressKey.java create mode 100644 src/main/java/org/xbib/netty/http/client/SslClientAuthMode.java create mode 100644 src/main/java/org/xbib/netty/http/client/TrafficLoggingHandler.java create mode 100644 src/main/java/org/xbib/netty/http/client/package-info.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/AkamaiTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/ElasticsearchTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/ExceptionTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/GoogleTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/Http2PushioTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/Http2Test.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/IndexHbzTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/XbibTest.java create mode 100644 src/test/java/org/xbib/netty/http/client/test/package-info.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..644a0f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/data +/work +/logs +/.idea +/target +.DS_Store +/.settings +/.classpath +/.project +/.gradle +/build +*~ \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..387b777 --- /dev/null +++ b/build.gradle @@ -0,0 +1,121 @@ + +plugins { + id "org.sonarqube" version "2.2" + id "org.xbib.gradle.plugin.asciidoctor" version "1.5.4.1.0" + id "io.codearte.nexus-staging" version "0.7.0" +} + +printf "Host: %s\nOS: %s %s %s\nJVM: %s %s %s %s\nGroovy: %s\nGradle: %s\n" + + "Build: group: ${project.group} name: ${project.name} version: ${project.version}\n", + InetAddress.getLocalHost(), + System.getProperty("os.name"), + System.getProperty("os.arch"), + System.getProperty("os.version"), + System.getProperty("java.version"), + System.getProperty("java.vm.version"), + System.getProperty("java.vm.vendor"), + System.getProperty("java.vm.name"), + GroovySystem.getVersion(), + gradle.gradleVersion + +apply plugin: 'java' +apply plugin: 'maven' +apply plugin: 'signing' +apply plugin: 'findbugs' +apply plugin: 'pmd' +apply plugin: 'checkstyle' +apply plugin: "jacoco" +apply plugin: 'org.xbib.gradle.plugin.asciidoctor' +apply plugin: "io.codearte.nexus-staging" + + +repositories { + mavenCentral() +} + +configurations { + alpnagent + asciidoclet + wagon +} + +dependencies { + compile "io.netty:netty-codec-http2:${project.property('netty.version')}" + compile "io.netty:netty-handler-proxy:${project.property('netty.version')}" + compile "io.netty:netty-tcnative-boringssl-static:${project.property('tcnative.version')}" + alpnagent "org.mortbay.jetty.alpn:jetty-alpn-agent:${project.property('alpnagent.version')}" + testCompile "junit:junit:${project.property('junit.version')}" + asciidoclet "org.asciidoctor:asciidoclet:${project.property('asciidoclet.version')}" + wagon "org.apache.maven.wagon:wagon-ssh:${project.property('wagon.version')}" +} + +sourceCompatibility = JavaVersion.VERSION_1_8 +targetCompatibility = JavaVersion.VERSION_1_8 + +[compileJava, compileTestJava]*.options*.encoding = 'UTF-8' +tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:all" +} + +jar { + manifest { + attributes('Implementation-Version': project.version) + } +} + +test { + jvmArgs "-javaagent:" + configurations.alpnagent.asPath + //include 'org/xbib/netty/http/client/test/Http2Test*' + testLogging { + showStandardStreams = false + exceptionFormat = 'full' + } +} + +asciidoctor { + backends 'html5' + separateOutputDirs = false + attributes 'source-highlighter': 'coderay', + toc : '', + idprefix : '', + idseparator : '-', + stylesheet: "${projectDir}/src/docs/asciidoc/css/foundation.css" +} + +javadoc { + options.docletpath = configurations.asciidoclet.files.asType(List) + options.doclet = 'org.asciidoctor.Asciidoclet' + options.overview = "src/docs/asciidoclet/overview.adoc" + options.addStringOption "-base-dir", "${projectDir}" + options.addStringOption "-attribute", + "name=${project.name},version=${project.version},title-link=https://github.com/jprante/${project.name}" + configure(options) { + noTimestamp = true + } +} + +task javadocJar(type: Jar, dependsOn: classes) { + from javadoc + into "build/tmp" + classifier 'javadoc' +} + +task sourcesJar(type: Jar, dependsOn: classes) { + from sourceSets.main.allSource + into "build/tmp" + classifier 'sources' +} + +artifacts { + archives javadocJar, sourcesJar +} + +if (project.hasProperty('signing.keyId')) { + signing { + sign configurations.archives + } +} + +apply from: 'gradle/ext.gradle' +apply from: 'gradle/publish.gradle' +apply from: 'gradle/sonarqube.gradle' diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..b22b02d --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7557ac9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +group = org.xbib +name = netty-http-client +version = 4.1.10.0 + +netty.version = 4.1.10.Final +tcnative.version = 2.0.1.Final +alpnagent.version = 2.0.6 +junit.version = 4.12 +asciidoclet.version = 1.5.4 +wagon.version = 2.12 diff --git a/gradle/ext.gradle b/gradle/ext.gradle new file mode 100644 index 0000000..dcb8f32 --- /dev/null +++ b/gradle/ext.gradle @@ -0,0 +1,9 @@ + +ext { + user = 'jprante' + name = 'netty-http-client' + description = 'A java client for Elasticsearch' + scmUrl = 'https://github.com/' + user + '/' + name + scmConnection = 'scm:git:git://github.com/' + user + '/' + name + '.git' + scmDeveloperConnection = 'scm:git:git://github.com/' + user + '/' + name + '.git' +} diff --git a/gradle/publish.gradle b/gradle/publish.gradle new file mode 100644 index 0000000..ccafc9b --- /dev/null +++ b/gradle/publish.gradle @@ -0,0 +1,70 @@ + +task xbibUpload(type: Upload) { + configuration = configurations.archives + uploadDescriptor = true + repositories { + if (project.hasProperty("xbibUsername")) { + mavenDeployer { + configuration = configurations.wagon + repository(url: 'sftp://xbib.org/repository') { + authentication(userName: xbibUsername, privateKey: xbibPrivateKey) + } + } + } + } +} + +task sonaTypeUpload(type: Upload) { + configuration = configurations.archives + uploadDescriptor = true + repositories { + if (project.hasProperty('ossrhUsername')) { + mavenDeployer { + beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } + repository(url: 'https://oss.sonatype.org/service/local/staging/deploy/maven2') { + authentication(userName: ossrhUsername, password: ossrhPassword) + } + snapshotRepository(url: 'https://oss.sonatype.org/content/repositories/snapshots') { + authentication(userName: ossrhUsername, password: ossrhPassword) + } + pom.project { + groupId project.group + artifactId project.name + version project.version + name project.name + description description + packaging 'jar' + inceptionYear '2012' + url scmUrl + organization { + name 'xbib' + url 'http://xbib.org' + } + developers { + developer { + id user + name 'Jörg Prante' + email 'joergprante@gmail.com' + url 'https://github.com/jprante' + } + } + scm { + url scmUrl + connection scmConnection + developerConnection scmDeveloperConnection + } + licenses { + license { + name 'The Apache License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + } + } + } + } +} + +nexusStaging { + packageGroup = "org.xbib" +} diff --git a/gradle/sonarqube.gradle b/gradle/sonarqube.gradle new file mode 100644 index 0000000..ba85ed2 --- /dev/null +++ b/gradle/sonarqube.gradle @@ -0,0 +1,41 @@ +tasks.withType(FindBugs) { + ignoreFailures = true + reports { + xml.enabled = false + html.enabled = true + } +} +tasks.withType(Pmd) { + ignoreFailures = true + reports { + xml.enabled = true + html.enabled = true + } +} +tasks.withType(Checkstyle) { + ignoreFailures = true + reports { + xml.enabled = true + html.enabled = true + } +} + +jacocoTestReport { + reports { + xml.enabled true + csv.enabled false + xml.destination "${buildDir}/reports/jacoco-xml" + html.destination "${buildDir}/reports/jacoco-html" + } +} + +sonarqube { + properties { + property "sonar.projectName", "${project.group} ${project.name}" + property "sonar.sourceEncoding", "UTF-8" + property "sonar.tests", "src/integration-test/java" + property "sonar.scm.provider", "git" + property "sonar.java.coveragePlugin", "jacoco" + property "sonar.junit.reportsPath", "build/test-results/test/" + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..978e78add67788ef74763d6c7f717df4d16a28de GIT binary patch literal 54783 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 Cp!tOW literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d42d371 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Apr 17 15:12:33 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.5-all.zip diff --git a/src/docs/asciidoc/css/foundation.css b/src/docs/asciidoc/css/foundation.css new file mode 100644 index 0000000..27be611 --- /dev/null +++ b/src/docs/asciidoc/css/foundation.css @@ -0,0 +1,684 @@ +/*! normalize.css v2.1.2 | MIT License | git.io/normalize */ +/* ========================================================================== HTML5 display definitions ========================================================================== */ +/** Correct `block` display not defined in IE 8/9. */ +article, aside, details, figcaption, figure, footer, header, hgroup, main, nav, section, summary { display: block; } + +/** Correct `inline-block` display not defined in IE 8/9. */ +audio, canvas, video { display: inline-block; } + +/** Prevent modern browsers from displaying `audio` without controls. Remove excess height in iOS 5 devices. */ +audio:not([controls]) { display: none; height: 0; } + +/** Address `[hidden]` styling not present in IE 8/9. Hide the `template` element in IE, Safari, and Firefox < 22. */ +[hidden], template { display: none; } + +script { display: none !important; } + +/* ========================================================================== Base ========================================================================== */ +/** 1. Set default font family to sans-serif. 2. Prevent iOS text size adjust after orientation change, without disabling user zoom. */ +html { font-family: sans-serif; /* 1 */ -ms-text-size-adjust: 100%; /* 2 */ -webkit-text-size-adjust: 100%; /* 2 */ } + +/** Remove default margin. */ +body { margin: 0; } + +/* ========================================================================== Links ========================================================================== */ +/** Remove the gray background color from active links in IE 10. */ +a { background: transparent; } + +/** Address `outline` inconsistency between Chrome and other browsers. */ +a:focus { outline: thin dotted; } + +/** Improve readability when focused and also mouse hovered in all browsers. */ +a:active, a:hover { outline: 0; } + +/* ========================================================================== Typography ========================================================================== */ +/** Address variable `h1` font-size and margin within `section` and `article` contexts in Firefox 4+, Safari 5, and Chrome. */ +h1 { font-size: 2em; margin: 0.67em 0; } + +/** Address styling not present in IE 8/9, Safari 5, and Chrome. */ +abbr[title] { border-bottom: 1px dotted; } + +/** Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. */ +b, strong { font-weight: bold; } + +/** Address styling not present in Safari 5 and Chrome. */ +dfn { font-style: italic; } + +/** Address differences between Firefox and other browsers. */ +hr { -moz-box-sizing: content-box; box-sizing: content-box; height: 0; } + +/** Address styling not present in IE 8/9. */ +mark { background: #ff0; color: #000; } + +/** Correct font family set oddly in Safari 5 and Chrome. */ +code, kbd, pre, samp { font-family: monospace, serif; font-size: 1em; } + +/** Improve readability of pre-formatted text in all browsers. */ +pre { white-space: pre-wrap; } + +/** Set consistent quote types. */ +q { quotes: "\201C" "\201D" "\2018" "\2019"; } + +/** Address inconsistent and variable font size in all browsers. */ +small { font-size: 80%; } + +/** Prevent `sub` and `sup` affecting `line-height` in all browsers. */ +sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } + +sup { top: -0.5em; } + +sub { bottom: -0.25em; } + +/* ========================================================================== Embedded content ========================================================================== */ +/** Remove border when inside `a` element in IE 8/9. */ +img { border: 0; } + +/** Correct overflow displayed oddly in IE 9. */ +svg:not(:root) { overflow: hidden; } + +/* ========================================================================== Figures ========================================================================== */ +/** Address margin not present in IE 8/9 and Safari 5. */ +figure { margin: 0; } + +/* ========================================================================== Forms ========================================================================== */ +/** Define consistent border, margin, and padding. */ +fieldset { border: 1px solid #c0c0c0; margin: 0 2px; padding: 0.35em 0.625em 0.75em; } + +/** 1. Correct `color` not being inherited in IE 8/9. 2. Remove padding so people aren't caught out if they zero out fieldsets. */ +legend { border: 0; /* 1 */ padding: 0; /* 2 */ } + +/** 1. Correct font family not being inherited in all browsers. 2. Correct font size not being inherited in all browsers. 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. */ +button, input, select, textarea { font-family: inherit; /* 1 */ font-size: 100%; /* 2 */ margin: 0; /* 3 */ } + +/** Address Firefox 4+ setting `line-height` on `input` using `!important` in the UA stylesheet. */ +button, input { line-height: normal; } + +/** Address inconsistent `text-transform` inheritance for `button` and `select`. All other form control elements do not inherit `text-transform` values. Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. Correct `select` style inheritance in Firefox 4+ and Opera. */ +button, select { text-transform: none; } + +/** 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` and `video` controls. 2. Correct inability to style clickable `input` types in iOS. 3. Improve usability and consistency of cursor style between image-type `input` and others. */ +button, html input[type="button"], input[type="reset"], input[type="submit"] { -webkit-appearance: button; /* 2 */ cursor: pointer; /* 3 */ } + +/** Re-set default cursor for disabled elements. */ +button[disabled], html input[disabled] { cursor: default; } + +/** 1. Address box sizing set to `content-box` in IE 8/9. 2. Remove excess padding in IE 8/9. */ +input[type="checkbox"], input[type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } + +/** 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome (include `-moz` to future-proof). */ +input[type="search"] { -webkit-appearance: textfield; /* 1 */ -moz-box-sizing: content-box; -webkit-box-sizing: content-box; /* 2 */ box-sizing: content-box; } + +/** Remove inner padding and search cancel button in Safari 5 and Chrome on OS X. */ +input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } + +/** Remove inner padding and border in Firefox 4+. */ +button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } + +/** 1. Remove default vertical scrollbar in IE 8/9. 2. Improve readability and alignment in all browsers. */ +textarea { overflow: auto; /* 1 */ vertical-align: top; /* 2 */ } + +/* ========================================================================== Tables ========================================================================== */ +/** Remove most spacing between table cells. */ +table { border-collapse: collapse; border-spacing: 0; } + +meta.foundation-mq-small { font-family: "only screen and (min-width: 768px)"; width: 768px; } + +meta.foundation-mq-medium { font-family: "only screen and (min-width:1280px)"; width: 1280px; } + +meta.foundation-mq-large { font-family: "only screen and (min-width:1440px)"; width: 1440px; } + +*, *:before, *:after { -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; } + +html, body { font-size: 100%; } + +body { background: white; color: #222222; padding: 0; margin: 0; font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif; font-weight: normal; font-style: normal; line-height: 1; position: relative; cursor: auto; } + +a:hover { cursor: pointer; } + +img, object, embed { max-width: 100%; height: auto; } + +object, embed { height: 100%; } + +img { -ms-interpolation-mode: bicubic; } + +#map_canvas img, #map_canvas embed, #map_canvas object, .map_canvas img, .map_canvas embed, .map_canvas object { max-width: none !important; } + +.left { float: left !important; } + +.right { float: right !important; } + +.text-left { text-align: left !important; } + +.text-right { text-align: right !important; } + +.text-center { text-align: center !important; } + +.text-justify { text-align: justify !important; } + +.hide { display: none; } + +.antialiased { -webkit-font-smoothing: antialiased; } + +img { display: inline-block; vertical-align: middle; } + +textarea { height: auto; min-height: 50px; } + +select { width: 100%; } + +object, svg { display: inline-block; vertical-align: middle; } + +.center { margin-left: auto; margin-right: auto; } + +.spread { width: 100%; } + +p.lead, .paragraph.lead > p, #preamble > .sectionbody > .paragraph:first-of-type p { font-size: 1.21875em; line-height: 1.6; } + +.subheader, .admonitionblock td.content > .title, .audioblock > .title, .exampleblock > .title, .imageblock > .title, .listingblock > .title, .literalblock > .title, .stemblock > .title, .openblock > .title, .paragraph > .title, .quoteblock > .title, table.tableblock > .title, .verseblock > .title, .videoblock > .title, .dlist > .title, .olist > .title, .ulist > .title, .qlist > .title, .hdlist > .title { line-height: 1.4; color: #6f6f6f; font-weight: 300; margin-top: 0.2em; margin-bottom: 0.5em; } + +/* Typography resets */ +div, dl, dt, dd, ul, ol, li, h1, h2, h3, #toctitle, .sidebarblock > .content > .title, h4, h5, h6, pre, form, p, blockquote, th, td { margin: 0; padding: 0; direction: ltr; } + +/* Default Link Styles */ +a { color: #2ba6cb; text-decoration: none; line-height: inherit; } +a:hover, a:focus { color: #2795b6; } +a img { border: none; } + +/* Default paragraph styles */ +p { font-family: inherit; font-weight: normal; font-size: 1em; line-height: 1.6; margin-bottom: 1.25em; text-rendering: optimizeLegibility; } +p aside { font-size: 0.875em; line-height: 1.35; font-style: italic; } + +/* Default header styles */ +h1, h2, h3, #toctitle, .sidebarblock > .content > .title, h4, h5, h6 { font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif; font-weight: bold; font-style: normal; color: #222222; text-rendering: optimizeLegibility; margin-top: 1em; margin-bottom: 0.5em; line-height: 1.2125em; } +h1 small, h2 small, h3 small, #toctitle small, .sidebarblock > .content > .title small, h4 small, h5 small, h6 small { font-size: 60%; color: #6f6f6f; line-height: 0; } + +h1 { font-size: 2.125em; } + +h2 { font-size: 1.6875em; } + +h3, #toctitle, .sidebarblock > .content > .title { font-size: 1.375em; } + +h4 { font-size: 1.125em; } + +h5 { font-size: 1.125em; } + +h6 { font-size: 1em; } + +hr { border: solid #dddddd; border-width: 1px 0 0; clear: both; margin: 1.25em 0 1.1875em; height: 0; } + +/* Helpful Typography Defaults */ +em, i { font-style: italic; line-height: inherit; } + +strong, b { font-weight: bold; line-height: inherit; } + +small { font-size: 60%; line-height: inherit; } + +code { font-family: Consolas, "Liberation Mono", Courier, monospace; font-weight: bold; color: #7f0a0c; } + +/* Lists */ +ul, ol, dl { font-size: 1em; line-height: 1.6; margin-bottom: 1.25em; list-style-position: outside; font-family: inherit; } + +ul, ol { margin-left: 1.5em; } +ul.no-bullet, ol.no-bullet { margin-left: 1.5em; } + +/* Unordered Lists */ +ul li ul, ul li ol { margin-left: 1.25em; margin-bottom: 0; font-size: 1em; /* Override nested font-size change */ } +ul.square li ul, ul.circle li ul, ul.disc li ul { list-style: inherit; } +ul.square { list-style-type: square; } +ul.circle { list-style-type: circle; } +ul.disc { list-style-type: disc; } +ul.no-bullet { list-style: none; } + +/* Ordered Lists */ +ol li ul, ol li ol { margin-left: 1.25em; margin-bottom: 0; } + +/* Definition Lists */ +dl dt { margin-bottom: 0.3125em; font-weight: bold; } +dl dd { margin-bottom: 1.25em; } + +/* Abbreviations */ +abbr, acronym { text-transform: uppercase; font-size: 90%; color: #222222; border-bottom: 1px dotted #dddddd; cursor: help; } + +abbr { text-transform: none; } + +/* Blockquotes */ +blockquote { margin: 0 0 1.25em; padding: 0.5625em 1.25em 0 1.1875em; border-left: 1px solid #dddddd; } +blockquote cite { display: block; font-size: 0.8125em; color: #555555; } +blockquote cite:before { content: "\2014 \0020"; } +blockquote cite a, blockquote cite a:visited { color: #555555; } + +blockquote, blockquote p { line-height: 1.6; color: #6f6f6f; } + +/* Microformats */ +.vcard { display: inline-block; margin: 0 0 1.25em 0; border: 1px solid #dddddd; padding: 0.625em 0.75em; } +.vcard li { margin: 0; display: block; } +.vcard .fn { font-weight: bold; font-size: 0.9375em; } + +.vevent .summary { font-weight: bold; } +.vevent abbr { cursor: auto; text-decoration: none; font-weight: bold; border: none; padding: 0 0.0625em; } + +@media only screen and (min-width: 768px) { h1, h2, h3, #toctitle, .sidebarblock > .content > .title, h4, h5, h6 { line-height: 1.4; } + h1 { font-size: 2.75em; } + h2 { font-size: 2.3125em; } + h3, #toctitle, .sidebarblock > .content > .title { font-size: 1.6875em; } + h4 { font-size: 1.4375em; } } +/* Tables */ +table { background: white; margin-bottom: 1.25em; border: solid 1px #dddddd; } +table thead, table tfoot { background: whitesmoke; font-weight: bold; } +table thead tr th, table thead tr td, table tfoot tr th, table tfoot tr td { padding: 0.5em 0.625em 0.625em; font-size: inherit; color: #222222; text-align: left; } +table tr th, table tr td { padding: 0.5625em 0.625em; font-size: inherit; color: #222222; } +table tr.even, table tr.alt, table tr:nth-of-type(even) { background: #f9f9f9; } +table thead tr th, table tfoot tr th, table tbody tr td, table tr td, table tfoot tr td { display: table-cell; line-height: 1.4; } + +body { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; tab-size: 4; } + +h1, h2, h3, #toctitle, .sidebarblock > .content > .title, h4, h5, h6 { line-height: 1.4; } + +.clearfix:before, .clearfix:after, .float-group:before, .float-group:after { content: " "; display: table; } +.clearfix:after, .float-group:after { clear: both; } + +*:not(pre) > code { font-size: inherit; font-style: normal !important; letter-spacing: 0; padding: 0; line-height: inherit; word-wrap: break-word; } +*:not(pre) > code.nobreak { word-wrap: normal; } +*:not(pre) > code.nowrap { white-space: nowrap; } + +pre, pre > code { line-height: 1.4; color: black; font-family: monospace, serif; font-weight: normal; } + +em em { font-style: normal; } + +strong strong { font-weight: normal; } + +.keyseq { color: #555555; } + +kbd { font-family: Consolas, "Liberation Mono", Courier, monospace; display: inline-block; color: #222222; font-size: 0.65em; line-height: 1.45; background-color: #f7f7f7; border: 1px solid #ccc; -webkit-border-radius: 3px; border-radius: 3px; -webkit-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 0.1em white inset; box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 0.1em white inset; margin: 0 0.15em; padding: 0.2em 0.5em; vertical-align: middle; position: relative; top: -0.1em; white-space: nowrap; } + +.keyseq kbd:first-child { margin-left: 0; } + +.keyseq kbd:last-child { margin-right: 0; } + +.menuseq, .menu { color: #090909; } + +b.button:before, b.button:after { position: relative; top: -1px; font-weight: normal; } + +b.button:before { content: "["; padding: 0 3px 0 2px; } + +b.button:after { content: "]"; padding: 0 2px 0 3px; } + +#header, #content, #footnotes, #footer { width: 100%; margin-left: auto; margin-right: auto; margin-top: 0; margin-bottom: 0; max-width: 62.5em; *zoom: 1; position: relative; padding-left: 0.9375em; padding-right: 0.9375em; } +#header:before, #header:after, #content:before, #content:after, #footnotes:before, #footnotes:after, #footer:before, #footer:after { content: " "; display: table; } +#header:after, #content:after, #footnotes:after, #footer:after { clear: both; } + +#content { margin-top: 1.25em; } + +#content:before { content: none; } + +#header > h1:first-child { color: black; margin-top: 2.25rem; margin-bottom: 0; } +#header > h1:first-child + #toc { margin-top: 8px; border-top: 1px solid #dddddd; } +#header > h1:only-child, body.toc2 #header > h1:nth-last-child(2) { border-bottom: 1px solid #dddddd; padding-bottom: 8px; } +#header .details { border-bottom: 1px solid #dddddd; line-height: 1.45; padding-top: 0.25em; padding-bottom: 0.25em; padding-left: 0.25em; color: #555555; display: -ms-flexbox; display: -webkit-flex; display: flex; -ms-flex-flow: row wrap; -webkit-flex-flow: row wrap; flex-flow: row wrap; } +#header .details span:first-child { margin-left: -0.125em; } +#header .details span.email a { color: #6f6f6f; } +#header .details br { display: none; } +#header .details br + span:before { content: "\00a0\2013\00a0"; } +#header .details br + span.author:before { content: "\00a0\22c5\00a0"; color: #6f6f6f; } +#header .details br + span#revremark:before { content: "\00a0|\00a0"; } +#header #revnumber { text-transform: capitalize; } +#header #revnumber:after { content: "\00a0"; } + +#content > h1:first-child:not([class]) { color: black; border-bottom: 1px solid #dddddd; padding-bottom: 8px; margin-top: 0; padding-top: 1rem; margin-bottom: 1.25rem; } + +#toc { border-bottom: 1px solid #dddddd; padding-bottom: 0.5em; } +#toc > ul { margin-left: 0.125em; } +#toc ul.sectlevel0 > li > a { font-style: italic; } +#toc ul.sectlevel0 ul.sectlevel1 { margin: 0.5em 0; } +#toc ul { font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif; list-style-type: none; } +#toc li { line-height: 1.3334; margin-top: 0.3334em; } +#toc a { text-decoration: none; } +#toc a:active { text-decoration: underline; } + +#toctitle { color: #6f6f6f; font-size: 1.2em; } + +@media only screen and (min-width: 768px) { #toctitle { font-size: 1.375em; } + body.toc2 { padding-left: 15em; padding-right: 0; } + #toc.toc2 { margin-top: 0 !important; background-color: #f2f2f2; position: fixed; width: 15em; left: 0; top: 0; border-right: 1px solid #dddddd; border-top-width: 0 !important; border-bottom-width: 0 !important; z-index: 1000; padding: 1.25em 1em; height: 100%; overflow: auto; } + #toc.toc2 #toctitle { margin-top: 0; margin-bottom: 0.8rem; font-size: 1.2em; } + #toc.toc2 > ul { font-size: 0.9em; margin-bottom: 0; } + #toc.toc2 ul ul { margin-left: 0; padding-left: 1em; } + #toc.toc2 ul.sectlevel0 ul.sectlevel1 { padding-left: 0; margin-top: 0.5em; margin-bottom: 0.5em; } + body.toc2.toc-right { padding-left: 0; padding-right: 15em; } + body.toc2.toc-right #toc.toc2 { border-right-width: 0; border-left: 1px solid #dddddd; left: auto; right: 0; } } +@media only screen and (min-width: 1280px) { body.toc2 { padding-left: 20em; padding-right: 0; } + #toc.toc2 { width: 20em; } + #toc.toc2 #toctitle { font-size: 1.375em; } + #toc.toc2 > ul { font-size: 0.95em; } + #toc.toc2 ul ul { padding-left: 1.25em; } + body.toc2.toc-right { padding-left: 0; padding-right: 20em; } } +#content #toc { border-style: solid; border-width: 1px; border-color: #d9d9d9; margin-bottom: 1.25em; padding: 1.25em; background: #f2f2f2; -webkit-border-radius: 0; border-radius: 0; } +#content #toc > :first-child { margin-top: 0; } +#content #toc > :last-child { margin-bottom: 0; } + +#footer { max-width: 100%; background-color: #222222; padding: 1.25em; } + +#footer-text { color: #dddddd; line-height: 1.44; } + +.sect1 { padding-bottom: 0.625em; } + +@media only screen and (min-width: 768px) { .sect1 { padding-bottom: 1.25em; } } +.sect1 + .sect1 { border-top: 1px solid #dddddd; } + +#content h1 > a.anchor, h2 > a.anchor, h3 > a.anchor, #toctitle > a.anchor, .sidebarblock > .content > .title > a.anchor, h4 > a.anchor, h5 > a.anchor, h6 > a.anchor { position: absolute; z-index: 1001; width: 1.5ex; margin-left: -1.5ex; display: block; text-decoration: none !important; visibility: hidden; text-align: center; font-weight: normal; } +#content h1 > a.anchor:before, h2 > a.anchor:before, h3 > a.anchor:before, #toctitle > a.anchor:before, .sidebarblock > .content > .title > a.anchor:before, h4 > a.anchor:before, h5 > a.anchor:before, h6 > a.anchor:before { content: "\00A7"; font-size: 0.85em; display: block; padding-top: 0.1em; } +#content h1:hover > a.anchor, #content h1 > a.anchor:hover, h2:hover > a.anchor, h2 > a.anchor:hover, h3:hover > a.anchor, #toctitle:hover > a.anchor, .sidebarblock > .content > .title:hover > a.anchor, h3 > a.anchor:hover, #toctitle > a.anchor:hover, .sidebarblock > .content > .title > a.anchor:hover, h4:hover > a.anchor, h4 > a.anchor:hover, h5:hover > a.anchor, h5 > a.anchor:hover, h6:hover > a.anchor, h6 > a.anchor:hover { visibility: visible; } +#content h1 > a.link, h2 > a.link, h3 > a.link, #toctitle > a.link, .sidebarblock > .content > .title > a.link, h4 > a.link, h5 > a.link, h6 > a.link { color: #222222; text-decoration: none; } +#content h1 > a.link:hover, h2 > a.link:hover, h3 > a.link:hover, #toctitle > a.link:hover, .sidebarblock > .content > .title > a.link:hover, h4 > a.link:hover, h5 > a.link:hover, h6 > a.link:hover { color: #151515; } + +.audioblock, .imageblock, .literalblock, .listingblock, .stemblock, .videoblock { margin-bottom: 1.25em; } + +.admonitionblock td.content > .title, .audioblock > .title, .exampleblock > .title, .imageblock > .title, .listingblock > .title, .literalblock > .title, .stemblock > .title, .openblock > .title, .paragraph > .title, .quoteblock > .title, table.tableblock > .title, .verseblock > .title, .videoblock > .title, .dlist > .title, .olist > .title, .ulist > .title, .qlist > .title, .hdlist > .title { text-rendering: optimizeLegibility; text-align: left; } + +table.tableblock > caption.title { white-space: nowrap; overflow: visible; max-width: 0; } + +.paragraph.lead > p, #preamble > .sectionbody > .paragraph:first-of-type p { color: black; } + +table.tableblock #preamble > .sectionbody > .paragraph:first-of-type p { font-size: inherit; } + +.admonitionblock > table { border-collapse: separate; border: 0; background: none; width: 100%; } +.admonitionblock > table td.icon { text-align: center; width: 80px; } +.admonitionblock > table td.icon img { max-width: initial; } +.admonitionblock > table td.icon .title { font-weight: bold; font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif; text-transform: uppercase; } +.admonitionblock > table td.content { padding-left: 1.125em; padding-right: 1.25em; border-left: 1px solid #dddddd; color: #555555; } +.admonitionblock > table td.content > :last-child > :last-child { margin-bottom: 0; } + +.exampleblock > .content { border-style: solid; border-width: 1px; border-color: #e6e6e6; margin-bottom: 1.25em; padding: 1.25em; background: white; -webkit-border-radius: 0; border-radius: 0; } +.exampleblock > .content > :first-child { margin-top: 0; } +.exampleblock > .content > :last-child { margin-bottom: 0; } + +.sidebarblock { border-style: solid; border-width: 1px; border-color: #d9d9d9; margin-bottom: 1.25em; padding: 1.25em; background: #f2f2f2; -webkit-border-radius: 0; border-radius: 0; } +.sidebarblock > :first-child { margin-top: 0; } +.sidebarblock > :last-child { margin-bottom: 0; } +.sidebarblock > .content > .title { color: #6f6f6f; margin-top: 0; } + +.exampleblock > .content > :last-child > :last-child, .exampleblock > .content .olist > ol > li:last-child > :last-child, .exampleblock > .content .ulist > ul > li:last-child > :last-child, .exampleblock > .content .qlist > ol > li:last-child > :last-child, .sidebarblock > .content > :last-child > :last-child, .sidebarblock > .content .olist > ol > li:last-child > :last-child, .sidebarblock > .content .ulist > ul > li:last-child > :last-child, .sidebarblock > .content .qlist > ol > li:last-child > :last-child { margin-bottom: 0; } + +.literalblock pre, .listingblock pre:not(.highlight), .listingblock pre[class="highlight"], .listingblock pre[class^="highlight "], .listingblock pre.CodeRay, .listingblock pre.prettyprint { background: #eeeeee; } +.sidebarblock .literalblock pre, .sidebarblock .listingblock pre:not(.highlight), .sidebarblock .listingblock pre[class="highlight"], .sidebarblock .listingblock pre[class^="highlight "], .sidebarblock .listingblock pre.CodeRay, .sidebarblock .listingblock pre.prettyprint { background: #f2f1f1; } + +.literalblock pre, .literalblock pre[class], .listingblock pre, .listingblock pre[class] { border: 1px solid #cccccc; -webkit-border-radius: 0; border-radius: 0; word-wrap: break-word; padding: 0.8em 0.8em 0.65em 0.8em; font-size: 0.8125em; } +.literalblock pre.nowrap, .literalblock pre[class].nowrap, .listingblock pre.nowrap, .listingblock pre[class].nowrap { overflow-x: auto; white-space: pre; word-wrap: normal; } +@media only screen and (min-width: 768px) { .literalblock pre, .literalblock pre[class], .listingblock pre, .listingblock pre[class] { font-size: 0.90625em; } } +@media only screen and (min-width: 1280px) { .literalblock pre, .literalblock pre[class], .listingblock pre, .listingblock pre[class] { font-size: 1em; } } + +.literalblock.output pre { color: #eeeeee; background-color: black; } + +.listingblock pre.highlightjs { padding: 0; } +.listingblock pre.highlightjs > code { padding: 0.8em 0.8em 0.65em 0.8em; -webkit-border-radius: 0; border-radius: 0; } + +.listingblock > .content { position: relative; } + +.listingblock code[data-lang]:before { display: none; content: attr(data-lang); position: absolute; font-size: 0.75em; top: 0.425rem; right: 0.5rem; line-height: 1; text-transform: uppercase; color: #999; } + +.listingblock:hover code[data-lang]:before { display: block; } + +.listingblock.terminal pre .command:before { content: attr(data-prompt); padding-right: 0.5em; color: #999; } + +.listingblock.terminal pre .command:not([data-prompt]):before { content: "$"; } + +table.pyhltable { border-collapse: separate; border: 0; margin-bottom: 0; background: none; } + +table.pyhltable td { vertical-align: top; padding-top: 0; padding-bottom: 0; line-height: 1.4; } + +table.pyhltable td.code { padding-left: .75em; padding-right: 0; } + +pre.pygments .lineno, table.pyhltable td:not(.code) { color: #999; padding-left: 0; padding-right: .5em; border-right: 1px solid #dddddd; } + +pre.pygments .lineno { display: inline-block; margin-right: .25em; } + +table.pyhltable .linenodiv { background: none !important; padding-right: 0 !important; } + +.quoteblock { margin: 0 1em 1.25em 1.5em; display: table; } +.quoteblock > .title { margin-left: -1.5em; margin-bottom: 0.75em; } +.quoteblock blockquote, .quoteblock blockquote p { color: #6f6f6f; font-size: 1.15rem; line-height: 1.75; word-spacing: 0.1em; letter-spacing: 0; font-style: italic; text-align: justify; } +.quoteblock blockquote { margin: 0; padding: 0; border: 0; } +.quoteblock blockquote:before { content: "\201c"; float: left; font-size: 2.75em; font-weight: bold; line-height: 0.6em; margin-left: -0.6em; color: #6f6f6f; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } +.quoteblock blockquote > .paragraph:last-child p { margin-bottom: 0; } +.quoteblock .attribution { margin-top: 0.5em; margin-right: 0.5ex; text-align: right; } +.quoteblock .quoteblock { margin-left: 0; margin-right: 0; padding: 0.5em 0; border-left: 3px solid #555555; } +.quoteblock .quoteblock blockquote { padding: 0 0 0 0.75em; } +.quoteblock .quoteblock blockquote:before { display: none; } + +.verseblock { margin: 0 1em 1.25em 1em; } +.verseblock pre { font-family: "Open Sans", "DejaVu Sans", sans; font-size: 1.15rem; color: #6f6f6f; font-weight: 300; text-rendering: optimizeLegibility; } +.verseblock pre strong { font-weight: 400; } +.verseblock .attribution { margin-top: 1.25rem; margin-left: 0.5ex; } + +.quoteblock .attribution, .verseblock .attribution { font-size: 0.8125em; line-height: 1.45; font-style: italic; } +.quoteblock .attribution br, .verseblock .attribution br { display: none; } +.quoteblock .attribution cite, .verseblock .attribution cite { display: block; letter-spacing: -0.025em; color: #555555; } + +.quoteblock.abstract { margin: 0 0 1.25em 0; display: block; } +.quoteblock.abstract blockquote, .quoteblock.abstract blockquote p { text-align: left; word-spacing: 0; } +.quoteblock.abstract blockquote:before, .quoteblock.abstract blockquote p:first-of-type:before { display: none; } + +table.tableblock { max-width: 100%; border-collapse: separate; } +table.tableblock td > .paragraph:last-child p > p:last-child, table.tableblock th > p:last-child, table.tableblock td > p:last-child { margin-bottom: 0; } + +table.tableblock, th.tableblock, td.tableblock { border: 0 solid #dddddd; } + +table.grid-all th.tableblock, table.grid-all td.tableblock { border-width: 0 1px 1px 0; } + +table.grid-all tfoot > tr > th.tableblock, table.grid-all tfoot > tr > td.tableblock { border-width: 1px 1px 0 0; } + +table.grid-cols th.tableblock, table.grid-cols td.tableblock { border-width: 0 1px 0 0; } + +table.grid-all * > tr > .tableblock:last-child, table.grid-cols * > tr > .tableblock:last-child { border-right-width: 0; } + +table.grid-rows th.tableblock, table.grid-rows td.tableblock { border-width: 0 0 1px 0; } + +table.grid-all tbody > tr:last-child > th.tableblock, table.grid-all tbody > tr:last-child > td.tableblock, table.grid-all thead:last-child > tr > th.tableblock, table.grid-rows tbody > tr:last-child > th.tableblock, table.grid-rows tbody > tr:last-child > td.tableblock, table.grid-rows thead:last-child > tr > th.tableblock { border-bottom-width: 0; } + +table.grid-rows tfoot > tr > th.tableblock, table.grid-rows tfoot > tr > td.tableblock { border-width: 1px 0 0 0; } + +table.frame-all { border-width: 1px; } + +table.frame-sides { border-width: 0 1px; } + +table.frame-topbot { border-width: 1px 0; } + +th.halign-left, td.halign-left { text-align: left; } + +th.halign-right, td.halign-right { text-align: right; } + +th.halign-center, td.halign-center { text-align: center; } + +th.valign-top, td.valign-top { vertical-align: top; } + +th.valign-bottom, td.valign-bottom { vertical-align: bottom; } + +th.valign-middle, td.valign-middle { vertical-align: middle; } + +table thead th, table tfoot th { font-weight: bold; } + +tbody tr th { display: table-cell; line-height: 1.4; background: whitesmoke; } + +tbody tr th, tbody tr th p, tfoot tr th, tfoot tr th p { color: #222222; font-weight: bold; } + +p.tableblock > code:only-child { background: none; padding: 0; } + +p.tableblock { font-size: 1em; } + +td > div.verse { white-space: pre; } + +ol { margin-left: 1.75em; } + +ul li ol { margin-left: 1.5em; } + +dl dd { margin-left: 1.125em; } + +dl dd:last-child, dl dd:last-child > :last-child { margin-bottom: 0; } + +ol > li p, ul > li p, ul dd, ol dd, .olist .olist, .ulist .ulist, .ulist .olist, .olist .ulist { margin-bottom: 0.625em; } + +ul.unstyled, ol.unnumbered, ul.checklist, ul.none { list-style-type: none; } + +ul.unstyled, ol.unnumbered, ul.checklist { margin-left: 0.625em; } + +ul.checklist li > p:first-child > .fa-square-o:first-child, ul.checklist li > p:first-child > .fa-check-square-o:first-child { width: 1em; font-size: 0.85em; } + +ul.checklist li > p:first-child > input[type="checkbox"]:first-child { width: 1em; position: relative; top: 1px; } + +ul.inline { margin: 0 auto 0.625em auto; margin-left: -1.375em; margin-right: 0; padding: 0; list-style: none; overflow: hidden; } +ul.inline > li { list-style: none; float: left; margin-left: 1.375em; display: block; } +ul.inline > li > * { display: block; } + +.unstyled dl dt { font-weight: normal; font-style: normal; } + +ol.arabic { list-style-type: decimal; } + +ol.decimal { list-style-type: decimal-leading-zero; } + +ol.loweralpha { list-style-type: lower-alpha; } + +ol.upperalpha { list-style-type: upper-alpha; } + +ol.lowerroman { list-style-type: lower-roman; } + +ol.upperroman { list-style-type: upper-roman; } + +ol.lowergreek { list-style-type: lower-greek; } + +.hdlist > table, .colist > table { border: 0; background: none; } +.hdlist > table > tbody > tr, .colist > table > tbody > tr { background: none; } + +td.hdlist1, td.hdlist2 { vertical-align: top; padding: 0 0.625em; } + +td.hdlist1 { font-weight: bold; padding-bottom: 1.25em; } + +.literalblock + .colist, .listingblock + .colist { margin-top: -0.5em; } + +.colist > table tr > td:first-of-type { padding: 0 0.75em; line-height: 1; } +.colist > table tr > td:first-of-type img { max-width: initial; } +.colist > table tr > td:last-of-type { padding: 0.25em 0; } + +.thumb, .th { line-height: 0; display: inline-block; border: solid 4px white; -webkit-box-shadow: 0 0 0 1px #dddddd; box-shadow: 0 0 0 1px #dddddd; } + +.imageblock.left, .imageblock[style*="float: left"] { margin: 0.25em 0.625em 1.25em 0; } +.imageblock.right, .imageblock[style*="float: right"] { margin: 0.25em 0 1.25em 0.625em; } +.imageblock > .title { margin-bottom: 0; } +.imageblock.thumb, .imageblock.th { border-width: 6px; } +.imageblock.thumb > .title, .imageblock.th > .title { padding: 0 0.125em; } + +.image.left, .image.right { margin-top: 0.25em; margin-bottom: 0.25em; display: inline-block; line-height: 0; } +.image.left { margin-right: 0.625em; } +.image.right { margin-left: 0.625em; } + +a.image { text-decoration: none; display: inline-block; } +a.image object { pointer-events: none; } + +sup.footnote, sup.footnoteref { font-size: 0.875em; position: static; vertical-align: super; } +sup.footnote a, sup.footnoteref a { text-decoration: none; } +sup.footnote a:active, sup.footnoteref a:active { text-decoration: underline; } + +#footnotes { padding-top: 0.75em; padding-bottom: 0.75em; margin-bottom: 0.625em; } +#footnotes hr { width: 20%; min-width: 6.25em; margin: -0.25em 0 0.75em 0; border-width: 1px 0 0 0; } +#footnotes .footnote { padding: 0 0.375em 0 0.225em; line-height: 1.3334; font-size: 0.875em; margin-left: 1.2em; text-indent: -1.05em; margin-bottom: 0.2em; } +#footnotes .footnote a:first-of-type { font-weight: bold; text-decoration: none; } +#footnotes .footnote:last-of-type { margin-bottom: 0; } +#content #footnotes { margin-top: -0.625em; margin-bottom: 0; padding: 0.75em 0; } + +.gist .file-data > table { border: 0; background: #fff; width: 100%; margin-bottom: 0; } +.gist .file-data > table td.line-data { width: 99%; } + +div.unbreakable { page-break-inside: avoid; } + +.big { font-size: larger; } + +.small { font-size: smaller; } + +.underline { text-decoration: underline; } + +.overline { text-decoration: overline; } + +.line-through { text-decoration: line-through; } + +.aqua { color: #00bfbf; } + +.aqua-background { background-color: #00fafa; } + +.black { color: black; } + +.black-background { background-color: black; } + +.blue { color: #0000bf; } + +.blue-background { background-color: #0000fa; } + +.fuchsia { color: #bf00bf; } + +.fuchsia-background { background-color: #fa00fa; } + +.gray { color: #606060; } + +.gray-background { background-color: #7d7d7d; } + +.green { color: #006000; } + +.green-background { background-color: #007d00; } + +.lime { color: #00bf00; } + +.lime-background { background-color: #00fa00; } + +.maroon { color: #600000; } + +.maroon-background { background-color: #7d0000; } + +.navy { color: #000060; } + +.navy-background { background-color: #00007d; } + +.olive { color: #606000; } + +.olive-background { background-color: #7d7d00; } + +.purple { color: #600060; } + +.purple-background { background-color: #7d007d; } + +.red { color: #bf0000; } + +.red-background { background-color: #fa0000; } + +.silver { color: #909090; } + +.silver-background { background-color: #bcbcbc; } + +.teal { color: #006060; } + +.teal-background { background-color: #007d7d; } + +.white { color: #bfbfbf; } + +.white-background { background-color: #fafafa; } + +.yellow { color: #bfbf00; } + +.yellow-background { background-color: #fafa00; } + +span.icon > .fa { cursor: default; } + +.admonitionblock td.icon [class^="fa icon-"] { font-size: 2.5em; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); cursor: default; } +.admonitionblock td.icon .icon-note:before { content: "\f05a"; color: #207c98; } +.admonitionblock td.icon .icon-tip:before { content: "\f0eb"; text-shadow: 1px 1px 2px rgba(155, 155, 0, 0.8); color: #111; } +.admonitionblock td.icon .icon-warning:before { content: "\f071"; color: #bf6900; } +.admonitionblock td.icon .icon-caution:before { content: "\f06d"; color: #bf3400; } +.admonitionblock td.icon .icon-important:before { content: "\f06a"; color: #bf0000; } + +.conum[data-value] { display: inline-block; color: #fff !important; background-color: #222222; -webkit-border-radius: 100px; border-radius: 100px; text-align: center; font-size: 0.75em; width: 1.67em; height: 1.67em; line-height: 1.67em; font-family: "Open Sans", "DejaVu Sans", sans-serif; font-style: normal; font-weight: bold; } +.conum[data-value] * { color: #fff !important; } +.conum[data-value] + b { display: none; } +.conum[data-value]:after { content: attr(data-value); } +pre .conum[data-value] { position: relative; top: -0.125em; } + +b.conum * { color: inherit !important; } + +.conum:not([data-value]):empty { display: none; } + +.literalblock pre, .listingblock pre { background: #eeeeee; } diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..f57255f --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,11 @@ += Netty HTTP client +Jörg Prante +Version 4.1.9.0 +:sectnums: +:toc: preamble +:toclevels: 4 +:!toc-title: Content +:experimental: +:description: asynchronous Netty HTTP client for Java +:keywords: Java, Netty, HTTP, client +:icons: font diff --git a/src/docs/asciidoclet/overview.adoc b/src/docs/asciidoclet/overview.adoc new file mode 100644 index 0000000..0cbb854 --- /dev/null +++ b/src/docs/asciidoclet/overview.adoc @@ -0,0 +1,4 @@ += Netty HTTP client +Jörg Prante +Version 4.1.9.0 + diff --git a/src/main/java/org/xbib/netty/http/client/ExceptionListener.java b/src/main/java/org/xbib/netty/http/client/ExceptionListener.java new file mode 100644 index 0000000..4adc333 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/ExceptionListener.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +/** + */ +@FunctionalInterface +public interface ExceptionListener { + + /** + * Called when an exception is transported to a listener. + * @param throwable the exception + */ + void onException(Throwable throwable); +} diff --git a/src/main/java/org/xbib/netty/http/client/Http1Handler.java b/src/main/java/org/xbib/netty/http/client/Http1Handler.java new file mode 100755 index 0000000..7f2d84e --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/Http1Handler.java @@ -0,0 +1,105 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.pool.ChannelPool; +import io.netty.handler.codec.http.FullHttpResponse; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Netty channel handler for HTTP 1.1. + */ +@ChannelHandler.Sharable +final class Http1Handler extends ChannelInboundHandlerAdapter { + + private static final Logger logger = Logger.getLogger(Http1Handler.class.getName()); + + private final HttpClient httpClient; + + Http1Handler(HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * + * Read channel message, hand over content to response handler, and redirect to next URL if possible. + * @param ctx the channel handler context + * @param msg the channel message + * @throws Exception if processing of channel message fails + */ + @Override + public void channelRead(final ChannelHandlerContext ctx, Object msg) throws Exception { + logger.log(Level.FINE, () -> "channelRead msg " + msg.getClass().getName()); + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + if (msg instanceof FullHttpResponse) { + FullHttpResponse httpResponse = (FullHttpResponse) msg; + HttpResponseListener httpResponseListener = + ctx.channel().attr(HttpClientChannelContext.RESPONSE_LISTENER_ATTRIBUTE_KEY).get(); + if (httpResponseListener != null) { + httpResponseListener.onResponse(httpResponse); + } + if (httpClient.tryRedirect(ctx.channel(), httpResponse, httpRequestContext)) { + return; + } + logger.log(Level.FINE, () -> "success"); + httpRequestContext.success("response arrived"); + final ChannelPool channelPool = + ctx.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(ctx.channel()); + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + logger.log(Level.FINE, () -> "channelInactive " + ctx); + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + if (httpRequestContext.getRedirectCount().get() == 0 && !httpRequestContext.isSucceeded()) { + httpRequestContext.fail("channel inactive"); + } + final ChannelPool channelPool = + ctx.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(ctx.channel()); + } + + /** + * Forward channel exceptions to the exception listener. + * @param ctx the channel handler context + * @param cause the cause of the exception + * @throws Exception if forwarding fails + */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + ExceptionListener exceptionListener = + ctx.channel().attr(HttpClientChannelContext.EXCEPTION_LISTENER_ATTRIBUTE_KEY).get(); + logger.log(Level.FINE, () -> "exceptionCaught"); + if (exceptionListener != null) { + exceptionListener.onException(cause); + } + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + httpRequestContext.fail(cause.getMessage()); + final ChannelPool channelPool = + ctx.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(ctx.channel()); + } +} diff --git a/src/main/java/org/xbib/netty/http/client/Http2Handler.java b/src/main/java/org/xbib/netty/http/client/Http2Handler.java new file mode 100644 index 0000000..769d705 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/Http2Handler.java @@ -0,0 +1,161 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.pool.ChannelPool; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http2.HttpConversionUtil; +import io.netty.util.internal.PlatformDependent; + +import java.util.AbstractMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Netty channel handler for HTTP/2 responses. + */ +@ChannelHandler.Sharable +public class Http2Handler extends SimpleChannelInboundHandler { + + private static final Logger logger = Logger.getLogger(Http2Handler.class.getName()); + + private final Map> streamidPromiseMap; + + private final HttpClient httpClient; + + Http2Handler(HttpClient httpClient) { + this.streamidPromiseMap = PlatformDependent.newConcurrentHashMap(); + this.httpClient = httpClient; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse httpResponse) throws Exception { + logger.log(Level.FINE, () -> httpResponse.getClass().getName()); + Integer streamId = httpResponse.headers().getInt(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text()); + if (streamId == null) { + logger.log(Level.WARNING, () -> "stream ID missing"); + return; + } + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + Entry entry = streamidPromiseMap.get(streamId); + if (entry != null) { + HttpResponseListener httpResponseListener = + ctx.channel().attr(HttpClientChannelContext.RESPONSE_LISTENER_ATTRIBUTE_KEY).get(); + if (httpResponseListener != null) { + httpResponseListener.onResponse(httpResponse); + } + entry.getValue().setSuccess(); + if (httpClient.tryRedirect(ctx.channel(), httpResponse, httpRequestContext)) { + return; + } + logger.log(Level.FINE, () -> "success"); + httpRequestContext.success("response arrived"); + final ChannelPool channelPool = + ctx.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(ctx.channel()); + } else { + logger.log(Level.WARNING, () -> "stream id not found in promises: " + streamId); + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + logger.log(Level.FINE, ctx::toString); + final ChannelPool channelPool = + ctx.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(ctx.channel()); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + ExceptionListener exceptionListener = + ctx.channel().attr(HttpClientChannelContext.EXCEPTION_LISTENER_ATTRIBUTE_KEY).get(); + logger.log(Level.FINE, () -> "exception caught"); + if (exceptionListener != null) { + exceptionListener.onException(cause); + } + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + httpRequestContext.fail(cause.getMessage()); + final ChannelPool channelPool = + ctx.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(ctx.channel()); + } + + void put(int streamId, ChannelFuture channelFuture, ChannelPromise promise) { + logger.log(Level.FINE, "put stream ID " + streamId); + streamidPromiseMap.put(streamId, new AbstractMap.SimpleEntry<>(channelFuture, promise)); + } + + void awaitResponses(HttpRequestContext httpRequestContext, ExceptionListener exceptionListener) { + int timeout = httpRequestContext.getTimeout(); + Iterator>> iterator = streamidPromiseMap.entrySet().iterator(); + while (iterator.hasNext()) { + Entry> entry = iterator.next(); + ChannelFuture channelFuture = entry.getValue().getKey(); + if (!channelFuture.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS)) { + IllegalStateException illegalStateException = + new IllegalStateException("time out while waiting to write for stream id " + entry.getKey()); + if (exceptionListener != null) { + exceptionListener.onException(illegalStateException); + httpRequestContext.fail(illegalStateException.getMessage()); + final ChannelPool channelPool = + channelFuture.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(channelFuture.channel()); + } + throw illegalStateException; + } + if (!channelFuture.isSuccess()) { + throw new RuntimeException(channelFuture.cause()); + } + ChannelPromise promise = entry.getValue().getValue(); + if (!promise.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS)) { + IllegalStateException illegalStateException = + new IllegalStateException("time out while waiting for response on stream id " + entry.getKey()); + if (exceptionListener != null) { + exceptionListener.onException(illegalStateException); + httpRequestContext.fail(illegalStateException.getMessage()); + final ChannelPool channelPool = + channelFuture.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(channelFuture.channel()); + } + throw illegalStateException; + } + if (!promise.isSuccess()) { + RuntimeException runtimeException = new RuntimeException(promise.cause()); + if (exceptionListener != null) { + exceptionListener.onException(runtimeException); + httpRequestContext.fail(runtimeException.getMessage()); + final ChannelPool channelPool = + channelFuture.channel().attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(channelFuture.channel()); + } + throw runtimeException; + } + iterator.remove(); + } + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClient.java b/src/main/java/org/xbib/netty/http/client/HttpClient.java new file mode 100755 index 0000000..3049ed9 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClient.java @@ -0,0 +1,363 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBufAllocator; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelPromise; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.pool.ChannelPool; +import io.netty.channel.pool.FixedChannelPool; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.FutureListener; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.net.URLDecoder; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A Netty HTTP client. + */ +public final class HttpClient implements Closeable { + + private static final Logger logger = Logger.getLogger(HttpClient.class.getName()); + + private final ByteBufAllocator byteBufAllocator; + + private final EventLoopGroup eventLoopGroup; + + private final HttpClientChannelPoolMap poolMap; + + /** + * Create a new HTTP client. + */ + HttpClient(ByteBufAllocator byteBufAllocator, + EventLoopGroup eventLoopGroup, + Bootstrap bootstrap, + int maxConnections, + HttpClientChannelContext httpClientChannelContext) { + this.byteBufAllocator = byteBufAllocator; + this.eventLoopGroup = eventLoopGroup; + this.poolMap = new HttpClientChannelPoolMap(this, httpClientChannelContext, bootstrap, maxConnections); + } + + /** + * Create a builder to configure connecting. + * + * @return A builder + */ + public static HttpClientBuilder builder() { + return new HttpClientBuilder(); + } + + public HttpClientRequestBuilder prepareRequest(HttpMethod method) { + return new HttpClientRequestBuilder(this, method, byteBufAllocator); + } + + /** + * Prepare a HTTP GET request. + * + * @return a request builder + */ + public HttpClientRequestBuilder prepareGet() { + return prepareRequest(HttpMethod.GET); + } + + /** + * Prepare a HTTP HEAD request. + * + * @return a request builder + */ + public HttpClientRequestBuilder prepareHead() { + return prepareRequest(HttpMethod.HEAD); + } + + /** + * Prepare a HTTP PUT request. + * + * @return a request builder + */ + public HttpClientRequestBuilder preparePut() { + return prepareRequest(HttpMethod.PUT); + } + + /** + * Prepare a HTTP POST request. + * + * @return a request builder + */ + public HttpClientRequestBuilder preparePost() { + return prepareRequest(HttpMethod.POST); + } + + /** + * Prepare a HTTP DELETE request. + * + * @return a request builder + */ + public HttpClientRequestBuilder prepareDelete() { + return prepareRequest(HttpMethod.DELETE); + } + + /** + * Prepare a HTTP OPTIONS request. + * + * @return a request builder + */ + public HttpClientRequestBuilder prepareOptions() { + return prepareRequest(HttpMethod.OPTIONS); + } + + /** + * Prepare a HTTP PATCH request. + * + * @return a request builder + */ + public HttpClientRequestBuilder preparePatch() { + return prepareRequest(HttpMethod.PATCH); + } + + /** + * Prepare a HTTP TRACE request. + * + * @return a request builder + */ + public HttpClientRequestBuilder prepareTrace() { + return prepareRequest(HttpMethod.TRACE); + } + + public HttpClientChannelPoolMap poolMap() { + return poolMap; + } + + /** + * Close client. + */ + public void close() { + logger.log(Level.FINE, () -> "closing pool map"); + poolMap.close(); + logger.log(Level.FINE, () -> "closing event loop group"); + if (!eventLoopGroup.isTerminated()) { + eventLoopGroup.shutdownGracefully(); + } + logger.log(Level.FINE, () -> "closed"); + } + + void dispatch(HttpRequestContext httpRequestContext, HttpResponseListener httpResponseListener, + ExceptionListener exceptionListener) { + final URL url = httpRequestContext.getURL(); + final HttpRequest httpRequest = httpRequestContext.getHttpRequest(); + logger.log(Level.FINE, () -> "trying URL " + url); + if (httpRequestContext.isExpired()) { + httpRequestContext.fail("request expired"); + } + if (httpRequestContext.isFailed()) { + logger.log(Level.FINE, () -> "request is cancelled"); + return; + } + HttpVersion version = httpRequestContext.getHttpRequest().protocolVersion(); + InetAddressKey inetAddressKey = new InetAddressKey(url, version); + // effectivly disable pool for HTTP/2 + if (version.majorVersion() == 2) { + poolMap.remove(inetAddressKey); + } + final FixedChannelPool pool = poolMap.get(inetAddressKey); + logger.log(Level.FINE, () -> "connecting to " + inetAddressKey); + Future futureChannel = pool.acquire(); + futureChannel.addListener((FutureListener) future -> { + if (future.isSuccess()) { + Channel channel = future.getNow(); + channel.attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).set(pool); + channel.attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).set(httpRequestContext); + if (httpResponseListener != null) { + channel.attr(HttpClientChannelContext.RESPONSE_LISTENER_ATTRIBUTE_KEY).set(httpResponseListener); + } + if (exceptionListener != null) { + channel.attr(HttpClientChannelContext.EXCEPTION_LISTENER_ATTRIBUTE_KEY).set(exceptionListener); + } + if (httpRequestContext.isFailed()) { + logger.log(Level.FINE, () -> "detected fail, close now"); + future.cancel(true); + if (channel.isOpen()) { + channel.close(); + } + logger.log(Level.FINE, () -> "release channel to pool"); + pool.release(channel); + return; + } + if (httpRequest.protocolVersion().majorVersion() == 1) { + logger.log(Level.FINE, "HTTP1: write and flush " + httpRequest.toString()); + channel.writeAndFlush(httpRequest) + .addListener((ChannelFutureListener) future1 -> { + if (httpRequestContext.isFailed()) { + logger.log(Level.FINE, () -> "detected fail, close now"); + future1.cancel(true); + if (future1.channel().isOpen()) { + future1.channel().close(); + } + } + }); + } else if (httpRequest.protocolVersion().majorVersion() == 2) { + HttpClientChannelInitializer.Http2SettingsHandler http2SettingsHandler = + poolMap.getHttpClientChannelInitializer().getHttp2SettingsHandler(); + if (http2SettingsHandler != null) { + logger.log(Level.FINE, "HTTP2: waiting for settings"); + http2SettingsHandler.awaitSettings(httpRequestContext, exceptionListener); + } + Http2Handler http2Handler = poolMap.getHttpClientChannelInitializer().getHttp2Handler(); + if (http2Handler != null) { + logger.log(Level.FINE, () -> + "HTTP2: trying to write, streamID=" + httpRequestContext.getStreamId() + + " request: " + httpRequest.toString()); + ChannelPromise channelPromise = channel.newPromise(); + http2Handler.put(httpRequestContext.getStreamId(), channel.write(httpRequest), channelPromise); + channel.flush(); + logger.log(Level.FINE, "HTTP2: waiting for responses"); + http2Handler.awaitResponses(httpRequestContext, exceptionListener); + } + } + } else { + if (exceptionListener != null) { + exceptionListener.onException(future.cause()); + } + httpRequestContext.fail("channel pool failure"); + } + }); + } + + boolean tryRedirect(Channel channel, FullHttpResponse httpResponse, HttpRequestContext httpRequestContext) + throws IOException { + if (httpRequestContext.isFollowRedirect()) { + String redirUrl = findRedirect(httpRequestContext, httpResponse); + if (redirUrl != null) { + HttpMethod method = httpResponse.status().code() == 303 ? HttpMethod.GET : + httpRequestContext.getHttpRequest().method(); + if (httpRequestContext.getRedirectCount().getAndIncrement() < httpRequestContext.getMaxRedirects()) { + dispatchRedirect(channel, method, new URL(redirUrl), httpRequestContext); + } else { + httpRequestContext.fail("too many redirections"); + final ChannelPool channelPool = + channel.attr(HttpClientChannelContext.CHANNEL_POOL_ATTRIBUTE_KEY).get(); + channelPool.release(channel); + } + return true; + } + } + return false; + } + + private String findRedirect(HttpRequestContext httpRequestContext, HttpResponse httpResponse) + throws IOException { + if (httpResponse == null) { + return null; + } + switch (httpResponse.status().code()) { + case 300: + case 301: + case 302: + case 303: + case 305: + case 307: + case 308: + String location = URLDecoder.decode(httpResponse.headers().get(HttpHeaderNames.LOCATION), "UTF-8"); + if (location != null && (location.toLowerCase().startsWith("http://") || + location.toLowerCase().startsWith("https://"))) { + logger.log(Level.FINE, "(absolute) redirect to " + location); + return location; + } else { + logger.log(Level.FINE, "(relative->absolute) redirect to " + location); + return makeAbsolute(httpRequestContext.getURL(), location); + } + default: + break; + } + return null; + } + + private void dispatchRedirect(Channel channel, HttpMethod method, URL url, + HttpRequestContext httpRequestContext) { + final String uri = httpRequestContext.getHttpRequest().protocolVersion().majorVersion() == 2 ? + url.toExternalForm() : makeRelative(url); + final HttpRequest httpRequest; + if (method.equals(httpRequestContext.getHttpRequest().method()) && + httpRequestContext.getHttpRequest() instanceof DefaultFullHttpRequest) { + DefaultFullHttpRequest defaultFullHttpRequest = (DefaultFullHttpRequest) httpRequestContext.getHttpRequest(); + FullHttpRequest fullHttpRequest = defaultFullHttpRequest.copy(); + fullHttpRequest.setUri(uri); + httpRequest = fullHttpRequest; + } else { + httpRequest = new DefaultHttpRequest(httpRequestContext.getHttpRequest().protocolVersion(), method, uri); + } + for (Map.Entry e : httpRequestContext.getHttpRequest().headers().entries()) { + httpRequest.headers().add(e.getKey(), e.getValue()); + } + httpRequest.headers().set(HttpHeaderNames.HOST, url.getHost()); + HttpRequestContext redirectContext = new HttpRequestContext(url, httpRequest, + httpRequestContext); + logger.log(Level.FINE, "dispatchRedirect url = " + url + " with new request " + httpRequest.toString()); + HttpResponseListener httpResponseListener = + channel.attr(HttpClientChannelContext.RESPONSE_LISTENER_ATTRIBUTE_KEY).get(); + ExceptionListener exceptionListener = + channel.attr(HttpClientChannelContext.EXCEPTION_LISTENER_ATTRIBUTE_KEY).get(); + dispatch(redirectContext, httpResponseListener, exceptionListener); + } + + private String makeRelative(URL base) { + String uri = base.getPath(); + if (base.getQuery() != null) { + uri = uri + "?" + base.getQuery(); + } + return uri; + } + + private String makeAbsolute(URL base, String location) throws UnsupportedEncodingException { + String path = base.getPath() == null ? "/" : URLDecoder.decode(base.getPath(), "UTF-8"); + if (location.startsWith("/")) { + path = location; + } else if (path.endsWith("/")) { + path += location; + } else { + path += "/" + location; + } + String scheme = base.getProtocol(); + StringBuilder sb = new StringBuilder(scheme).append("://").append(base.getHost()); + int defaultPort = "http".equals(scheme) ? 80 : "https".equals(scheme) ? 443 : -1; + if (defaultPort != -1 && base.getPort() != -1 && defaultPort != base.getPort()) { + sb.append(":").append(base.getPort()); + } + if (path.charAt(0) != '/') { + sb.append('/'); + } + sb.append(path); + return sb.toString(); + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientBuilder.java b/src/main/java/org/xbib/netty/http/client/HttpClientBuilder.java new file mode 100644 index 0000000..eb45240 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientBuilder.java @@ -0,0 +1,326 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.proxy.HttpProxyHandler; +import io.netty.handler.proxy.Socks4ProxyHandler; +import io.netty.handler.proxy.Socks5ProxyHandler; +import io.netty.handler.ssl.CipherSuiteFilter; +import io.netty.handler.ssl.SslProvider; + +import java.io.InputStream; +import java.net.InetSocketAddress; +import javax.net.ssl.TrustManagerFactory; + +/** + * + */ +public class HttpClientBuilder implements HttpClientChannelContextDefaults { + + private ByteBufAllocator byteBufAllocator; + + private EventLoopGroup eventLoopGroup; + + private Class socketChannelClass; + + private Bootstrap bootstrap; + + // let Netty decide, where default is Runtime.getRuntime().availableProcessors() * 2 + private int threads = 0; + + private boolean tcpNodelay = DEFAULT_TCP_NODELAY; + + private boolean keepAlive = DEFAULT_SO_KEEPALIVE; + + private boolean reuseAddr = DEFAULT_SO_REUSEADDR; + + private int tcpSendBufferSize = DEFAULT_TCP_SEND_BUFFER_SIZE; + + private int tcpReceiveBufferSize = DEFAULT_TCP_RECEIVE_BUFFER_SIZE; + + private int maxChunkSize = DEFAULT_MAX_CHUNK_SIZE; + + private int maxInitialLineLength = DEFAULT_MAX_INITIAL_LINE_LENGTH; + + private int maxHeadersSize = DEFAULT_MAX_HEADERS_SIZE; + + private int maxConnections = DEFAULT_MAX_CONNECTIONS; + + private int maxContentLength = DEFAULT_MAX_CONTENT_LENGTH; + + private int maxCompositeBufferComponents = DEFAULT_MAX_COMPOSITE_BUFFER_COMPONENTS; + + private int connectTimeoutMillis = DEFAULT_TIMEOUT_MILLIS; + + private int readTimeoutMillis = DEFAULT_TIMEOUT_MILLIS; + + private boolean enableGzip = DEFAULT_ENABLE_GZIP; + + private boolean installHttp2Upgrade = DEFAULT_INSTALL_HTTP_UPGRADE2; + + private SslProvider sslProvider = DEFAULT_SSL_PROVIDER; + + private Iterable ciphers = DEFAULT_CIPHERS; + + private CipherSuiteFilter cipherSuiteFilter = DEFAULT_CIPHER_SUITE_FILTER; + + private TrustManagerFactory trustManagerFactory = DEFAULT_TRUST_MANAGER_FACTORY; + + private InputStream keyCertChainInputStream; + + private InputStream keyInputStream; + + private String keyPassword; + + private boolean useServerNameIdentification = DEFAULT_USE_SERVER_NAME_IDENTIFICATION; + + private SslClientAuthMode sslClientAuthMode = DEFAULT_SSL_CLIENT_AUTH_MODE; + + private HttpProxyHandler httpProxyHandler; + + private Socks4ProxyHandler socks4ProxyHandler; + + private Socks5ProxyHandler socks5ProxyHandler; + + public HttpClientBuilder withByteBufAllocator(ByteBufAllocator byteBufAllocator) { + this.byteBufAllocator = byteBufAllocator; + return this; + } + + public HttpClientBuilder withEventLoop(EventLoopGroup eventLoopGroup) { + this.eventLoopGroup = eventLoopGroup; + return this; + } + + public HttpClientBuilder withChannelClass(Class socketChannelClass) { + this.socketChannelClass = socketChannelClass; + return this; + } + + public HttpClientBuilder withBootstrap(Bootstrap bootstrap) { + this.bootstrap = bootstrap; + return this; + } + + public HttpClientBuilder setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + return this; + } + + public HttpClientBuilder setThreadCount(int count) { + this.threads = count; + return this; + } + + public HttpClientBuilder setTcpSendBufferSize(int tcpSendBufferSize) { + this.tcpSendBufferSize = tcpSendBufferSize; + return this; + } + + public HttpClientBuilder setTcpReceiveBufferSize(int tcpReceiveBufferSize) { + this.tcpReceiveBufferSize = tcpReceiveBufferSize; + return this; + } + + public HttpClientBuilder setTcpNodelay(boolean tcpNodelay) { + this.tcpNodelay = tcpNodelay; + return this; + } + + public HttpClientBuilder setKeepAlive(boolean keepAlive) { + this.keepAlive = keepAlive; + return this; + } + + public HttpClientBuilder setReuseAddr(boolean reuseAddr) { + this.reuseAddr = reuseAddr; + return this; + } + + public HttpClientBuilder setMaxChunkSize(int maxChunkSize) { + this.maxChunkSize = maxChunkSize; + return this; + } + + public HttpClientBuilder setMaxInitialLineLength(int maxInitialLineLength) { + this.maxInitialLineLength = maxInitialLineLength; + return this; + } + + public HttpClientBuilder setMaxHeadersSize(int maxHeadersSize) { + this.maxHeadersSize = maxHeadersSize; + return this; + } + + public HttpClientBuilder setMaxContentLength(int maxContentLength) { + this.maxContentLength = maxContentLength; + return this; + } + + public HttpClientBuilder setMaxCompositeBufferComponents(int maxCompositeBufferComponents) { + this.maxCompositeBufferComponents = maxCompositeBufferComponents; + return this; + } + + public HttpClientBuilder setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + return this; + } + + public HttpClientBuilder setReadTimeoutMillis(int readTimeoutMillis) { + this.readTimeoutMillis = readTimeoutMillis; + return this; + } + + public HttpClientBuilder setEnableGzip(boolean enableGzip) { + this.enableGzip = enableGzip; + return this; + } + + public HttpClientBuilder setInstallHttp2Upgrade(boolean installHttp2Upgrade) { + this.installHttp2Upgrade = installHttp2Upgrade; + return this; + } + + public HttpClientBuilder withSslProvider(SslProvider sslProvider) { + this.sslProvider = sslProvider; + return this; + } + + public HttpClientBuilder withJdkSslProvider() { + this.sslProvider = SslProvider.JDK; + return this; + } + + public HttpClientBuilder withOpenSSLSslProvider() { + this.sslProvider = SslProvider.OPENSSL; + return this; + } + + public HttpClientBuilder withCiphers(Iterable ciphers) { + this.ciphers = ciphers; + return this; + } + + public HttpClientBuilder withCipherSuiteFilter(CipherSuiteFilter cipherSuiteFilter) { + this.cipherSuiteFilter = cipherSuiteFilter; + return this; + } + + public HttpClientBuilder withTrustManagerFactory(TrustManagerFactory trustManagerFactory) { + this.trustManagerFactory = trustManagerFactory; + return this; + } + + public HttpClientBuilder setKeyCert(InputStream keyCertChainInputStream, InputStream keyInputStream) { + this.keyCertChainInputStream = keyCertChainInputStream; + this.keyInputStream = keyInputStream; + return this; + } + + public HttpClientBuilder setKeyCert(InputStream keyCertChainInputStream, InputStream keyInputStream, + String keyPassword) { + this.keyCertChainInputStream = keyCertChainInputStream; + this.keyInputStream = keyInputStream; + this.keyPassword = keyPassword; + return this; + } + + public HttpClientBuilder setUseServerNameIdentification(boolean useServerNameIdentification) { + this.useServerNameIdentification = useServerNameIdentification; + return this; + } + + public HttpClientBuilder setSslClientAuthMode(SslClientAuthMode sslClientAuthMode) { + this.sslClientAuthMode = sslClientAuthMode; + return this; + } + + public HttpClientBuilder setHttpProxyHandler(InetSocketAddress proxyAddress) { + this.httpProxyHandler = new HttpProxyHandler(proxyAddress); + return this; + } + + public HttpClientBuilder setHttpProxyHandler(InetSocketAddress proxyAddress, String username, String password) { + this.httpProxyHandler = new HttpProxyHandler(proxyAddress, username, password); + return this; + } + + public HttpClientBuilder setSocks4Proxy(InetSocketAddress proxyAddress) { + this.socks4ProxyHandler = new Socks4ProxyHandler(proxyAddress); + return this; + } + + public HttpClientBuilder setSocks4Proxy(InetSocketAddress proxyAddress, String username) { + this.socks4ProxyHandler = new Socks4ProxyHandler(proxyAddress, username); + return this; + } + + public HttpClientBuilder setSocks5Proxy(InetSocketAddress proxyAddress) { + this.socks5ProxyHandler = new Socks5ProxyHandler(proxyAddress); + return this; + } + + public HttpClientBuilder setSocks5Proxy(InetSocketAddress proxyAddress, String username, String password) { + this.socks5ProxyHandler = new Socks5ProxyHandler(proxyAddress, username, password); + return this; + } + + /** + * Build a HTTP client. + * @return a http client + */ + public HttpClient build() { + if (byteBufAllocator == null) { + byteBufAllocator = PooledByteBufAllocator.DEFAULT; + } + if (eventLoopGroup == null) { + eventLoopGroup = new NioEventLoopGroup(threads, new HttpClientThreadFactory()); + } + if (socketChannelClass == null) { + socketChannelClass = NioSocketChannel.class; + } + if (bootstrap == null) { + bootstrap = new Bootstrap(); + } + bootstrap.option(ChannelOption.TCP_NODELAY, tcpNodelay); + bootstrap.option(ChannelOption.SO_KEEPALIVE, keepAlive); + bootstrap.option(ChannelOption.SO_REUSEADDR, reuseAddr); + bootstrap.option(ChannelOption.SO_SNDBUF, tcpSendBufferSize); + bootstrap.option(ChannelOption.SO_RCVBUF, tcpReceiveBufferSize); + bootstrap.option(ChannelOption.ALLOCATOR, byteBufAllocator); + bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeoutMillis); + bootstrap.group(eventLoopGroup); + bootstrap.channel(socketChannelClass); + final HttpClientChannelContext httpClientChannelContext = + new HttpClientChannelContext(maxInitialLineLength, maxHeadersSize, maxChunkSize, maxContentLength, + maxCompositeBufferComponents, + readTimeoutMillis, enableGzip, installHttp2Upgrade, + sslProvider, ciphers, cipherSuiteFilter, trustManagerFactory, + keyCertChainInputStream, keyInputStream, keyPassword, + useServerNameIdentification, sslClientAuthMode, + httpProxyHandler, socks4ProxyHandler, socks5ProxyHandler); + return new HttpClient(byteBufAllocator, eventLoopGroup, bootstrap, maxConnections, httpClientChannelContext); + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientChannelContext.java b/src/main/java/org/xbib/netty/http/client/HttpClientChannelContext.java new file mode 100644 index 0000000..c72d7c6 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientChannelContext.java @@ -0,0 +1,206 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.channel.pool.ChannelPool; +import io.netty.handler.proxy.HttpProxyHandler; +import io.netty.handler.proxy.Socks4ProxyHandler; +import io.netty.handler.proxy.Socks5ProxyHandler; +import io.netty.handler.ssl.CipherSuiteFilter; +import io.netty.handler.ssl.SslProvider; +import io.netty.util.AttributeKey; + +import java.io.InputStream; +import javax.net.ssl.TrustManagerFactory; + +/** + */ +final class HttpClientChannelContext { + + static final AttributeKey CHANNEL_POOL_ATTRIBUTE_KEY = + AttributeKey.valueOf("httpClientChannelPool"); + + static final AttributeKey REQUEST_CONTEXT_ATTRIBUTE_KEY = + AttributeKey.valueOf("httpClientRequestContext"); + + static final AttributeKey RESPONSE_LISTENER_ATTRIBUTE_KEY = + AttributeKey.valueOf("httpClientResponseListener"); + + static final AttributeKey EXCEPTION_LISTENER_ATTRIBUTE_KEY = + AttributeKey.valueOf("httpClientExceptionListener"); + + private final int maxInitialLineLength; + + private final int maxHeaderSize; + + private final int maxChunkSize; + + private final int maxContentLength; + + private final int maxCompositeBufferComponents; + + private final int readTimeoutMillis; + + private final boolean enableGzip; + + private final boolean installHttp2Upgrade; + + private final SslProvider sslProvider; + + private final Iterable ciphers; + + private final CipherSuiteFilter cipherSuiteFilter; + + private final TrustManagerFactory trustManagerFactory; + + private final InputStream keyCertChainInputStream; + + private final InputStream keyInputStream; + + private final String keyPassword; + + private final boolean useServerNameIdentification; + + private final SslClientAuthMode sslClientAuthMode; + + private final HttpProxyHandler httpProxyHandler; + + private final Socks4ProxyHandler socks4ProxyHandler; + + private final Socks5ProxyHandler socks5ProxyHandler; + + HttpClientChannelContext(int maxInitialLineLength, + int maxHeaderSize, + int maxChunkSize, + int maxContentLength, + int maxCompositeBufferComponents, + int readTimeoutMillis, + boolean enableGzip, + boolean installHttp2Upgrade, + SslProvider sslProvider, + Iterable ciphers, + CipherSuiteFilter cipherSuiteFilter, + TrustManagerFactory trustManagerFactory, + InputStream keyCertChainInputStream, + InputStream keyInputStream, + String keyPassword, + boolean useServerNameIdentification, + SslClientAuthMode sslClientAuthMode, + HttpProxyHandler httpProxyHandler, + Socks4ProxyHandler socks4ProxyHandler, + Socks5ProxyHandler socks5ProxyHandler) { + this.maxInitialLineLength = maxInitialLineLength; + this.maxHeaderSize = maxHeaderSize; + this.maxChunkSize = maxChunkSize; + this.maxContentLength = maxContentLength; + this.maxCompositeBufferComponents = maxCompositeBufferComponents; + this.readTimeoutMillis = readTimeoutMillis; + this.enableGzip = enableGzip; + this.installHttp2Upgrade = installHttp2Upgrade; + this.sslProvider = sslProvider; + this.ciphers = ciphers; + this.cipherSuiteFilter = cipherSuiteFilter; + this.trustManagerFactory = trustManagerFactory; + this.keyCertChainInputStream = keyCertChainInputStream; + this.keyInputStream = keyInputStream; + this.keyPassword = keyPassword; + this.useServerNameIdentification = useServerNameIdentification; + this.sslClientAuthMode = sslClientAuthMode; + this.httpProxyHandler = httpProxyHandler; + this.socks4ProxyHandler = socks4ProxyHandler; + this.socks5ProxyHandler = socks5ProxyHandler; + } + + int getMaxInitialLineLength() { + return maxInitialLineLength; + } + + int getMaxHeaderSize() { + return maxHeaderSize; + } + + int getMaxChunkSize() { + return maxChunkSize; + } + + int getMaxContentLength() { + return maxContentLength; + } + + int getMaxCompositeBufferComponents() { + return maxCompositeBufferComponents; + } + + int getReadTimeoutMillis() { + return readTimeoutMillis; + } + + boolean isGzipEnabled() { + return enableGzip; + } + + boolean isInstallHttp2Upgrade() { + return installHttp2Upgrade; + } + + SslProvider getSslProvider() { + return sslProvider; + } + + Iterable getCiphers() { + return ciphers; + } + + CipherSuiteFilter getCipherSuiteFilter() { + return cipherSuiteFilter; + } + + TrustManagerFactory getTrustManagerFactory() { + return trustManagerFactory; + } + + InputStream getKeyCertChainInputStream() { + return keyCertChainInputStream; + } + + InputStream getKeyInputStream() { + return keyInputStream; + } + + String getKeyPassword() { + return keyPassword; + } + + boolean isUseServerNameIdentification() { + return useServerNameIdentification; + } + + SslClientAuthMode getSslClientAuthMode() { + return sslClientAuthMode; + } + + HttpProxyHandler getHttpProxyHandler() { + return httpProxyHandler; + } + + Socks4ProxyHandler getSocks4ProxyHandler() { + return socks4ProxyHandler; + } + + Socks5ProxyHandler getSocks5ProxyHandler() { + return socks5ProxyHandler; + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientChannelContextDefaults.java b/src/main/java/org/xbib/netty/http/client/HttpClientChannelContextDefaults.java new file mode 100644 index 0000000..847a881 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientChannelContextDefaults.java @@ -0,0 +1,122 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.handler.codec.http2.Http2SecurityUtil; +import io.netty.handler.ssl.CipherSuiteFilter; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.SupportedCipherSuiteFilter; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import javax.net.ssl.TrustManagerFactory; + +/** + */ +public interface HttpClientChannelContextDefaults { + + /** + * Default for TCP_NODELAY. + */ + boolean DEFAULT_TCP_NODELAY = true; + + /** + * Default for SO_KEEPALIVE. + */ + boolean DEFAULT_SO_KEEPALIVE = true; + + /** + * Default for SO_REUSEADDR. + */ + boolean DEFAULT_SO_REUSEADDR = true; + + /** + * Set TCP send buffer to 64k per socket. + */ + int DEFAULT_TCP_SEND_BUFFER_SIZE = 64 * 1024; + + /** + * Set TCP receive buffer to 64k per socket. + */ + int DEFAULT_TCP_RECEIVE_BUFFER_SIZE = 64 * 1024; + + /** + * Set HTTP chunk maximum size to 8k. + * See {@link io.netty.handler.codec.http.HttpClientCodec}. + */ + int DEFAULT_MAX_CHUNK_SIZE = 8 * 1024; + + /** + * Set HTTP initial line length to 4k. + * See {@link io.netty.handler.codec.http.HttpClientCodec}. + */ + int DEFAULT_MAX_INITIAL_LINE_LENGTH = 4 * 1024; + + /** + * Set HTTP maximum headers size to 8k. + * See {@link io.netty.handler.codec.http.HttpClientCodec}. + */ + int DEFAULT_MAX_HEADERS_SIZE = 8 * 1024; + + /** + * Set maximum content length to 100 MB. + */ + int DEFAULT_MAX_CONTENT_LENGTH = 100 * 1024 * 1024; + + /** + * This is Netty's default. + * See {@link io.netty.handler.codec.MessageAggregator#DEFAULT_MAX_COMPOSITEBUFFER_COMPONENTS}. + */ + int DEFAULT_MAX_COMPOSITE_BUFFER_COMPONENTS = 1024; + + /** + * Allow maximum concurrent connections to an {@link InetAddressKey}. + * Usually, browsers restrict concurrent connections to 8 for a single address. + */ + int DEFAULT_MAX_CONNECTIONS = 8; + + /** + * Default read/write timeout in milliseconds. + */ + int DEFAULT_TIMEOUT_MILLIS = 5000; + + /** + * Default for gzip codec. + */ + boolean DEFAULT_ENABLE_GZIP = true; + + /** + * Default for HTTP/2 only. + */ + boolean DEFAULT_INSTALL_HTTP_UPGRADE2 = false; + + /** + * Default SSL provider. + */ + SslProvider DEFAULT_SSL_PROVIDER = SslProvider.OPENSSL; + + Iterable DEFAULT_CIPHERS = Http2SecurityUtil.CIPHERS; + + CipherSuiteFilter DEFAULT_CIPHER_SUITE_FILTER = SupportedCipherSuiteFilter.INSTANCE; + + TrustManagerFactory DEFAULT_TRUST_MANAGER_FACTORY = InsecureTrustManagerFactory.INSTANCE; + + boolean DEFAULT_USE_SERVER_NAME_IDENTIFICATION = true; + + /** + * Default for SSL client authentication. + */ + SslClientAuthMode DEFAULT_SSL_CLIENT_AUTH_MODE = SslClientAuthMode.NONE; +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientChannelInitializer.java b/src/main/java/org/xbib/netty/http/client/HttpClientChannelInitializer.java new file mode 100644 index 0000000..5fcb76e --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientChannelInitializer.java @@ -0,0 +1,379 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.ChannelPromise; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.socket.ChannelInputShutdownReadComplete; +import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpClientUpgradeHandler; +import io.netty.handler.codec.http.HttpContentDecompressor; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http2.DefaultHttp2Connection; +import io.netty.handler.codec.http2.DelegatingDecompressorFrameListener; +import io.netty.handler.codec.http2.Http2ClientUpgradeCodec; +import io.netty.handler.codec.http2.Http2Connection; +import io.netty.handler.codec.http2.Http2ConnectionPrefaceWrittenEvent; +import io.netty.handler.codec.http2.Http2FrameLogger; +import io.netty.handler.codec.http2.Http2Settings; +import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandler; +import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandlerBuilder; +import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; +import io.netty.handler.ssl.SslCloseCompletionEvent; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.timeout.ReadTimeoutHandler; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLParameters; + +/** + */ +class HttpClientChannelInitializer extends ChannelInitializer { + + private static final Logger logger = Logger.getLogger(HttpClientChannelInitializer.class.getName()); + + private static final Http2FrameLogger frameLogger = + new Http2FrameLogger(LogLevel.TRACE, HttpClientChannelInitializer.class); + + private final HttpClientChannelContext context; + + private final Http1Handler http1Handler; + + private final Http2Handler http2Handler; + + private InetAddressKey key; + + private Http2SettingsHandler http2SettingsHandler; + + private UserEventLogger userEventLogger; + + HttpClientChannelInitializer(HttpClientChannelContext context, Http1Handler http1Handler, + Http2Handler http2Handler) { + this.context = context; + this.http1Handler = http1Handler; + this.http2Handler = http2Handler; + } + + void initChannel(SocketChannel ch, InetAddressKey key) throws Exception { + this.key = key; + initChannel(ch); + } + + @Override + protected void initChannel(SocketChannel ch) throws Exception { + logger.log(Level.FINE, () -> "initChannel with key = " + key); + ChannelPipeline pipeline = ch.pipeline(); + pipeline.addLast(new TrafficLoggingHandler()); + if (context.getHttpProxyHandler() != null) { + pipeline.addLast(context.getHttpProxyHandler()); + } + if (context.getSocks4ProxyHandler() != null) { + pipeline.addLast(context.getSocks4ProxyHandler()); + } + if (context.getSocks5ProxyHandler() != null) { + pipeline.addLast(context.getSocks5ProxyHandler()); + } + pipeline.addLast(new ReadTimeoutHandler(context.getReadTimeoutMillis(), TimeUnit.MILLISECONDS)); + http2SettingsHandler = new Http2SettingsHandler(ch.newPromise()); + userEventLogger = new UserEventLogger(); + if (context.getSslProvider() != null && key.isSecure()) { + configureEncrypted(ch); + } else { + configureClearText(ch); + } + logger.log(Level.FINE, () -> "initChannel pipeline handler names = " + ch.pipeline().names()); + } + + Http2SettingsHandler getHttp2SettingsHandler() { + return http2SettingsHandler; + } + + Http2Handler getHttp2Handler() { + return http2Handler; + } + + private void configureClearText(SocketChannel ch) { + ChannelPipeline pipeline = ch.pipeline(); + if (key.getVersion().majorVersion() == 1) { + HttpClientCodec http1connectionHandler = createHttp1ConnectionHandler(); + pipeline.addLast(http1connectionHandler); + configureHttp1Pipeline(pipeline); + } else if (key.getVersion().majorVersion() == 2) { + HttpToHttp2ConnectionHandler http2connectionHandler = createHttp2ConnectionHandler(); + if (context.isInstallHttp2Upgrade()) { + HttpClientCodec http1connectionHandler = createHttp1ConnectionHandler(); + Http2ClientUpgradeCodec upgradeCodec = + new Http2ClientUpgradeCodec(http2connectionHandler); + HttpClientUpgradeHandler upgradeHandler = + new HttpClientUpgradeHandler(http1connectionHandler, upgradeCodec, context.getMaxContentLength()); + UpgradeRequestHandler upgradeRequestHandler = + new UpgradeRequestHandler(); + pipeline.addLast(upgradeHandler); + pipeline.addLast(upgradeRequestHandler); + } else { + pipeline.addLast(http2connectionHandler); + } + configureHttp2Pipeline(pipeline); + configureHttp1Pipeline(pipeline); + } + } + + private void configureEncrypted(SocketChannel ch) throws SSLException { + ChannelPipeline pipeline = ch.pipeline(); + if (key.getVersion().majorVersion() == 2) { + final SslContext http2SslContext = SslContextBuilder.forClient() + .sslProvider(context.getSslProvider()) + .keyManager(context.getKeyCertChainInputStream(), context.getKeyInputStream(), context.getKeyPassword()) + .ciphers(context.getCiphers(), context.getCipherSuiteFilter()) + .trustManager(context.getTrustManagerFactory()) + .applicationProtocolConfig(new ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2, + ApplicationProtocolNames.HTTP_1_1)) + .build(); + SslHandler sslHandler = http2SslContext.newHandler(ch.alloc()); + try { + SSLEngine engine = sslHandler.engine(); + if (context.isUseServerNameIdentification()) { + // execute DNS lookup and/or reverse lookup if IP for host name + String fullQualifiedHostname = key.getInetSocketAddress().getHostName(); + SSLParameters params = engine.getSSLParameters(); + params.setServerNames(Arrays.asList(new SNIServerName[]{new SNIHostName(fullQualifiedHostname)})); + engine.setSSLParameters(params); + } + switch (context.getSslClientAuthMode()) { + case NEED: + engine.setNeedClientAuth(true); + break; + case WANT: + engine.setWantClientAuth(true); + break; + default: + break; + } + } finally { + pipeline.addLast(sslHandler); + } + pipeline.addLast(new Http2NegotiationHandler(ApplicationProtocolNames.HTTP_1_1)); + } else if (key.getVersion().majorVersion() == 1) { + final SslContext hhtp1SslContext = SslContextBuilder.forClient() + .sslProvider(context.getSslProvider()) + .keyManager(context.getKeyCertChainInputStream(), context.getKeyInputStream(), context.getKeyPassword()) + .ciphers(context.getCiphers(), context.getCipherSuiteFilter()) + .trustManager(context.getTrustManagerFactory()) + .build(); + SslHandler sslHandler = hhtp1SslContext.newHandler(ch.alloc()); + switch (context.getSslClientAuthMode()) { + case NEED: + sslHandler.engine().setNeedClientAuth(true); + break; + case WANT: + sslHandler.engine().setWantClientAuth(true); + break; + default: + break; + } + pipeline.addLast(sslHandler); + HttpClientCodec http1connectionHandler = createHttp1ConnectionHandler(); + pipeline.addLast(http1connectionHandler); + configureHttp1Pipeline(pipeline); + } + } + + private void configureHttp1Pipeline(ChannelPipeline pipeline) { + if (context.isGzipEnabled()) { + pipeline.addLast(new HttpContentDecompressor()); + } + HttpObjectAggregator httpObjectAggregator = + new HttpObjectAggregator(context.getMaxContentLength(), false); + httpObjectAggregator.setMaxCumulationBufferComponents(context.getMaxCompositeBufferComponents()); + pipeline.addLast(httpObjectAggregator); + pipeline.addLast(http1Handler); + } + + private void configureHttp2Pipeline(ChannelPipeline pipeline) { + pipeline.addLast(http2SettingsHandler); + pipeline.addLast(userEventLogger); + pipeline.addLast(http2Handler); + } + + private HttpClientCodec createHttp1ConnectionHandler() { + return new HttpClientCodec(context.getMaxInitialLineLength(), context.getMaxHeaderSize(), context.getMaxChunkSize()); + } + + private HttpToHttp2ConnectionHandler createHttp2ConnectionHandler() { + final Http2Connection http2Connection = new DefaultHttp2Connection(false); + return new HttpToHttp2ConnectionHandlerBuilder() + .connection(http2Connection) + .frameLogger(frameLogger) + .frameListener(new DelegatingDecompressorFrameListener(http2Connection, + new InboundHttp2ToHttpAdapterBuilder(http2Connection) + .maxContentLength(context.getMaxContentLength()) + .propagateSettings(true) + .validateHttpHeaders(false) + .build())) + .build(); + } + + private class Http2NegotiationHandler extends ApplicationProtocolNegotiationHandler { + + Http2NegotiationHandler(String fallbackProtocol) { + super(fallbackProtocol); + } + + @Override + protected void configurePipeline(ChannelHandlerContext ctx, String protocol) throws Exception { + if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { + HttpToHttp2ConnectionHandler http2connectionHandler = createHttp2ConnectionHandler(); + ctx.pipeline().addLast(http2connectionHandler); + configureHttp2Pipeline(ctx.pipeline()); + logger.log(Level.FINE, "negotiated HTTP/2: handler = " + ctx.pipeline().names()); + return; + } + if (ApplicationProtocolNames.HTTP_1_1.equals(protocol)) { + HttpClientCodec http1connectionHandler = createHttp1ConnectionHandler(); + ctx.pipeline().addLast(http1connectionHandler); + configureHttp1Pipeline(ctx.pipeline()); + logger.log(Level.FINE, "negotiated HTTP/1.1: handler = " + ctx.pipeline().names()); + return; + } + ctx.close(); + throw new IllegalStateException("unexpected protocol: " + protocol); + } + } + + class Http2SettingsHandler extends SimpleChannelInboundHandler { + + private final ChannelPromise promise; + + Http2SettingsHandler(ChannelPromise promise) { + this.promise = promise; + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Http2Settings msg) throws Exception { + promise.setSuccess(); + ctx.pipeline().remove(this); + logger.log(Level.FINE, "settings handler removed, pipeline = " + ctx.pipeline().names()); + } + + /** + * Forward channel exceptions to the exception listener. + * @param ctx the channel handler context + * @param cause the cause of the exception + * @throws Exception if forwarding fails + */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + ExceptionListener exceptionListener = + ctx.channel().attr(HttpClientChannelContext.EXCEPTION_LISTENER_ATTRIBUTE_KEY).get(); + logger.log(Level.FINE, () -> "exceptionCaught"); + if (exceptionListener != null) { + exceptionListener.onException(cause); + } + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + httpRequestContext.fail(cause.getMessage()); + } + + void awaitSettings(HttpRequestContext httpRequestContext, ExceptionListener exceptionListener) throws Exception { + int timeout = httpRequestContext.getTimeout(); + if (!promise.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS)) { + IllegalStateException exception = new IllegalStateException("time out while waiting for HTTP/2 settings"); + if (exceptionListener != null) { + exceptionListener.onException(exception); + httpRequestContext.fail(exception.getMessage()); + } + throw exception; + } + if (!promise.isSuccess()) { + throw new RuntimeException(promise.cause()); + } + } + } + + @Sharable + private class UpgradeRequestHandler extends ChannelInboundHandlerAdapter { + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + DefaultFullHttpRequest upgradeRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/"); + ctx.writeAndFlush(upgradeRequest); + super.channelActive(ctx); + ctx.pipeline().remove(this); + logger.log(Level.FINE, "upgrade request handler removed, pipeline = " + ctx.pipeline().names()); + } + + /** + * Forward channel exceptions to the exception listener. + * @param ctx the channel handler context + * @param cause the cause of the exception + * @throws Exception if forwarding fails + */ + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + ExceptionListener exceptionListener = + ctx.channel().attr(HttpClientChannelContext.EXCEPTION_LISTENER_ATTRIBUTE_KEY).get(); + logger.log(Level.FINE, () -> "exceptionCaught"); + if (exceptionListener != null) { + exceptionListener.onException(cause); + } + final HttpRequestContext httpRequestContext = + ctx.channel().attr(HttpClientChannelContext.REQUEST_CONTEXT_ATTRIBUTE_KEY).get(); + httpRequestContext.fail(cause.getMessage()); + } + } + + @Sharable + private class UserEventLogger extends ChannelInboundHandlerAdapter { + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + logger.log(Level.FINE, () -> "got user event " + evt); + if (evt instanceof Http2ConnectionPrefaceWrittenEvent || + evt instanceof SslCloseCompletionEvent || + evt instanceof ChannelInputShutdownReadComplete) { + // Expected events + logger.log(Level.FINE, () -> "user event is expected: " + evt); + return; + } + super.userEventTriggered(ctx, evt); + } + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolHandler.java b/src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolHandler.java new file mode 100644 index 0000000..c226706 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolHandler.java @@ -0,0 +1,74 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.channel.Channel; +import io.netty.channel.pool.ChannelPoolHandler; +import io.netty.channel.socket.SocketChannel; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * + */ +public class HttpClientChannelPoolHandler implements ChannelPoolHandler { + + private static final Logger logger = Logger.getLogger(HttpClientChannelPoolHandler.class.getName()); + + private final HttpClientChannelInitializer channelInitializer; + + private final InetAddressKey key; + + private final AtomicInteger active = new AtomicInteger(); + + private int peak; + + HttpClientChannelPoolHandler(HttpClientChannelInitializer channelInitializer, InetAddressKey key) { + this.channelInitializer = channelInitializer; + this.key = key; + } + + @Override + public void channelCreated(Channel ch) throws Exception { + logger.log(Level.INFO, () -> "channel created " + ch + " key:" + key); + channelInitializer.initChannel((SocketChannel) ch, key); + int n = active.incrementAndGet(); + if (n > peak) { + peak = n; + } + } + + @Override + public void channelAcquired(Channel ch) throws Exception { + logger.log(Level.INFO, () -> "channel acquired from pool " + ch); + } + + @Override + public void channelReleased(Channel ch) throws Exception { + logger.log(Level.INFO, () -> "channel released to pool " + ch); + active.decrementAndGet(); + } + + public int getActive() { + return active.get(); + } + + public int getPeak() { + return peak; + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolMap.java b/src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolMap.java new file mode 100644 index 0000000..c8f07fa --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientChannelPoolMap.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.pool.AbstractChannelPoolMap; +import io.netty.channel.pool.FixedChannelPool; + +/** + * + */ +public class HttpClientChannelPoolMap extends AbstractChannelPoolMap { + + private final HttpClient httpClient; + + private final HttpClientChannelContext httpClientChannelContext; + + private final Bootstrap bootstrap; + + private final int maxConnections; + + private HttpClientChannelInitializer httpClientChannelInitializer; + + private HttpClientChannelPoolHandler httpClientChannelPoolHandler; + + HttpClientChannelPoolMap(HttpClient httpClient, + HttpClientChannelContext httpClientChannelContext, + Bootstrap bootstrap, + int maxConnections) { + this.httpClient = httpClient; + this.httpClientChannelContext = httpClientChannelContext; + this.bootstrap = bootstrap; + this.maxConnections = maxConnections; + } + + @Override + protected FixedChannelPool newPool(InetAddressKey key) { + this.httpClientChannelInitializer = new HttpClientChannelInitializer(httpClientChannelContext, + new Http1Handler(httpClient), new Http2Handler(httpClient)); + this.httpClientChannelPoolHandler = new HttpClientChannelPoolHandler(httpClientChannelInitializer, key); + return new FixedChannelPool(bootstrap.remoteAddress(key.getInetSocketAddress()), + httpClientChannelPoolHandler, maxConnections); + } + + public HttpClientChannelInitializer getHttpClientChannelInitializer() { + return httpClientChannelInitializer; + } + + public HttpClientChannelPoolHandler getHttpClientChannelPoolHandler() { + return httpClientChannelPoolHandler; + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientRequestBuilder.java b/src/main/java/org/xbib/netty/http/client/HttpClientRequestBuilder.java new file mode 100644 index 0000000..d6bb46d --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientRequestBuilder.java @@ -0,0 +1,352 @@ +package org.xbib.netty.http.client; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http2.HttpConversionUtil; +import io.netty.util.AsciiString; +import io.netty.util.CharsetUtil; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +/** + * + */ +public class HttpClientRequestBuilder implements HttpRequestBuilder, HttpRequestDefaults { + + private static final AtomicInteger streamId = new AtomicInteger(3); + + private final HttpClient httpClient; + + private final ByteBufAllocator byteBufAllocator; + + private final DefaultHttpHeaders headers; + + private final List removeHeaders; + + private final HttpMethod httpMethod; + + private int timeout = DEFAULT_TIMEOUT_MILLIS; + + private HttpVersion httpVersion = DEFAULT_HTTP_VERSION; + + private String userAgent = DEFAULT_USER_AGENT; + + private boolean gzip = DEFAULT_GZIP; + + private boolean followRedirect = DEFAULT_FOLLOW_REDIRECT; + + private int maxRedirects = DEFAULT_MAX_REDIRECT; + + private URL url; + + private ByteBuf body; + + private HttpRequest httpRequest; + + private HttpRequestContext httpRequestContext; + + private HttpResponseListener httpResponseListener; + + private ExceptionListener exceptionListener; + + /** + * Construct HTTP client request builder. + * + * @param httpClient HTTP client + * @param httpMethod HTTP method + * @param byteBufAllocator byte buf allocator + */ + HttpClientRequestBuilder(HttpClient httpClient, HttpMethod httpMethod, ByteBufAllocator byteBufAllocator) { + this.httpClient = httpClient; + this.httpMethod = httpMethod; + this.byteBufAllocator = byteBufAllocator; + this.headers = new DefaultHttpHeaders(); + this.removeHeaders = new ArrayList<>(); + } + + @Override + public HttpRequestBuilder setTimeout(int timeout) { + this.timeout = timeout; + return this; + } + + protected int getTimeout() { + return timeout; + } + + @Override + public HttpRequestBuilder setURL(String url) { + try { + this.url = new URL(url); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + return this; + } + + protected URL getURL() { + return url; + } + + @Override + public HttpRequestBuilder addHeader(String name, Object value) { + headers.add(name, value); + return this; + } + + @Override + public HttpRequestBuilder setHeader(String name, Object value) { + headers.set(name, value); + return this; + } + + @Override + public HttpRequestBuilder removeHeader(String name) { + removeHeaders.add(name); + return this; + } + + @Override + public HttpRequestBuilder contentType(String contentType) { + addHeader(HttpHeaderNames.CONTENT_TYPE, contentType); + return this; + } + + @Override + public HttpRequestBuilder setVersion(String httpVersion) { + this.httpVersion = HttpVersion.valueOf(httpVersion); + return this; + } + + protected HttpVersion getVersion() { + return httpVersion; + } + + @Override + public HttpRequestBuilder acceptGzip(boolean gzip) { + this.gzip = gzip; + return this; + } + + @Override + public HttpRequestBuilder setFollowRedirect(boolean followRedirect) { + this.followRedirect = followRedirect; + return this; + } + + protected boolean isFollowRedirect() { + return followRedirect; + } + + @Override + public HttpRequestBuilder setMaxRedirects(int maxRedirects) { + this.maxRedirects = maxRedirects; + return this; + } + + protected int getMaxRedirects() { + return maxRedirects; + } + + @Override + public HttpRequestBuilder setUserAgent(String userAgent) { + this.userAgent = userAgent; + return this; + } + + @Override + public HttpRequestBuilder text(String text) throws IOException { + setBody(text, HttpHeaderValues.TEXT_PLAIN); + return this; + } + + @Override + public HttpRequestBuilder json(String json) throws IOException { + setBody(json, HttpHeaderValues.APPLICATION_JSON); + return this; + } + + @Override + public HttpRequestBuilder xml(String xml) throws IOException { + setBody(xml, "application/xml"); + return this; + } + + @Override + public HttpRequestBuilder setBody(CharSequence charSequence, String contentType) throws IOException { + setBody(charSequence.toString().getBytes(CharsetUtil.UTF_8), AsciiString.of(contentType)); + return this; + } + + @Override + public HttpRequestBuilder setBody(byte[] buf, String contentType) throws IOException { + setBody(buf, AsciiString.of(contentType)); + return this; + } + + @Override + public HttpRequestBuilder setBody(ByteBuf body, String contentType) throws IOException { + setBody(body, AsciiString.of(contentType)); + return this; + } + + @Override + public HttpRequest build() { + if (url == null) { + throw new IllegalStateException("URL not set"); + } + if (url.getHost() == null) { + throw new IllegalStateException("URL host not set: " + url); + } + DefaultHttpRequest httpRequest = createHttpRequest(); + String scheme = url.getProtocol(); + StringBuilder sb = new StringBuilder(url.getHost()); + int defaultPort = "http".equals(scheme) ? 80 : "https".equals(scheme) ? 443 : -1; + if (defaultPort != -1 && url.getPort() != -1 && defaultPort != url.getPort()) { + sb.append(":").append(url.getPort()); + } + if (httpVersion.majorVersion() == 2) { + // this is a hack, because we only use the "origin-form" in request URIs + httpRequest.headers().set(HttpConversionUtil.ExtensionHeaderNames.SCHEME.text(), scheme); + } + httpRequest.headers().add(HttpHeaderNames.HOST, sb.toString()); + httpRequest.headers().add(HttpHeaderNames.DATE, + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.ofInstant(Instant.now(), ZoneId.of("GMT")))); + if (userAgent != null) { + httpRequest.headers().add(HttpHeaderNames.USER_AGENT, userAgent); + } + if (gzip) { + httpRequest.headers().add(HttpHeaderNames.ACCEPT_ENCODING, "gzip"); + } + httpRequest.headers().setAll(headers); + if (!httpRequest.headers().contains(HttpHeaderNames.ACCEPT)) { + httpRequest.headers().add(HttpHeaderNames.ACCEPT, "*/*"); + } + // RFC 2616 Section 14.10 + // "An HTTP/1.1 client that does not support persistent connections MUST include the "close" connection + // option in every request message." + if (httpVersion.majorVersion() == 1 && !httpRequest.headers().contains(HttpHeaderNames.CONNECTION)) { + httpRequest.headers().add(HttpHeaderNames.CONNECTION, "close"); + } + // forced removal of headers, at last + for (String headerName : removeHeaders) { + httpRequest.headers().remove(headerName); + } + return httpRequest; + } + + private DefaultHttpRequest createHttpRequest() { + // Regarding request-target URI: + // RFC https://tools.ietf.org/html/rfc7230#section-5.3.2 + // would allow url.toExternalForm as absolute-form, + // but some servers do not support that. So, we create origin-form. + // But for HTTP/2, we should create the absolute-form, otherwise + // netty will throw "java.lang.IllegalArgumentException: :scheme must be specified." + String requestTarget = toOriginForm(url); + return body == null ? + new DefaultHttpRequest(httpVersion, httpMethod, requestTarget) : + new DefaultFullHttpRequest(httpVersion, httpMethod, requestTarget, body); + } + + private String toOriginForm(URL base) { + StringBuilder sb = new StringBuilder(); + String path = base.getPath() != null && !base.getPath().isEmpty() ? base.getPath() : "/"; + String query = base.getQuery(); + String ref = base.getRef(); + if (path.charAt(0) != '/') { + sb.append('/'); + } + sb.append(path); + if (query != null && !query.isEmpty()) { + sb.append('?').append(query); + } + if (ref != null && !ref.isEmpty()) { + sb.append('#').append(ref); + } + return sb.toString(); + } + + private void addHeader(AsciiString name, Object value) { + headers.add(name, value); + } + + private void setBody(CharSequence charSequence, AsciiString contentType) throws IOException { + setBody(charSequence.toString().getBytes(CharsetUtil.UTF_8), contentType); + } + + private void setBody(byte[] buf, AsciiString contentType) throws IOException { + ByteBuf buffer = byteBufAllocator.buffer(buf.length).writeBytes(buf); + setBody(buffer, contentType); + } + + private void setBody(ByteBuf body, AsciiString contentType) throws IOException { + this.body = body; + addHeader(HttpHeaderNames.CONTENT_LENGTH, (long) body.readableBytes()); + addHeader(HttpHeaderNames.CONTENT_TYPE, contentType); + } + + @Override + public HttpRequestBuilder onResponse(HttpResponseListener httpResponseListener) { + this.httpResponseListener = httpResponseListener; + return this; + } + + @Override + public HttpRequestBuilder onError(ExceptionListener exceptionListener) { + this.exceptionListener = exceptionListener; + return this; + } + + @Override + public HttpRequestContext execute() { + if (httpRequest == null) { + httpRequest = build(); + } + if (httpRequestContext == null) { + httpRequestContext = new HttpRequestContext(getURL(), + httpRequest, + new AtomicBoolean(false), + new AtomicBoolean(false), + getTimeout(), System.currentTimeMillis(), + isFollowRedirect(), getMaxRedirects(), new AtomicInteger(0), + new CountDownLatch(1), streamId.get()); + } + if (httpResponseListener == null) { + httpResponseListener = httpRequestContext; + } + httpClient.dispatch(httpRequestContext, httpResponseListener, exceptionListener); + return httpRequestContext; + } + + @Override + public CompletableFuture execute(Function supplier) { + final CompletableFuture completableFuture = new CompletableFuture<>(); + onResponse(response -> completableFuture.complete(supplier.apply(response))); + onError(completableFuture::completeExceptionally); + execute(); + return completableFuture; + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientThreadFactory.java b/src/main/java/org/xbib/netty/http/client/HttpClientThreadFactory.java new file mode 100644 index 0000000..4c426a6 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientThreadFactory.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import java.util.concurrent.ThreadFactory; + +class HttpClientThreadFactory implements ThreadFactory { + + private int number = 0; + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "org-xbib-netty-http-client-pool-" + (number++)); + thread.setDaemon(true); + return thread; + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpClientUserAgent.java b/src/main/java/org/xbib/netty/http/client/HttpClientUserAgent.java new file mode 100644 index 0000000..003b0ee --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpClientUserAgent.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.bootstrap.Bootstrap; + +import java.util.Optional; + +/** + */ +public final class HttpClientUserAgent { + + /** + * The default valut for {@code User-Agent}. + */ + private static final String USER_AGENT = String.format("XbibHttpClient/%s (Java/%s/%s) (Netty/%s)", + httpClientVersion(), javaVendor(), javaVersion(), nettyVersion()); + + private HttpClientUserAgent() { + } + + public static String getUserAgent() { + return USER_AGENT; + } + + private static String httpClientVersion() { + return Optional.ofNullable(HttpClient.class.getPackage().getImplementationVersion()) + .orElse("unknown"); + } + + private static String javaVendor() { + return Optional.ofNullable(System.getProperty("java.vendor")) + .orElse("unknown"); + } + + private static String javaVersion() { + return Optional.ofNullable(System.getProperty("java.version")) + .orElse("unknown"); + } + + private static String nettyVersion() { + return Optional.ofNullable(Bootstrap.class.getPackage().getImplementationVersion()) + .orElse("unknown"); + } +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpRequestBuilder.java b/src/main/java/org/xbib/netty/http/client/HttpRequestBuilder.java new file mode 100644 index 0000000..49ee16a --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpRequestBuilder.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpRequest; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + */ +public interface HttpRequestBuilder { + + HttpRequestBuilder setTimeout(int timeout); + + HttpRequestBuilder setVersion(String httpVersion); + + HttpRequestBuilder setURL(String url); + + HttpRequestBuilder setHeader(String name, Object value); + + HttpRequestBuilder addHeader(String name, Object value); + + HttpRequestBuilder removeHeader(String name); + + HttpRequestBuilder contentType(String contentType); + + HttpRequestBuilder acceptGzip(boolean gzip); + + HttpRequestBuilder setFollowRedirect(boolean followRedirect); + + HttpRequestBuilder setMaxRedirects(int maxRedirects); + + HttpRequestBuilder setUserAgent(String userAgent); + + HttpRequestBuilder setBody(CharSequence charSequence, String contentType) throws IOException; + + HttpRequestBuilder text(String text) throws IOException; + + HttpRequestBuilder json(String jsonText) throws IOException; + + HttpRequestBuilder xml(String xmlText) throws IOException; + + HttpRequestBuilder setBody(byte[] buf, String contentType) throws IOException; + + HttpRequestBuilder setBody(ByteBuf body, String contentType) throws IOException; + + HttpRequestBuilder onError(ExceptionListener exceptionListener); + + HttpRequestBuilder onResponse(HttpResponseListener httpResponseListener); + + HttpRequest build(); + + HttpRequestContext execute(); + + CompletableFuture execute(Function supplier); +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpRequestContext.java b/src/main/java/org/xbib/netty/http/client/HttpRequestContext.java new file mode 100755 index 0000000..4fc8716 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpRequestContext.java @@ -0,0 +1,185 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpRequest; + +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * + */ +public final class HttpRequestContext implements HttpResponseListener, HttpRequestDefaults { + + private static final Logger logger = Logger.getLogger(HttpRequestContext.class.getName()); + + private final URL url; + + private final HttpRequest httpRequest; + + private final AtomicBoolean succeeded; + + private final AtomicBoolean failed; + + private final boolean followRedirect; + + private final int maxRedirects; + + private final AtomicInteger redirectCount; + + private final Integer timeout; + + private final Long startTime; + + private final CountDownLatch latch; + + private final Integer streamId; + + private FullHttpResponse httpResponse; + + private Long stopTime; + + HttpRequestContext(URL url, HttpRequest httpRequest, + AtomicBoolean succeeded, AtomicBoolean failed, + int timeout, Long startTime, + boolean followRedirect, int maxRedirects, AtomicInteger redirectCount, + CountDownLatch latch, Integer streamId) { + this.url = url; + this.httpRequest = httpRequest; + this.succeeded = succeeded; + this.failed = failed; + this.timeout = timeout; + this.startTime = startTime; + this.followRedirect = followRedirect; + this.maxRedirects = maxRedirects; + this.redirectCount = redirectCount; + this.latch = latch; + this.streamId = streamId; + } + + HttpRequestContext(URL url, HttpRequest httpRequest, HttpRequestContext httpRequestContext) { + this.url = url; + this.httpRequest = httpRequest; + this.succeeded = httpRequestContext.succeeded; + this.failed = httpRequestContext.failed; + this.failed.lazySet(false); // reset + this.timeout = httpRequestContext.timeout; + this.startTime = httpRequestContext.startTime; + this.followRedirect = httpRequestContext.followRedirect; + this.maxRedirects = httpRequestContext.maxRedirects; + this.redirectCount = httpRequestContext.redirectCount; + this.latch = httpRequestContext.latch; + this.streamId = httpRequestContext.streamId; + } + + public URL getURL() { + return url; + } + + public HttpRequest getHttpRequest() { + return httpRequest; + } + + public int getTimeout() { + return timeout; + } + + public long getStartTime() { + return startTime; + } + + public boolean isSucceeded() { + return succeeded.get(); + } + + public boolean isFailed() { + return failed.get(); + } + + public boolean isFollowRedirect() { + return followRedirect; + } + + public int getMaxRedirects() { + return maxRedirects; + } + + public AtomicInteger getRedirectCount() { + return redirectCount; + } + + public boolean isExpired() { + return timeout != null && System.currentTimeMillis() > startTime + timeout; + } + + public long took() { + return stopTime != null ? stopTime - startTime : -1L; + } + + public long remaining() { + return (startTime + timeout) - System.currentTimeMillis(); + } + + public CountDownLatch getLatch() { + return latch; + } + + public Integer getStreamId() { + return streamId; + } + + public HttpRequestContext get() throws InterruptedException { + return waitFor(DEFAULT_TIMEOUT_MILLIS, TimeUnit.SECONDS); + } + + public HttpRequestContext waitFor(long value, TimeUnit timeUnit) throws InterruptedException { + latch.await(value, timeUnit); + stopTime = System.currentTimeMillis(); + return this; + } + + public void success(String reason) { + logger.log(Level.FINE, () -> "success because of " + reason); + if (succeeded.compareAndSet(false, true)) { + latch.countDown(); + + } + } + + public void fail(String reason) { + logger.log(Level.FINE, () -> "failed because of " + reason); + if (failed.compareAndSet(false, true)) { + latch.countDown(); + } + } + + @Override + public void onResponse(FullHttpResponse fullHttpResponse) { + this.httpResponse = fullHttpResponse; + } + + public FullHttpResponse getHttpResponse() { + return httpResponse; + } + +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpRequestDefaults.java b/src/main/java/org/xbib/netty/http/client/HttpRequestDefaults.java new file mode 100644 index 0000000..190e5fd --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpRequestDefaults.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.handler.codec.http.HttpVersion; + +/** + */ +public interface HttpRequestDefaults { + + int DEFAULT_TIMEOUT_MILLIS = 5000; + + HttpVersion DEFAULT_HTTP_VERSION = HttpVersion.HTTP_1_1; + + String DEFAULT_USER_AGENT = HttpClientUserAgent.getUserAgent(); + + boolean DEFAULT_GZIP = true; + + boolean DEFAULT_FOLLOW_REDIRECT = true; + + int DEFAULT_MAX_REDIRECT = 10; +} diff --git a/src/main/java/org/xbib/netty/http/client/HttpResponseListener.java b/src/main/java/org/xbib/netty/http/client/HttpResponseListener.java new file mode 100644 index 0000000..b462987 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/HttpResponseListener.java @@ -0,0 +1,26 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.handler.codec.http.FullHttpResponse; + +/** + */ +@FunctionalInterface +public interface HttpResponseListener { + + void onResponse(FullHttpResponse fullHttpResponse); +} diff --git a/src/main/java/org/xbib/netty/http/client/InetAddressKey.java b/src/main/java/org/xbib/netty/http/client/InetAddressKey.java new file mode 100644 index 0000000..acb98b1 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/InetAddressKey.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.handler.codec.http.HttpVersion; + +import java.net.InetSocketAddress; +import java.net.URL; + +/** + */ +public class InetAddressKey { + + private final InetSocketAddress inetSocketAddress; + + private final HttpVersion version; + + private final Boolean secure; + + InetAddressKey(URL url, HttpVersion version) { + this.version = version; + String protocol = url.getProtocol(); + this.secure = "https".equals(protocol); + int port = url.getPort(); + if (port == -1) { + port = "http".equals(protocol) ? 80 : (secure ? 443 : -1); + } + this.inetSocketAddress = new InetSocketAddress(url.getHost(), port); + } + + InetAddressKey(InetSocketAddress inetSocketAddress, HttpVersion version, boolean secure) { + this.inetSocketAddress = inetSocketAddress; + this.version = version; + this.secure = secure; + } + + InetSocketAddress getInetSocketAddress() { + return inetSocketAddress; + } + + HttpVersion getVersion() { + return version; + } + + boolean isSecure() { + return secure; + } + + public String toString() { + return inetSocketAddress + " (version:" + version + ",secure:" + secure + ")"; + } + + @Override + public boolean equals(Object object) { + return object instanceof InetAddressKey && + inetSocketAddress.equals(((InetAddressKey) object).inetSocketAddress) && + version.equals(((InetAddressKey) object).version) && + secure == ((InetAddressKey) object).secure; + } + + @Override + public int hashCode() { + return inetSocketAddress.hashCode() ^ version.hashCode() ^ secure.hashCode(); + } +} diff --git a/src/main/java/org/xbib/netty/http/client/SslClientAuthMode.java b/src/main/java/org/xbib/netty/http/client/SslClientAuthMode.java new file mode 100644 index 0000000..0de5849 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/SslClientAuthMode.java @@ -0,0 +1,23 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +/** + * + */ +public enum SslClientAuthMode { + NONE, WANT, NEED +} diff --git a/src/main/java/org/xbib/netty/http/client/TrafficLoggingHandler.java b/src/main/java/org/xbib/netty/http/client/TrafficLoggingHandler.java new file mode 100644 index 0000000..1a1940e --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/TrafficLoggingHandler.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; + +/** + * A Netty handler that logs the I/O traffic of a connection. + */ +public final class TrafficLoggingHandler extends LoggingHandler { + + public TrafficLoggingHandler() { + super("client", LogLevel.TRACE); + } + + @Override + public void channelRegistered(ChannelHandlerContext ctx) throws Exception { + ctx.fireChannelRegistered(); + } + + @Override + public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { + ctx.fireChannelUnregistered(); + } + + @Override + public void flush(ChannelHandlerContext ctx) throws Exception { + ctx.flush(); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (msg instanceof ByteBuf && !((ByteBuf) msg).isReadable()) { + ctx.write(msg, promise); + } else { + super.write(ctx, msg, promise); + } + } +} diff --git a/src/main/java/org/xbib/netty/http/client/package-info.java b/src/main/java/org/xbib/netty/http/client/package-info.java new file mode 100644 index 0000000..aef4b83 --- /dev/null +++ b/src/main/java/org/xbib/netty/http/client/package-info.java @@ -0,0 +1,4 @@ +/** + * Classes for Netty HTTP client. + */ +package org.xbib.netty.http.client; diff --git a/src/test/java/org/xbib/netty/http/client/test/AkamaiTest.java b/src/test/java/org/xbib/netty/http/client/test/AkamaiTest.java new file mode 100644 index 0000000..baf2292 --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/AkamaiTest.java @@ -0,0 +1,71 @@ +package org.xbib.netty.http.client.test; + +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; + +import java.nio.charset.StandardCharsets; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class AkamaiTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testAkamai() throws Exception { + + // here we can not deal with server PUSH_PROMISE as response to headers, a go-away frame is written. + // Probably because promised stream id is 2 which is smaller than 3? + + /* + ----------------INBOUND-------------------- + [id: 0x27d23874, L:/192.168.178.23:52376 - R:http2.akamai.com/104.94.191.203:443] + PUSH_PROMISE: streamId=3, promisedStreamId=2, headers=DefaultHttp2Headers[:method: GET, + :path: /resources/push.css, :authority: http2.akamai.com, :scheme: https, host: http2.akamai.com, + user-agent: XbibHttpClient/unknown (Java/Azul Systems, Inc./1.8.0_112) (Netty/4.1.9.Final), + accept-encoding: gzip], padding=0 + ------------------------------------ + 2017-05-01 18:53:46.076 AM FEINSTEN [org.xbib.netty.http.client.HttpClientChannelInitializer] + io.netty.handler.codec.http2.Http2FrameLogger log + ----------------OUTBOUND-------------------- + [id: 0x27d23874, L:/192.168.178.23:52376 - R:http2.akamai.com/104.94.191.203:443] + GO_AWAY: lastStreamId=2, errorCode=1, length=75, bytes=556e7265636f676e697a656420485454... + */ + + HttpClient httpClient = HttpClient.builder() + .build(); + + httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("https://http2.akamai.com/demo/h2_demo_frame.html") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/ElasticsearchTest.java b/src/test/java/org/xbib/netty/http/client/test/ElasticsearchTest.java new file mode 100644 index 0000000..9300727 --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/ElasticsearchTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client.test; + +import org.junit.Ignore; +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; +import org.xbib.netty.http.client.HttpRequestBuilder; +import org.xbib.netty.http.client.HttpRequestContext; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class ElasticsearchTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.INFO); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.INFO); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testElasticsearchCreateDocument() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + HttpRequestContext requestContext = httpClient.preparePut() + .setURL("http://localhost:9200/test/test/1") + .json("{\"text\":\"Hello World\"}") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .execute() + .get(); + httpClient.close(); + logger.log(Level.FINE, "took = " + requestContext.took()); + } + + @Test + public void testElasticsearchMatchQuery() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + HttpRequestContext requestContext = httpClient.preparePost() + .setURL("http://localhost:9200/test/_search") + .json("{\"query\":{\"match\":{\"_all\":\"Hello World\"}}}") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .execute() + .get(); + httpClient.close(); + logger.log(Level.FINE, "took = " + requestContext.took()); + } + + @Test + public void testElasticsearchConcurrent() throws Exception { + int max = 100; + HttpClient httpClient = HttpClient.builder() + .build(); + List queries = new ArrayList<>(); + for (int i = 0; i < max; i++) { + queries.add(createQuery(httpClient)); + } + List contexts = new ArrayList<>(); + for (int i = 0; i < max; i++) { + contexts.add(queries.get(i).execute()); + } + List responses = new ArrayList<>(); + for (int i = 0; i < max; i++) { + responses.add(contexts.get(i).get()); + } + for (int i = 0; i < max; i++) { + logger.log(Level.FINE, "took = " + responses.get(i).took()); + } + httpClient.close(); + logger.log(Level.INFO, "pool peak = " + httpClient.poolMap().getHttpClientChannelPoolHandler().getPeak()); + } + + private HttpRequestBuilder createQuery(HttpClient httpClient) throws IOException { + return httpClient.preparePost() + .setURL("http://localhost:9200/test/_search") + .json("{\"query\":{\"match\":{\"_all\":\"Hello World\"}}}") + .addHeader("connection", "keep-alive") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/ExceptionTest.java b/src/test/java/org/xbib/netty/http/client/test/ExceptionTest.java new file mode 100644 index 0000000..ddc4aee --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/ExceptionTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client.test; + +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; + +import java.nio.charset.StandardCharsets; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class ExceptionTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testConnectionRefused() throws Exception { + + // this basically tests if the connection refuse terminates. + + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setURL("http://localhost:1234") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .execute() + .get(); + httpClient.close(); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/GoogleTest.java b/src/test/java/org/xbib/netty/http/client/test/GoogleTest.java new file mode 100644 index 0000000..9ad7564 --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/GoogleTest.java @@ -0,0 +1,140 @@ +package org.xbib.netty.http.client.test; + +import org.junit.Ignore; +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; +import org.xbib.netty.http.client.HttpRequestBuilder; +import org.xbib.netty.http.client.HttpRequestContext; + +import java.nio.charset.StandardCharsets; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class GoogleTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testGoogleHttp1() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setURL("http://www.google.com") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testGoogleWithoutFollowRedirects() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setURL("http://google.com") + .setFollowRedirect(false) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + logger.log(Level.INFO, "pool size = " + httpClient.poolMap().size()); + httpClient.close(); + } + + @Test + public void testGoogleHttps1() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setURL("https://www.google.com") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testGoogleHttp2() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + + httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("https://www.google.com") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testGoogleHttpTwo() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + + HttpRequestBuilder builder1 = httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("https://www.google.com") + .setTimeout(10000) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }); + + HttpRequestBuilder builder2 = httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("https://www.google.com") + .setTimeout(10000) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }); + + // only sequential ... this sucks. + + HttpRequestContext context1 = builder1.execute(); + context1.get(); + + HttpRequestContext context2 = builder2.execute(); + context2.get(); + + httpClient.close(); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/Http2PushioTest.java b/src/test/java/org/xbib/netty/http/client/test/Http2PushioTest.java new file mode 100644 index 0000000..6647873 --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/Http2PushioTest.java @@ -0,0 +1,52 @@ +package org.xbib.netty.http.client.test; + +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; + +import java.nio.charset.StandardCharsets; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class Http2PushioTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testHttpPushIo() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + + httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("https://http2-push.io") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/Http2Test.java b/src/test/java/org/xbib/netty/http/client/test/Http2Test.java new file mode 100644 index 0000000..26b65bc --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/Http2Test.java @@ -0,0 +1,209 @@ +package org.xbib.netty.http.client.test; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http2.DefaultHttp2Connection; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DelegatingDecompressorFrameListener; +import io.netty.handler.codec.http2.Http2Connection; +import io.netty.handler.codec.http2.Http2ConnectionHandler; +import io.netty.handler.codec.http2.Http2ConnectionHandlerBuilder; +import io.netty.handler.codec.http2.Http2Exception; +import io.netty.handler.codec.http2.Http2FrameAdapter; +import io.netty.handler.codec.http2.Http2FrameLogger; +import io.netty.handler.codec.http2.Http2SecurityUtil; +import io.netty.handler.codec.http2.Http2Settings; +import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.SupportedCipherSuiteFilter; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import org.junit.Test; +import org.xbib.netty.http.client.Http2Handler; +import org.xbib.netty.http.client.TrafficLoggingHandler; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class Http2Test { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + + private static final Logger logger = Logger.getLogger(""); + + private final int serverExpectedDataFrames = 1; + + + public void testGeneric() throws Exception { + final InetSocketAddress inetSocketAddress = new InetSocketAddress("http2-push.io", 443); + final CountDownLatch dataLatch = new CountDownLatch(serverExpectedDataFrames); + EventLoopGroup group = new NioEventLoopGroup(); + Channel clientChannel = null; + try { + Bootstrap bs = new Bootstrap(); + bs.group(group); + bs.channel(NioSocketChannel.class); + bs.handler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + ch.pipeline().addLast(new TrafficLoggingHandler()); + SslContext sslContext = SslContextBuilder.forClient() + .sslProvider(SslProvider.OPENSSL) + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .keyManager((InputStream) null, null, null) + .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) + .applicationProtocolConfig(new ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2, + ApplicationProtocolNames.HTTP_1_1)) + .build(); + SslHandler sslHandler = sslContext.newHandler(ch.alloc()); + SSLEngine engine = sslHandler.engine(); + String fullQualifiedHostname = inetSocketAddress.getHostName(); + SSLParameters params = engine.getSSLParameters(); + params.setServerNames(Arrays.asList(new SNIServerName[]{new SNIHostName(fullQualifiedHostname)})); + engine.setSSLParameters(params); + ch.pipeline().addLast(sslHandler); + Http2ConnectionHandlerBuilder builder = new Http2ConnectionHandlerBuilder(); + builder.server(false); + builder.frameListener(new Http2FrameAdapter() { + @Override + public void onSettingsRead(ChannelHandlerContext ctx, Http2Settings settings) + throws Http2Exception { + Http2ConnectionHandler handler = ctx.pipeline().get(Http2ConnectionHandler.class); + handler.encoder().writeHeaders(ctx, 3, + new DefaultHttp2Headers().method(HttpMethod.GET.asciiName()) + .path("/") + .scheme("https") + .authority(inetSocketAddress.getHostName()), + 0, true, ctx.newPromise()); + ctx.channel().flush(); + } + + @Override + public int onDataRead(ChannelHandlerContext ctx, int streamId, ByteBuf data, int padding, + boolean endOfStream) throws Http2Exception { + dataLatch.countDown(); + return super.onDataRead(ctx, streamId, data, padding, endOfStream); + } + }); + builder.frameLogger(new Http2FrameLogger(LogLevel.INFO, "client")); + ch.pipeline().addLast(builder.build()); + } + }); + clientChannel = bs.connect(inetSocketAddress).syncUninterruptibly().channel(); + dataLatch.await(); + } finally { + if (clientChannel != null) { + clientChannel.close(); + } + group.shutdownGracefully(); + } + } + + + public void testHttpAdapter() throws Exception { + final InetSocketAddress inetSocketAddress = new InetSocketAddress("http2-push.io", 443); + final CountDownLatch dataLatch = new CountDownLatch(serverExpectedDataFrames); + EventLoopGroup group = new NioEventLoopGroup(); + Channel clientChannel = null; + try { + Bootstrap bs = new Bootstrap(); + bs.group(group); + bs.channel(NioSocketChannel.class); + bs.handler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + ch.pipeline().addLast(new TrafficLoggingHandler()); + SslContext sslContext = SslContextBuilder.forClient() + .sslProvider(SslProvider.OPENSSL) + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .keyManager((InputStream) null, null, null) + .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) + .applicationProtocolConfig(new ApplicationProtocolConfig( + ApplicationProtocolConfig.Protocol.ALPN, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, + ApplicationProtocolNames.HTTP_2, + ApplicationProtocolNames.HTTP_1_1)) + .build(); + SslHandler sslHandler = sslContext.newHandler(ch.alloc()); + SSLEngine engine = sslHandler.engine(); + String fullQualifiedHostname = inetSocketAddress.getHostName(); + SSLParameters params = engine.getSSLParameters(); + params.setServerNames(Arrays.asList(new SNIServerName[]{new SNIHostName(fullQualifiedHostname)})); + engine.setSSLParameters(params); + ch.pipeline().addLast(sslHandler); + // settings handler + final Http2Connection http2Connection = new DefaultHttp2Connection(false); + Http2ConnectionHandler http2ConnectionHandler = new Http2ConnectionHandlerBuilder() + .frameLogger(new Http2FrameLogger(LogLevel.INFO, "client")) + .frameListener(new DelegatingDecompressorFrameListener(http2Connection, + new InboundHttp2ToHttpAdapterBuilder(http2Connection) + .maxContentLength(1024 * 1024) + .propagateSettings(true) + .validateHttpHeaders(false) + .build())) + .build(); + ch.pipeline().addLast(http2ConnectionHandler); + ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + dataLatch.countDown(); + } + }); + } + }); + clientChannel = bs.connect(inetSocketAddress).syncUninterruptibly().channel(); + dataLatch.await(); + } finally { + if (clientChannel != null) { + clientChannel.close(); + } + group.shutdownGracefully(); + } + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/IndexHbzTest.java b/src/test/java/org/xbib/netty/http/client/test/IndexHbzTest.java new file mode 100644 index 0000000..bb93b8d --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/IndexHbzTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client.test; + +import io.netty.handler.codec.http.FullHttpResponse; +import org.junit.Ignore; +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; +import org.xbib.netty.http.client.HttpRequestBuilder; +import org.xbib.netty.http.client.HttpRequestContext; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class IndexHbzTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testIndexHbz() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setVersion("HTTP/1.1") + .setURL("http://index.hbz-nrw.de") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testIndexHbzHttps() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setVersion("HTTP/1.1") + .setURL("https://index.hbz-nrw.de") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testIndexHbzWithCompletableFuture() throws Exception { + // fetches "test" as content from index.hbz-nrw.de and continues with sending another URL to google.com + + // tricky: google.com does not completely redirect because the first httpResponseStringFunction wins + // and generates the desired string result + + HttpClient httpClient = HttpClient.builder() + .build(); + + final Function httpResponseStringFunction = + response -> response.content().toString(StandardCharsets.UTF_8); + + final CompletableFuture completableFuture = httpClient.prepareGet() + .setURL("http://index.hbz-nrw.de") + .execute(httpResponseStringFunction) + .exceptionally(Throwable::getMessage) + .thenCompose(content -> httpClient.prepareGet() + .setURL("http://google.com/?query=" + content) + .execute(httpResponseStringFunction)); + + String result = completableFuture.join(); + + logger.log(Level.INFO, "completablefuture result = " + result); + + httpClient.close(); + } + + @Test + public void testIndexHbzH2() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("https://index.hbz-nrw.de") + .setTimeout(5000) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testIndexHbzH2C() throws Exception { + + // times out waiting for http2 settings frame + + HttpClient httpClient = HttpClient.builder() + .setInstallHttp2Upgrade(true) + .build(); + httpClient.prepareGet() + .setVersion("HTTP/2.0") + .setURL("http://index.hbz-nrw.de") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testIndexHbzConcurrent() throws Exception { + + HttpClient httpClient = HttpClient.builder() + .build(); + + HttpRequestBuilder builder1 = httpClient.prepareGet() + .setVersion("HTTP/1.1") + .setURL("http://index.hbz-nrw.de") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }); + + HttpRequestBuilder builder2 = httpClient.prepareGet() + .setVersion("HTTP/1.1") + .setURL("http://index.hbz-nrw.de") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }); + + HttpRequestContext context1 = builder1.execute(); + HttpRequestContext context2 = builder2.execute(); + context1.get(); + context2.get(); + + httpClient.close(); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/XbibTest.java b/src/test/java/org/xbib/netty/http/client/test/XbibTest.java new file mode 100644 index 0000000..aab4bb4 --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/XbibTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2017 Jörg Prante + * + * Jörg Prante 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 org.xbib.netty.http.client.test; + +import io.netty.handler.codec.http.FullHttpResponse; +import org.junit.Ignore; +import org.junit.Test; +import org.xbib.netty.http.client.HttpClient; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + */ +public class XbibTest { + + static { + System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-7s [%3$s] %2$s %5$s %6$s%n"); + LogManager.getLogManager().reset(); + Logger rootLogger = LogManager.getLogManager().getLogger(""); + Handler handler = new ConsoleHandler(); + handler.setFormatter(new SimpleFormatter()); + rootLogger.addHandler(handler); + rootLogger.setLevel(Level.ALL); + for (Handler h : rootLogger.getHandlers()) { + handler.setFormatter(new SimpleFormatter()); + h.setLevel(Level.ALL); + } + } + + private static final Logger logger = Logger.getLogger(""); + + @Test + public void testXbibOrgWithDefaults() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + httpClient.prepareGet() + .setURL("http://xbib.org") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testXbibOrgWithCompletableFuture() throws Exception { + HttpClient httpClient = HttpClient.builder() + .setTcpNodelay(true) + .build(); + + final Function httpResponseStringFunction = + response -> response.content().toString(StandardCharsets.UTF_8); + + final CompletableFuture completableFuture = httpClient.prepareGet() + .setURL("http://index.hbz-nrw.de") + .execute(httpResponseStringFunction) + .exceptionally(Throwable::getMessage) + .thenCompose(content -> httpClient.prepareGet() + .setURL("http://google.de/?query=" + content) + .execute(httpResponseStringFunction)); + + String result = completableFuture.join(); + + logger.log(Level.FINE, "completablefuture result = " + result); + + httpClient.close(); + } + + @Test + public void testXbibOrgWithProxy() throws Exception { + HttpClient httpClient = HttpClient.builder() + .setHttpProxyHandler(new InetSocketAddress("80.241.223.251", 8080)) + .setConnectTimeoutMillis(30000) + .setReadTimeoutMillis(30000) + .build(); + httpClient.prepareGet() + .setURL("http://xbib.org") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .execute() + .get(); + httpClient.close(); + } + + @Test + public void testXbibOrgWithVeryShortReadTimeout() throws Exception { + logger.log(Level.FINE, "start"); + HttpClient httpClient = HttpClient.builder() + .setReadTimeoutMillis(50) + .build(); + httpClient.prepareGet() + .setURL("http://xbib.org") + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .execute() + .get(); + httpClient.close(); + logger.log(Level.FINE, "end"); + } + + @Test + public void testXbibTwoSequentialRequests() throws Exception { + HttpClient httpClient = HttpClient.builder() + .build(); + + httpClient.prepareGet() + .setVersion("HTTP/1.1") + .setURL("http://xbib.org") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + + httpClient.prepareGet() + .setVersion("HTTP/1.1") + .setURL("http://xbib.org") + .onError(e -> logger.log(Level.SEVERE, e.getMessage(), e)) + .onResponse(fullHttpResponse -> { + String response = fullHttpResponse.content().toString(StandardCharsets.UTF_8); + logger.log(Level.INFO, "status = " + fullHttpResponse.status() + " response body = " + response); + }) + .execute() + .get(); + + httpClient.close(); + } +} diff --git a/src/test/java/org/xbib/netty/http/client/test/package-info.java b/src/test/java/org/xbib/netty/http/client/test/package-info.java new file mode 100644 index 0000000..6f8b69d --- /dev/null +++ b/src/test/java/org/xbib/netty/http/client/test/package-info.java @@ -0,0 +1,4 @@ +/** + * Classes for testing Netty HTTP client. + */ +package org.xbib.netty.http.client.test;