diff --git a/common-api/src/main/kotlin/com/itangcent/http/ApacheHttpClient.kt b/common-api/src/main/kotlin/com/itangcent/http/ApacheHttpClient.kt index 0788f7708..4a4f3e80e 100644 --- a/common-api/src/main/kotlin/com/itangcent/http/ApacheHttpClient.kt +++ b/common-api/src/main/kotlin/com/itangcent/http/ApacheHttpClient.kt @@ -52,11 +52,8 @@ open class ApacheHttpClient : HttpClient { private val httpClient: org.apache.http.client.HttpClient - constructor() { - val basicCookieStore = BasicCookieStore() - this.apacheCookieStore = ApacheCookieStore(basicCookieStore) - this.httpClientContext!!.cookieStore = basicCookieStore - this.httpClient = HttpClients.custom() + constructor() : this( + HttpClients.custom() .setConnectionManager(PoolingHttpClientConnectionManager().also { it.maxTotal = 50 it.defaultMaxPerRoute = 20 @@ -73,10 +70,8 @@ open class ApacheHttpClient : HttpClient { .setSocketTimeout(30 * 1000) .setCookieSpec(CookieSpecs.STANDARD).build() ) - .setSSLHostnameVerifier(NOOP_HOST_NAME_VERIFIER) - .setSSLSocketFactory(SSLSF) .build() - } + ) constructor(httpClient: org.apache.http.client.HttpClient) { val basicCookieStore = BasicCookieStore() @@ -187,13 +182,7 @@ open class ApacheHttpClient : HttpClient { } @ScriptTypeName("request") -class ApacheHttpRequest : AbstractHttpRequest { - - private val apacheHttpClient: ApacheHttpClient - - constructor(apacheHttpClient: ApacheHttpClient) : super() { - this.apacheHttpClient = apacheHttpClient - } +class ApacheHttpRequest(private val apacheHttpClient: ApacheHttpClient) : AbstractHttpRequest() { /** * Executes HTTP request using the [apacheHttpClient]. @@ -214,13 +203,7 @@ fun HttpRequest.contentType(contentType: ContentType): HttpRequest { * The implement of [CookieStore] by [org.apache.http.client.CookieStore]. */ @ScriptTypeName("cookieStore") -class ApacheCookieStore : CookieStore { - - private var cookieStore: org.apache.http.client.CookieStore - - constructor(cookieStore: org.apache.http.client.CookieStore) { - this.cookieStore = cookieStore - } +class ApacheCookieStore(private var cookieStore: org.apache.http.client.CookieStore) : CookieStore { /** * Adds an [Cookie], replacing any existing equivalent cookies. @@ -281,7 +264,7 @@ class ApacheHttpResponse( * * @return the status of the response, or {@code null} if not yet set */ - override fun code(): Int? { + override fun code(): Int { val statusLine = response.statusLine return statusLine.statusCode } @@ -314,9 +297,11 @@ class ApacheHttpResponse( } /** - * Cache the bytes message of this response. + * the bytes message of this response. */ - private var bytes: ByteArray? = null + private val bodyBytes: ByteArray by lazy { + response.entity.toByteArray() + } /** * Obtains the bytes message of this response. @@ -324,17 +309,8 @@ class ApacheHttpResponse( * @return the response bytes, or * {@code null} if there is none */ - override fun bytes(): ByteArray? { - if (bytes == null) { - synchronized(this) - { - if (bytes == null) { - val entity = response.entity - bytes = entity.toByteArray() - } - } - } - return bytes!! + override fun bytes(): ByteArray { + return bodyBytes } /** @@ -353,17 +329,12 @@ class ApacheHttpResponse( * The implement of [Cookie] by [org.apache.http.cookie.Cookie]. */ @ScriptTypeName("cookie") -class ApacheCookie : Cookie { - private val cookie: org.apache.http.cookie.Cookie +class ApacheCookie(private val cookie: org.apache.http.cookie.Cookie) : Cookie { fun getWrapper(): org.apache.http.cookie.Cookie { return cookie } - constructor(cookie: org.apache.http.cookie.Cookie) { - this.cookie = cookie - } - override fun getName(): String? { return cookie.name } @@ -404,7 +375,7 @@ class ApacheCookie : Cookie { return cookie.isSecure } - override fun getVersion(): Int? { + override fun getVersion(): Int { return cookie.version } diff --git a/common-api/src/main/kotlin/com/itangcent/http/HttpRequest.kt b/common-api/src/main/kotlin/com/itangcent/http/HttpRequest.kt index d3bc85e74..45683d71b 100644 --- a/common-api/src/main/kotlin/com/itangcent/http/HttpRequest.kt +++ b/common-api/src/main/kotlin/com/itangcent/http/HttpRequest.kt @@ -82,14 +82,20 @@ interface Cookie { * {@code null} if no such comment has been defined. * Compatible only.Obsolete. * @return comment + * + * @deprecated it is only supported by Apache HttpClient */ + @Deprecated("Obsolete") fun getComment(): String? /** * If a user agent (web browser) presents this cookie to a user, the * cookie's purpose will be described by the information at this URL. * Compatible only.Obsolete. + * + * @deprecated it is only supported by Apache HttpClient */ + @Deprecated("Obsolete") fun getCommentURL(): String? /** @@ -129,7 +135,10 @@ interface Cookie { /** * Get the Port attribute. It restricts the ports to which a cookie * may be returned in a Cookie request header. + * + * @deprecated it is only supported by Apache HttpClient */ + @Deprecated("Obsolete") fun getPorts(): IntArray? /** @@ -146,10 +155,17 @@ interface Cookie { * Compatible only.Obsolete. * * @return the version of the cookie. + * @deprecated it is only supported by Apache HttpClient */ + @Deprecated("Obsolete") fun getVersion(): Int? } +fun Cookie.isExpired(): Boolean { + val expiryDate = this.getExpiryDate() + return expiryDate != null && expiryDate < System.currentTimeMillis() +} + @ScriptTypeName("cookie") interface MutableCookie : Cookie { @@ -157,8 +173,16 @@ interface MutableCookie : Cookie { fun setValue(value: String?) + /** + * @deprecated it is only supported by Apache HttpClient + */ + @Deprecated("Obsolete") fun setComment(comment: String?) + /** + * @deprecated it is only supported by Apache HttpClient + */ + @Deprecated("Obsolete") fun setCommentURL(commentURL: String?) /** @@ -193,7 +217,10 @@ interface MutableCookie : Cookie { * Sets the Port attribute. It restricts the ports to which a cookie * may be returned in a Cookie request header. * Compatible only.Obsolete. + * + * @deprecated it is only supported by Apache HttpClient */ + @Deprecated("Obsolete") fun setPorts(ports: IntArray?) /** @@ -218,7 +245,10 @@ interface MutableCookie : Cookie { * @param version the version of the cookie. * * @see Cookie.getVersion + * + * @deprecated it is only supported by Apache HttpClient */ + @Deprecated("Obsolete") fun setVersion(version: Int?) } diff --git a/common-api/src/test/kotlin/com/itangcent/http/ApacheHttpClientTest.kt b/common-api/src/test/kotlin/com/itangcent/http/ApacheHttpClientTest.kt index 634014b48..741914fed 100644 --- a/common-api/src/test/kotlin/com/itangcent/http/ApacheHttpClientTest.kt +++ b/common-api/src/test/kotlin/com/itangcent/http/ApacheHttpClientTest.kt @@ -350,309 +350,309 @@ class ApacheHttpClientTest { //skip test if connect timed out } } - - open class AbstractCallTest { - - protected lateinit var httpClient: org.apache.http.client.HttpClient - protected lateinit var httpResponse: HttpResponse - protected lateinit var httpEntity: HttpEntity - - protected var responseCode: Int = 200 - protected lateinit var responseBody: String - protected lateinit var responseHeaders: Array> - protected lateinit var responseCharset: Charset - - protected lateinit var httpUriRequest: HttpUriRequest - protected var closed: Boolean = false - - @BeforeEach - fun setUp() { - //by default - responseCode = 200 - responseBody = "{}" - responseHeaders = arrayOf() - responseCharset = Charsets.UTF_8 - closed = false - - httpClient = mock() - httpResponse = mock(extraInterfaces = arrayOf(Closeable::class)) - httpEntity = mock() - - httpClient.stub { - this.on(httpClient.execute(any(), any())) - .doAnswer { - httpUriRequest = it.getArgument(0) - httpResponse - } - } - (httpResponse as Closeable).stub { - this.on((httpResponse as Closeable).close()) - .doAnswer { - closed = true - } - } - httpResponse.stub { - this.on(httpResponse.statusLine) - .doAnswer { BasicStatusLine(HttpVersion.HTTP_1_0, responseCode, "") } - this.on(httpResponse.entity) - .thenReturn(httpEntity) - this.on(httpResponse.allHeaders) - .doAnswer { - responseHeaders.mapToTypedArray { - org.apache.http.message.BasicHeader( - it.first, - it.second - ) - } +} + +open class AbstractCallTest { + + protected lateinit var httpClient: org.apache.http.client.HttpClient + protected lateinit var httpResponse: HttpResponse + protected lateinit var httpEntity: HttpEntity + + protected var responseCode: Int = 200 + protected lateinit var responseBody: String + protected lateinit var responseHeaders: Array> + protected lateinit var responseCharset: Charset + + protected lateinit var httpUriRequest: HttpUriRequest + protected var closed: Boolean = false + + @BeforeEach + fun setUp() { + //by default + responseCode = 200 + responseBody = "{}" + responseHeaders = arrayOf() + responseCharset = Charsets.UTF_8 + closed = false + + httpClient = mock() + httpResponse = mock(extraInterfaces = arrayOf(Closeable::class)) + httpEntity = mock() + + httpClient.stub { + this.on(httpClient.execute(any(), any())) + .doAnswer { + httpUriRequest = it.getArgument(0) + httpResponse + } + } + (httpResponse as Closeable).stub { + this.on((httpResponse as Closeable).close()) + .doAnswer { + closed = true + } + } + httpResponse.stub { + this.on(httpResponse.statusLine) + .doAnswer { BasicStatusLine(HttpVersion.HTTP_1_0, responseCode, "") } + this.on(httpResponse.entity) + .thenReturn(httpEntity) + this.on(httpResponse.allHeaders) + .doAnswer { + responseHeaders.mapToTypedArray { + org.apache.http.message.BasicHeader( + it.first, + it.second + ) } - } - httpEntity.stub { - this.on(httpEntity.content) - .doAnswer { responseBody.byteInputStream(responseCharset) } - } + } + } + httpEntity.stub { + this.on(httpEntity.content) + .doAnswer { responseBody.byteInputStream(responseCharset) } } } +} - open class CallTest : AbstractCallTest() { +open class CallTest : AbstractCallTest() { - @Test - fun testCallPostJson() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf( - "Content-type" to "application/json;charset=UTF-8", - "x-token" to "123", "x-token" to "987", - "Content-Disposition" to "attachment; filename=\"test.json\"" - ) + @Test + fun testCallPostJson() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf( + "Content-type" to "application/json;charset=UTF-8", + "x-token" to "123", "x-token" to "987", + "Content-Disposition" to "attachment; filename=\"test.json\"" + ) + + val httpClient = ApacheHttpClient(this.httpClient) + val httpRequest = httpClient + .post("https://www.apache.org/licenses/LICENSE-2.0") + .param("hello", "hello") + .contentType("application/json") + .body("hello") + val httpResponse = httpRequest + .call() + assertSame(httpRequest, httpResponse.request()) + + assertTrue(httpUriRequest is HttpEntityEnclosingRequest) + val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) + assertTrue(httpUriRequest.entity is StringEntity) + + assertEquals(200, httpResponse.code()) + assertEquals("ok", httpResponse.string()) + assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) + + assertEquals(true, httpResponse.containsHeader("Content-type")) + assertEquals(false, httpResponse.containsHeader("y-token")) + assertEquals("application/json;charset=UTF-8", httpResponse.contentType()) + assertEquals("123", httpResponse.firstHeader("x-token")) + assertEquals("987", httpResponse.lastHeader("x-token")) + assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) + assertEquals("test.json", httpResponse.getHeaderFileName()) + httpResponse.close() + assertTrue(closed) + } - val httpClient = ApacheHttpClient(this.httpClient) - val httpRequest = httpClient - .post("https://www.apache.org/licenses/LICENSE-2.0") - .param("hello", "hello") - .contentType("application/json") - .body("hello") - val httpResponse = httpRequest - .call() - assertSame(httpRequest, httpResponse.request()) - - assertTrue(httpUriRequest is HttpEntityEnclosingRequest) - val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) - assertTrue(httpUriRequest.entity is StringEntity) - - assertEquals(200, httpResponse.code()) - assertEquals("ok", httpResponse.string()) - assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) - - assertEquals(true, httpResponse.containsHeader("Content-type")) - assertEquals(false, httpResponse.containsHeader("y-token")) - assertEquals("application/json;charset=UTF-8", httpResponse.contentType()) - assertEquals("123", httpResponse.firstHeader("x-token")) - assertEquals("987", httpResponse.lastHeader("x-token")) - assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) - assertEquals("test.json", httpResponse.getHeaderFileName()) - httpResponse.close() - assertTrue(closed) - } + @Test + fun testCallPostFormData() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf( + "Content-type" to "application/json;charset=UTF-8", + "x-token" to "123", "x-token" to "987", + "Content-Disposition" to "attachment; filename=\"\"" + ) + + val httpClient = ApacheHttpClient(this.httpClient) + val httpRequest = httpClient + .post("https://www.apache.org/licenses/LICENSE-2.0") + .contentType(ContentType.MULTIPART_FORM_DATA) + .param("hello", "hello") + val httpResponse = httpRequest + .call() + assertSame(httpRequest, httpResponse.request()) + assertEquals(200, httpResponse.code()) + assertEquals("ok", httpResponse.string()) + assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) + + assertTrue(httpUriRequest is HttpEntityEnclosingRequest) + val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) + assertEquals("org.apache.http.entity.mime.MultipartFormEntity", httpUriRequest.entity::class.qualifiedName) + + assertEquals(true, httpResponse.containsHeader("Content-type")) + assertEquals(false, httpResponse.containsHeader("y-token")) + assertEquals("application/json;charset=UTF-8", httpResponse.contentType()) + assertEquals("123", httpResponse.firstHeader("x-token")) + assertEquals("987", httpResponse.lastHeader("x-token")) + assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) + assertNull(httpResponse.getHeaderFileName()) + httpResponse.close() + assertTrue(closed) + } - @Test - fun testCallPostFormData() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf( - "Content-type" to "application/json;charset=UTF-8", - "x-token" to "123", "x-token" to "987", - "Content-Disposition" to "attachment; filename=\"\"" - ) + @Test + fun testCallPostUrlencoded() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf( + "Content-type" to "application/json;charset=UTF-8", + "x-token" to "123", "x-token" to "987" + ) + + val httpClient = ApacheHttpClient(this.httpClient) + val httpRequest = httpClient + .post("https://www.apache.org/licenses/LICENSE-2.0") + .contentType(ContentType.APPLICATION_FORM_URLENCODED) + .param("hello", "hello") + val httpResponse = httpRequest + .call() + assertSame(httpRequest, httpResponse.request()) + assertEquals(200, httpResponse.code()) + assertEquals("ok", httpResponse.string()) + assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) + + assertTrue(httpUriRequest is HttpEntityEnclosingRequest) + val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) + assertTrue(httpUriRequest.entity is UrlEncodedFormEntity) + + assertEquals(true, httpResponse.containsHeader("Content-type")) + assertEquals(false, httpResponse.containsHeader("y-token")) + assertEquals("application/json;charset=UTF-8", httpResponse.contentType()) + assertEquals("123", httpResponse.firstHeader("x-token")) + assertEquals("987", httpResponse.lastHeader("x-token")) + assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) + assertNull(httpResponse.getHeaderFileName()) + httpResponse.close() + assertTrue(closed) + } + + @Test + fun testCallPostBodyOverForm() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf( + "Content-type" to "application/json", + "x-token" to "123", "x-token" to "987", + "Content-Disposition" to "attachment; filename=\"test.json\"" + ) + + val httpClient = ApacheHttpClient(this.httpClient) + val httpRequest = httpClient + .post("https://www.apache.org/licenses/LICENSE-2.0") + .contentType(ContentType.MULTIPART_FORM_DATA) + .param("hello", "hello") + .body("hello") + val httpResponse = httpRequest + .call() + assertSame(httpRequest, httpResponse.request()) + + assertTrue(httpUriRequest is HttpEntityEnclosingRequest) + val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) + assertTrue(httpUriRequest.entity is StringEntity) + + assertEquals(200, httpResponse.code()) + assertEquals("ok", httpResponse.string()) + assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) + + assertEquals(true, httpResponse.containsHeader("Content-type")) + assertEquals(false, httpResponse.containsHeader("y-token")) + assertEquals("application/json", httpResponse.contentType()) + assertEquals("123", httpResponse.firstHeader("x-token")) + assertEquals("987", httpResponse.lastHeader("x-token")) + assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) + assertEquals("test.json", httpResponse.getHeaderFileName()) + httpResponse.close() + assertTrue(closed) + } + + @Test + fun testUrlWithOutQuestionMark() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf("Content-type" to "application/json;charset=UTF-8") + + val httpClient = ApacheHttpClient(this.httpClient) + val httpRequest = httpClient + .get("https://www.apache.org/licenses/LICENSE-2.0") + .query("x", "1") + .query("y", "2") + .contentType("application/json") + val httpResponse = httpRequest + .call() + assertSame(httpRequest, httpResponse.request()) + + assertFalse(httpUriRequest is HttpEntityEnclosingRequest) + assertEquals("https://www.apache.org/licenses/LICENSE-2.0?x=1&y=2", httpUriRequest.uri.toString()) + httpResponse.close() + assertTrue(closed) + } - val httpClient = ApacheHttpClient(this.httpClient) - val httpRequest = httpClient + @Test + fun testUrlWithQuestionMark() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf("Content-type" to "application/json;charset=UTF-8") + + val httpClient = ApacheHttpClient(this.httpClient) + val httpRequest = httpClient + .get("https://www.apache.org/licenses/LICENSE-2.0?x=1") + .query("y", "2") + .contentType("application/json") + val httpResponse = httpRequest + .call() + assertSame(httpRequest, httpResponse.request()) + + assertFalse(httpUriRequest is HttpEntityEnclosingRequest) + assertEquals("https://www.apache.org/licenses/LICENSE-2.0?x=1&y=2", httpUriRequest.uri.toString()) + httpResponse.close() + assertTrue(closed) + } +} + +class PostFileTest : AbstractCallTest() { + + @JvmField + @TempDir + var tempDir: Path? = null + + @Test + fun testCallPostFileFormData() { + responseCode = 200 + responseBody = "ok" + responseHeaders = arrayOf("Content-type" to "application/json;charset=UTF-8") + + assertThrows { + ApacheHttpClient(this.httpClient) .post("https://www.apache.org/licenses/LICENSE-2.0") .contentType(ContentType.MULTIPART_FORM_DATA) .param("hello", "hello") - val httpResponse = httpRequest + .fileParam("file", "${tempDir}/a.txt") .call() - assertSame(httpRequest, httpResponse.request()) - assertEquals(200, httpResponse.code()) - assertEquals("ok", httpResponse.string()) - assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) - - assertTrue(httpUriRequest is HttpEntityEnclosingRequest) - val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) - assertEquals("org.apache.http.entity.mime.MultipartFormEntity", httpUriRequest.entity::class.qualifiedName) - - assertEquals(true, httpResponse.containsHeader("Content-type")) - assertEquals(false, httpResponse.containsHeader("y-token")) - assertEquals("application/json;charset=UTF-8", httpResponse.contentType()) - assertEquals("123", httpResponse.firstHeader("x-token")) - assertEquals("987", httpResponse.lastHeader("x-token")) - assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) - assertNull(httpResponse.getHeaderFileName()) - httpResponse.close() - assertTrue(closed) } - @Test - fun testCallPostUrlencoded() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf( - "Content-type" to "application/json;charset=UTF-8", - "x-token" to "123", "x-token" to "987" - ) - - val httpClient = ApacheHttpClient(this.httpClient) - val httpRequest = httpClient + FileUtils.forceMkdir(File("${tempDir}/a")) + assertThrows { + ApacheHttpClient(this.httpClient) .post("https://www.apache.org/licenses/LICENSE-2.0") - .contentType(ContentType.APPLICATION_FORM_URLENCODED) + .contentType(ContentType.MULTIPART_FORM_DATA) .param("hello", "hello") - val httpResponse = httpRequest + .fileParam("file", "${tempDir}/a") .call() - assertSame(httpRequest, httpResponse.request()) - assertEquals(200, httpResponse.code()) - assertEquals("ok", httpResponse.string()) - assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) - - assertTrue(httpUriRequest is HttpEntityEnclosingRequest) - val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) - assertTrue(httpUriRequest.entity is UrlEncodedFormEntity) - - assertEquals(true, httpResponse.containsHeader("Content-type")) - assertEquals(false, httpResponse.containsHeader("y-token")) - assertEquals("application/json;charset=UTF-8", httpResponse.contentType()) - assertEquals("123", httpResponse.firstHeader("x-token")) - assertEquals("987", httpResponse.lastHeader("x-token")) - assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) - assertNull(httpResponse.getHeaderFileName()) - httpResponse.close() - assertTrue(closed) } - @Test - fun testCallPostBodyOverForm() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf( - "Content-type" to "application/json", - "x-token" to "123", "x-token" to "987", - "Content-Disposition" to "attachment; filename=\"test.json\"" - ) - - val httpClient = ApacheHttpClient(this.httpClient) - val httpRequest = httpClient + val txtFile = File("${tempDir}/a/a.txt") + FileUtils.forceMkdirParent(txtFile) + FileUtils.write(txtFile, "abc") + assertDoesNotThrow { + ApacheHttpClient(this.httpClient) .post("https://www.apache.org/licenses/LICENSE-2.0") .contentType(ContentType.MULTIPART_FORM_DATA) .param("hello", "hello") - .body("hello") - val httpResponse = httpRequest + .fileParam("file", "${tempDir}/a/a.txt") + .fileParam("file", null) .call() - assertSame(httpRequest, httpResponse.request()) - - assertTrue(httpUriRequest is HttpEntityEnclosingRequest) - val httpUriRequest = (this.httpUriRequest as HttpEntityEnclosingRequest) - assertTrue(httpUriRequest.entity is StringEntity) - - assertEquals(200, httpResponse.code()) - assertEquals("ok", httpResponse.string()) - assertEquals("ok", httpResponse.stream().readString(Charsets.UTF_8)) - - assertEquals(true, httpResponse.containsHeader("Content-type")) - assertEquals(false, httpResponse.containsHeader("y-token")) - assertEquals("application/json", httpResponse.contentType()) - assertEquals("123", httpResponse.firstHeader("x-token")) - assertEquals("987", httpResponse.lastHeader("x-token")) - assertArrayEquals(arrayOf("123", "987"), httpResponse.headers("x-token")) - assertEquals("test.json", httpResponse.getHeaderFileName()) - httpResponse.close() - assertTrue(closed) - } - - @Test - fun testUrlWithOutQuestionMark() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf("Content-type" to "application/json;charset=UTF-8") - - val httpClient = ApacheHttpClient(this.httpClient) - val httpRequest = httpClient - .get("https://www.apache.org/licenses/LICENSE-2.0") - .query("x", "1") - .query("y", "2") - .contentType("application/json") - val httpResponse = httpRequest - .call() - assertSame(httpRequest, httpResponse.request()) - - assertFalse(httpUriRequest is HttpEntityEnclosingRequest) - assertEquals("https://www.apache.org/licenses/LICENSE-2.0?x=1&y=2", httpUriRequest.uri.toString()) - httpResponse.close() - assertTrue(closed) - } - - @Test - fun testUrlWithQuestionMark() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf("Content-type" to "application/json;charset=UTF-8") - - val httpClient = ApacheHttpClient(this.httpClient) - val httpRequest = httpClient - .get("https://www.apache.org/licenses/LICENSE-2.0?x=1") - .query("y", "2") - .contentType("application/json") - val httpResponse = httpRequest - .call() - assertSame(httpRequest, httpResponse.request()) - - assertFalse(httpUriRequest is HttpEntityEnclosingRequest) - assertEquals("https://www.apache.org/licenses/LICENSE-2.0?x=1&y=2", httpUriRequest.uri.toString()) - httpResponse.close() - assertTrue(closed) - } - } - - class PostFileTest : AbstractCallTest() { - - @JvmField - @TempDir - var tempDir: Path? = null - - @Test - fun testCallPostFileFormData() { - responseCode = 200 - responseBody = "ok" - responseHeaders = arrayOf("Content-type" to "application/json;charset=UTF-8") - - assertThrows { - ApacheHttpClient(this.httpClient) - .post("https://www.apache.org/licenses/LICENSE-2.0") - .contentType(ContentType.MULTIPART_FORM_DATA) - .param("hello", "hello") - .fileParam("file", "${tempDir}/a.txt") - .call() - } - - FileUtils.forceMkdir(File("${tempDir}/a")) - assertThrows { - ApacheHttpClient(this.httpClient) - .post("https://www.apache.org/licenses/LICENSE-2.0") - .contentType(ContentType.MULTIPART_FORM_DATA) - .param("hello", "hello") - .fileParam("file", "${tempDir}/a") - .call() - } - - val txtFile = File("${tempDir}/a/a.txt") - FileUtils.forceMkdirParent(txtFile) - FileUtils.write(txtFile, "abc") - assertDoesNotThrow { - ApacheHttpClient(this.httpClient) - .post("https://www.apache.org/licenses/LICENSE-2.0") - .contentType(ContentType.MULTIPART_FORM_DATA) - .param("hello", "hello") - .fileParam("file", "${tempDir}/a/a.txt") - .fileParam("file", null) - .call() - } } } } \ No newline at end of file diff --git a/idea-plugin/build.gradle.kts b/idea-plugin/build.gradle.kts index b99dfe1f9..21bc1d88b 100644 --- a/idea-plugin/build.gradle.kts +++ b/idea-plugin/build.gradle.kts @@ -78,6 +78,10 @@ dependencies { // https://mvnrepository.com/artifact/org.xerial/sqlite-jdbc implementation("org.xerial:sqlite-jdbc:3.34.0") + // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // https://search.maven.org/artifact/org.mockito.kotlin/mockito-kotlin/3.2.0/jar testImplementation("org.mockito.kotlin:mockito-kotlin:3.2.0") diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiDashBoardAction.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiDashBoardAction.kt index da7e6a7da..d37b501b9 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiDashBoardAction.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiDashBoardAction.kt @@ -28,7 +28,7 @@ class YapiDashBoardAction : ApiExportAction("YapiDashBoard") { builder.bind(YapiDashBoard::class) { it.singleton() } builder.bind(YapiApiDashBoardExporter::class) { it.singleton() } - builder.bind(YapiApiHelper::class) { it.with(YapiCachedApiHelper::class).singleton() } + builder.bind(YapiApiHelper::class) { it.with(CachedYapiApiHelper::class).singleton() } builder.bind(HttpClientProvider::class) { it.with(ConfigurableHttpClientProvider::class).singleton() } //allow cache api diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiExportAction.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiExportAction.kt index 266a2e318..19d5dca6a 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiExportAction.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/actions/YapiExportAction.kt @@ -26,7 +26,7 @@ class YapiExportAction : ApiExportAction("Export Yapi") { builder.bind(HttpClientProvider::class) { it.with(ConfigurableHttpClientProvider::class).singleton() } builder.bind(LinkResolver::class) { it.with(YapiLinkResolver::class).singleton() } - builder.bind(YapiApiHelper::class) { it.with(YapiCachedApiHelper::class).singleton() } + builder.bind(YapiApiHelper::class) { it.with(CachedYapiApiHelper::class).singleton() } builder.bind(ClassExporter::class) { it.with(CompositeClassExporter::class).singleton() } diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/suv/SuvApiExporter.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/suv/SuvApiExporter.kt index 3058f8891..3463543dc 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/suv/SuvApiExporter.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/suv/SuvApiExporter.kt @@ -407,7 +407,7 @@ open class SuvApiExporter { builder.bind(LocalFileRepository::class) { it.with(DefaultLocalFileRepository::class).singleton() } - builder.bind(YapiApiHelper::class) { it.with(YapiCachedApiHelper::class).singleton() } + builder.bind(YapiApiHelper::class) { it.with(CachedYapiApiHelper::class).singleton() } builder.bind(HttpClientProvider::class) { it.with(ConfigurableHttpClientProvider::class).singleton() } builder.bind(LinkResolver::class) { it.with(YapiLinkResolver::class).singleton() } diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/yapi/YapiCachedApiHelper.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/yapi/CachedYapiApiHelper.kt similarity index 97% rename from idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/yapi/YapiCachedApiHelper.kt rename to idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/yapi/CachedYapiApiHelper.kt index 1a9647c7d..f5e57f95c 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/yapi/YapiCachedApiHelper.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/yapi/CachedYapiApiHelper.kt @@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap * cache: * projectToken -> projectId */ -open class YapiCachedApiHelper : DefaultYapiApiHelper() { +open class CachedYapiApiHelper : DefaultYapiApiHelper() { @Inject private val localFileRepository: LocalFileRepository? = null diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.form b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.form index 65ceacfb6..cdab274e4 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.form +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.form @@ -3,7 +3,7 @@ - + @@ -1407,7 +1407,7 @@ - + @@ -1418,7 +1418,7 @@ - + @@ -1452,7 +1452,7 @@ - + @@ -1470,9 +1470,9 @@ - + - + @@ -1496,6 +1496,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.kt index 91344e7ce..962d00c8c 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/dialog/EasyApiSettingGUI.kt @@ -29,8 +29,6 @@ import com.itangcent.idea.utils.isDoubleClick import com.itangcent.intellij.context.ActionContext import com.itangcent.intellij.extend.rx.ThrottleHelper import com.itangcent.intellij.logger.Logger -import com.itangcent.suv.http.ConfigurableHttpClientProvider -import com.itangcent.common.utils.ResourceUtils import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.event.MouseListener @@ -165,6 +163,10 @@ class EasyApiSettingGUI : AbstractEasyApiSettingGUI() { private var recommendedCheckBox: JCheckBox? = null + private var unsafeSslCheckBox: JCheckBox? = null + + private var httpClientComboBox: JComboBox? = null + private var httpTimeOutTextField: JTextField? = null private var trustHostsTextArea: JTextArea? = null @@ -253,6 +255,9 @@ class EasyApiSettingGUI : AbstractEasyApiSettingGUI() { markdownFormatTypeComboBox!!.model = DefaultComboBoxModel(MarkdownFormatType.values().mapToTypedArray { it.name }) + httpClientComboBox!!.model = + DefaultComboBoxModel(HttpClientType.values().mapToTypedArray { it.name.lowercase().capitalize() }) + //endregion general----------------------------------------------------- } @@ -300,6 +305,8 @@ class EasyApiSettingGUI : AbstractEasyApiSettingGUI() { this.trustHostsTextArea!!.text = settings.trustHosts.joinToString(separator = "\n") this.maxDeepTextField!!.text = settings.inferMaxDeep.toString() + this.unsafeSslCheckBox!!.isSelected = settings.unsafeSsl + this.httpClientComboBox!!.selectedItem = settings.httpClient this.httpTimeOutTextField!!.text = settings.httpTimeOut.toString() refresh() @@ -606,8 +613,9 @@ class EasyApiSettingGUI : AbstractEasyApiSettingGUI() { settings.yapiExportMode = yapiExportModeComboBox!!.selectedItem as? String ?: YapiExportMode.ALWAYS_UPDATE.name settings.yapiReqBodyJson5 = yapiReqBodyJson5CheckBox!!.isSelected settings.yapiResBodyJson5 = yapiResBodyJson5CheckBox!!.isSelected - settings.httpTimeOut = - httpTimeOutTextField!!.text.toIntOrNull() ?: ConfigurableHttpClientProvider.defaultHttpTimeOut + settings.unsafeSsl = unsafeSslCheckBox!!.isSelected + settings.httpClient = httpClientComboBox!!.selectedItem as? String ?: HttpClientType.APACHE.name + settings.httpTimeOut = httpTimeOutTextField!!.text.toIntOrNull() ?: 10 settings.useRecommendConfig = recommendedCheckBox!!.isSelected settings.logLevel = (logLevelComboBox!!.selected() ?: CommonSettingsHelper.CoarseLogLevel.LOW).getLevel() diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/HttpClientType.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/HttpClientType.kt new file mode 100644 index 000000000..04f32f32c --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/HttpClientType.kt @@ -0,0 +1,10 @@ +package com.itangcent.idea.plugin.settings + +/** + * @author joe.wu + * @date 2024/04/28 + */ +enum class HttpClientType { + APACHE, + OKHTTP, +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/Settings.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/Settings.kt index ae00b7f4e..d13ff75ed 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/Settings.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/Settings.kt @@ -82,6 +82,10 @@ class Settings : ProjectSettingsSupport, ApplicationSettingsSupport { override var trustHosts: Array = DEFAULT_TRUST_HOSTS + override var unsafeSsl: Boolean = false + + override var httpClient: String = "Apache" + //endregion /** @@ -156,6 +160,8 @@ class Settings : ProjectSettingsSupport, ApplicationSettingsSupport { if (yapiReqBodyJson5 != other.yapiReqBodyJson5) return false if (yapiResBodyJson5 != other.yapiResBodyJson5) return false if (httpTimeOut != other.httpTimeOut) return false + if (unsafeSsl != other.unsafeSsl) return false + if (httpClient != other.httpClient) return false if (!trustHosts.contentEquals(other.trustHosts)) return false if (useRecommendConfig != other.useRecommendConfig) return false if (recommendConfigs != other.recommendConfigs) return false @@ -201,6 +207,8 @@ class Settings : ProjectSettingsSupport, ApplicationSettingsSupport { result = 31 * result + yapiReqBodyJson5.hashCode() result = 31 * result + yapiResBodyJson5.hashCode() result = 31 * result + httpTimeOut + result = 31 * result + unsafeSsl.hashCode() + result = 31 * result + httpClient.hashCode() result = 31 * result + trustHosts.contentHashCode() result = 31 * result + useRecommendConfig.hashCode() result = 31 * result + recommendConfigs.hashCode() @@ -215,7 +223,19 @@ class Settings : ProjectSettingsSupport, ApplicationSettingsSupport { } override fun toString(): String { - return "Settings(methodDocEnable=$methodDocEnable, genericEnable=$genericEnable, feignEnable=$feignEnable, jaxrsEnable=$jaxrsEnable, actuatorEnable=$actuatorEnable, pullNewestDataBefore=$pullNewestDataBefore, postmanToken=$postmanToken, postmanWorkspace=$postmanWorkspace, postmanExportMode=$postmanExportMode, postmanCollections=$postmanCollections, wrapCollection=$wrapCollection, autoMergeScript=$autoMergeScript, postmanJson5FormatType='$postmanJson5FormatType', queryExpanded=$queryExpanded, formExpanded=$formExpanded, readGetter=$readGetter, readSetter=$readSetter, inferEnable=$inferEnable, inferMaxDeep=$inferMaxDeep, selectedOnly=$selectedOnly, yapiServer=$yapiServer, yapiTokens=$yapiTokens, enableUrlTemplating=$enableUrlTemplating, switchNotice=$switchNotice, loginMode=$loginMode, yapiExportMode=$yapiExportMode, yapiReqBodyJson5=$yapiReqBodyJson5, yapiResBodyJson5=$yapiResBodyJson5, httpTimeOut=$httpTimeOut, trustHosts=${trustHosts.contentToString()}, useRecommendConfig=$useRecommendConfig, recommendConfigs='$recommendConfigs', logLevel=$logLevel, logCharset='$logCharset', outputDemo=$outputDemo, outputCharset='$outputCharset', markdownFormatType='$markdownFormatType', builtInConfig=$builtInConfig), remoteConfig=$remoteConfig)" + return "Settings(methodDocEnable=$methodDocEnable, genericEnable=$genericEnable, feignEnable=$feignEnable," + + " jaxrsEnable=$jaxrsEnable, actuatorEnable=$actuatorEnable, pullNewestDataBefore=$pullNewestDataBefore," + + " postmanToken=$postmanToken, postmanWorkspace=$postmanWorkspace, postmanExportMode=$postmanExportMode," + + " postmanCollections=$postmanCollections, wrapCollection=$wrapCollection, autoMergeScript=$autoMergeScript," + + " postmanJson5FormatType='$postmanJson5FormatType', queryExpanded=$queryExpanded, formExpanded=$formExpanded," + + " readGetter=$readGetter, readSetter=$readSetter, inferEnable=$inferEnable, inferMaxDeep=$inferMaxDeep," + + " selectedOnly=$selectedOnly, yapiServer=$yapiServer, yapiTokens=$yapiTokens, enableUrlTemplating=$enableUrlTemplating," + + " switchNotice=$switchNotice, loginMode=$loginMode, yapiExportMode=$yapiExportMode, yapiReqBodyJson5=$yapiReqBodyJson5," + + " yapiResBodyJson5=$yapiResBodyJson5, httpTimeOut=$httpTimeOut, unsafeSsl=$unsafeSsl, " + + " httpClient='$httpClient', trustHosts=${trustHosts.contentToString()}," + + " useRecommendConfig=$useRecommendConfig, recommendConfigs='$recommendConfigs', logLevel=$logLevel, logCharset='$logCharset'," + + " outputDemo=$outputDemo, outputCharset='$outputCharset', markdownFormatType='$markdownFormatType'," + + " builtInConfig=$builtInConfig), remoteConfig=$remoteConfig)" } companion object { diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/helper/HttpSettingsHelper.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/helper/HttpSettingsHelper.kt index bfa17711c..a54570e1c 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/helper/HttpSettingsHelper.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/helper/HttpSettingsHelper.kt @@ -3,10 +3,12 @@ package com.itangcent.idea.plugin.settings.helper import com.google.inject.Inject import com.google.inject.Singleton import com.intellij.openapi.ui.Messages +import com.itangcent.idea.plugin.settings.HttpClientType import com.itangcent.idea.plugin.settings.SettingBinder import com.itangcent.idea.plugin.settings.update import com.itangcent.idea.plugin.utils.RegexUtils import com.itangcent.idea.swing.MessagesHelper +import com.itangcent.intellij.logger.Logger import java.net.URL import java.util.concurrent.TimeUnit @@ -19,6 +21,9 @@ class HttpSettingsHelper { @Inject private lateinit var messagesHelper: MessagesHelper + @Inject + private lateinit var logger: Logger + //region trustHosts---------------------------------------------------- fun checkTrustUrl(url: String, dumb: Boolean = true): Boolean { @@ -48,12 +53,15 @@ class HttpSettingsHelper { return false } if (settings.yapiServer == host - || trustHosts.contains(host)) { + || trustHosts.contains(host) + ) { return true } if (!dumb) { - val trustRet = messagesHelper.showYesNoDialog("Do you trust [$host]?", - "Trust Host", Messages.getQuestionIcon()) + val trustRet = messagesHelper.showYesNoDialog( + "Do you trust [$host]?", + "Trust Host", Messages.getQuestionIcon() + ) return if (trustRet == Messages.YES) { addTrustHost(host) @@ -104,12 +112,29 @@ class HttpSettingsHelper { return timeUnit.convert(settingBinder.read().httpTimeOut.toLong(), TimeUnit.SECONDS).toInt() } + fun unsafeSsl(): Boolean { + return settingBinder.read().unsafeSsl + } + + fun httpClientType(): HttpClientType { + return settingBinder.read().httpClient.uppercase().let { + try { + HttpClientType.valueOf(it) + } catch (e: Exception) { + logger.error("invalid httpClient type:$it") + HttpClientType.APACHE + } + } + } + companion object { val HOST_RESOLVERS: Array<(String) -> String?> = arrayOf({ if (it.startsWith("https://raw.githubusercontent.com")) { val url = if (it.endsWith("/")) it else "$it/" - return@arrayOf RegexUtils.extract("https://raw.githubusercontent.com/(.*?)/.*", - url, "https://raw.githubusercontent.com/$1") + return@arrayOf RegexUtils.extract( + "https://raw.githubusercontent.com/(.*?)/.*", + url, "https://raw.githubusercontent.com/$1" + ) } return@arrayOf null }) diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/xml/ApplicationSettings.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/xml/ApplicationSettings.kt index fc572d4c3..e2b4b9f59 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/xml/ApplicationSettings.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/settings/xml/ApplicationSettings.kt @@ -38,6 +38,9 @@ interface ApplicationSettingsSupport { //unit:s var httpTimeOut: Int var trustHosts: Array + var unsafeSsl: Boolean + var httpClient: String + //enable to use recommend config var useRecommendConfig: Boolean @@ -77,7 +80,6 @@ interface ApplicationSettingsSupport { newSetting.yapiExportMode = this.yapiExportMode newSetting.yapiReqBodyJson5 = this.yapiReqBodyJson5 newSetting.yapiResBodyJson5 = this.yapiResBodyJson5 - newSetting.httpTimeOut = this.httpTimeOut newSetting.useRecommendConfig = this.useRecommendConfig newSetting.recommendConfigs = this.recommendConfigs newSetting.logLevel = this.logLevel @@ -86,6 +88,9 @@ interface ApplicationSettingsSupport { newSetting.outputCharset = this.outputCharset newSetting.markdownFormatType = this.markdownFormatType newSetting.builtInConfig = this.builtInConfig + newSetting.httpTimeOut = this.httpTimeOut + newSetting.unsafeSsl = this.unsafeSsl + newSetting.httpClient = this.httpClient newSetting.trustHosts = this.trustHosts newSetting.remoteConfig = this.remoteConfig } @@ -158,6 +163,10 @@ class ApplicationSettings : ApplicationSettingsSupport { override var trustHosts: Array = Settings.DEFAULT_TRUST_HOSTS + override var unsafeSsl: Boolean = false + + override var httpClient: String = "Apache" + //endregion //enable to use recommend config diff --git a/idea-plugin/src/main/kotlin/com/itangcent/idea/utils/OkHttpClient.kt b/idea-plugin/src/main/kotlin/com/itangcent/idea/utils/OkHttpClient.kt new file mode 100644 index 000000000..cbe2f4751 --- /dev/null +++ b/idea-plugin/src/main/kotlin/com/itangcent/idea/utils/OkHttpClient.kt @@ -0,0 +1,304 @@ +package com.itangcent.idea.utils + +import com.itangcent.annotation.script.ScriptTypeName +import com.itangcent.common.utils.GsonUtils +import com.itangcent.http.* +import com.itangcent.http.Cookie +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * @author joe.wu + * @date 2024/04/27 + */ +@ScriptTypeName("httpClient") +class OkHttpClient : HttpClient { + + private val cookieStore: OkHttpCookieStore = OkHttpCookieStore() + + private val client: okhttp3.OkHttpClient + + constructor(clientBuilder: okhttp3.OkHttpClient.Builder) { + this.client = clientBuilder.cookieJar(cookieStore).build() + } + + constructor() : this( + okhttp3.OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + ) + + override fun cookieStore(): CookieStore { + return cookieStore + } + + override fun request(): HttpRequest { + return OkHttpRequest(this) + } + + fun call(request: OkHttpRequest): HttpResponse { + val builder = Request.Builder() + + // Handle URL and query parameters + val httpUrlBuilder = + request.url()?.toHttpUrlOrNull()?.newBuilder() ?: throw IllegalArgumentException("Invalid URL") + request.querys()?.forEach { query -> + httpUrlBuilder.addQueryParameter(query.name() ?: "", query.value()) + } + builder.url(httpUrlBuilder.build()) + + // Handle headers + request.headers()?.forEach { header -> + builder.addHeader(header.name() ?: "", header.value() ?: "") + } + + // Handle request body + val requestBody = buildRequestBody(request) + + // Set request method and body + builder.method(request.method(), requestBody) + + val call = client.newCall(builder.build()) + val response = call.execute() + + return OkHttpResponse(request, response) + } + + private fun buildRequestBody(request: OkHttpRequest): RequestBody? { + if (request.method().equals("GET", ignoreCase = true)) return null + + if (request.contentType()?.startsWith("application/x-www-form-urlencoded") == true) { + val formBodyBuilder = FormBody.Builder() + request.params()?.forEach { param -> + formBodyBuilder.add(param.name() ?: "", param.value() ?: "") + } + formBodyBuilder.build() + } + + if (request.contentType()?.startsWith("multipart/form-data") == true) { + val builder = MultipartBody.Builder().setType(MultipartBody.FORM) + request.params()?.forEach { param -> + if (param.type() == "file") { + val file = File(param.value()!!) + if (!file.exists()) { + throw IllegalArgumentException("file ${file.absolutePath} not exists") + } + builder.addFormDataPart( + param.name() ?: "", + file.name, + file.asRequestBody(contentType = "application/octet-stream".toMediaType()) + ) + } else { + builder.addFormDataPart(param.name() ?: "", param.value() ?: "") + } + } + return builder.build() + } + + val body = request.body() ?: return null + return (when (body) { + is String -> body + else -> GsonUtils.toJson(body) + }).toRequestBody((request.contentType() ?: "application/json; charset=utf-8").toMediaType()) + } +} + +class OkHttpCookieStore : CookieJar, CookieStore { + private val cookieStore = mutableMapOf>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + cookieStore[url.host] = cookies.filter { !it.isExpired() } + } + + override fun loadForRequest(url: HttpUrl): List { + val cookies = cookieStore[url.host] ?: return emptyList() + return cookies.asSequence() + .filter { !it.isExpired() } + .filter { it.matches(url) } + .toList() + } + + override fun addCookie(cookie: Cookie?) { + if (cookie == null || cookie.isExpired()) { + return + } + val domain = cookie.getDomain() ?: return + val existingCookies = cookieStore.getOrDefault(domain, emptyList()) + cookieStore[domain] = existingCookies.toMutableList() + cookie.asOkHttpCookie() + + } + + override fun addCookies(cookies: Array?) { + cookies?.forEach { addCookie(it) } + } + + override fun cookies(): List { + return cookieStore.values.asSequence() + .flatten() + .filter { !it.isExpired() } + .map { OkHttpCookie(it) } + .toList() + } + + override fun clear() { + cookieStore.clear() + } + + override fun newCookie(): MutableCookie { + return BasicCookie() + } +} + +private fun okhttp3.Cookie.isExpired(): Boolean { + return expiresAt < System.currentTimeMillis() +} + +@ScriptTypeName("cookie") +class OkHttpCookie(private val cookie: okhttp3.Cookie) : Cookie { + + fun getWrapper(): okhttp3.Cookie { + return cookie + } + + override fun getName(): String { + return cookie.name + } + + override fun getValue(): String { + return cookie.value + } + + override fun getDomain(): String { + return cookie.domain + } + + override fun getPath(): String { + return cookie.path + } + + override fun getExpiryDate(): Long { + return cookie.expiresAt + } + + override fun isPersistent(): Boolean { + return cookie.persistent + } + + override fun isSecure(): Boolean { + return cookie.secure + } + + override fun getComment(): String? { + // OkHttp's Cookie class does not support comments; return null or an empty string if needed. + return null + } + + override fun getCommentURL(): String? { + // OkHttp's Cookie class does not support comment URLs; return null. + return null + } + + override fun getPorts(): IntArray? { + // OkHttp's Cookie class does not support ports; return null. + return null + } + + override fun getVersion(): Int { + // OkHttp's Cookie class does not explicitly handle version; typically version 1 (Netscape spec) is assumed. + return 1 + } + + override fun toString(): String { + return cookie.toString() + } +} + +fun Cookie.asOkHttpCookie(): okhttp3.Cookie { + if (this is OkHttpCookie) { + return this.getWrapper() + } + + // Build a new OkHttp Cookie from generic Cookie interface + return okhttp3.Cookie.Builder().apply { + name(this@asOkHttpCookie.getName() ?: throw IllegalArgumentException("Cookie name cannot be null")) + value(this@asOkHttpCookie.getValue() ?: throw IllegalArgumentException("Cookie value cannot be null")) + domain(this@asOkHttpCookie.getDomain() ?: throw IllegalArgumentException("Cookie domain cannot be null")) + path(this@asOkHttpCookie.getPath() ?: "/") // Default to root if path is not specified + + if (this@asOkHttpCookie.getExpiryDate() != null) { + expiresAt(this@asOkHttpCookie.getExpiryDate()!!) + } else { + // If no expiry is set, use a far future date to mimic a non-expiring cookie + expiresAt(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365)) // Plus one year + } + + if (this@asOkHttpCookie.isSecure()) { + secure() + } + }.build() +} + +@ScriptTypeName("request") +class OkHttpRequest(private val client: OkHttpClient) : AbstractHttpRequest() { + override fun call(): HttpResponse { + return client.call(this) + } +} + +@ScriptTypeName("response") +class OkHttpResponse( + private val request: HttpRequest, + private val response: Response +) : AbstractHttpResponse(), AutoCloseable { + + /** + * Obtains the status code of the HTTP response. + * + * @return the HTTP status code + */ + override fun code(): Int { + return response.code + } + + /** + * Obtains all headers of the HTTP response. + * + * @return a list of headers (name-value pairs) + */ + override fun headers(): List { + return response.headers.names().map { name -> BasicHttpHeader(name, response.header(name)) } + } + + /** + * the bytes message of this response. + */ + private val bodyBytes: ByteArray? by lazy { + response.body?.bytes() + } + + /** + * Obtains the byte array of the response body if available. + * + * @return the byte array of the response body, or null if no body is available + */ + override fun bytes(): ByteArray? { + return bodyBytes + } + + override fun request(): HttpRequest { + return request + } + + /** + * Closes the response to free resources. + */ + override fun close() { + response.close() + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProvider.kt b/idea-plugin/src/main/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProvider.kt index 94eb2e1f5..357282ee9 100644 --- a/idea-plugin/src/main/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProvider.kt +++ b/idea-plugin/src/main/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProvider.kt @@ -7,7 +7,9 @@ import com.itangcent.annotation.script.ScriptTypeName import com.itangcent.http.* import com.itangcent.idea.plugin.api.export.core.ClassExportRuleKeys import com.itangcent.idea.plugin.rule.SuvRuleContext +import com.itangcent.idea.plugin.settings.HttpClientType import com.itangcent.idea.plugin.settings.helper.HttpSettingsHelper +import com.itangcent.idea.utils.OkHttpClient import com.itangcent.intellij.config.ConfigReader import com.itangcent.intellij.config.rule.RuleComputer import com.itangcent.intellij.logger.Logger @@ -16,6 +18,7 @@ import org.apache.http.client.config.RequestConfig import org.apache.http.config.SocketConfig import org.apache.http.impl.client.HttpClients import org.apache.http.impl.conn.PoolingHttpClientConnectionManager +import org.apache.http.ssl.SSLContexts import java.io.ByteArrayInputStream import java.io.InputStream import java.nio.charset.Charset @@ -28,8 +31,8 @@ import java.util.concurrent.TimeUnit @Singleton class ConfigurableHttpClientProvider : AbstractHttpClientProvider() { - @Inject(optional = true) - protected val httpSettingsHelper: HttpSettingsHelper? = null + @Inject + protected lateinit var httpSettingsHelper: HttpSettingsHelper @Inject(optional = true) protected val configReader: ConfigReader? = null @@ -46,50 +49,81 @@ class ConfigurableHttpClientProvider : AbstractHttpClientProvider() { * @return An instance of HttpClient. */ override fun buildHttpClient(): HttpClient { - val httpClientBuilder = HttpClients.custom() + return when (httpSettingsHelper.httpClientType()) { + HttpClientType.APACHE -> { + buildApacheHttpClient() + } + + HttpClientType.OKHTTP -> { + buildOkHttpClient() + } + }.wrap() + } - val config = readHttpConfig() + private fun buildApacheHttpClient(): ApacheHttpClient { + var httpClientBuilder = HttpClients.custom() - httpClientBuilder + val timeOutInMills = httpSettingsHelper.httpTimeOut(TimeUnit.MILLISECONDS) + httpClientBuilder = httpClientBuilder .setConnectionManager(PoolingHttpClientConnectionManager().also { it.maxTotal = 50 it.defaultMaxPerRoute = 20 }) .setDefaultSocketConfig( SocketConfig.custom() - .setSoTimeout(config.timeOut) + .setSoTimeout(timeOutInMills) .build() ) .setDefaultRequestConfig( RequestConfig.custom() - .setConnectTimeout(config.timeOut) - .setConnectionRequestTimeout(config.timeOut) - .setSocketTimeout(config.timeOut) + .setConnectTimeout(timeOutInMills) + .setConnectionRequestTimeout(timeOutInMills) + .setSocketTimeout(timeOutInMills) .setCookieSpec(CookieSpecs.STANDARD).build() ) - .setSSLHostnameVerifier(NOOP_HOST_NAME_VERIFIER) - .setSSLSocketFactory(SSLSF) - return HttpClientWrapper(ApacheHttpClient(httpClientBuilder.build())) + if (httpSettingsHelper.unsafeSsl()) { + httpClientBuilder = httpClientBuilder.setSSLHostnameVerifier(NOOP_HOST_NAME_VERIFIER) + .setSSLSocketFactory(SSLSF) + } + + return ApacheHttpClient(httpClientBuilder.build()) } - private fun readHttpConfig(): HttpConfig { - val httpConfig = HttpConfig() + private fun buildOkHttpClient(): OkHttpClient { + val timeOutInMills = httpSettingsHelper.httpTimeOut(TimeUnit.MILLISECONDS).toLong() + val builder = okhttp3.OkHttpClient.Builder() + .connectTimeout(timeOutInMills, TimeUnit.MILLISECONDS) + .readTimeout(timeOutInMills, TimeUnit.MILLISECONDS) + .writeTimeout(timeOutInMills, TimeUnit.MILLISECONDS) + + if (httpSettingsHelper.unsafeSsl()) { + builder.hostnameVerifier { _, _ -> true } + val trustAllCert = object : javax.net.ssl.X509TrustManager { + override fun checkClientTrusted( + chain: Array?, + authType: String? + ) { + } - httpSettingsHelper?.let { - httpConfig.timeOut = it.httpTimeOut(TimeUnit.MILLISECONDS) - } + override fun checkServerTrusted( + chain: Array?, + authType: String? + ) { + } - if (configReader != null) { - try { - configReader.first("http.timeOut")?.toLong() - ?.let { httpConfig.timeOut = TimeUnit.SECONDS.toMillis(it).toInt() } - } catch (e: NumberFormatException) { - logger.warn("http.timeOut must be a number") + override fun getAcceptedIssuers(): Array { + return arrayOf() + } } + builder.sslSocketFactory(SSLContexts.createSystemDefault().socketFactory, trustAllCert) } - return httpConfig + return OkHttpClient(builder) + } + + private fun HttpClient.wrap(): HttpClient { + return HttpClientWrapper(this) } /** @@ -235,9 +269,7 @@ class ConfigurableHttpClientProvider : AbstractHttpClientProvider() { */ override fun call(): HttpResponse { val url = url() ?: throw IllegalArgumentException("url not be set") - if (httpSettingsHelper != null - && !httpSettingsHelper.checkTrustUrl(url, false) - ) { + if (!httpSettingsHelper.checkTrustUrl(url, false)) { logger.warn("[access forbidden] call:$url") return EmptyHttpResponse(this) } @@ -338,17 +370,4 @@ class ConfigurableHttpClientProvider : AbstractHttpClientProvider() { return this.discarded } } - - /** - * A data class that holds configuration settings for the HTTP client. - */ - class HttpConfig { - - //default 10s - var timeOut: Int = defaultHttpTimeOut - } - - companion object { - const val defaultHttpTimeOut: Int = 10 - } } \ No newline at end of file diff --git a/idea-plugin/src/test/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProviderTest.kt b/idea-plugin/src/test/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProviderTest.kt index 4eb5aba13..668f6bc74 100644 --- a/idea-plugin/src/test/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProviderTest.kt +++ b/idea-plugin/src/test/kotlin/com/itangcent/suv/http/ConfigurableHttpClientProviderTest.kt @@ -1,7 +1,12 @@ package com.itangcent.suv.http +import com.itangcent.common.utils.DateUtils +import com.itangcent.http.ApacheCookie +import com.itangcent.http.asApacheCookie +import com.itangcent.idea.plugin.settings.HttpClientType import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -9,7 +14,7 @@ import kotlin.test.assertTrue /** * Test case of [ConfigurableHttpClientProvider] */ -internal class ConfigurableHttpClientProviderTest : HttpClientProviderTest() { +internal abstract class ConfigurableHttpClientProviderTest : HttpClientProviderTest() { override val httpClientProviderClass get() = ConfigurableHttpClientProvider::class @@ -84,6 +89,56 @@ internal class ConfigurableHttpClientProviderTest : HttpClientProviderTest() { } } +internal class ApacheHttpClientProviderTest : ConfigurableHttpClientProviderTest() { + override fun setUp() { + settings.httpClient = HttpClientType.APACHE.name + } + + @Test + fun testApacheCookies() { + val httpClient = httpClientProvider.getHttpClient() + val cookieStore = httpClient.cookieStore() + cookieStore.clear() + + val token = cookieStore.newCookie() + token.setName("token") + token.setValue("111111") + token.setExpiryDate(DateUtils.parse("2021-01-01").time) + token.setDomain("github.com") + token.setPorts(intArrayOf(9999)) + token.setComment("for auth") + token.setCommentURL("http://www.apache.org/licenses/LICENSE-2.0") + token.setSecure(false) + token.setPath("/") + token.setVersion(100) + token.setExpiryDate(DateUtils.parse("2099-01-01").time) + + var apacheCookie = token.asApacheCookie() + + val packageApacheCookie = ApacheCookie(apacheCookie) + assertEquals("token", packageApacheCookie.getName()) + assertEquals("111111", packageApacheCookie.getValue()) + assertEquals("github.com", packageApacheCookie.getDomain()) + assertEquals("for auth", packageApacheCookie.getComment()) + assertEquals("http://www.apache.org/licenses/LICENSE-2.0", packageApacheCookie.getCommentURL()) + assertEquals("/", packageApacheCookie.getPath()) + assertEquals(100, packageApacheCookie.getVersion()) + assertContentEquals(intArrayOf(9999), packageApacheCookie.getPorts()) + assertEquals(DateUtils.parse("2099-01-01").time, packageApacheCookie.getExpiryDate()) + assertTrue(packageApacheCookie.isPersistent()) + + token.setPorts(null) + apacheCookie = token.asApacheCookie() + assertNull(apacheCookie.commentURL) + assertTrue(apacheCookie.isPersistent) + } +} + +internal class OkHttpClientProviderTest : ConfigurableHttpClientProviderTest() { + override fun setUp() { + settings.httpClient = HttpClientType.OKHTTP.name + } +} internal class NonConfigConfigurableHttpClientProviderTest : HttpClientProviderTest() { diff --git a/idea-plugin/src/test/kotlin/com/itangcent/suv/http/HttpClientProviderTest.kt b/idea-plugin/src/test/kotlin/com/itangcent/suv/http/HttpClientProviderTest.kt index 3494442b4..a8c01e89f 100644 --- a/idea-plugin/src/test/kotlin/com/itangcent/suv/http/HttpClientProviderTest.kt +++ b/idea-plugin/src/test/kotlin/com/itangcent/suv/http/HttpClientProviderTest.kt @@ -32,11 +32,13 @@ internal abstract class HttpClientProviderTest : AdvancedContextTest() { abstract val httpClientProviderClass: KClass + protected val settings = Settings() + override fun bind(builder: ActionContext.ActionContextBuilder) { super.bind(builder) builder.bind(HttpClientProvider::class) { it.with(httpClientProviderClass) } builder.bind(SettingBinder::class) { - it.toInstance(SettingBinderAdaptor(Settings().also { settings -> + it.toInstance(SettingBinderAdaptor(settings.also { settings -> settings.trustHosts = arrayOf( "https://jsonplaceholder.typicode.com", "!http://forbidden.com" @@ -361,12 +363,8 @@ internal abstract class HttpClientProviderTest : AdvancedContextTest() { token.setValue("111111") token.setExpiryDate(DateUtils.parse("2021-01-01").time) token.setDomain("github.com") - token.setPorts(intArrayOf(9999)) - token.setComment("for auth") - token.setCommentURL("http://www.apache.org/licenses/LICENSE-2.0") token.setSecure(false) token.setPath("/") - token.setVersion(100) assertTrue(token.isPersistent()) //add cookie which is expired @@ -382,10 +380,7 @@ internal abstract class HttpClientProviderTest : AdvancedContextTest() { assertEquals("token", it.getName()) assertEquals("111111", it.getValue()) assertEquals("github.com", it.getDomain()) - assertEquals("for auth", it.getComment()) - assertEquals("http://www.apache.org/licenses/LICENSE-2.0", it.getCommentURL()) assertEquals("/", it.getPath()) - assertEquals(100, it.getVersion()) assertEquals(false, it.isSecure()) assertEquals(DateUtils.parse("2099-01-01").time, it.getExpiryDate()) @@ -393,10 +388,7 @@ internal abstract class HttpClientProviderTest : AdvancedContextTest() { assertEquals("token", fromJson.getName()) assertEquals("111111", fromJson.getValue()) assertEquals("github.com", fromJson.getDomain()) - assertEquals("for auth", fromJson.getComment()) - assertEquals("http://www.apache.org/licenses/LICENSE-2.0", fromJson.getCommentURL()) assertEquals("/", fromJson.getPath()) - assertEquals(100, fromJson.getVersion()) assertEquals(false, fromJson.isSecure()) assertEquals(DateUtils.parse("2099-01-01").time, fromJson.getExpiryDate()) @@ -405,10 +397,7 @@ internal abstract class HttpClientProviderTest : AdvancedContextTest() { assertEquals("token", mutable.getName()) assertEquals("111111", mutable.getValue()) assertEquals("github.com", mutable.getDomain()) - assertEquals("for auth", mutable.getComment()) - assertEquals("http://www.apache.org/licenses/LICENSE-2.0", mutable.getCommentURL()) assertEquals("/", mutable.getPath()) - assertEquals(100, mutable.getVersion()) assertEquals(false, mutable.isSecure()) assertEquals(DateUtils.parse("2099-01-01").time, mutable.getExpiryDate()) @@ -422,17 +411,5 @@ internal abstract class HttpClientProviderTest : AdvancedContextTest() { assertTrue(cookieStore.cookies().isEmpty()) cookieStore.addCookies(cookies.toTypedArray()) assertEquals(1, cookies.size) - - token.setPorts(null) - val apacheCookie = token.asApacheCookie() - assertNull(apacheCookie.commentURL) - assertTrue(apacheCookie.isPersistent) - - val packageApacheCookie = ApacheCookie(apacheCookie) - assertEquals("token", packageApacheCookie.getName()) - assertEquals("111111", packageApacheCookie.getValue()) - assertEquals("github.com", packageApacheCookie.getDomain()) - assertEquals("for auth", packageApacheCookie.getComment()) - assertTrue(packageApacheCookie.isPersistent()) } } \ No newline at end of file diff --git a/plugin-adapter/build/kotlin/pluginadapterjar-classes.txt b/plugin-adapter/build/kotlin/pluginadapterjar-classes.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/plugin-adapter/build/libs/plugin-adapter.jar b/plugin-adapter/build/libs/plugin-adapter.jar deleted file mode 100644 index 6ffdb4af3..000000000 Binary files a/plugin-adapter/build/libs/plugin-adapter.jar and /dev/null differ diff --git a/plugin-adapter/build/tmp/jar/MANIFEST.MF b/plugin-adapter/build/tmp/jar/MANIFEST.MF deleted file mode 100644 index 59499bce4..000000000 --- a/plugin-adapter/build/tmp/jar/MANIFEST.MF +++ /dev/null @@ -1,2 +0,0 @@ -Manifest-Version: 1.0 -