diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..81dcf70 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f962596..d750a71 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,76 +1,22 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: + workflow_dispatch: push: - branches: [ develop, master] + branches: [ 'develop', 'master', 'releases/**' ] pull_request: # The branches below must be a subset of the branches above - branches: [ develop ] + branches: [ 'develop', 'master', 'releases/**' ] schedule: - cron: '0 2 * * 4' jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'java' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + codeql-analysis: + uses: wultra/wultra-infrastructure/.github/workflows/codeql-analysis.yml@develop + secrets: inherit + with: + languages: "['java']" + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support \ No newline at end of file diff --git a/.github/workflows/coverity-scan.yml b/.github/workflows/coverity-scan.yml index 8a887f3..aa644e3 100644 --- a/.github/workflows/coverity-scan.yml +++ b/.github/workflows/coverity-scan.yml @@ -3,7 +3,7 @@ name: Run Coverity scan and upload results on: workflow_dispatch: schedule: - - cron: '0 10 1 * *' # monthly + - cron: '0 10 1 * *' # monthly jobs: diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3ccbc9f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: java -jdk: - - openjdk11 -env: - global: - - secure: "GejYYyFcDabMavP3wNcOhuAOAaeQ2VYmFA+tSpt4Y4Al1rLDRKXEXmsYzvRTTYXjbAOMPc1rjgxGPA855XKguweO/zAx83w4+PssfJ/tTHW7epUxqlMYqT0u9Z7xpgRTk+IoQvd4RaLksEv2RvhNOlwUzpWGAPGUdwduG2/rLlwVTDSM0Ie97Rtb5NJ9Caku1XvVLHD4JjBsBcmOmvuNbQUK/YMTXmfmvadimrhVdL1B7F7cRZR+qpY2nHFa3pTrtQaRq6rRvbTCUtGawSq32sUVcQDxPQmNiRdzF9qvl0W4Gsb5KYQVfvVPMN5h5eIrr7txvQkEH2bSkn6pty351cb/SFdery9o1F9Ze22akFUZn7ULsKCOoIjOK+fmPQJdjf4O85nfaOxfoxkZ7l8Fv+qc/Ymmr6Q40XmtnzuZ+eUxC1WZINcioA1MGDi4zLtZz/Drz3NnhYpryrixu5ao9uFkiTa8AxiWGj/bffGC7yv+9wQ+uCQQGxzV9JcySGV0HhQ7pcIdKHV9N+Sk0P5VdBrjDQ5XKpq1uSqhaLltB4crwmAvjQ7w4YvXkH93VZxsiw3mI1yJU1Fam5fwgbRiHUXuU43rFS+BgrANvSVFwg6r+kkNluOfwgmbrNsgOKRG2qaHGLMW6CrS5Ea2PVZNcwtcmxwfUzeRZx4abf7RCdY=" - -before_install: - - echo -n | openssl s_client -connect https://scan.coverity.com:443 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | sudo tee -a /etc/ssl/certs/ca- - -addons: - coverity_scan: - project: - name: "wultra/lime-java-core" - description: Build submitted via Travis CI - notification_email: roman.strobl@wultra.com - build_command_prepend: "mvn clean" - build_command: "mvn -DskipTests=true compile" - branch_pattern: coverity_scan \ No newline at end of file diff --git a/README.md b/README.md index 4f524e9..6240f01 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,21 @@ The following options are available for the builder: - `username` - proxy username - `password` - proxy password - `connectionTimeout` - connection timeout in milliseconds (default: 5000 ms) +- `responseTimeout` - Maximum duration allowed between each network-level read operations. (default: no timeout) +- `maxIdleTime` - ConnectionProvider max idle time. (default: no max idle time) +- `maxLifeTime` - ConnectionProvider max life time. (default: no max life time) +- `keepAliveEnabled` - Keep-Alive probe feature flag (default: false) +- `keepAliveIdle` - Keep-Alive idle time +- `keepAliveInterval` - Keep-Alive retransmission interval time +- `keepAliveCount` - Keep-Alive retransmission limit - `acceptInvalidSslCertificate` - whether invalid SSL certificate is accepted (default: false) - `maxInMemorySize` - maximum in memory request size (default: 1048576 bytes) - `httpBasicAuth` - HTTP basic authentication (default: disabled) - `username` - username for HTTP basic authentication - `password` - password for HTTP basic authentication +- `httpDigestAuth` - HTTP digest authentication (default: disabled) + - `username` - username for HTTP digest authentication + - `password` - password for HTTP digest authentication - `certificateAuth` - certificate authentication (default: disabled) - `useCustomKeyStore` - whether custom keystore should be used for certificate authentication (default: false) - `keyStoreLocation` - resource location of keystore (e.g. `file:/path_to_keystore`) @@ -142,9 +152,15 @@ The following options are available for the builder: - `trustStoreLocation` - resource location of truststore (e.g. `file:/path_to_truststore`) - `trustStorePassword` - truststore password - `trustStoreBytes` - byte data with truststore (alternative configuration way to `trustStoreLocation`) -- `objectMapper` - custom object mapper for JSON serialization +- `modules` - jackson modules +- `jacksonProperties` - jackson properties for custom object mapper + - `serialization` - Jackson on/off features that affect the way Java objects are serialized. + - `deserialization` - Jackson on/off features that affect the way Java objects are deserialized, e.g. `FAIL_ON_UNKNOWN_PROPERTIES=true` - `filter` - custom `ExchangeFilterFunction` for applying a filter during communication - `defaultHttpHeaders` - custom `HttpHeaders` to be added to all requests as default HTTP headers +- `followRedirectEnabled` - whether HTTP redirect responses are followed by the client (default: false) +- `simpleLoggingEnabled` - whether simple one-line logging of HTTP method, URL and response status code is enabled (default: false) +- `logErrorResponsesAsWarnings` - whether responses with error status codes are logged on WARN level in simple logging (default: true) ### Calling HTTP Methods Using REST Client @@ -159,6 +175,9 @@ Once the rest client is initialized, you can use the following methods. Each met - `put` - a blocking PUT call with a generic request / response - `putNonBlocking` - a non-blocking PUT call with a generic request / response with `onSuccess` and `onError` consumers - `putObject` - a blocking PUT call with `ObjectRequest` / `ObjectResponse` +- `delete` - a blocking DELETE call with a generic response +- `deleteNonBlocking` - a non-blocking DELETE call with a generic response with `onSuccess` and `onError` consumers +- `deleteObject` - a blocking DELETE call with `ObjectResponse` - `patch` - a blocking PATCH call with a generic request / response - `patchNonBlocking` - a non-blocking PATCH call with a generic request / response with `onSuccess` and `onError` consumers - `patchObject` - a blocking PATCH call with `ObjectRequest` / `ObjectResponse` @@ -166,10 +185,6 @@ Once the rest client is initialized, you can use the following methods. Each met - `headNonBlocking` - a non-blocking HEAD call with a generic request with `onSuccess` and `onError` consumers - `headObject` - a blocking HEAD call with `ObjectRequest` -- `delete` - a blocking DELETE call with a generic response -- `deleteNonBlocking` - a non-blocking DELETE call with a generic response with `onSuccess` and `onError` consumers -- `deleteObject` - a blocking DELETE call with `ObjectResponse` - The `path` parameter specified in requests can be either: - a partial request path, in this case the `baseUrl` parameter must be configured during initialization @@ -210,9 +225,30 @@ In case any HTTP error occurs during a blocking HTTP request execution, a `RestC Non-blocking methods provide an `onError` consumer for custom error handling. -### Logging +### Simple One-Line Logging + +You can enable simple one-line logging using `RestClientConfiguration`: + +```java +config.setSimpleLoggingEnabled(true); +``` + +The log messages use `INFO` and `WARN` levels based on the status code: + +``` +2023-01-31 12:09:14.014 INFO 64851 --- [ctor-http-nio-2] c.w.c.r.client.base.DefaultRestClient : RestClient GET https://localhost:49728/api/test/response: 200 OK +2023-01-31 12:09:15.367 WARN 64851 --- [ctor-http-nio-4] c.w.c.r.client.base.DefaultRestClient : RestClient POST https://localhost:49728/api/test/error-response: 400 BAD_REQUEST +``` + +You can disable logging on `WARN` level, in this case log messages always use the `INFO` level: + +```java +config.setLogErrorResponsesAsWarnings(false); +``` + +### Detailed Logging -To enable request / response logging, set level of `com.wultra.core.rest.client.base.DefaultRestClient` to `TRACE`. +To enable detailed request / response logging, set level of `com.wultra.core.rest.client.base.DefaultRestClient` to `TRACE`. #### Request Example @@ -284,6 +320,9 @@ The following properties can be configured in case the default configuration nee - `audit.db.table.param.enabled` - flag if logging params to parameters database is enabled (default: `false`) - `audit.db.batch.size` - database batch size (default: `1000`) +You can configure database schema used by the auditing library using regular Spring JPA/Hibernate property in your application: +- `spring.jpa.properties.hibernate.default_schema` - database database schema (default: none) + ### Audit Levels Following audit levels are available: @@ -361,3 +400,27 @@ Auditing with parameters and type of audit message: param.put("operation_id", operationId); audit.info("an access message", AuditDetail.builder().type("ACCESS").params(param).build()); ``` + + +## Wultra HTTP Common + +The `http-common` project provides common functionality for HTTP stack. + + +### Features + + +#### RequestContextConverter + +`RequestContextConverter` converts `HttpServletRequest` to a Wultra specific class `RequestContext`. +This context object contains _user agent_ and best-effort guess of the _client IP address_. + + +## Wultra Annotations + +The `annotations` project provides common annotations. + +Right now, these annotations are available: + +- `PublicApi` - Marker for interfaces intended **to be called by extension**. +- `PublicSpi` - Marker for interfaces intended **to be implemented by extensions** and called by core. diff --git a/annotations/pom.xml b/annotations/pom.xml new file mode 100644 index 0000000..28c43e0 --- /dev/null +++ b/annotations/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + + io.getlime.core + lime-java-core-parent + 1.7.0 + + + annotations + Common annotations + + \ No newline at end of file diff --git a/annotations/src/main/java/com/wultra/core/annotations/PublicApi.java b/annotations/src/main/java/com/wultra/core/annotations/PublicApi.java new file mode 100644 index 0000000..107c61a --- /dev/null +++ b/annotations/src/main/java/com/wultra/core/annotations/PublicApi.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +/** + * Marker for interfaces intended to be called by extension. Implementation may not be exposed by the core functionality. + *

+ * New methods can be added. + * Those API clients that used to call the previously existing methods, should not need to care about the new ones. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.TYPE, ElementType.FIELD}) +@Documented +public @interface PublicApi { +} diff --git a/annotations/src/main/java/com/wultra/core/annotations/PublicSpi.java b/annotations/src/main/java/com/wultra/core/annotations/PublicSpi.java new file mode 100644 index 0000000..ae5ba4c --- /dev/null +++ b/annotations/src/main/java/com/wultra/core/annotations/PublicSpi.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +/** + * Marker for interfaces intended to be implemented by extensions. These interfaces are called by core functionality. + *

+ * Slight compatible modifications of the contract are allowed. + * Do not add new methods to interfaces or abstract classes implemented by some providers. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.TYPE, ElementType.FIELD}) +@Documented +public @interface PublicSpi { +} diff --git a/audit-base/pom.xml b/audit-base/pom.xml index e040145..dc8138a 100644 --- a/audit-base/pom.xml +++ b/audit-base/pom.xml @@ -6,7 +6,7 @@ io.getlime.core lime-java-core-parent - 1.6.0 + 1.7.0 audit-base @@ -28,9 +28,8 @@ jackson-datatype-jsr310 - org.projectlombok - lombok - provided + jakarta.annotation + jakarta.annotation-api org.springframework.boot @@ -40,7 +39,6 @@ com.h2database h2 - ${h2.version} test @@ -62,7 +60,7 @@ maven-surefire-plugin - 2.22.2 + 3.1.2 diff --git a/audit-base/src/main/java/com/wultra/core/audit/base/AuditFactory.java b/audit-base/src/main/java/com/wultra/core/audit/base/AuditFactory.java index 2b2a41c..d3918b0 100644 --- a/audit-base/src/main/java/com/wultra/core/audit/base/AuditFactory.java +++ b/audit-base/src/main/java/com/wultra/core/audit/base/AuditFactory.java @@ -47,13 +47,9 @@ public AuditFactory(AuditConfiguration configuration, DatabaseAudit databaseAudi * @return Audit interface. */ public Audit getAudit() { - switch (configuration.getStorageType()) { - case DATABASE: - return databaseAudit; - - default: - throw new IllegalStateException("Unsupported storage type: " + configuration.getStorageType()); - } + return switch (configuration.getStorageType()) { + case DATABASE -> databaseAudit; + }; } } diff --git a/audit-base/src/main/java/com/wultra/core/audit/base/database/DatabaseAuditWriter.java b/audit-base/src/main/java/com/wultra/core/audit/base/database/DatabaseAuditWriter.java index 4a5bb1a..137e296 100644 --- a/audit-base/src/main/java/com/wultra/core/audit/base/database/DatabaseAuditWriter.java +++ b/audit-base/src/main/java/com/wultra/core/audit/base/database/DatabaseAuditWriter.java @@ -22,6 +22,7 @@ import com.wultra.core.audit.base.util.ClassUtil; import com.wultra.core.audit.base.util.JsonUtil; import com.wultra.core.audit.base.util.StringUtil; +import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -30,10 +31,9 @@ import org.springframework.jdbc.core.PreparedStatementCallback; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.StringUtils; -import javax.annotation.PreDestroy; import java.io.PrintWriter; import java.io.StringWriter; import java.sql.PreparedStatement; @@ -62,6 +62,7 @@ public class DatabaseAuditWriter implements AuditWriter { private final BlockingQueue queue; private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; private final String tableNameAudit; private final String tableNameParam; @@ -76,7 +77,7 @@ public class DatabaseAuditWriter implements AuditWriter { private String insertAuditLog; private String insertAuditParam; - private String dbSchema; + private final String dbSchema; private final JsonUtil jsonUtil = new JsonUtil(); @@ -88,11 +89,17 @@ public class DatabaseAuditWriter implements AuditWriter { * * @param configuration Audit configuration. * @param jdbcTemplate Spring JDBC template. + * @param transactionTemplate Transaction template. */ @Autowired - public DatabaseAuditWriter(AuditConfiguration configuration, JdbcTemplate jdbcTemplate) { + public DatabaseAuditWriter( + final AuditConfiguration configuration, + final JdbcTemplate jdbcTemplate, + final TransactionTemplate transactionTemplate) { + this.queue = new LinkedBlockingDeque<>(configuration.getEventQueueSize()); this.jdbcTemplate = jdbcTemplate; + this.transactionTemplate = transactionTemplate; this.dbSchema = configuration.getDbDefaultSchema(); this.tableNameAudit = addDbSchema(dbSchema, configuration.getDbTableNameAudit()); this.tableNameParam = addDbSchema(dbSchema, configuration.getDbTableNameParam()); @@ -141,117 +148,126 @@ public void write(AuditRecord auditRecord) { } @Override - @Transactional public void flush() { + if (transactionTemplate == null) { + logger.error("Transaction template is not available"); + return; + } if (jdbcTemplate.getDataSource() == null) { logger.error("Data source is not available"); return; } synchronized (FLUSH_LOCK) { - while (!queue.isEmpty()) { - try { - final List auditsToPersist = new ArrayList<>(batchSize); - final List paramsToPersist = new ArrayList<>(); - for (int i = 0; i < batchSize; i++) { - AuditRecord record = queue.take(); - auditsToPersist.add(record); - for (Map.Entry entry : record.getParam().entrySet()) { - paramsToPersist.add(new AuditParam(record.getId(), record.getTimestamp(), entry.getKey(), entry.getValue())); + transactionTemplate.executeWithoutResult(status -> { + while (!queue.isEmpty()) { + try { + final List auditsToPersist = new ArrayList<>(batchSize); + final List paramsToPersist = new ArrayList<>(); + for (int i = 0; i < batchSize; i++) { + AuditRecord record = queue.take(); + auditsToPersist.add(record); + for (Map.Entry entry : record.getParam().entrySet()) { + paramsToPersist.add(new AuditParam(record.getId(), record.getTimestamp(), entry.getKey(), entry.getValue())); + } + if (queue.isEmpty()) { + break; + } } - if (queue.isEmpty()) { - break; - } - } - final int[] insertCountsLog = jdbcTemplate.batchUpdate(insertAuditLog, - new BatchPreparedStatementSetter() { - public void setValues(PreparedStatement ps, int i) throws SQLException { - AuditRecord record = auditsToPersist.get(i); - ps.setString(1, record.getId()); - ps.setString(2, applicationName); - ps.setString(3, record.getLevel().toString()); - String auditType = record.getType(); - if (auditType == null) { - ps.setNull(4, Types.VARCHAR); - } else { - ps.setString(4, StringUtil.trim(record.getType(), 256)); + final int[] insertCountsLog = jdbcTemplate.batchUpdate(insertAuditLog, + new BatchPreparedStatementSetter() { + public void setValues(PreparedStatement ps, int i) throws SQLException { + AuditRecord record = auditsToPersist.get(i); + ps.setString(1, record.getId()); + ps.setString(2, applicationName); + ps.setString(3, record.getLevel().toString()); + String auditType = record.getType(); + if (auditType == null) { + ps.setNull(4, Types.VARCHAR); + } else { + ps.setString(4, StringUtil.trim(record.getType(), 256)); + } + ps.setTimestamp(5, new Timestamp(record.getTimestamp().getTime())); + ps.setString(6, record.getMessage()); + Throwable throwable = record.getThrowable(); + if (throwable == null) { + ps.setNull(7, Types.VARCHAR); + ps.setNull(8, Types.VARCHAR); + } else { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + throwable.printStackTrace(pw); + ps.setString(7, throwable.getMessage()); + ps.setString(8, sw.toString()); + } + ps.setString(9, jsonUtil.serializeMap(record.getParam())); + ps.setString(10, StringUtil.trim(record.getCallingClass(), 256)); + ps.setString(11, StringUtil.trim(record.getThreadName(), 256)); + ps.setString(12, version); + ps.setTimestamp(13, new Timestamp(buildTime.toEpochMilli())); } - ps.setTimestamp(5, new Timestamp(record.getTimestamp().getTime())); - ps.setString(6, record.getMessage()); - Throwable throwable = record.getThrowable(); - if (throwable == null) { - ps.setNull(7, Types.VARCHAR); - ps.setNull(8, Types.VARCHAR); - } else { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - throwable.printStackTrace(pw); - ps.setString(7, throwable.getMessage()); - ps.setString(8, sw.toString()); - } - ps.setString(9, jsonUtil.serializeMap(record.getParam())); - ps.setString(10, StringUtil.trim(record.getCallingClass().getName(), 256)); - ps.setString(11, StringUtil.trim(record.getThreadName(), 256)); - ps.setString(12, version); - ps.setTimestamp(13, new Timestamp(buildTime.toEpochMilli())); - } - public int getBatchSize() { - return auditsToPersist.size(); - } - }); - if (!paramLoggingEnabled) { - logger.debug("Audit log batch insert succeeded, audit record count: {}, audit param is disabled", insertCountsLog.length); - continue; - } - final int[] insertCountsParam = jdbcTemplate.batchUpdate(insertAuditParam, - new BatchPreparedStatementSetter() { - public void setValues(PreparedStatement ps, int i) throws SQLException { - AuditParam record = paramsToPersist.get(i); - ps.setString(1, record.getAuditLogId()); - ps.setTimestamp(2, new Timestamp(record.getTimestamp().getTime())); - ps.setString(3, StringUtil.trim(record.getKey(), 256)); - Object value = record.getValue(); - if (value == null) { - ps.setNull(4, Types.VARCHAR); - } else if (value instanceof CharSequence) { - ps.setString(4, StringUtil.trim(value.toString(), 4000)); - } else { - ps.setString(4, StringUtil.trim(jsonUtil.serializeObject(value), 4000)); + public int getBatchSize() { + return auditsToPersist.size(); + } + }); + if (!paramLoggingEnabled) { + logger.debug("Audit log batch insert succeeded, audit record count: {}, audit param is disabled", insertCountsLog.length); + continue; + } + final int[] insertCountsParam = jdbcTemplate.batchUpdate(insertAuditParam, + new BatchPreparedStatementSetter() { + public void setValues(PreparedStatement ps, int i) throws SQLException { + AuditParam record = paramsToPersist.get(i); + ps.setString(1, record.auditLogId()); + ps.setTimestamp(2, new Timestamp(record.timestamp().getTime())); + ps.setString(3, StringUtil.trim(record.key(), 256)); + Object value = record.value(); + if (value == null) { + ps.setNull(4, Types.VARCHAR); + } else if (value instanceof CharSequence) { + ps.setString(4, StringUtil.trim(value.toString(), 4000)); + } else { + ps.setString(4, StringUtil.trim(jsonUtil.serializeObject(value), 4000)); + } } - } - public int getBatchSize() { - return paramsToPersist.size(); - } - }); - logger.debug("Audit log batch insert succeeded, audit record count: {}, audit param count: {}", insertCountsLog.length, insertCountsParam.length); - } catch (InterruptedException ex) { - logger.warn(ex.getMessage(), ex); + public int getBatchSize() { + return paramsToPersist.size(); + } + }); + logger.debug("Audit log batch insert succeeded, audit record count: {}, audit param count: {}", insertCountsLog.length, insertCountsParam.length); + } catch (InterruptedException ex) { + logger.warn(ex.getMessage(), ex); + } } - } + }); } - } @Override - @Transactional public void cleanup() { + if (transactionTemplate == null) { + logger.error("Transaction template is not available"); + return; + } if (jdbcTemplate.getDataSource() == null) { logger.error("Data source is not available"); return; } final LocalDateTime cleanupLimit = LocalDateTime.now().minusDays(cleanupDays); synchronized (CLEANUP_LOCK) { - jdbcTemplate.execute("DELETE FROM " + tableNameAudit + " WHERE timestamp_created < ?", (PreparedStatementCallback) ps -> { - ps.setTimestamp(1, Timestamp.valueOf(cleanupLimit)); - return ps.execute(); - }); - jdbcTemplate.execute("DELETE FROM " + tableNameParam + " WHERE timestamp_created < ?", (PreparedStatementCallback) ps -> { - ps.setTimestamp(1, Timestamp.valueOf(cleanupLimit)); - return ps.execute(); + transactionTemplate.executeWithoutResult(status -> { + jdbcTemplate.execute("DELETE FROM " + tableNameAudit + " WHERE timestamp_created < ?", (PreparedStatementCallback) ps -> { + ps.setTimestamp(1, Timestamp.valueOf(cleanupLimit)); + return ps.execute(); + }); + jdbcTemplate.execute("DELETE FROM " + tableNameParam + " WHERE timestamp_created < ?", (PreparedStatementCallback) ps -> { + ps.setTimestamp(1, Timestamp.valueOf(cleanupLimit)); + return ps.execute(); + }); + logger.debug("Audit records older than {} were deleted", cleanupLimit); }); - logger.debug("Audit records older than {} were deleted", cleanupLimit); } } diff --git a/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditParam.java b/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditParam.java index f5a71f6..39cf68f 100644 --- a/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditParam.java +++ b/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditParam.java @@ -18,86 +18,15 @@ import org.springframework.lang.NonNull; import java.util.Date; -import java.util.Objects; /** * Audit parameter model class. * + * @param auditLogId Audit log identifier. + * @param timestamp Timestamp when audit record was created. + * @param key Parameter key. + * @param value Parameter value. * @author Roman Strobl, roman.strobl@wultra.com */ -public class AuditParam { - - private final String auditLogId; - private final Date timestamp; - private final String key; - private final Object value; - - /** - * Audit parameter constructor. - * @param auditLogId Audit log identifier. - * @param timestamp Timestamp when audit record was created. - * @param key Parameter key. - * @param value Parameter value. - */ - public AuditParam(@NonNull String auditLogId, @NonNull Date timestamp, @NonNull String key, Object value) { - this.auditLogId = auditLogId; - this.timestamp = timestamp; - this.key = key; - this.value = value; - } - - /** - * Get audit log identifier. - * @return Audit log identifier. - */ - public String getAuditLogId() { - return auditLogId; - } - - /** - * Get audit timestamp. - * @return Audit timestamp. - */ - public Date getTimestamp() { - return timestamp; - } - - /** - * Get parameter key. - * @return Parameter key. - */ - public String getKey() { - return key; - } - - /** - * Get parameter value. - * @return Parameter value. - */ - public Object getValue() { - return value; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - AuditParam that = (AuditParam) o; - return auditLogId.equals(that.auditLogId) && timestamp.equals(that.timestamp) && key.equals(that.key) && Objects.equals(value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(auditLogId, timestamp, key, value); - } - - @Override - public String toString() { - return "AuditParam{" + - "auditLogId='" + auditLogId + '\'' + - ", timestamp=" + timestamp + - ", key='" + key + '\'' + - ", value=" + value + - '}'; - } +public record AuditParam(@NonNull String auditLogId, @NonNull Date timestamp, @NonNull String key, Object value) { } diff --git a/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditRecord.java b/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditRecord.java index d240b83..7a971c7 100644 --- a/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditRecord.java +++ b/audit-base/src/main/java/com/wultra/core/audit/base/model/AuditRecord.java @@ -38,7 +38,7 @@ public class AuditRecord { private final Map param; private String message; private Throwable throwable; - private Class callingClass; + private String callingClass; private String threadName; /** @@ -125,18 +125,18 @@ public Map getParam() { } /** - * Get calling class. - * @return Calling class. + * Get calling class name. + * @return Calling class name. */ - public Class getCallingClass() { + public String getCallingClass() { return callingClass; } /** - * Set calling class. - * @param callingClass Calling class. + * Set calling class name. + * @param callingClass Calling class name. */ - public void setCallingClass(Class callingClass) { + public void setCallingClass(final String callingClass) { this.callingClass = callingClass; } diff --git a/audit-base/src/main/java/com/wultra/core/audit/base/util/ClassUtil.java b/audit-base/src/main/java/com/wultra/core/audit/base/util/ClassUtil.java index f013471..0a65414 100644 --- a/audit-base/src/main/java/com/wultra/core/audit/base/util/ClassUtil.java +++ b/audit-base/src/main/java/com/wultra/core/audit/base/util/ClassUtil.java @@ -15,43 +15,49 @@ */ package com.wultra.core.audit.base.util; +import lombok.extern.slf4j.Slf4j; + import java.util.List; /** * Utility for obtaining information about calling class. + * + * @author Roman Strobl, roman.strobl@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com */ -public class ClassUtil extends SecurityManager { +@Slf4j +public final class ClassUtil { - private static final ClassUtil INSTANCE = new ClassUtil(); + private ClassUtil() { + throw new IllegalStateException("Should not be instantiated"); + } /** * Get calling class. * @param packageFilter Packages to filter out when resolving calling class. * @return Calling class. */ - public static Class getCallingClass(final List packageFilter) { - final Class[] trace = INSTANCE.getClassContext(); - if (trace == null) { - return null; - } + public static String getCallingClass(final List packageFilter) { + final StackTraceElement[] trace = Thread.currentThread().getStackTrace(); - for (final Class t : trace) { - if (t.isAssignableFrom(ClassUtil.class)) { + for (final StackTraceElement t : trace) { + final String className = t.getClassName(); + if (Thread.class.getName().equals(className) || ClassUtil.class.getName().equals(className)) { continue; } - if (!packageMatches(t.getPackage().getName(), packageFilter)) { - return t; + if (!packageMatches(className, packageFilter)) { + return className; } } - return trace[trace.length - 1]; + return trace[trace.length - 1].getClassName(); } - private static boolean packageMatches(String pkg, List packageFilter) { + private static boolean packageMatches(final String className, List packageFilter) { if (packageFilter == null) { return false; } for (final String pf : packageFilter) { - if (pkg.startsWith(pf)) { + if (className.startsWith(pf)) { return true; } } diff --git a/http-common/pom.xml b/http-common/pom.xml new file mode 100644 index 0000000..25798d3 --- /dev/null +++ b/http-common/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + io.getlime.core + lime-java-core-parent + 1.7.0 + + + http-common + Common HTTP functionality + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + \ No newline at end of file diff --git a/http-common/src/main/java/com/wultra/core/http/common/request/RequestContext.java b/http-common/src/main/java/com/wultra/core/http/common/request/RequestContext.java new file mode 100644 index 0000000..28ceb4c --- /dev/null +++ b/http-common/src/main/java/com/wultra/core/http/common/request/RequestContext.java @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.http.common.request; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** + * Context of the HTTP request. + * + * @author Petr Dvorak, petr@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Builder +@Getter +@ToString +@EqualsAndHashCode +public class RequestContext { + + private String ipAddress; + private String userAgent; + +} diff --git a/http-common/src/main/java/com/wultra/core/http/common/request/RequestContextConverter.java b/http-common/src/main/java/com/wultra/core/http/common/request/RequestContextConverter.java new file mode 100644 index 0000000..a738983 --- /dev/null +++ b/http-common/src/main/java/com/wultra/core/http/common/request/RequestContextConverter.java @@ -0,0 +1,89 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.http.common.request; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.List; + +/** + * Converter for HTTP request context information. + * + * @author Petr Dvorak, petr@wultra.com + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +public final class RequestContextConverter { + + /** + * List of HTTP headers that may contain the actual IP address + * when hidden behind a proxy component. + */ + private static final List HTTP_HEADERS_IP_ADDRESS = List.of( + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + ); + + private static final String HTTP_HEADER_USER_AGENT = "User-Agent"; + + private RequestContextConverter() { + throw new IllegalStateException("Should not be instantiated"); + } + + /** + * Convert HTTP Servlet Request to request context representation. + * + * @param source HttpServletRequest instance. + * @return Request context data. + */ + public static RequestContext convert(final HttpServletRequest source) { + if (source == null) { + return null; + } + return RequestContext.builder() + .userAgent(source.getHeader(HTTP_HEADER_USER_AGENT)) + .ipAddress(getClientIpAddress(source)) + .build(); + } + + /** + * Obtain the best-effort guess of the client IP address. + * + * @param request HttpServletRequest instance. + * @return Best-effort information about the client IP address. + */ + private static String getClientIpAddress(final HttpServletRequest request) { + for (String header : HTTP_HEADERS_IP_ADDRESS) { + final String ip = request.getHeader(header); + if (isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) { + return ip; + } + } + return request.getRemoteAddr(); + } + + private static boolean isNotBlank(final String value) { + return !(value == null || value.trim().isEmpty()); + } +} diff --git a/http-common/src/test/java/com/wultra/core/http/common/request/RequestContextConverterTest.java b/http-common/src/test/java/com/wultra/core/http/common/request/RequestContextConverterTest.java new file mode 100644 index 0000000..d500f17 --- /dev/null +++ b/http-common/src/test/java/com/wultra/core/http/common/request/RequestContextConverterTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.http.common.request; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Test for {@link RequestContextConverter}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +class RequestContextConverterTest { + + @Test + void testNullRequest() { + final RequestContext result = RequestContextConverter.convert(null); + + assertNull(result); + } + + @Test + void testUserAgentNull() { + final MockHttpServletRequest request = new MockHttpServletRequest(); + final RequestContext result = RequestContextConverter.convert(request); + + assertNull(result.getUserAgent()); + } + + @Test + void testUserAgent() { + final MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0"); + + final RequestContext result = RequestContextConverter.convert(request); + + assertEquals("Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", result.getUserAgent()); + } + + @Test + void testIpAddress() { + final MockHttpServletRequest request = new MockHttpServletRequest(); + final RequestContext result = RequestContextConverter.convert(request); + + assertEquals("127.0.0.1", result.getIpAddress()); + } + + @Test + void testIpAddressProxy() { + final MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Proxy-Client-IP", "\t"); + request.addHeader("HTTP_X_FORWARDED_FOR", "unKNOWN"); + request.addHeader("HTTP_X_FORWARDED", "192.168.1.134"); + + final RequestContext result = RequestContextConverter.convert(request); + + assertEquals("192.168.1.134", result.getIpAddress()); + } +} diff --git a/pom.xml b/pom.xml index 093ded3..b2a2dc5 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ Wultra - Core Java Libraries io.getlime.core lime-java-core-parent - 1.6.0 + 1.7.0 pom 2017 @@ -43,19 +43,21 @@ + annotations audit-base + http-common rest-model-base rest-client-base UTF-8 - 8 - 8 + 17 + 17 - 2.6.14 - 2.1.214 + 3.1.2 + 3.0.1 @@ -70,12 +72,20 @@ + + + org.projectlombok + lombok + provided + + + org.apache.maven.plugins maven-source-plugin - 3.2.1 + 3.3.0 attach-sources @@ -89,7 +99,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.4.1 + 3.5.0 false @@ -105,7 +115,41 @@ org.apache.maven.plugins maven-deploy-plugin - 3.0.0 + 3.1.1 + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-banned-dependencies + + enforce + + + + + + + javax.* + + jakarta.servlet:jakarta.servlet-api:* + jakarta.servlet.jsp:jakarta.servlet.jsp-api:* + + com.sun.mail + com.sun.xml.bind + + + + jakarta.*:*:jar:*:provided + + javax.cache:* + + + + + + diff --git a/rest-client-base/pom.xml b/rest-client-base/pom.xml index 2b2a4fc..787ae09 100644 --- a/rest-client-base/pom.xml +++ b/rest-client-base/pom.xml @@ -6,7 +6,7 @@ io.getlime.core lime-java-core-parent - 1.6.0 + 1.7.0 rest-client-base @@ -19,6 +19,11 @@ org.springframework.boot spring-boot-starter-webflux + + com.google.code.findbugs + annotations + ${findbugs-annotations.version} + org.springframework.boot spring-boot-starter @@ -49,13 +54,20 @@ org.slf4j slf4j-api + + + + io.github.vzhn + netty-http-authenticator + 1.5 + maven-surefire-plugin - 2.22.2 + 3.1.2 diff --git a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DefaultRestClient.java b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DefaultRestClient.java index 7b3c352..befdb3c 100644 --- a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DefaultRestClient.java +++ b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DefaultRestClient.java @@ -15,6 +15,7 @@ */ package com.wultra.core.rest.client.base; +import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.type.TypeFactory; import com.wultra.core.rest.client.base.util.SslUtils; @@ -23,17 +24,18 @@ import io.getlime.core.rest.model.base.response.ObjectResponse; import io.getlime.core.rest.model.base.response.Response; import io.netty.channel.ChannelOption; +import io.netty.channel.epoll.EpollChannelOption; +import io.netty.channel.socket.nio.NioChannelOption; import io.netty.handler.logging.LogLevel; import io.netty.handler.ssl.SslContext; +import jdk.net.ExtendedSocketOptions; import org.reactivestreams.Publisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.core.io.buffer.DataBufferLimitException; +import org.springframework.http.*; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.codec.ClientCodecConfigurer; import org.springframework.http.codec.json.Jackson2JsonDecoder; @@ -44,6 +46,7 @@ import org.springframework.web.reactive.function.client.*; import reactor.core.publisher.Mono; import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; import reactor.netty.tcp.SslProvider; import reactor.netty.transport.ProxyProvider; import reactor.netty.transport.logging.AdvancedByteBufFormat; @@ -53,6 +56,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.time.Duration; +import java.util.*; import java.util.function.Consumer; /** @@ -66,6 +70,7 @@ public class DefaultRestClient implements RestClient { private WebClient webClient; private final RestClientConfiguration config; + private final Collection modules; /** * Construct default REST client without any additional configuration. @@ -75,6 +80,7 @@ public class DefaultRestClient implements RestClient { public DefaultRestClient(String baseUrl) throws RestClientException { this.config = new RestClientConfiguration(); this.config.setBaseUrl(baseUrl); + this.modules = Collections.emptyList(); // Use default WebClient settings initializeWebClient(); } @@ -82,11 +88,13 @@ public DefaultRestClient(String baseUrl) throws RestClientException { /** * Construct default REST client with specified configuration. * @param config REST client configuration. + * @param modules jackson modules * @throws RestClientException Thrown in case client initialization fails. */ - public DefaultRestClient(RestClientConfiguration config) throws RestClientException { + public DefaultRestClient(final RestClientConfiguration config, final Module... modules) throws RestClientException { // Use WebClient configuration from the config constructor parameter this.config = config; + this.modules = modules == null ? Collections.emptyList() : Arrays.asList(modules); initializeWebClient(); } @@ -95,9 +103,10 @@ public DefaultRestClient(RestClientConfiguration config) throws RestClientExcept * @param builder REST client builder. * @throws RestClientException Thrown in case client initialization fails. */ - private DefaultRestClient(Builder builder) throws RestClientException { + private DefaultRestClient(final Builder builder) throws RestClientException { // Use WebClient settings from the builder this.config = builder.config; + this.modules = builder.modules; initializeWebClient(); } @@ -105,6 +114,7 @@ private DefaultRestClient(Builder builder) throws RestClientException { * Initialize WebClient instance and configure it based on client configuration. */ private void initializeWebClient() throws RestClientException { + validateConfiguration(config); if (config.getBaseUrl() != null) { try { new URI(config.getBaseUrl()); @@ -114,7 +124,7 @@ private void initializeWebClient() throws RestClientException { } final WebClient.Builder builder = WebClient.builder(); final SslContext sslContext = SslUtils.prepareSslContext(config); - HttpClient httpClient = HttpClient.create() + HttpClient httpClient = createHttpClient(config) .wiretap(this.getClass().getCanonicalName(), LogLevel.TRACE, AdvancedByteBufFormat.TEXTUAL) .followRedirect(config.isFollowRedirectEnabled()); if (sslContext != null) { @@ -130,8 +140,12 @@ private void initializeWebClient() throws RestClientException { if (config.getConnectionTimeout() != null) { httpClient = httpClient.option( ChannelOption.CONNECT_TIMEOUT_MILLIS, - config.getConnectionTimeout()); + Math.toIntExact(config.getConnectionTimeout().toMillis())); } + if (config.isKeepAliveEnabled()) { + httpClient = configureKeepAlive(httpClient, config); + } + final Duration responseTimeout = config.getResponseTimeout(); if (responseTimeout != null) { logger.debug("Setting response timeout {}", responseTimeout); @@ -151,35 +165,121 @@ private void initializeWebClient() throws RestClientException { }); } - final ObjectMapper objectMapper = config.getObjectMapper(); + final Optional objectMapperOptional = createObjectMapper(config, modules); final ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() .codecs(configurer -> { ClientCodecConfigurer.ClientDefaultCodecs defaultCodecs = configurer.defaultCodecs(); - if (objectMapper != null) { + objectMapperOptional.ifPresent(objectMapper -> { defaultCodecs.jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON)); defaultCodecs.jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON)); - } + }); defaultCodecs.maxInMemorySize(config.getMaxInMemorySize()); }) .build(); builder.exchangeStrategies(exchangeStrategies); - if (config.getHttpBasicAuthUsername() != null) { + if (config.isHttpBasicAuthEnabled() && config.getHttpBasicAuthUsername() != null) { + logger.info("Configuring HTTP Basic Authentication"); builder.filter(ExchangeFilterFunctions .basicAuthentication(config.getHttpBasicAuthUsername(), config.getHttpBasicAuthPassword())); } - + if (config.isHttpDigestAuthEnabled() && config.getHttpDigestAuthUsername() != null) { + logger.info("Configuring HTTP Digest Authentication"); + builder.filter(DigestAuthenticationFilterFunction + .digestAuthentication(config.getHttpDigestAuthUsername(), config.getHttpDigestAuthPassword())); + } if (config.getFilter() != null) { builder.filter(config.getFilter()); } if (config.getDefaultHttpHeaders() != null) { builder.defaultHeaders(httpHeaders -> httpHeaders.addAll(config.getDefaultHttpHeaders())); } + if (config.isSimpleLoggingEnabled()) { + builder.filter((request, next) -> { + final String requestLogMessage = "RestClient " + request.method() + " " + request.url(); + return next.exchange(request) + .doOnNext(response -> { + final HttpStatusCode statusCode = response.statusCode(); + if (config.isLogErrorResponsesAsWarnings() && statusCode.isError()) { + logger.warn("{}: {}", requestLogMessage, statusCode); + } else { + logger.info("{}: {}", requestLogMessage, statusCode); + } + }); + }); + } final ReactorClientHttpConnector connector = new ReactorClientHttpConnector(httpClient); webClient = builder.baseUrl(config.getBaseUrl()).clientConnector(connector).build(); } + private static HttpClient configureKeepAlive(final HttpClient httpClient, final RestClientConfiguration config) throws RestClientException { + final Duration keepAliveIdle = config.getKeepAliveIdle(); + final Duration keepAliveInterval = config.getKeepAliveInterval(); + final Integer keepAliveCount = config.getKeepAliveCount(); + logger.info("Configuring Keep-Alive, idle={}, interval={}, count={}", keepAliveIdle, keepAliveInterval, keepAliveCount); + if (keepAliveIdle == null || keepAliveInterval == null || keepAliveCount == null) { + throw new RestClientException("All Keep-Alive properties must be specified."); + } + + final int keepIdleSeconds = Math.toIntExact(keepAliveIdle.toSeconds()); + final int keepIntervalSeconds = Math.toIntExact(keepAliveInterval.toSeconds()); + + return httpClient.option(ChannelOption.SO_KEEPALIVE, true) + .option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPIDLE), keepIdleSeconds) + .option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPINTERVAL), keepIntervalSeconds) + .option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPCOUNT), keepAliveCount) + .option(EpollChannelOption.TCP_KEEPIDLE, keepIdleSeconds) + .option(EpollChannelOption.TCP_KEEPINTVL, keepIntervalSeconds) + .option(EpollChannelOption.TCP_KEEPCNT, keepAliveCount); + } + + /** + * Create HttpClient with default HttpConnectionProvider or custom one, if specified in the given config. + * @param config Config to create connection provider if specified. + * @return Http client. + */ + private static HttpClient createHttpClient(final RestClientConfiguration config) { + final Duration maxIdleTime = config.getMaxIdleTime(); + final Duration maxLifeTime = config.getMaxLifeTime(); + if (maxIdleTime != null || maxLifeTime != null) { + logger.info("Configuring custom connection provider, maxIdleTime={}, maxLifeTime={}", maxIdleTime, maxLifeTime); + final ConnectionProvider.Builder providerBuilder = ConnectionProvider.builder("custom"); + if (maxIdleTime != null) { + providerBuilder.maxIdleTime(maxIdleTime); + } + if (maxLifeTime != null) { + providerBuilder.maxLifeTime(maxLifeTime); + } + return HttpClient.create(providerBuilder.build()); + } else { + return HttpClient.create(); + } + } + + private static Optional createObjectMapper(final RestClientConfiguration config, Collection modules) { + final RestClientConfiguration.JacksonConfiguration jacksonConfiguration = config.getJacksonConfiguration(); + if (jacksonConfiguration == null && modules.isEmpty()) { + return Optional.empty(); + } + + logger.debug("Configuring object mapper"); + final ObjectMapper objectMapper = new ObjectMapper(); + if (jacksonConfiguration != null) { + jacksonConfiguration.getDeserialization().forEach(objectMapper::configure); + jacksonConfiguration.getSerialization().forEach(objectMapper::configure); + } + objectMapper.registerModules(modules); + + return Optional.of(objectMapper); + } + + private static void validateConfiguration(final RestClientConfiguration config) throws RestClientException { + if (config.isHttpBasicAuthEnabled() && config.isHttpDigestAuthEnabled()) { + throw new RestClientException("Both HTTP Basic and Digest authentication is enabled"); + } + } + @Override public ResponseEntity get(String path, ParameterizedTypeReference responseType) throws RestClientException { return get(path, null, null, responseType); @@ -273,6 +373,13 @@ public ResponseEntity post(String path, Object request, MultiValueMap ObjectResponse headObject(String path, MultiValueMap ParameterizedTypeReference> getTypeReference(Class responseType) { - return new ParameterizedTypeReference>(){ + return new ParameterizedTypeReference<>() { @Override public Type getType() { return TypeFactory.defaultInstance().constructParametricType(ObjectResponse.class, responseType); @@ -736,11 +843,14 @@ public static class Builder { private final RestClientConfiguration config; + private final Collection modules; + /** * Construct new builder with given base URL. */ public Builder() { config = new RestClientConfiguration(); + modules = new HashSet<>(); } /** @@ -796,11 +906,82 @@ public ProxyBuilder proxy() { * @param connectionTimeout Connection timeout. * @return Builder. */ - public Builder connectionTimeout(Integer connectionTimeout) { + public Builder connectionTimeout(Duration connectionTimeout) { config.setConnectionTimeout(connectionTimeout); return this; } + + /** + * Configure response timeout. {@code Null} means no response timeout. + * @param responseTimeout Response timeout. + * @return Builder. + */ + public Builder responseTimeout(final Duration responseTimeout) { + config.setResponseTimeout(responseTimeout); + return this; + } + + /** + * Configure ConnectionProvider max idle time. {@code Null} means no max idle time. + * @param maxIdleTime Max idle time. + * @return Builder. + */ + public Builder maxIdleTime(final Duration maxIdleTime) { + config.setMaxIdleTime(maxIdleTime); + return this; + } + + /** + * Configure ConnectionProvider max life time. {@code Null} means no max life time. + * @param maxLifeTime Max life time. + * @return Builder. + */ + public Builder maxLifeTime(Duration maxLifeTime) { + config.setMaxLifeTime(maxLifeTime); + return this; + } + + /** + * Configure Keep-Alive probe. + * @param keepAliveEnabled Keep-Alive enabled. + * @return Builder. + */ + public Builder keepAliveEnabled(boolean keepAliveEnabled) { + config.setKeepAliveEnabled(keepAliveEnabled); + return this; + } + + /** + * Configure Keep-Alive idle interval. + * @param keepAliveIdle Keep-Alive idle interval. + * @return Builder. + */ + public Builder keepAliveIdle(Duration keepAliveIdle) { + config.setKeepAliveIdle(keepAliveIdle); + return this; + } + + /** + * Configure Keep-Alive retransmission interval. + * @param keepAliveInterval Keep-Alive retransmission interval. + * @return Builder. + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + config.setKeepAliveInterval(keepAliveInterval); + return this; + } + + /** + * Configure Keep-Alive retransmission limit. + * @param keepAliveCount Keep-Alive retransmission limit. + * @return Builder. + */ + public Builder keepAliveCount(Integer keepAliveCount) { + config.setKeepAliveCount(keepAliveCount); + return this; + } + /** * Configure whether invalid SSL certificate is accepted. * @param acceptInvalidSslCertificate Whether invalid SSL certificate is accepted. @@ -831,22 +1012,22 @@ public HttpBasicAuthBuilder httpBasicAuth() { } /** - * Configure certificate authentication. + * Configure HTTP digest authentication. + * * @return Builder. */ - public CertificateAuthBuilder certificateAuth() { - config.setCertificateAuthEnabled(true); - return new CertificateAuthBuilder(this); + public HttpDigestAuthBuilder httpDigestAuth() { + config.setHttpDigestAuthEnabled(true); + return new HttpDigestAuthBuilder(this); } /** - * Configure Object Mapper. - * @param objectMapper Object Mapper. + * Configure certificate authentication. * @return Builder. */ - public Builder objectMapper(ObjectMapper objectMapper) { - config.setObjectMapper(objectMapper); - return this; + public CertificateAuthBuilder certificateAuth() { + config.setCertificateAuthEnabled(true); + return new CertificateAuthBuilder(this); } /** @@ -869,6 +1050,25 @@ public Builder filter(ExchangeFilterFunction filter) { return this; } + /** + * Configure jackson. + * @return JacksonConfigurationBuilder. + */ + public Builder jacksonConfiguration(RestClientConfiguration.JacksonConfiguration jacksonConfiguration) { + config.setJacksonConfiguration(jacksonConfiguration); + return this; + } + + /** + * Configure jackson modules. + * @param modules Jackson modules. + * @return Builder. + */ + public Builder modules(Collection modules) { + modules.addAll(modules); + return this; + } + } /** @@ -981,6 +1181,54 @@ public Builder build() { } } + /** + * HTTP digest authentication builder. + */ + public static class HttpDigestAuthBuilder { + + private final Builder mainBuilder; + + /** + * HTTP digest authentication builder constructor. + * + * @param mainBuilder Parent builder. + */ + private HttpDigestAuthBuilder(Builder mainBuilder) { + this.mainBuilder = mainBuilder; + } + + /** + * Configure HTTP digest authentication username. + * + * @param digestAuthUsername HTTP digest authentication username. + * @return Builder. + */ + public HttpDigestAuthBuilder username(String digestAuthUsername) { + mainBuilder.config.setHttpDigestAuthUsername(digestAuthUsername); + return this; + } + + /** + * Configure HTTP digest authentication password. + * + * @param digestAuthPassword HTTP digest authentication password. + * @return Builder. + */ + public HttpDigestAuthBuilder password(String digestAuthPassword) { + mainBuilder.config.setHttpDigestAuthPassword(digestAuthPassword); + return this; + } + + /** + * Build the builder. + * + * @return Builder. + */ + public Builder build() { + return mainBuilder; + } + } + /** * Certificate authentication builder. */ diff --git a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DigestAuthenticationFilterFunction.java b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DigestAuthenticationFilterFunction.java new file mode 100644 index 0000000..ccb2802 --- /dev/null +++ b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/DigestAuthenticationFilterFunction.java @@ -0,0 +1,99 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.rest.client.base; + +import me.vzhilin.auth.DigestAuthenticator; +import me.vzhilin.auth.parser.ChallengeResponse; +import me.vzhilin.auth.parser.ChallengeResponseParser; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.*; +import reactor.core.publisher.Mono; + +import java.text.ParseException; + +/** + * Specialization of {@link ExchangeFilterFunction} to support Digest Authentication according to + * RFC 7616 + * using {@code io.github.vzhn:netty-http-authenticator} library. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +class DigestAuthenticationFilterFunction implements ExchangeFilterFunction { + + private final DigestAuthenticator digestAuthenticator; + + DigestAuthenticationFilterFunction(DigestAuthenticator digestAuthenticator) { + this.digestAuthenticator = digestAuthenticator; + } + + @Override + public Mono filter(final ClientRequest request, final ExchangeFunction next) { + return next.exchange(addAuthorizationHeader(request)) + .flatMap(response -> { + if (isUnauthorized(response)) { + updateAuthenticator(request, response); + return next.exchange(addAuthorizationHeader(request)) + .doOnNext(it -> { + if (isUnauthorized(it)) { + updateAuthenticator(request, it); + } + }); + } else { + return Mono.just(response); + } + }); + } + + private static boolean isUnauthorized(ClientResponse response) { + return response.statusCode() == HttpStatus.UNAUTHORIZED; + } + + private ClientRequest addAuthorizationHeader(final ClientRequest request) { + final String authorization = digestAuthenticator.authorizationHeader(request.method().name(), request.url().getPath()); + if (authorization != null) { + return ClientRequest.from(request) + .headers(headers -> headers.set(HttpHeaders.AUTHORIZATION, authorization)) + .build(); + } else { + return request; + } + } + + private void updateAuthenticator(final ClientRequest request, final ClientResponse response) { + final HttpHeaders headers = response.headers().asHttpHeaders(); + final String authenticateHeader = headers.getFirst(HttpHeaders.WWW_AUTHENTICATE); + if (authenticateHeader != null) { + try { + final ChallengeResponse challenge = new ChallengeResponseParser(authenticateHeader).parseChallenge(); + digestAuthenticator.onResponseReceived(challenge, HttpStatus.UNAUTHORIZED.value()); + } catch (ParseException e) { + throw new WebClientRequestException(e, request.method(), request.url(), headers); + } + } + } + + /** + * Return a filter that applies HTTP Digest Authentication. + * + * @param username – the username + * @param password – the password + * @return the filter + */ + public static ExchangeFilterFunction digestAuthentication(final String username, final String password) { + return new DigestAuthenticationFilterFunction(new DigestAuthenticator(username, password)); + } +} diff --git a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientConfiguration.java b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientConfiguration.java index e965866..f00ee1b 100644 --- a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientConfiguration.java +++ b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientConfiguration.java @@ -15,7 +15,10 @@ */ package com.wultra.core.rest.client.base; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.SerializationFeature; +import lombok.Getter; +import lombok.Setter; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.lang.Nullable; @@ -23,9 +26,12 @@ import java.time.Duration; import java.util.Arrays; +import java.util.EnumMap; +import java.util.Map; /** * REST client configuration. + * This class is safe to use as {@code org.springframework.boot.context.properties.ConfigurationProperties}. * * @author Roman Strobl, roman.strobl@wultra.com */ @@ -44,9 +50,17 @@ public class RestClientConfiguration { private String proxyPassword; // HTTP connection timeout - private Integer connectionTimeout = 5000; + private Duration connectionTimeout = Duration.ofMillis(5000); private Duration responseTimeout; + private Duration maxIdleTime; + private Duration maxLifeTime; + + private boolean keepAliveEnabled; + private Duration keepAliveIdle; + private Duration keepAliveInterval; + private Integer keepAliveCount; + // TLS certificate settings private boolean acceptInvalidSslCertificate = false; private Duration handshakeTimeout; @@ -59,6 +73,11 @@ public class RestClientConfiguration { private String httpBasicAuthUsername; private String httpBasicAuthPassword; + // HTTP Digest Authentication + private boolean httpDigestAuthEnabled = false; + private String httpDigestAuthUsername; + private String httpDigestAuthPassword; + // TLS client certificate authentication private boolean certificateAuthEnabled = false; private boolean useCustomKeyStore = false; @@ -74,8 +93,7 @@ public class RestClientConfiguration { private String trustStoreLocation; private String trustStorePassword; - // Custom object mapper - private ObjectMapper objectMapper; + private JacksonConfiguration jacksonConfiguration; // Custom default HTTP headers private HttpHeaders defaultHttpHeaders; @@ -85,10 +103,20 @@ public class RestClientConfiguration { // Handling responses settings /** - * Enables/disables auto-redirect of HTTP 30x statuses + * Enables/disables auto-redirect of HTTP 30x statuses. */ private boolean followRedirectEnabled = false; + /** + * Enables/disables simple one-line logging of HTTP requests and responses. + */ + private boolean simpleLoggingEnabled = false; + + /** + * Enables/disables usage of WARNING level in simple one-line logging. + */ + private boolean logErrorResponsesAsWarnings = true; + /** * Get base URL. * @return Base URL. @@ -222,19 +250,122 @@ public void setProxyPassword(String proxyPassword) { * * @return Connection timeout. */ - public Integer getConnectionTimeout() { + public Duration getConnectionTimeout() { return connectionTimeout; } /** - * Set connection timeout in milliseconds. + * Set connection timeout. * - * @param connectionTimeout Connection timeout in milliseconds. + * @param connectionTimeout Connection timeout as a Duration object. */ - public void setConnectionTimeout(Integer connectionTimeout) { + public void setConnectionTimeout(Duration connectionTimeout) { this.connectionTimeout = connectionTimeout; } + + /** + * Get max idle time. + * + * @return Max idle time. + */ + public Duration getMaxIdleTime() { + return maxIdleTime; + } + + /** + * Set the options to use for configuring ConnectionProvider max idle time. + * {@code Null} means no max idle time. + * + * @param maxIdleTime Max idle time. + */ + public void setMaxIdleTime(Duration maxIdleTime) { + this.maxIdleTime = maxIdleTime; + } + + /** + * Get max life time. + * + * @return Max life time. + */ + public Duration getMaxLifeTime() { + return maxLifeTime; + } + + /** + * Set the options to use for configuring ConnectionProvider max life time. + * {@code Null} means no max life time. + * + * @param maxLifeTime Max life time. + */ + public void setMaxLifeTime(Duration maxLifeTime) { + this.maxLifeTime = maxLifeTime; + } + + /** + * Return whether Keep-Alive is enabled. + * @return {@code True} if keep-alive enabled- + */ + public boolean isKeepAliveEnabled() { + return keepAliveEnabled; + } + + /** + * Set whether Keep-Alive is enabled + * @param keepAliveEnabled Keep-Alive. + */ + public void setKeepAliveEnabled(boolean keepAliveEnabled) { + this.keepAliveEnabled = keepAliveEnabled; + } + + /** + * Get Keep-Alive idle time. + * @return Keep-Alive idle time. + */ + public Duration getKeepAliveIdle() { + return keepAliveIdle; + } + + /** + * Set Keep-Alive idle time. + * @param keepAliveIdle Keep-Alive idle time. + */ + public void setKeepAliveIdle(Duration keepAliveIdle) { + this.keepAliveIdle = keepAliveIdle; + } + + /** + * Get Keep-Alive retransmission interval time. + * @return Keep-Alive retransmission interval time. + */ + public Duration getKeepAliveInterval() { + return keepAliveInterval; + } + + /** + * Set Keep-Alive retransmission interval time. + * @param keepAliveInterval Keep-Alive retransmission interval time. + */ + public void setKeepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + /** + * Get Keep-Alive retransmission limit. + * @return Keep-Alive retransmission limit. + */ + public Integer getKeepAliveCount() { + return keepAliveCount; + } + + /** + * Set Keep-Alive retransmission limit. + * @param keepAliveCount Keep-Alive retransmission limit. + */ + public void setKeepAliveCount(Integer keepAliveCount) { + this.keepAliveCount = keepAliveCount; + } + /** * Get whether invalid SSL certificate is accepted. * @return Whether invalid SSL certificate is accepted. @@ -315,6 +446,54 @@ public void setHttpBasicAuthPassword(String httpBasicAuthPassword) { this.httpBasicAuthPassword = httpBasicAuthPassword; } + /** + * Get whether digest HTTP authentication is enabled. + * @return Whether digest HTTP authentication is enabled. + */ + public boolean isHttpDigestAuthEnabled() { + return httpDigestAuthEnabled; + } + + /** + * Set whether digest HTTP authentication is enabled. + * @param httpDigestAuthEnabled Whether digest HTTP authentication is enabled. + */ + public void setHttpDigestAuthEnabled(boolean httpDigestAuthEnabled) { + this.httpDigestAuthEnabled = httpDigestAuthEnabled; + } + + /** + * Get username for digest HTTP authentication. + * @return Username for digest HTTP authentication. + */ + public String getHttpDigestAuthUsername() { + return httpDigestAuthUsername; + } + + /** + * Set username for digest HTTP authentication. + * @param httpDigestAuthUsername Username for digest HTTP authentication. + */ + public void setHttpDigestAuthUsername(String httpDigestAuthUsername) { + this.httpDigestAuthUsername = httpDigestAuthUsername; + } + + /** + * Get password for digest HTTP authentication. + * @return Password for digest HTTP authentication. + */ + public String getHttpDigestAuthPassword() { + return httpDigestAuthPassword; + } + + /** + * Set password for digest HTTP authentication. + * @param httpDigestAuthPassword Password for digest HTTP authentication. + */ + public void setHttpDigestAuthPassword(String httpDigestAuthPassword) { + this.httpDigestAuthPassword = httpDigestAuthPassword; + } + /** * Get whether client TLS certificate authentication is enabled. * @return Whether client TLS certificate authentication is enabled. @@ -500,19 +679,19 @@ public void setTrustStorePassword(String trustStorePassword) { } /** - * Get the object mapper. + * Get the jackson configuration. * @return Object mapper. */ - public ObjectMapper getObjectMapper() { - return objectMapper; + public JacksonConfiguration getJacksonConfiguration() { + return jacksonConfiguration; } /** - * Set the object mapper. - * @param objectMapper Object mapper. + * Set the jackson configuration. + * @param jacksonConfiguration jacksonConfiguration. */ - public void setObjectMapper(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + public void setJacksonConfiguration(JacksonConfiguration jacksonConfiguration) { + this.jacksonConfiguration = jacksonConfiguration; } /** @@ -600,4 +779,49 @@ public void setFollowRedirectEnabled(boolean followRedirectEnabled) { this.followRedirectEnabled = followRedirectEnabled; } + /** + * Get whether simple one-line logging of HTTP requests and responses is enabled. + * @return Whether simple logging is enabled. + */ + public boolean isSimpleLoggingEnabled() { + return simpleLoggingEnabled; + } + + /** + * Set whether simple one-line logging of HTTP requests and responses is enabled. + * @param simpleLoggingEnabled Whether simple logging is enabled. + */ + public void setSimpleLoggingEnabled(boolean simpleLoggingEnabled) { + this.simpleLoggingEnabled = simpleLoggingEnabled; + } + + /** + * Get whether error HTTP responses are logged as warnings. + * @return Whether error HTTP responses are logged as warnings. + */ + public boolean isLogErrorResponsesAsWarnings() { + return logErrorResponsesAsWarnings; + } + + /** + * Set whether error HTTP responses are logged as warnings. + * @param logErrorResponsesAsWarnings Whether error HTTP responses are logged as warnings. + */ + public void setLogErrorResponsesAsWarnings(boolean logErrorResponsesAsWarnings) { + this.logErrorResponsesAsWarnings = logErrorResponsesAsWarnings; + } + + @Getter + @Setter + public static class JacksonConfiguration { + /** + * Jackson on/off features that affect the way Java objects are serialized. + */ + private final Map serialization = new EnumMap<>(SerializationFeature.class); + + /** + * Jackson on/off features that affect the way Java objects are deserialized. + */ + private final Map deserialization = new EnumMap<>(DeserializationFeature.class); + } } diff --git a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientException.java b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientException.java index 18eda56..aa22d27 100644 --- a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientException.java +++ b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/RestClientException.java @@ -17,7 +17,7 @@ import io.getlime.core.rest.model.base.response.ErrorResponse; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; /** * REST client exception. @@ -29,7 +29,7 @@ public class RestClientException extends Exception { /** * HTTP status. */ - private HttpStatus statusCode; + private HttpStatusCode statusCode; /** * Raw response. @@ -70,7 +70,7 @@ public RestClientException(String message, Throwable cause) { * @param response Raw response. * @param responseHeaders Response HTTP headers. */ - public RestClientException(String message, HttpStatus statusCode, String response, HttpHeaders responseHeaders) { + public RestClientException(String message, HttpStatusCode statusCode, String response, HttpHeaders responseHeaders) { super(message); this.statusCode = statusCode; this.response = response; @@ -85,7 +85,7 @@ public RestClientException(String message, HttpStatus statusCode, String respons * @param responseHeaders Response HTTP headers. * @param errorResponse Error response. */ - public RestClientException(String message, HttpStatus statusCode, String response, HttpHeaders responseHeaders, ErrorResponse errorResponse) { + public RestClientException(String message, HttpStatusCode statusCode, String response, HttpHeaders responseHeaders, ErrorResponse errorResponse) { super(message); this.statusCode = statusCode; this.response = response; @@ -97,7 +97,7 @@ public RestClientException(String message, HttpStatus statusCode, String respons * Get HTTP status code. * @return HTTP status code. */ - public HttpStatus getStatusCode() { + public HttpStatusCode getStatusCode() { return statusCode; } diff --git a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/util/SslUtils.java b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/util/SslUtils.java index 80c4ae6..decbcfd 100644 --- a/rest-client-base/src/main/java/com/wultra/core/rest/client/base/util/SslUtils.java +++ b/rest-client-base/src/main/java/com/wultra/core/rest/client/base/util/SslUtils.java @@ -35,7 +35,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; /** * SSL certificate utilities. @@ -107,8 +106,8 @@ public static SslContext prepareSslContext(RestClientConfiguration config) throw throw new RestClientException("Invalid or missing key with alias: " + config.getKeyAlias()); } final X509Certificate[] x509CertificateChain = Arrays.stream(certChain) - .map(certificate -> (X509Certificate) certificate) - .collect(Collectors.toList()) + .map(X509Certificate.class::cast) + .toList() .toArray(new X509Certificate[certChain.length]); sslContextBuilder.keyManager(privateKey, config.getKeyStorePassword(), x509CertificateChain); } diff --git a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/DefaultRestClientTest.java b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/DefaultRestClientTest.java index bc92457..3e872fa 100644 --- a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/DefaultRestClientTest.java +++ b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/DefaultRestClientTest.java @@ -15,6 +15,10 @@ */ package com.wultra.core.rest.client.base; +import org.slf4j.LoggerFactory; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.classic.Logger; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.wultra.core.rest.client.base.model.TestRequest; @@ -27,7 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.buffer.DefaultDataBuffer; import org.springframework.core.io.buffer.DefaultDataBufferFactory; @@ -49,6 +53,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -61,18 +66,27 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class DefaultRestClientTest { + private static final String PUBLIC_PATH = "/public/api/test"; + private static final String PRIVATE_PATH = "/private/api/test"; + @LocalServerPort private int port; private RestClient restClient; + private String publicBaseUrl; + private String privateBaseUrl; + // Timeout for synchronization of non-blocking calls using countdown latch private static final int SYNCHRONIZATION_TIMEOUT = 10000; @BeforeEach void initRestClient() throws RestClientException { - RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + final RestClientConfiguration config = prepareConfiguration(); + final String baseUrl = "https://localhost:" + port; + publicBaseUrl = baseUrl + PUBLIC_PATH; + privateBaseUrl = baseUrl + PRIVATE_PATH; + config.setBaseUrl(publicBaseUrl); restClient = new DefaultRestClient(config); } @@ -92,12 +106,13 @@ private RestClientConfiguration prepareConfiguration() { config.setHttpBasicAuthUsername("test"); config.setHttpBasicAuthPassword("test"); config.setResponseTimeout(Duration.ofSeconds(10)); + config.setSimpleLoggingEnabled(true); return config; } @Test void testGetWithResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.get("/response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.get("/response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); } @@ -119,20 +134,20 @@ void testGetWithResponseNonBlocking() throws RestClientException, InterruptedExc countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.getNonBlocking("/response", new ParameterizedTypeReference(){}, onSuccess, onError); + restClient.getNonBlocking("/response", new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testGetWithTestResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.get("/test-response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.get("/test-response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("test response", responseEntity.getBody().getResponse()); } @Test void testGetWithObjectResponse() throws RestClientException { - ResponseEntity> responseEntity = restClient.get("/object-response", new ParameterizedTypeReference>() {}); + final ResponseEntity> responseEntity = restClient.get("/object-response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); assertEquals("object response", responseEntity.getBody().getResponseObject().getResponse()); @@ -147,7 +162,7 @@ void testGetWithObjectResponseObject() throws RestClientException { @Test void testPostWithResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.post("/response", null, new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.post("/response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); } @@ -169,20 +184,20 @@ void testPostWithResponseNonBlocking() throws RestClientException, InterruptedEx countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.postNonBlocking("/response", null, new ParameterizedTypeReference(){}, onSuccess, onError); + restClient.postNonBlocking("/response", null, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testPostWithTestResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.post("/test-response", null, new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.post("/test-response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("test response", responseEntity.getBody().getResponse()); } @Test void testPostWithObjectResponse() throws RestClientException { - ResponseEntity> responseEntity = restClient.post("/object-response", null, new ParameterizedTypeReference>() {}); + final ResponseEntity> responseEntity = restClient.post("/object-response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); assertEquals("object response", responseEntity.getBody().getResponseObject().getResponse()); @@ -199,7 +214,7 @@ void testPostWithObjectResponseObject() throws RestClientException { void testPostWithObjectRequestResponse() throws RestClientException { String requestData = String.valueOf(System.currentTimeMillis()); ObjectRequest request = new ObjectRequest<>(new TestRequest(requestData)); - ResponseEntity> responseEntity = restClient.post("/object-request-response", request, new ParameterizedTypeReference>(){}); + final ResponseEntity> responseEntity = restClient.post("/object-request-response", request, new ParameterizedTypeReference<>(){}); assertNotNull(responseEntity); assertNotNull(responseEntity.getBody()); assertNotNull(responseEntity.getBody().getResponseObject()); @@ -229,13 +244,13 @@ void testPostWithObjectRequestResponseNonBlocking() throws RestClientException, countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.postNonBlocking("/object-request-response", request, new ParameterizedTypeReference>(){}, onSuccess, onError); + restClient.postNonBlocking("/object-request-response", request, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testPutWithResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.put("/response", null, new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.put("/response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); } @@ -257,20 +272,20 @@ void testPutWithResponseNonBlocking() throws RestClientException, InterruptedExc countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.putNonBlocking("/response", null, new ParameterizedTypeReference(){}, onSuccess, onError); + restClient.putNonBlocking("/response", null, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testPutWithTestResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.put("/test-response", null, new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.put("/test-response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("test response", responseEntity.getBody().getResponse()); } @Test void testPutWithObjectResponse() throws RestClientException { - ResponseEntity> responseEntity = restClient.put("/object-response", null, new ParameterizedTypeReference>() {}); + final ResponseEntity> responseEntity = restClient.put("/object-response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); assertEquals("object response", responseEntity.getBody().getResponseObject().getResponse()); @@ -287,7 +302,7 @@ void testPutWithObjectResponseObject() throws RestClientException { void testPutWithObjectRequestResponse() throws RestClientException { String requestData = String.valueOf(System.currentTimeMillis()); ObjectRequest request = new ObjectRequest<>(new TestRequest(requestData)); - ResponseEntity> responseEntity = restClient.put("/object-request-response", request, new ParameterizedTypeReference>(){}); + final ResponseEntity> responseEntity = restClient.put("/object-request-response", request, new ParameterizedTypeReference<>(){}); assertNotNull(responseEntity); assertNotNull(responseEntity.getBody()); assertNotNull(responseEntity.getBody().getResponseObject()); @@ -317,13 +332,13 @@ void testPutWithObjectRequestResponseNonBlocking() throws RestClientException, I countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.putNonBlocking("/object-request-response", request, new ParameterizedTypeReference>(){}, onSuccess, onError); + restClient.putNonBlocking("/object-request-response", request, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testDeleteWithResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.delete("/response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.delete("/response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); } @@ -345,20 +360,20 @@ void testDeleteWithResponseNonBlocking() throws RestClientException, Interrupted countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.deleteNonBlocking("/response", new ParameterizedTypeReference(){}, onSuccess, onError); + restClient.deleteNonBlocking("/response", new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testDeleteWithTestResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.delete("/test-response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.delete("/test-response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("test response", responseEntity.getBody().getResponse()); } @Test void testDeleteWithObjectResponse() throws RestClientException { - ResponseEntity> responseEntity = restClient.delete("/object-response", new ParameterizedTypeReference>() {}); + final ResponseEntity> responseEntity = restClient.delete("/object-response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); assertEquals("object response", responseEntity.getBody().getResponseObject().getResponse()); @@ -413,7 +428,7 @@ void testPostWithErrorResponseNonBlocking() throws RestClientException, Interrup assertEquals("Test message", ex.getErrorResponse().getResponseObject().getMessage()); countDownLatch.countDown(); }; - restClient.postNonBlocking("/error-response", null, new ParameterizedTypeReference>(){}, onSuccess, onError); + restClient.postNonBlocking("/error-response", null, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @@ -421,7 +436,7 @@ void testPostWithErrorResponseNonBlocking() throws RestClientException, Interrup void testGetWithFullUrl() throws RestClientException { RestClientConfiguration config = prepareConfiguration(); restClient = new DefaultRestClient(config); - Response response = restClient.getObject("https://localhost:" + port + "/api/test/response"); + Response response = restClient.getObject(publicBaseUrl + "/response"); assertNotNull(response); assertEquals("OK", response.getStatus()); } @@ -430,7 +445,7 @@ void testGetWithFullUrl() throws RestClientException { void testPostWithFullUrl() throws RestClientException { RestClientConfiguration config = prepareConfiguration(); restClient = new DefaultRestClient(config); - Response response = restClient.postObject("https://localhost:" + port + "/api/test/response", null); + Response response = restClient.postObject(publicBaseUrl + "/response", null); assertNotNull(response); assertEquals("OK", response.getStatus()); } @@ -439,14 +454,14 @@ void testPostWithFullUrl() throws RestClientException { void testPutWithFullUrl() throws RestClientException { RestClientConfiguration config = prepareConfiguration(); restClient = new DefaultRestClient(config); - Response response = restClient.putObject("https://localhost:" + port + "/api/test/response", null); + Response response = restClient.putObject(publicBaseUrl + "/response", null); assertNotNull(response); assertEquals("OK", response.getStatus()); } @Test void testPatchWithResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.patch("/response", null, new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.patch("/response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); } @@ -468,20 +483,20 @@ void testPatchWithResponseNonBlocking() throws RestClientException, InterruptedE countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.patchNonBlocking("/response", null, new ParameterizedTypeReference(){}, onSuccess, onError); + restClient.patchNonBlocking("/response", null, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testPatchWithTestResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.patch("/test-response", null, new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.patch("/test-response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("test response", responseEntity.getBody().getResponse()); } @Test void testPatchWithObjectResponse() throws RestClientException { - ResponseEntity> responseEntity = restClient.patch("/object-response", null, new ParameterizedTypeReference>() {}); + final ResponseEntity> responseEntity = restClient.patch("/object-response", null, new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); assertEquals("object response", responseEntity.getBody().getResponseObject().getResponse()); @@ -498,7 +513,7 @@ void testPatchWithObjectResponseObject() throws RestClientException { void testPatchWithObjectRequestResponse() throws RestClientException { String requestData = String.valueOf(System.currentTimeMillis()); ObjectRequest request = new ObjectRequest<>(new TestRequest(requestData)); - ResponseEntity> responseEntity = restClient.patch("/object-request-response", request, new ParameterizedTypeReference>(){}); + final ResponseEntity> responseEntity = restClient.patch("/object-request-response", request, new ParameterizedTypeReference<>(){}); assertNotNull(responseEntity); assertNotNull(responseEntity.getBody()); assertNotNull(responseEntity.getBody().getResponseObject()); @@ -528,13 +543,13 @@ void testPatchWithObjectRequestResponseNonBlocking() throws RestClientException, countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.patchNonBlocking("/object-request-response", request, new ParameterizedTypeReference>(){}, onSuccess, onError); + restClient.patchNonBlocking("/object-request-response", request, new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testHeadWithResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.head("/response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.head("/response", new ParameterizedTypeReference<>() {}); assertFalse(responseEntity.getHeaders().isEmpty()); assertNull(responseEntity.getBody()); } @@ -557,13 +572,13 @@ void testHeadWithResponseNonBlocking() throws RestClientException, InterruptedEx countDownLatch.countDown(); }; Consumer onError = error -> Assertions.fail(error.getMessage()); - restClient.headNonBlocking("/response", new ParameterizedTypeReference(){}, onSuccess, onError); + restClient.headNonBlocking("/response", new ParameterizedTypeReference<>(){}, onSuccess, onError); assertTrue(countDownLatch.await(SYNCHRONIZATION_TIMEOUT, TimeUnit.MILLISECONDS)); } @Test void testHeadWithTestResponse() throws RestClientException { - ResponseEntity responseEntity = restClient.head("/test-response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.head("/test-response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getHeaders()); assertFalse(responseEntity.getHeaders().isEmpty()); assertNull(responseEntity.getBody()); @@ -571,7 +586,7 @@ void testHeadWithTestResponse() throws RestClientException { @Test void testHeadWithObjectResponse() throws RestClientException { - ResponseEntity> responseEntity = restClient.head("/object-response", new ParameterizedTypeReference>() {}); + final ResponseEntity> responseEntity = restClient.head("/object-response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getHeaders()); assertFalse(responseEntity.getHeaders().isEmpty()); assertNull(responseEntity.getBody()); @@ -588,7 +603,7 @@ void testHeadWithObjectResponseObject() throws RestClientException { void testHeadWithFullUrl() throws RestClientException { RestClientConfiguration config = prepareConfiguration(); restClient = new DefaultRestClient(config); - Response response = restClient.headObject("https://localhost:" + port + "/api/test/response"); + Response response = restClient.headObject(publicBaseUrl + "/response"); assertNotNull(response); assertEquals("OK", response.getStatus()); } @@ -597,7 +612,7 @@ void testHeadWithFullUrl() throws RestClientException { void testPatchWithFullUrl() throws RestClientException { RestClientConfiguration config = prepareConfiguration(); restClient = new DefaultRestClient(config); - Response response = restClient.patchObject("https://localhost:" + port + "/api/test/response", null); + Response response = restClient.patchObject(publicBaseUrl + "/response", null); assertNotNull(response); assertEquals("OK", response.getStatus()); } @@ -606,7 +621,7 @@ void testPatchWithFullUrl() throws RestClientException { void testDeleteWithFullUrl() throws RestClientException { RestClientConfiguration config = prepareConfiguration(); restClient = new DefaultRestClient(config); - Response response = restClient.deleteObject("https://localhost:" + port + "/api/test/response"); + Response response = restClient.deleteObject(publicBaseUrl + "/response"); assertNotNull(response); assertEquals("OK", response.getStatus()); } @@ -620,7 +635,7 @@ void testPostWithDataBuffer() throws RestClientException, JsonProcessingExceptio DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); DefaultDataBuffer dataBuffer = factory.wrap(ByteBuffer.wrap(data)); Object dataBufferFlux = Flux.just(dataBuffer); - ResponseEntity> responseEntity = restClient.post("/object-request-response", dataBufferFlux, new ParameterizedTypeReference>(){}); + final ResponseEntity> responseEntity = restClient.post("/object-request-response", dataBufferFlux, new ParameterizedTypeReference<>(){}); assertNotNull(responseEntity); assertNotNull(responseEntity.getBody()); assertNotNull(responseEntity.getBody().getResponseObject()); @@ -638,8 +653,8 @@ void testPostWithMultipartData() throws RestClientException { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); - ResponseEntity> responseEntity = - restClient.post("/multipart-request-response", bodyBuilder.build(), null, headers, new ParameterizedTypeReference>(){}); + final ResponseEntity> responseEntity = + restClient.post("/multipart-request-response", bodyBuilder.build(), null, headers, new ParameterizedTypeReference<>(){}); assertNotNull(responseEntity); assertNotNull(responseEntity.getBody()); assertNotNull(responseEntity.getBody().getResponseObject()); @@ -647,6 +662,25 @@ void testPostWithMultipartData() throws RestClientException { assertEquals(requestData, responseEntity.getBody().getResponseObject().getResponse()); } + @Test + void testPostWithLargeServerResponse() { + final Logger defaultRestClientLogger = (Logger) LoggerFactory.getLogger(DefaultRestClient.class); + final ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + defaultRestClientLogger.addAppender(listAppender); + + final RestClientException exception = assertThrows(RestClientException.class, + () -> restClient.post("/object-response-large", null, new ParameterizedTypeReference() { + })); + + final List logsList = listAppender.list; + assertFalse(logsList.isEmpty()); + assertEquals(1, logsList.stream().filter( + logEvent -> logEvent.getMessage().equals("Error while retrieving large server response")).count()); + assertNotNull(exception.getMessage()); + } + + @Test void testDefaultHttpHeaders() throws RestClientException { String headerName = "Header-Name"; @@ -655,12 +689,12 @@ void testDefaultHttpHeaders() throws RestClientException { headers.set(headerName, headerVaue); RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + config.setBaseUrl(publicBaseUrl); config.setDefaultHttpHeaders(headers); RestClient restClient = new DefaultRestClient(config); - ResponseEntity> responseEntity = - restClient.post("/request-headers-response", null, new ParameterizedTypeReference>(){}); + final ResponseEntity> responseEntity = + restClient.post("/request-headers-response", null, new ParameterizedTypeReference<>(){}); assertTrue(responseEntity.getHeaders().containsKey(headerName)); assertEquals(headerVaue, responseEntity.getHeaders().getFirst(headerName)); } @@ -680,7 +714,7 @@ void testConfiguration() throws RestClientException { @Test void testCustomKeyStoreTrustStoreBytes() throws Exception { RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + config.setBaseUrl(publicBaseUrl); configureCustomKeyStore(config); configureCustomTrustStore(config); @@ -692,7 +726,7 @@ void testCustomKeyStoreTrustStoreBytes() throws Exception { @Test void testCustomKeyStoreBytes() throws Exception { RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + config.setBaseUrl(publicBaseUrl); configureCustomKeyStore(config); restClient = new DefaultRestClient(config); @@ -703,7 +737,7 @@ void testCustomKeyStoreBytes() throws Exception { @Test void testCustomTrustStoreBytes() throws Exception { RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + config.setBaseUrl(publicBaseUrl); configureCustomTrustStore(config); restClient = new DefaultRestClient(config); @@ -714,28 +748,55 @@ void testCustomTrustStoreBytes() throws Exception { @Test void testRedirectShouldNotFollowByDefault() throws Exception { RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + config.setBaseUrl(publicBaseUrl); assertFalse(config.isFollowRedirectEnabled(), "Following HTTP redirects should be disabled by default"); restClient = new DefaultRestClient(config); - ResponseEntity responseEntity = restClient.get("/redirect-to-response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.get("/redirect-to-response", new ParameterizedTypeReference<>() {}); assertEquals(HttpStatus.FOUND, responseEntity.getStatusCode()); } @Test void testRedirectShouldFollowWhenEnabled() throws Exception { RestClientConfiguration config = prepareConfiguration(); - config.setBaseUrl("https://localhost:" + port + "/api/test"); + config.setBaseUrl(publicBaseUrl); config.setFollowRedirectEnabled(true); restClient = new DefaultRestClient(config); - ResponseEntity responseEntity = restClient.get("/redirect-to-response", new ParameterizedTypeReference() {}); + final ResponseEntity responseEntity = restClient.get("/redirect-to-response", new ParameterizedTypeReference<>() {}); + assertNotNull(responseEntity.getBody()); + assertEquals("OK", responseEntity.getBody().getStatus()); + } + + @Test + void testGetWithResponseDigest() throws RestClientException { + final RestClientConfiguration config = prepareConfiguration(); + config.setBaseUrl(privateBaseUrl); + config.setHttpBasicAuthEnabled(false); + config.setHttpDigestAuthEnabled(true); + config.setHttpDigestAuthUsername("test-digest-user"); + config.setHttpDigestAuthPassword("top-secret"); + final RestClient restClient = new DefaultRestClient(config); + + final ResponseEntity responseEntity = restClient.get("/response", new ParameterizedTypeReference<>() {}); assertNotNull(responseEntity.getBody()); assertEquals("OK", responseEntity.getBody().getStatus()); } + @Test + void testGetWithResponseDigestAuthFailed() throws RestClientException { + final RestClientConfiguration config = prepareConfiguration(); + config.setBaseUrl(privateBaseUrl); + final RestClient restClient = new DefaultRestClient(config); + + final RestClientException exception = assertThrows(RestClientException.class, + () -> restClient.get("/response", new ParameterizedTypeReference() {})); + + assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatusCode()); + } + private static Object getField(final Object parentBean, String path) { final String[] pathParts = path.split("\\."); final String fieldName = pathParts[0]; diff --git a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/config/WebSecurityConfig.java b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/config/WebSecurityConfig.java index ac8a161..a558998 100644 --- a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/config/WebSecurityConfig.java +++ b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/config/WebSecurityConfig.java @@ -15,9 +15,16 @@ */ package com.wultra.core.rest.client.base.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint; +import org.springframework.security.web.authentication.www.DigestAuthenticationFilter; + +import java.util.UUID; /** * Security configuration. @@ -25,11 +32,43 @@ * @author Roman Strobl, roman.strobl@wultra.com */ @Configuration -public class WebSecurityConfig extends WebSecurityConfigurerAdapter { +public class WebSecurityConfig { + + private static final String ENTRY_POINT_KEY = UUID.randomUUID().toString(); + + private DigestAuthenticationEntryPoint authenticationEntryPoint() { + final DigestAuthenticationEntryPoint entryPoint = new DigestAuthenticationEntryPoint(); + entryPoint.setRealmName("Test App Realm"); + entryPoint.setKey(ENTRY_POINT_KEY); + return entryPoint; + } + + private DigestAuthenticationFilter digestAuthenticationFilter() { + final DigestAuthenticationFilter filter = new DigestAuthenticationFilter(); + filter.setUserDetailsService(userDetailsService()); + filter.setAuthenticationEntryPoint(authenticationEntryPoint()); + filter.setCreateAuthenticatedToken(true); + return filter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http.csrf().disable() + .exceptionHandling(e -> e.authenticationEntryPoint(authenticationEntryPoint())) + .addFilter(digestAuthenticationFilter()) + .authorizeHttpRequests() + .requestMatchers("/private/**").authenticated() + .anyRequest().permitAll() + .and().build(); + } - @Override - protected void configure(HttpSecurity http) throws Exception { - http.csrf().disable(); + @Bean + public UserDetailsService userDetailsService() { + return username -> User.builder() + .username("test-digest-user") + .password("top-secret") + .authorities("ROLE_USER") + .build(); } } diff --git a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/PrivateTestRestController.java b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/PrivateTestRestController.java new file mode 100644 index 0000000..34ec038 --- /dev/null +++ b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/PrivateTestRestController.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Wultra s.r.o. + * + * 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. + */ +package com.wultra.core.rest.client.base.controller; + +import io.getlime.core.rest.model.base.response.Response; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Rest controller for tests. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@RestController +@RequestMapping("/private/api/test") +public class PrivateTestRestController { + + @GetMapping("/response") + public Response testGetWithResponse() { + return new Response(); + } +} diff --git a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/TestRestController.java b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/PublicTestRestController.java similarity index 91% rename from rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/TestRestController.java rename to rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/PublicTestRestController.java index ed739c8..874f6c1 100644 --- a/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/TestRestController.java +++ b/rest-client-base/src/test/java/com/wultra/core/rest/client/base/controller/PublicTestRestController.java @@ -24,12 +24,12 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.annotation.Secured; import org.springframework.web.bind.annotation.*; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.net.URI; +import java.util.Arrays; import java.util.Enumeration; /** @@ -38,9 +38,8 @@ * @author Roman Strobl, roman.strobl@wultra.com */ @RestController -@RequestMapping("/api/test") -@Secured("ROLE_TEST_USER") -public class TestRestController { +@RequestMapping("/public/api/test") +public class PublicTestRestController { @GetMapping("/response") public Response testGetWithResponse() { @@ -86,6 +85,12 @@ public ObjectResponse testPostWithMultipartRequestAndResponse(@Req return new ObjectResponse<>(testResponse); } + @PostMapping("/object-response-large") + public ObjectResponse testPostWithLargeServerResponse() { + TestResponse testResponse = new TestResponse(Arrays.toString(new byte[10 * 1024 * 1024])); + return new ObjectResponse<>(testResponse); + } + @PutMapping("/response") public Response testPutWithResponse() { return new Response(); @@ -164,7 +169,7 @@ public Response testRequestHeadersResponse(HttpServletRequest request, HttpServl @GetMapping("/redirect-to-response") public ResponseEntity testRedirect() { return ResponseEntity.status(HttpStatus.FOUND) - .location(URI.create("/api/test/response")) + .location(URI.create("/public/api/test/response")) .build(); } diff --git a/rest-model-base/pom.xml b/rest-model-base/pom.xml index 643829d..b344f15 100644 --- a/rest-model-base/pom.xml +++ b/rest-model-base/pom.xml @@ -6,7 +6,7 @@ io.getlime.core lime-java-core-parent - 1.6.0 + 1.7.0 rest-model-base diff --git a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/entity/Error.java b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/entity/Error.java index 071256d..236ed6d 100644 --- a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/entity/Error.java +++ b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/entity/Error.java @@ -16,7 +16,7 @@ package io.getlime.core.rest.model.base.entity; -import javax.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotBlank; /** * Transport object for RESTful API representing an error instance. diff --git a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/request/ObjectRequest.java b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/request/ObjectRequest.java index 9182b0a..f0eacab 100644 --- a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/request/ObjectRequest.java +++ b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/request/ObjectRequest.java @@ -16,8 +16,8 @@ package io.getlime.core.rest.model.base.request; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; /** * Simple class representing a request with an object. diff --git a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ErrorResponse.java b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ErrorResponse.java index 321c26a..c7c13fe 100644 --- a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ErrorResponse.java +++ b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ErrorResponse.java @@ -17,8 +17,8 @@ import io.getlime.core.rest.model.base.entity.Error; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /** * Class representing an error response. diff --git a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ObjectResponse.java b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ObjectResponse.java index cac118f..6ac663e 100644 --- a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ObjectResponse.java +++ b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/ObjectResponse.java @@ -16,8 +16,8 @@ package io.getlime.core.rest.model.base.response; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; /** * Generic response with status and object of a custom class. diff --git a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/Response.java b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/Response.java index e8c0f6c..2eb09a9 100644 --- a/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/Response.java +++ b/rest-model-base/src/main/java/io/getlime/core/rest/model/base/response/Response.java @@ -16,7 +16,7 @@ package io.getlime.core.rest.model.base.response; -import javax.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotBlank; /** * Simple status only response object.