FINERACT-2380: create feign client

This commit is contained in:
Attila Budai
2025-10-17 14:25:39 +02:00
committed by Adam Saghy
parent f193bbbdf1
commit 553347b320
56 changed files with 7906 additions and 21 deletions
@@ -0,0 +1,32 @@
**/README.md
**/pom.xml
**/build.sbt
**/*.gradle
**/.gitignore
**/git_push.sh
**/api/*
**/gradle*
**/gradle
**/src/main/AndroidManifest.xml
# https://issues.apache.org/jira/browse/FINERACT-1231
**/feign/*.java
!**/feign/CollectionFormats.java
!**/feign/StringUtil.java
# Manual API overrides - do not regenerate
# https://issues.apache.org/jira/browse/FINERACT-1263
**/services/RunReportsApi.java
**/services/ImagesApi.java
**/services/DocumentsApiFixed.java
# Utility classes - do not regenerate
**/util/*.java
**/adapter/*.java
# Feign configuration - do not regenerate
**/feign/FineractFeignClient.java
**/feign/FineractFeignClientConfig.java
**/feign/BasicAuthRequestInterceptor.java
**/feign/ObjectMapperFactory.java
**/feign/FeignException.java
+194
View File
@@ -0,0 +1,194 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
apply plugin: 'org.openapi.generator'
apply plugin: 'jacoco'
description = 'Fineract Client with Feign'
apply from: 'dependencies.gradle'
openApiMeta {
generatorName = 'Fineract-Feign'
packageName = 'org.apache.fineract.client.feign'
outputFolder = "$buildDir/meta".toString()
}
openApiValidate {
inputSpec = "file:///$swaggerFile"
recommend = true
}
tasks.register('buildJavaSdk', org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
generatorName = 'java'
library = 'feign'
verbose = false
validateSpec = false
skipValidateSpec = true
inputSpec = "file:///$swaggerFile"
outputDir = "$buildDir/generated/temp-java".toString()
templateDir = "$projectDir/src/main/resources/templates/java"
groupId = 'org.apache.fineract'
apiPackage = 'org.apache.fineract.client.feign.services'
invokerPackage = 'org.apache.fineract.client.feign'
modelPackage = 'org.apache.fineract.client.models'
generateModelTests = false
generateApiTests = false
ignoreFileOverride = "$projectDir/.openapi-generator-ignore"
configOptions = [
dateLibrary : 'java8',
library : 'feign',
feignVersion : '13.6',
feignApacheHttpClient : 'true',
useFeign13 : 'true',
useFeignApacheHttpClient : 'true',
hideGenerationTimestamp : 'true',
containerDefaultToNull : 'true',
oauth2Implementation : 'none',
useJakartaEe : 'true'
]
dependsOn(':fineract-provider:resolve')
}
sourceSets {
main {
java {
srcDirs = [
new File(buildDir, "generated/java/src/main/java"),
"$projectDir/src/main/java"
]
destinationDirectory = layout.buildDirectory.dir('classes/java/main').get().asFile
}
output.resourcesDir = layout.buildDirectory.dir('resources/main').get().asFile
}
test {
java {
destinationDirectory = layout.buildDirectory.dir('classes/java/test').get().asFile
}
output.resourcesDir = layout.buildDirectory.dir('resources/test').get().asFile
}
}
tasks.withType(Jar).configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
task cleanupGeneratedJavaFiles() {
def tempDir = file("$buildDir/generated/temp-java")
def targetDir = file("$buildDir/generated/java")
inputs.dir(tempDir)
outputs.dir(targetDir)
doLast {
copy {
from tempDir
into targetDir
filter { line ->
line
.replaceAll(", \\)", ")")
.replaceAll(", , @HeaderMap", ", @HeaderMap")
.replaceAll("\\(, ", "(")
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
}
dependsOn("buildJavaSdk")
}
tasks.named('compileJava') {
outputs.cacheIf { true }
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
}
tasks.named('sourcesJar') {
dependsOn(cleanupGeneratedJavaFiles)
mustRunAfter(cleanupGeneratedJavaFiles)
from(sourceSets.main.java.srcDirs) {
include "**/*.java"
}
}
tasks.named('licenseFormatMain') {
dependsOn(cleanupGeneratedJavaFiles)
mustRunAfter(cleanupGeneratedJavaFiles)
source = sourceSets.main.java.srcDirs
}
tasks.named('licenseMain') {
dependsOn(licenseFormatMain)
mustRunAfter(licenseFormatMain)
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.compilerArgs << '-parameters'
options.errorprone {
excludedPaths = '.*/build/generated/java/src/main/java/.*'
}
}
test {
useJUnitPlatform()
}
configurations {
generatedCompileClasspath.extendsFrom implementation
generatedRuntimeClasspath.extendsFrom runtimeClasspath
}
javadoc {
options.encoding = 'UTF-8'
}
spotbugsMain {
enabled = false
}
spotbugsTest {
enabled = false
}
jacoco {
toolVersion = "0.8.11"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
csv.required = false
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/build/generated/**',
'**/org/apache/fineract/client/models/**',
'**/org/apache/fineract/client/services/**Api.class',
'**/org/apache/fineract/client/auth/**'
])
}))
}
}
test {
finalizedBy jacocoTestReport
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
dependencies {
// Feign dependencies
implementation(
'io.github.openfeign:feign-core:13.6',
'io.github.openfeign:feign-jackson:13.6',
'io.github.openfeign:feign-slf4j:13.6',
'io.github.openfeign:feign-hc5:13.6',
'io.github.openfeign:feign-okhttp:13.6',
'io.github.openfeign.form:feign-form:3.8.0',
'org.apache.httpcomponents.client5:httpclient5:5.2.1',
'com.squareup.okhttp3:okhttp:4.12.0',
'com.fasterxml.jackson.core:jackson-databind',
'com.fasterxml.jackson.datatype:jackson-datatype-jsr310',
'com.fasterxml.jackson.datatype:jackson-datatype-jdk8',
'jakarta.annotation:jakarta.annotation-api:3.0.0',
'io.swagger.core.v3:swagger-annotations-jakarta:2.2.15',
'org.apache.commons:commons-lang3:3.12.0',
'org.slf4j:slf4j-api:1.7.36',
'org.projectlombok:lombok'
)
// Test dependencies
testImplementation(
'org.junit.jupiter:junit-jupiter-api:5.11.3',
'org.junit.jupiter:junit-jupiter-engine:5.11.3',
'org.mockito:mockito-core:5.14.2',
'org.assertj:assertj-core:3.26.3',
'org.slf4j:slf4j-simple:1.7.36',
'org.wiremock:wiremock-standalone'
)
}
@@ -0,0 +1,84 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.adapter;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import java.io.IOException;
import org.apache.fineract.client.models.ExternalId;
/**
* Custom Jackson adapter for ExternalId type serialization and deserialization. This adapter ensures that ExternalId
* objects are properly serialized to their string value and deserialized from string values.
*/
public final class ExternalIdAdapter {
private ExternalIdAdapter() {}
/**
* Jackson Serializer for ExternalId. Serializes an ExternalId object to its string value, or null if the ExternalId
* or its value is null.
*/
public static class Serializer extends JsonSerializer<ExternalId> {
@Override
public void serialize(ExternalId value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null || value.getValue() == null) {
gen.writeNull();
} else {
gen.writeString(value.getValue());
}
}
}
/**
* Jackson Deserializer for ExternalId. Deserializes a string value to an ExternalId object, or null if the input is
* null.
*/
public static class Deserializer extends JsonDeserializer<ExternalId> {
@Override
public ExternalId deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String value = p.getValueAsString();
if (value == null) {
return null;
}
ExternalId externalId = new ExternalId();
externalId.setValue(value);
return externalId;
}
}
/**
* Creates a Jackson SimpleModule configured with the ExternalId serializer and deserializer.
*
* @return A configured SimpleModule ready to be registered with an ObjectMapper
*/
public static SimpleModule createModule() {
SimpleModule module = new SimpleModule("ExternalIdModule");
module.addSerializer(ExternalId.class, new Serializer());
module.addDeserializer(ExternalId.class, new Deserializer());
return module;
}
}
@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* Request interceptor that adds Basic Authentication header to requests.
*/
public class BasicAuthRequestInterceptor implements RequestInterceptor {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BASIC_AUTH_PREFIX = "Basic ";
private final String credentials;
/**
* Creates a new BasicAuthRequestInterceptor with the specified credentials.
*
* @param username
* the username for authentication
* @param password
* the password for authentication
*/
public BasicAuthRequestInterceptor(String username, String password) {
if (username == null || password == null) {
throw new IllegalArgumentException("Username and password cannot be null");
}
String auth = username + ":" + password;
this.credentials = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
}
@Override
public void apply(RequestTemplate template) {
template.header(AUTHORIZATION_HEADER, BASIC_AUTH_PREFIX + credentials);
}
}
@@ -0,0 +1,115 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import feign.Request;
import java.nio.charset.Charset;
/**
* Base exception class for Feign client exceptions.
*/
public class FeignException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final int status;
private final Request request;
private final byte[] responseBody;
private final String developerMessage;
private final String userMessage;
protected FeignException(int status, String message, Request request) {
this(status, message, request, (byte[]) null);
}
protected FeignException(int status, String message, Request request, Throwable cause) {
this(status, message, request, null, cause);
}
protected FeignException(int status, String message, Request request, byte[] responseBody) {
super(message);
this.status = status;
this.request = request;
this.responseBody = responseBody;
this.developerMessage = null;
this.userMessage = null;
}
protected FeignException(int status, String message, Request request, byte[] responseBody, Throwable cause) {
super(message, cause);
this.status = status;
this.request = request;
this.responseBody = responseBody;
this.developerMessage = null;
this.userMessage = null;
}
public FeignException(int status, String message, Request request, byte[] responseBody, String developerMessage, String userMessage) {
super(message);
this.status = status;
this.request = request;
this.responseBody = responseBody;
this.developerMessage = developerMessage;
this.userMessage = userMessage;
}
public int status() {
return status;
}
public Request request() {
return request;
}
public byte[] responseBody() {
return responseBody;
}
public String responseBodyAsString() {
return responseBody != null ? new String(responseBody, Charset.defaultCharset()) : null;
}
public String getDeveloperMessage() {
return developerMessage;
}
public String getUserMessage() {
return userMessage;
}
@Override
public String getMessage() {
StringBuilder sb = new StringBuilder();
sb.append("status ").append(status);
if (userMessage != null) {
sb.append(": ").append(userMessage);
}
if (developerMessage != null) {
sb.append(" (").append(developerMessage).append(")");
}
if (super.getMessage() != null && userMessage == null && developerMessage == null) {
sb.append(": ").append(super.getMessage());
}
return sb.toString();
}
}
@@ -0,0 +1,107 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.Response;
import feign.codec.ErrorDecoder;
import java.io.IOException;
import java.io.InputStream;
public class FineractErrorDecoder implements ErrorDecoder {
private final ErrorDecoder defaultDecoder = new Default();
private final ObjectMapper objectMapper = ObjectMapperFactory.getShared();
@Override
public Exception decode(String methodKey, Response response) {
try {
if (response.body() != null) {
byte[] bodyData = readResponseBody(response);
try {
JsonNode rootNode = objectMapper.readTree(bodyData);
String developerMessage = extractField(rootNode, "developerMessage");
String userMessage = extractField(rootNode, "userMessage");
String validationErrors = extractValidationErrors(rootNode);
if (developerMessage != null || userMessage != null || validationErrors != null) {
String enhancedDeveloperMessage = developerMessage;
if (validationErrors != null) {
enhancedDeveloperMessage = validationErrors;
}
return new FeignException(response.status(), userMessage != null ? userMessage : enhancedDeveloperMessage,
response.request(), bodyData, enhancedDeveloperMessage, userMessage);
}
} catch (IOException e) {
return defaultDecoder.decode(methodKey, response);
}
}
} catch (IOException e) {
return defaultDecoder.decode(methodKey, response);
}
return defaultDecoder.decode(methodKey, response);
}
private byte[] readResponseBody(Response response) throws IOException {
if (response.body() == null) {
return new byte[0];
}
try (InputStream inputStream = response.body().asInputStream()) {
return inputStream.readAllBytes();
}
}
private String extractField(JsonNode node, String fieldName) {
JsonNode fieldNode = node.get(fieldName);
return fieldNode != null && !fieldNode.isNull() ? fieldNode.asText() : null;
}
private String extractValidationErrors(JsonNode rootNode) {
JsonNode errorsNode = rootNode.get("errors");
if (errorsNode != null && errorsNode.isArray() && errorsNode.size() > 0) {
StringBuilder errors = new StringBuilder("Validation errors: ");
for (JsonNode error : errorsNode) {
String parameterName = extractField(error, "parameterName");
String defaultUserMessage = extractField(error, "defaultUserMessage");
String developerMessage = extractField(error, "developerMessage");
if (errors.length() > "Validation errors: ".length()) {
errors.append("; ");
}
if (parameterName != null) {
errors.append("[").append(parameterName).append("] ");
}
if (defaultUserMessage != null) {
errors.append(defaultUserMessage);
} else if (developerMessage != null) {
errors.append(developerMessage);
}
}
return errors.length() > "Validation errors: ".length() ? errors.toString() : null;
}
return null;
}
}
@@ -0,0 +1,293 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import feign.Client;
import feign.Feign;
import feign.Request;
import feign.Retryer;
import feign.codec.Encoder;
import feign.hc5.ApacheHttp5Client;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import feign.slf4j.Slf4jLogger;
import java.security.cert.X509Certificate;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
/**
* Configuration class for Feign client.
*/
public final class FineractFeignClientConfig {
public enum HttpClientType {
APACHE, OKHTTP
}
private final String baseUrl;
private final String username;
private final String password;
private final String tenantId;
private final int connectTimeout;
private final int readTimeout;
private final boolean debugEnabled;
private final long connectionTimeToLive;
private final TimeUnit connectionTimeToLiveUnit;
private final boolean disableSslVerification;
private final int maxConnTotal;
private final int maxConnPerRoute;
private final long idleConnectionEvictionTime;
private final TimeUnit idleConnectionEvictionTimeUnit;
private final HttpClientType clientType;
private volatile Client cachedHttpClient;
private FineractFeignClientConfig(Builder builder) {
this.baseUrl = builder.baseUrl;
this.username = builder.username;
this.password = builder.password;
this.tenantId = builder.tenantId;
this.connectTimeout = builder.connectTimeout;
this.readTimeout = builder.readTimeout;
this.debugEnabled = builder.debugEnabled;
this.connectionTimeToLive = builder.connectionTimeToLive;
this.connectionTimeToLiveUnit = builder.connectionTimeToLiveUnit;
this.disableSslVerification = builder.disableSslVerification;
this.maxConnTotal = builder.maxConnTotal;
this.maxConnPerRoute = builder.maxConnPerRoute;
this.idleConnectionEvictionTime = builder.idleConnectionEvictionTime;
this.idleConnectionEvictionTimeUnit = builder.idleConnectionEvictionTimeUnit;
this.clientType = builder.clientType;
}
public static Builder builder() {
return new Builder();
}
public <T> T createClient(Class<T> apiType) {
JacksonEncoder jacksonEncoder = new JacksonEncoder(ObjectMapperFactory.getShared());
Encoder multipartEncoder = new FineractMultipartEncoder(jacksonEncoder);
return Feign.builder().client(getOrCreateHttpClient()).encoder(multipartEncoder)
.decoder(new JacksonDecoder(ObjectMapperFactory.getShared())).errorDecoder(new FineractErrorDecoder())
.options(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, true))
.retryer(Retryer.NEVER_RETRY).requestInterceptor(new BasicAuthRequestInterceptor(username, password))
.requestInterceptor(new TenantIdRequestInterceptor(tenantId)).logger(new Slf4jLogger(apiType))
.logLevel(debugEnabled ? feign.Logger.Level.FULL : feign.Logger.Level.BASIC).target(apiType, baseUrl);
}
private Client getOrCreateHttpClient() {
if (cachedHttpClient == null) {
synchronized (this) {
if (cachedHttpClient == null) {
cachedHttpClient = createHttpClient();
}
}
}
return cachedHttpClient;
}
private Client createHttpClient() {
switch (clientType) {
case APACHE:
return createApacheHttpClient();
case OKHTTP:
return createOkHttpClient();
default:
throw new IllegalStateException("Unsupported HTTP client type: " + clientType);
}
}
private Client createApacheHttpClient() {
try {
PoolingHttpClientConnectionManagerBuilder connManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(maxConnTotal).setMaxConnPerRoute(maxConnPerRoute);
if (disableSslVerification) {
SSLContext sslContext = createTrustAllSslContext();
SSLConnectionSocketFactory sslSocketFactory = SSLConnectionSocketFactoryBuilder.create().setSslContext(sslContext).build();
connManagerBuilder.setSSLSocketFactory(sslSocketFactory);
}
if (connectionTimeToLive > 0) {
connManagerBuilder.setConnectionTimeToLive(TimeValue.of(connectionTimeToLive, connectionTimeToLiveUnit));
}
PoolingHttpClientConnectionManager connectionManager = connManagerBuilder.build();
CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(connectionManager)
.setDefaultRequestConfig(RequestConfig.custom().setConnectTimeout(Timeout.ofMilliseconds(connectTimeout))
.setResponseTimeout(Timeout.ofMilliseconds(readTimeout)).build())
.evictIdleConnections(TimeValue.of(idleConnectionEvictionTime, idleConnectionEvictionTimeUnit))
.evictExpiredConnections().build();
return new ApacheHttp5Client(httpClient);
} catch (Exception e) {
throw new RuntimeException("Failed to create Apache HTTP client", e);
}
}
private Client createOkHttpClient() {
try {
okhttp3.OkHttpClient.Builder builder = new okhttp3.OkHttpClient.Builder().connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
.connectionPool(new okhttp3.ConnectionPool(maxConnTotal, connectionTimeToLive > 0 ? connectionTimeToLive : 5,
connectionTimeToLive > 0 ? connectionTimeToLiveUnit : TimeUnit.MINUTES));
if (disableSslVerification) {
SSLContext sslContext = createTrustAllSslContext();
builder.sslSocketFactory(sslContext.getSocketFactory(), createTrustAllManager());
builder.hostnameVerifier((hostname, session) -> true);
}
return new feign.okhttp.OkHttpClient(builder.build());
} catch (Exception e) {
throw new RuntimeException("Failed to create OkHttp client", e);
}
}
private X509TrustManager createTrustAllManager() {
return new X509TrustManager() {
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
@Override
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
@Override
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
};
}
private SSLContext createTrustAllSslContext() throws Exception {
TrustManager[] trustAllCerts = new TrustManager[] { createTrustAllManager() };
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
return sslContext;
}
public static class Builder {
private String baseUrl;
private String username;
private String password;
private String tenantId = "default";
private int connectTimeout = 30000; // 30 seconds
private int readTimeout = 60000; // 60 seconds
private boolean debugEnabled = false;
private long connectionTimeToLive = -1;
private TimeUnit connectionTimeToLiveUnit = TimeUnit.MILLISECONDS;
private boolean disableSslVerification = false;
private int maxConnTotal = 200;
private int maxConnPerRoute = 20;
private long idleConnectionEvictionTime = 30;
private TimeUnit idleConnectionEvictionTimeUnit = TimeUnit.SECONDS;
private HttpClientType clientType = HttpClientType.APACHE;
public Builder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
}
public Builder credentials(String username, String password) {
this.username = username;
this.password = password;
return this;
}
public Builder tenantId(String tenantId) {
this.tenantId = tenantId;
return this;
}
public Builder connectTimeout(int timeout, TimeUnit unit) {
this.connectTimeout = (int) unit.toMillis(timeout);
return this;
}
public Builder readTimeout(int timeout, TimeUnit unit) {
this.readTimeout = (int) unit.toMillis(timeout);
return this;
}
public Builder connectionTimeToLive(long ttl, TimeUnit unit) {
this.connectionTimeToLive = ttl;
this.connectionTimeToLiveUnit = unit;
return this;
}
public Builder debugEnabled(boolean debugEnabled) {
this.debugEnabled = debugEnabled;
return this;
}
public Builder disableSslVerification(boolean disableSslVerification) {
this.disableSslVerification = disableSslVerification;
return this;
}
public Builder maxConnTotal(int maxConnTotal) {
this.maxConnTotal = maxConnTotal;
return this;
}
public Builder maxConnPerRoute(int maxConnPerRoute) {
this.maxConnPerRoute = maxConnPerRoute;
return this;
}
public Builder idleConnectionEvictionTime(long time, TimeUnit unit) {
this.idleConnectionEvictionTime = time;
this.idleConnectionEvictionTimeUnit = unit;
return this;
}
public Builder httpClientType(HttpClientType clientType) {
this.clientType = clientType;
return this;
}
public FineractFeignClientConfig build() {
if (baseUrl == null || baseUrl.trim().isEmpty()) {
throw new IllegalStateException("baseUrl is required");
}
if (username == null || username.trim().isEmpty()) {
throw new IllegalStateException("username is required");
}
if (password == null) {
throw new IllegalStateException("password is required");
}
return new FineractFeignClientConfig(this);
}
}
}
@@ -0,0 +1,198 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.core5.http.HttpEntity;
/**
* Custom multipart encoder for Fineract Feign clients. Uses Apache HttpClient's MultipartEntityBuilder to properly
* construct multipart/form-data requests.
*/
public class FineractMultipartEncoder implements Encoder {
private final Encoder delegate;
public FineractMultipartEncoder(Encoder delegate) {
this.delegate = delegate;
}
@Override
public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
if (object instanceof MultipartData) {
encodeMultipart((MultipartData) object, template);
} else if (object instanceof File) {
encodeFileAsMultipart((File) object, template);
} else if (object instanceof String && template.headers().containsKey("Content-Type")) {
String contentType = template.headers().get("Content-Type").iterator().next();
if (contentType.startsWith("text/html") || contentType.startsWith("text/plain")) {
byte[] bodyBytes = ((String) object).getBytes(StandardCharsets.UTF_8);
template.body(bodyBytes, StandardCharsets.UTF_8);
} else {
delegate.encode(object, bodyType, template);
}
} else {
delegate.encode(object, bodyType, template);
}
}
private void encodeFileAsMultipart(File file, RequestTemplate template) throws EncodeException {
try {
byte[] fileData = Files.readAllBytes(file.toPath());
String contentType = detectContentType(file);
MultipartData multipartData = new MultipartData().addFile("file", file.getName(), fileData, contentType);
encodeMultipart(multipartData, template);
} catch (IOException e) {
throw new EncodeException("Failed to encode File as multipart: " + file, e);
}
}
private String detectContentType(File file) {
try {
String contentType = Files.probeContentType(file.toPath());
return contentType != null ? contentType : "application/octet-stream";
} catch (IOException e) {
return "application/octet-stream";
}
}
private void encodeMultipart(MultipartData data, RequestTemplate template) throws EncodeException {
try {
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
builder.setMode(org.apache.hc.client5.http.entity.mime.HttpMultipartMode.STRICT);
for (MultipartData.Part part : data.getParts()) {
if (part.getFileData() != null) {
org.apache.hc.core5.http.ContentType ct = org.apache.hc.core5.http.ContentType.create(part.getContentType());
builder.addBinaryBody(part.getName(), part.getFileData(), ct, part.getFileName());
} else if (part.getTextValue() != null) {
builder.addTextBody(part.getName(), part.getTextValue(), org.apache.hc.core5.http.ContentType.TEXT_PLAIN);
}
}
HttpEntity entity = builder.build();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
entity.writeTo(outputStream);
byte[] body = outputStream.toByteArray();
template.body(body, null);
template.removeHeader("Content-Type");
String contentTypeValue = entity.getContentType();
String cleanContentType = contentTypeValue;
if (contentTypeValue.contains(";")) {
int firstSemicolon = contentTypeValue.indexOf(';');
String mainType = contentTypeValue.substring(0, firstSemicolon).trim();
String paramsSection = contentTypeValue.substring(firstSemicolon + 1);
String boundary = null;
int boundaryIndex = paramsSection.indexOf("boundary=");
if (boundaryIndex != -1) {
int boundaryStart = boundaryIndex;
int boundaryEnd = paramsSection.indexOf(';', boundaryStart);
if (boundaryEnd == -1) {
boundary = paramsSection.substring(boundaryStart).trim();
} else {
boundary = paramsSection.substring(boundaryStart, boundaryEnd).trim();
}
}
if (boundary != null) {
cleanContentType = mainType + "; " + boundary;
}
}
template.header("Content-Type", cleanContentType);
} catch (IOException e) {
throw new EncodeException("Failed to encode multipart request", e);
}
}
public static class MultipartData {
private final java.util.List<Part> parts = new java.util.ArrayList<>();
public MultipartData addFile(String name, String fileName, byte[] data, String contentType) {
parts.add(new Part(name, fileName, data, contentType));
return this;
}
public MultipartData addText(String name, String value) {
parts.add(new Part(name, value));
return this;
}
public java.util.List<Part> getParts() {
return parts;
}
public static class Part {
private final String name;
private final String fileName;
private final byte[] fileData;
private final String contentType;
private final String textValue;
public Part(String name, String fileName, byte[] fileData, String contentType) {
this.name = name;
this.fileName = fileName;
this.fileData = fileData;
this.contentType = contentType;
this.textValue = null;
}
public Part(String name, String textValue) {
this.name = name;
this.textValue = textValue;
this.fileName = null;
this.fileData = null;
this.contentType = null;
}
public String getName() {
return name;
}
public String getFileName() {
return fileName;
}
public byte[] getFileData() {
return fileData;
}
public String getContentType() {
return contentType;
}
public String getTextValue() {
return textValue;
}
}
}
}
@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.fineract.client.adapter.ExternalIdAdapter;
/**
* Factory for creating and configuring Jackson ObjectMapper instances.
*/
public final class ObjectMapperFactory {
private static final ObjectMapper INSTANCE = createObjectMapper();
private ObjectMapperFactory() {
// Private constructor to prevent instantiation
}
/**
* Creates and configures a new ObjectMapper instance.
*
* @return A new configured ObjectMapper instance
*/
public static ObjectMapper createObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
// Configure the ObjectMapper
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
// Register Java 8 date/time support
mapper.registerModule(new JavaTimeModule());
// Register ExternalId adapter
mapper.registerModule(ExternalIdAdapter.createModule());
// Disable FAIL_ON_EMPTY_BEANS for empty responses
mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
return mapper;
}
/**
* Returns a shared, pre-configured ObjectMapper instance.
*
* @return A shared ObjectMapper instance
*/
public static ObjectMapper getShared() {
return INSTANCE;
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import feign.RequestInterceptor;
import feign.RequestTemplate;
public class TenantIdRequestInterceptor implements RequestInterceptor {
private final String tenantId;
public TenantIdRequestInterceptor(String tenantId) {
this.tenantId = tenantId;
}
@Override
public void apply(RequestTemplate template) {
template.header("Fineract-Platform-TenantId", tenantId);
if (!template.headers().containsKey("Content-Type")) {
template.header("Content-Type", "application/json");
}
if (!template.headers().containsKey("Accept")) {
template.header("Accept", "application/json");
}
}
}
@@ -0,0 +1,140 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.services;
import feign.Param;
import feign.RequestLine;
import feign.Response;
import java.util.List;
import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentData;
import org.apache.fineract.client.models.PostEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.PutEntityTypeEntityIdDocumentsResponse;
/**
* This class was originally generated by OpenAPI Generator (https://openapi-generator.tech), but then had to be
* manually edited to manually fix https://issues.apache.org/jira/browse/FINERACT-1227. If we could fix our OpenAPI /
* Swagger YAML generation from the JAX RS and OpenAPI annotation to have the correct notation for binary document files
* and images, then this could be removed again.
*/
public interface DocumentsApiFixed {
/**
* Create a Document Note: A document is created using a Multi-part form upload Body Parts name : Name or summary of
* the document description : Description of the document file : The file to be uploaded Mandatory Fields : file and
* description
*
* @param entityType
* entityType (required)
* @param entityId
* entityId (required)
* @param file
* (required)
* @param name
* name (optional)
* @param description
* description (optional)
* @return PostEntityTypeEntityIdDocumentsResponse
*/
@RequestLine("POST /v1/{entityType}/{entityId}/documents")
PostEntityTypeEntityIdDocumentsResponse createDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
org.apache.fineract.client.feign.FineractMultipartEncoder.MultipartData multipartData);
/**
* Remove a Document
*
* @param entityType
* entityType (required)
* @param entityId
* entityId (required)
* @param documentId
* documentId (required)
* @return DeleteEntityTypeEntityIdDocumentsResponse
*/
@RequestLine("DELETE /v1/{entityType}/{entityId}/documents/{documentId}")
DeleteEntityTypeEntityIdDocumentsResponse deleteDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
@Param("documentId") Long documentId);
/**
* Retrieve Binary File associated with Document Request used to download the file associated with the document
* Example Requests: clients/1/documents/1/attachment loans/1/documents/1/attachment
*
* @param entityType
* entityType (required)
* @param entityId
* entityId (required)
* @param documentId
* documentId (required)
* @return Response
*/
@RequestLine("GET /v1/{entityType}/{entityId}/documents/{documentId}/attachment")
@feign.Headers("Accept: */*")
Response downloadFile(@Param("entityType") String entityType, @Param("entityId") Long entityId, @Param("documentId") Long documentId);
/**
* Retrieve a Document Example Requests: clients/1/documents/1 loans/1/documents/1
* client_identifiers/1/documents/1?fields=name,description
*
* @param entityType
* entityType (required)
* @param entityId
* entityId (required)
* @param documentId
* documentId (required)
* @return DocumentData
*/
@RequestLine("GET /v1/{entityType}/{entityId}/documents/{documentId}")
DocumentData getDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
@Param("documentId") Long documentId);
/**
* List documents Example Requests: clients/1/documents client_identifiers/1/documents
* loans/1/documents?fields=name,description
*
* @param entityType
* entityType (required)
* @param entityId
* entityId (required)
* @return List&lt;DocumentData&gt;
*/
@RequestLine("GET /v1/{entityType}/{entityId}/documents")
List<DocumentData> retrieveAllDocuments(@Param("entityType") String entityType, @Param("entityId") Long entityId);
/**
* Update a Document Note: A document is updated using a Multi-part form upload Body Parts name Name or summary of
* the document description Description of the document file The file to be uploaded
*
* @param entityType
* entityType (required)
* @param entityId
* entityId (required)
* @param documentId
* documentId (required)
* @param file
* (optional)
* @param name
* name (optional)
* @param description
* description (optional)
* @return PutEntityTypeEntityIdDocumentsResponse
*/
@RequestLine("PUT /v1/{entityType}/{entityId}/documents/{documentId}")
PutEntityTypeEntityIdDocumentsResponse updateDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
@Param("documentId") Long documentId, org.apache.fineract.client.feign.FineractMultipartEncoder.MultipartData multipartData);
}
@@ -0,0 +1,97 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.services;
import feign.Headers;
import feign.Param;
import feign.QueryMap;
import feign.RequestLine;
import feign.Response;
import java.io.File;
import java.util.Map;
/**
* Client API (Feign) for /images.
*
* This class is entirely hand-written, inspired by DocumentsApiFixed, and from /images methods which currently end up
* in DefaultApi (see <a href="https://issues.apache.org/jira/browse/FINERACT-1222">FINERACT-1222</a>), but fixed for
* bugs in the code generation (see <a href="https://issues.apache.org/jira/browse/FINERACT-1227">FINERACT-1227</a>).
*
* Note: For image uploads, use {@link #prepareFileUpload(File)} to prepare file data as a Data URL.
*/
public interface ImagesApi {
@RequestLine("POST /v1/{entityType}/{entityId}/images")
@Headers("Content-Type: text/html")
Response create(@Param("entityType") String entityType, @Param("entityId") Long entityId, String dataUrl);
@RequestLine("GET /v1/{entityType}/{entityId}/images")
Response get(@Param("entityType") String entityType, @Param("entityId") Long entityId, @QueryMap Map<String, Object> queryParams);
@RequestLine("PUT /v1/{entityType}/{entityId}/images")
@Headers("Content-Type: text/html")
Response update(@Param("entityType") String entityType, @Param("entityId") Long entityId, String dataUrl);
@RequestLine("DELETE /v1/{entityType}/{entityId}/images")
Response delete(@Param("entityType") String entityType, @Param("entityId") Long entityId);
static String prepareFileUpload(File file) {
try {
byte[] fileData = java.nio.file.Files.readAllBytes(file.toPath());
String contentType = detectMediaType(file.getName());
String dataUrlPrefix = getDataUrlPrefix(contentType);
String base64Data = java.util.Base64.getEncoder().encodeToString(fileData);
return dataUrlPrefix + base64Data;
} catch (java.io.IOException e) {
throw new RuntimeException("Failed to prepare file for upload: " + file, e);
}
}
private static String getDataUrlPrefix(String contentType) {
return "data:" + contentType + ";base64,";
}
private static String detectMediaType(String fileName) {
if (fileName == null) {
return "application/octet-stream";
}
int dotPos = fileName.lastIndexOf('.');
if (dotPos == -1) {
return "application/octet-stream";
}
String ext = fileName.substring(dotPos + 1).toLowerCase();
switch (ext) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "tif":
case "tiff":
return "image/tiff";
case "pdf":
return "application/pdf";
default:
return "application/octet-stream";
}
}
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.services;
import feign.Param;
import feign.QueryMap;
import feign.RequestLine;
import feign.Response;
import java.util.Map;
import org.apache.fineract.client.models.RunReportsResponse;
public interface RunReportsApi {
/**
* Running a Report This resource allows you to run and receive output from pre-defined Apache Fineract reports.
* Reports can also be used to provide data for searching and workflow functionality. The default output is a JSON
* formatted "Generic Resultset". The Generic Resultset contains Column Heading as well as Data information.
* However, you can export to CSV format by simply adding "&amp;exportCSV=true" to the end of your URL. If Pentaho
* reports have been pre-defined, they can also be run through this resource. Pentaho reports can return HTML, PDF
* or CSV formats. The Apache Fineract reference application uses a JQuery plugin called stretchy reporting which,
* itself, uses this reports resource to provide a pretty flexible reporting User Interface (UI). Example Requests:
* runreports/Client%20Listing?R_officeId=1 runreports/Client%20Listing?R_officeId=1&amp;exportCSV=true
* runreports/OfficeIdSelectOne?R_officeId=1&amp;parameterType=true
* runreports/OfficeIdSelectOne?R_officeId=1&amp;parameterType=true&amp;exportCSV=true
* runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&amp;R_loanOfficerId=-1&amp;R_officeId=1&amp;R_startDate=2013-04-16&amp;output-type=HTML&amp;R_officeId=1
* runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&amp;R_loanOfficerId=-1&amp;R_officeId=1&amp;R_startDate=2013-04-16&amp;output-type=XLS&amp;R_officeId=1
* runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&amp;R_loanOfficerId=-1&amp;R_officeId=1&amp;R_startDate=2013-04-16&amp;output-type=CSV&amp;R_officeId=1
* runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&amp;R_loanOfficerId=-1&amp;R_officeId=1&amp;R_startDate=2013-04-16&amp;output-type=PDF&amp;R_officeId=1
*
* @param reportName
* reportName (required)
* @param parameters
* Dynamic query parameters for the report (required)
* @return RunReportsResponse
*/
@RequestLine("GET /v1/runreports/{reportName}")
RunReportsResponse runReportGetData(@Param("reportName") String reportName, @QueryMap Map<String, String> parameters);
/**
* Run Report which returns a response such as a PDF, CSV, XLS or XLSX file.
*
* @param reportName
* reportName (required)
* @param parameters
* Dynamic query parameters for the report (required)
* @return Response containing the file content
*/
@RequestLine("GET /v1/runreports/{reportName}")
Response runReportGetFile(@Param("reportName") String reportName, @QueryMap Map<String, String> parameters);
}
@@ -0,0 +1,91 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* Exception thrown by {@link FeignCalls} utility when Feign calls fail.
*/
@Slf4j
@Getter
public class CallFailedRuntimeException extends RuntimeException {
private final int status;
private final String developerMessage;
public CallFailedRuntimeException(FeignException cause) {
super(createMessage(cause), cause);
this.status = cause.status();
this.developerMessage = extractDeveloperMessage(cause);
}
private static String createMessage(FeignException e) {
StringBuilder sb = new StringBuilder("HTTP failed: status=").append(e.status());
if (e.request() != null) {
sb.append(", request=").append(e.request().url());
}
String contentString = e.contentUTF8();
if (contentString != null && !contentString.isEmpty()) {
sb.append(", errorBody=").append(contentString);
}
return sb.toString();
}
private static String extractDeveloperMessage(FeignException e) {
try {
byte[] content = e.content();
if (content == null || content.length == 0) {
return e.getMessage();
}
String contentString = new String(content, StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(contentString);
if (root.has("developerMessage")) {
return root.get("developerMessage").asText();
}
if (root.has("errors")) {
JsonNode errors = root.get("errors");
if (errors.isArray() && errors.size() > 0) {
JsonNode firstError = errors.get(0);
if (firstError.has("developerMessage")) {
return firstError.get("developerMessage").asText();
}
}
}
return contentString;
} catch (IOException ex) {
log.warn("Failed to extract developer message from error response", ex);
return e.getMessage();
}
}
}
@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.util;
import feign.FeignException;
import java.util.function.Supplier;
/**
* Extension methods for Feign calls. This class is recommended to be statically imported.
*/
public final class FeignCalls {
private FeignCalls() {}
/**
* Execute a Feign call, expecting success, returning strongly typed body. This covers the most typical use and is
* thus most used.
*
* @param feignCall
* the Feign call to execute
* @return the result of the successful call (never null)
* @throws CallFailedRuntimeException
* thrown if a problem occurred talking to the server, or the HTTP response code was not [200..300)
* successful
*/
public static <T> T ok(Supplier<T> feignCall) throws CallFailedRuntimeException {
try {
return feignCall.get();
} catch (FeignException e) {
throw new CallFailedRuntimeException(e);
}
}
/**
* Execute a Feign call that returns void. This is useful for operations that don't return a response body.
*
* @param feignCall
* the Feign call to execute
* @throws CallFailedRuntimeException
* thrown if a problem occurred talking to the server, or the HTTP response code was not [200..300)
* successful
*/
public static void executeVoid(Runnable feignCall) throws CallFailedRuntimeException {
try {
feignCall.run();
} catch (FeignException e) {
throw new CallFailedRuntimeException(e);
}
}
/**
* Execute a Feign call, returning the result without throwing CallFailedRuntimeException. This is useful when you
* want to handle FeignException yourself.
*
* @param feignCall
* the Feign call to execute
* @return the result of the call
* @throws FeignException
* thrown if a problem occurred talking to the server
*/
public static <T> T execute(Supplier<T> feignCall) throws FeignException {
return feignCall.get();
}
}
@@ -0,0 +1,136 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.util;
import feign.Response;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Optional;
import org.apache.fineract.client.feign.services.DocumentsApiFixed;
import org.apache.fineract.client.feign.services.ImagesApi;
/**
* Convenience utilities for file handling in Feign API calls.
*
* Provides helper methods for: - Media type detection from file extensions - File name extraction from HTTP response
* headers - Content type probing
*
* Used in conjunction with {@link DocumentsApiFixed} and {@link ImagesApi} which use Data URL format for file uploads
* (base64-encoded strings with data URI scheme).
*/
public final class FeignParts {
private FeignParts() {}
/**
* Determine the media type based on file extension.
*
* @param fileName
* the name of the file
* @return the media type string, or null if not recognized
*/
public static String mediaType(String fileName) {
if (fileName == null) {
return null;
}
int dotPos = fileName.lastIndexOf('.');
if (dotPos == -1) {
return null;
}
String ext = fileName.substring(dotPos + 1).toLowerCase();
switch (ext) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "tif":
case "tiff":
return "image/tiff";
case "gif":
return "image/gif";
case "pdf":
return "application/pdf";
case "docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case "doc":
return "application/msword";
case "xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case "xls":
return "application/vnd.ms-excel";
case "odt":
return "application/vnd.oasis.opendocument.text";
case "ods":
return "application/vnd.oasis.opendocument.spreadsheet";
case "txt":
return "text/plain";
default:
return "application/octet-stream";
}
}
/**
* Extract the file name from the Content-Disposition header of a response.
*
* @param response
* the HTTP response
* @return Optional containing the file name if present
*/
public static Optional<String> fileName(Response response) {
if (response.headers() == null) {
return Optional.empty();
}
java.util.Collection<String> contentDispositionHeaders = response.headers().get("Content-Disposition");
if (contentDispositionHeaders == null || contentDispositionHeaders.isEmpty()) {
return Optional.empty();
}
String contentDisposition = contentDispositionHeaders.iterator().next();
if (contentDisposition == null) {
return Optional.empty();
}
int i = contentDisposition.indexOf("; filename=\"");
if (i == -1) {
return Optional.empty();
}
return Optional.of(contentDisposition.substring(i + "; filename=\"".length(), contentDisposition.length() - 1));
}
/**
* Probe the content type of a file using Files.probeContentType.
*
* @param file
* the file to probe
* @return the content type, or application/octet-stream as fallback
* @throws IOException
* if an I/O error occurs
*/
public static String probeContentType(File file) throws IOException {
String contentType = Files.probeContentType(file.toPath());
if (contentType == null) {
contentType = mediaType(file.getName());
}
return contentType != null ? contentType : "application/octet-stream";
}
}
@@ -0,0 +1,47 @@
package {{package}};
import feign.*;
import feign.Param;
import feign.RequestLine;
{{#imports}}import {{import}};
{{/imports}}
import java.util.List;
import java.util.Map;
public interface {{classname}} {
{{#operations}}
{{#operation}}
/**
* {{summary}}
* {{notes}}
{{#allParams}}
* @param {{paramName}} {{description}}
{{/allParams}}
* @return {{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}
*/
@RequestLine("{{httpMethod}} {{path}}")
{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{operationId}}(
{{#pathParams}}
@Param("{{baseName}}") {{{dataType}}} {{paramName}}{{^-last}},{{/-last}}
{{/pathParams}}
{{#bodyParam}}
{{#hasPathParams}},{{/hasPathParams}}{{{dataType}}} {{paramName}}
{{/bodyParam}}
{{#hasQueryParams}}
{{#hasPathParams}}{{^bodyParam}},{{/bodyParam}}{{/hasPathParams}}{{#bodyParam}},{{/bodyParam}}
@QueryMap Map<String, Object> queryParams
{{/hasQueryParams}}
{{#hasHeaderParams}}
{{#hasPathParams}}{{^bodyParam}}{{^hasQueryParams}},{{/hasQueryParams}}{{/bodyParam}}{{/hasPathParams}}
{{#bodyParam}}{{^hasQueryParams}},{{/hasQueryParams}}{{/bodyParam}}
{{#hasQueryParams}},{{/hasQueryParams}}
@HeaderMap Map<String, Object> headerParams
{{/hasHeaderParams}}
);
{{/operation}}
{{/operations}}
}
@@ -0,0 +1,192 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.adapter;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.fineract.client.feign.ObjectMapperFactory;
import org.apache.fineract.client.models.ExternalId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ExternalIdAdapterTest {
private ObjectMapper mapper;
@BeforeEach
void setUp() {
mapper = ObjectMapperFactory.getShared();
}
@Test
void testSerializeExternalId() throws Exception {
ExternalId externalId = new ExternalId().value("EXT-12345");
String json = mapper.writeValueAsString(externalId);
assertThat(json).isEqualTo("\"EXT-12345\"");
}
@Test
void testSerializeExternalIdWithSpecialCharacters() throws Exception {
ExternalId externalId = new ExternalId().value("EXT-ID-2024/01/15");
String json = mapper.writeValueAsString(externalId);
assertThat(json).isEqualTo("\"EXT-ID-2024/01/15\"");
}
@Test
void testSerializeNullExternalId() throws Exception {
ExternalId externalId = new ExternalId().value(null);
String json = mapper.writeValueAsString(externalId);
assertThat(json).isEqualTo("null");
}
@Test
void testSerializeExternalIdObjectNull() throws Exception {
ExternalId externalId = null;
String json = mapper.writeValueAsString(externalId);
assertThat(json).isEqualTo("null");
}
@Test
void testDeserializeExternalId() throws Exception {
String json = "\"EXT-67890\"";
ExternalId externalId = mapper.readValue(json, ExternalId.class);
assertThat(externalId).isNotNull();
assertThat(externalId.getValue()).isEqualTo("EXT-67890");
}
@Test
void testDeserializeExternalIdWithSpecialCharacters() throws Exception {
String json = "\"CLIENT-2024-001/ABC\"";
ExternalId externalId = mapper.readValue(json, ExternalId.class);
assertThat(externalId).isNotNull();
assertThat(externalId.getValue()).isEqualTo("CLIENT-2024-001/ABC");
}
@Test
void testDeserializeNullExternalId() throws Exception {
String json = "null";
ExternalId externalId = mapper.readValue(json, ExternalId.class);
assertThat(externalId).isNull();
}
@Test
void testRoundTripSerialization() throws Exception {
ExternalId original = new ExternalId().value("TEST-ID-001");
String json = mapper.writeValueAsString(original);
ExternalId deserialized = mapper.readValue(json, ExternalId.class);
assertThat(deserialized.getValue()).isEqualTo(original.getValue());
}
@Test
void testRoundTripSerializationWithUUID() throws Exception {
ExternalId original = new ExternalId().value("550e8400-e29b-41d4-a716-446655440000");
String json = mapper.writeValueAsString(original);
ExternalId deserialized = mapper.readValue(json, ExternalId.class);
assertThat(deserialized.getValue()).isEqualTo(original.getValue());
}
@Test
void testRoundTripSerializationWithNumericString() throws Exception {
ExternalId original = new ExternalId().value("123456789");
String json = mapper.writeValueAsString(original);
ExternalId deserialized = mapper.readValue(json, ExternalId.class);
assertThat(deserialized.getValue()).isEqualTo(original.getValue());
}
@Test
void testDeserializeEmptyString() throws Exception {
String json = "\"\"";
ExternalId externalId = mapper.readValue(json, ExternalId.class);
assertThat(externalId).isNotNull();
assertThat(externalId.getValue()).isEmpty();
}
@Test
void testSerializeExternalIdWithEmptyValue() throws Exception {
ExternalId externalId = new ExternalId().value("");
String json = mapper.writeValueAsString(externalId);
assertThat(json).isEqualTo("\"\"");
}
@Test
void testModuleCreation() {
assertThat(ExternalIdAdapter.createModule()).isNotNull();
assertThat(ExternalIdAdapter.createModule().getModuleName()).isEqualTo("ExternalIdModule");
}
@Test
void testDeserializeExternalIdWithWhitespace() throws Exception {
String json = "\" EXT-001 \"";
ExternalId externalId = mapper.readValue(json, ExternalId.class);
assertThat(externalId).isNotNull();
assertThat(externalId.getValue()).isEqualTo(" EXT-001 ");
}
@Test
void testSerializeExternalIdInObject() throws Exception {
TestObject obj = new TestObject();
obj.externalId = new ExternalId().value("TEST-123");
String json = mapper.writeValueAsString(obj);
assertThat(json).contains("\"externalId\":\"TEST-123\"");
}
@Test
void testDeserializeExternalIdInObject() throws Exception {
String json = "{\"externalId\":\"TEST-456\"}";
TestObject obj = mapper.readValue(json, TestObject.class);
assertThat(obj.externalId).isNotNull();
assertThat(obj.externalId.getValue()).isEqualTo("TEST-456");
}
static class TestObject {
public ExternalId externalId;
}
}
@@ -0,0 +1,140 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import feign.Request;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FeignExceptionTest {
private Request request;
private byte[] responseBody;
@BeforeEach
void setUp() {
request = Request.create(Request.HttpMethod.GET, "/api/test", Collections.emptyMap(), null, StandardCharsets.UTF_8, null);
responseBody = "Error response body".getBytes(StandardCharsets.UTF_8);
}
@Test
void testConstructorWithErrorDetails() {
String developerMessage = "Technical error details";
String userMessage = "User-friendly error message";
FeignException exception = new FeignException(400, "Error message", request, responseBody, developerMessage, userMessage);
assertNotNull(exception);
assertEquals(400, exception.status());
assertEquals(developerMessage, exception.getDeveloperMessage());
assertEquals(userMessage, exception.getUserMessage());
assertEquals(request, exception.request());
assertEquals(responseBody, exception.responseBody());
}
@Test
void testGetMessage() {
String developerMessage = "Developer error";
String userMessage = "User error";
FeignException exception = new FeignException(500, "Base message", request, responseBody, developerMessage, userMessage);
String message = exception.getMessage();
assertNotNull(message);
assertTrue(message.contains("500"));
assertTrue(message.contains(userMessage));
assertTrue(message.contains(developerMessage));
}
@Test
void testGetDeveloperMessage() {
String developerMessage = "This is a technical error";
FeignException exception = new FeignException(400, "Error", request, responseBody, developerMessage, null);
assertEquals(developerMessage, exception.getDeveloperMessage());
}
@Test
void testGetUserMessage() {
String userMessage = "This is a user-friendly error";
FeignException exception = new FeignException(400, "Error", request, responseBody, null, userMessage);
assertEquals(userMessage, exception.getUserMessage());
}
@Test
void testNullErrorMessages() {
FeignException exception = new FeignException(404, "Not found", request, responseBody, null, null);
assertNull(exception.getDeveloperMessage());
assertNull(exception.getUserMessage());
String message = exception.getMessage();
assertNotNull(message);
assertTrue(message.contains("404"));
assertTrue(message.contains("Not found"));
}
@Test
void testGetMessageWithOnlyUserMessage() {
String userMessage = "User error only";
FeignException exception = new FeignException(400, "Base message", request, responseBody, null, userMessage);
String message = exception.getMessage();
assertTrue(message.contains("400"));
assertTrue(message.contains(userMessage));
}
@Test
void testGetMessageWithOnlyDeveloperMessage() {
String developerMessage = "Developer error only";
FeignException exception = new FeignException(400, "Base message", request, responseBody, developerMessage, null);
String message = exception.getMessage();
assertTrue(message.contains("400"));
assertTrue(message.contains(developerMessage));
}
@Test
void testResponseBodyAsString() {
FeignException exception = new FeignException(400, "Error", request, responseBody, null, null);
String bodyString = exception.responseBodyAsString();
assertNotNull(bodyString);
assertEquals("Error response body", bodyString);
}
@Test
void testResponseBodyAsStringWithNullBody() {
FeignException exception = new FeignException(400, "Error", request, null, null, null);
String bodyString = exception.responseBodyAsString();
assertNull(bodyString);
}
}
@@ -0,0 +1,180 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import feign.Request;
import feign.Response;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FineractErrorDecoderTest {
private FineractErrorDecoder decoder;
private Request request;
@BeforeEach
void setUp() {
decoder = new FineractErrorDecoder();
request = Request.create(Request.HttpMethod.GET, "/api/test", Collections.emptyMap(), null, StandardCharsets.UTF_8, null);
}
@Test
void testDecodeValidFineractError() {
String jsonError = "{\"developerMessage\":\"Developer error message\",\"userMessage\":\"User error message\"}";
Response response = createResponse(400, jsonError);
Exception exception = decoder.decode("Test#method", response);
assertInstanceOf(FeignException.class, exception);
FeignException feignException = (FeignException) exception;
assertEquals(400, feignException.status());
assertEquals("User error message", feignException.getUserMessage());
assertEquals("Developer error message", feignException.getDeveloperMessage());
}
@Test
void testDecodeInvalidJson() {
String invalidJson = "This is not valid JSON";
Response response = createResponse(400, invalidJson);
Exception exception = decoder.decode("Test#method", response);
assertNotNull(exception);
}
@Test
void testDecodeNullBody() {
Response response = createResponse(404, null);
Exception exception = decoder.decode("Test#method", response);
assertNotNull(exception);
}
@Test
void testDecode400Error() {
String jsonError = "{\"developerMessage\":\"Bad request details\",\"userMessage\":\"Invalid input\"}";
Response response = createResponse(400, jsonError);
Exception exception = decoder.decode("Test#method", response);
assertInstanceOf(FeignException.class, exception);
FeignException feignException = (FeignException) exception;
assertEquals(400, feignException.status());
}
@Test
void testDecode404Error() {
String jsonError = "{\"developerMessage\":\"Resource not found details\",\"userMessage\":\"Not found\"}";
Response response = createResponse(404, jsonError);
Exception exception = decoder.decode("Test#method", response);
assertInstanceOf(FeignException.class, exception);
FeignException feignException = (FeignException) exception;
assertEquals(404, feignException.status());
}
@Test
void testDecode500Error() {
String jsonError = "{\"developerMessage\":\"Internal server error details\",\"userMessage\":\"Server error\"}";
Response response = createResponse(500, jsonError);
Exception exception = decoder.decode("Test#method", response);
assertInstanceOf(FeignException.class, exception);
FeignException feignException = (FeignException) exception;
assertEquals(500, feignException.status());
}
@Test
void testExtractDeveloperMessage() {
String jsonError = "{\"developerMessage\":\"Technical details here\"}";
Response response = createResponse(400, jsonError);
Exception exception = decoder.decode("Test#method", response);
assertInstanceOf(FeignException.class, exception);
FeignException feignException = (FeignException) exception;
assertEquals("Technical details here", feignException.getDeveloperMessage());
}
@Test
void testExtractUserMessage() {
String jsonError = "{\"userMessage\":\"User-friendly message\"}";
Response response = createResponse(400, jsonError);
Exception exception = decoder.decode("Test#method", response);
assertInstanceOf(FeignException.class, exception);
FeignException feignException = (FeignException) exception;
assertEquals("User-friendly message", feignException.getUserMessage());
}
private Response createResponse(int status, String body) {
Map<String, Collection<String>> headers = new HashMap<>();
Response.Body responseBody = null;
if (body != null) {
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
responseBody = new Response.Body() {
@Override
public Integer length() {
return bodyBytes.length;
}
@Override
public boolean isRepeatable() {
return true;
}
@Override
public ByteArrayInputStream asInputStream() {
return new ByteArrayInputStream(bodyBytes);
}
@Override
public java.io.Reader asReader() {
return new java.io.InputStreamReader(asInputStream(), StandardCharsets.UTF_8);
}
@Override
public java.io.Reader asReader(java.nio.charset.Charset charset) {
return new java.io.InputStreamReader(asInputStream(), charset);
}
@Override
public void close() {}
};
}
return Response.builder().status(status).reason("Test").request(request).headers(headers).body(responseBody).build();
}
}
@@ -0,0 +1,93 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import feign.RequestLine;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class FineractFeignClientConfigTest {
@Test
void testBuilderDefaults() {
FineractFeignClientConfig.Builder builder = FineractFeignClientConfig.builder();
FineractFeignClientConfig config = builder.baseUrl("http://localhost:8080").credentials("admin", "password").build();
assertNotNull(config);
}
@Test
void testBuilderConfiguration() {
String baseUrl = "http://example.com:8080";
String username = "testuser";
String password = "testpass";
int connectTimeoutSeconds = 10;
int readTimeoutSeconds = 30;
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials(username, password)
.connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS).readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.debugEnabled(true).build();
assertNotNull(config);
}
@Test
void testConnectionTimeToLiveConfiguration() {
long ttl = 5;
TimeUnit ttlUnit = TimeUnit.MINUTES;
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
.credentials("admin", "password").connectionTimeToLive(ttl, ttlUnit).build();
assertNotNull(config);
}
@Test
void testEncoderDecoderConfiguration() {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
.credentials("admin", "password").build();
assertNotNull(config);
}
@Test
void testErrorDecoderConfiguration() {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
.credentials("admin", "password").build();
assertNotNull(config);
}
@Test
void testClientCreation() {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
.credentials("admin", "password").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
TestApi client = config.createClient(TestApi.class);
assertNotNull(client);
}
interface TestApi {
@RequestLine("GET /test")
String test();
}
}
@@ -0,0 +1,120 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class ObjectMapperFactoryTest {
@Test
void testSharedObjectMapperNotNull() {
ObjectMapper mapper = ObjectMapperFactory.getShared();
assertNotNull(mapper);
}
@Test
void testSharedObjectMapperSingleton() {
ObjectMapper mapper1 = ObjectMapperFactory.getShared();
ObjectMapper mapper2 = ObjectMapperFactory.getShared();
assertSame(mapper1, mapper2);
}
@Test
void testJava8DateTimeSerialization() throws JsonProcessingException {
ObjectMapper mapper = ObjectMapperFactory.getShared();
LocalDate date = LocalDate.of(2024, 1, 15);
String json = mapper.writeValueAsString(new TestObject(date));
assertNotNull(json);
}
@Test
void testJava8DateTimeDeserialization() throws IOException {
ObjectMapper mapper = ObjectMapperFactory.getShared();
String json = "{\"date\":\"2024-01-15\"}";
TestObject obj = mapper.readValue(json, TestObject.class);
assertNotNull(obj);
assertNotNull(obj.getDate());
assertEquals(LocalDate.of(2024, 1, 15), obj.getDate());
}
@Test
void testNullHandling() throws JsonProcessingException {
ObjectMapper mapper = ObjectMapperFactory.getShared();
TestObject obj = new TestObject(null);
String json = mapper.writeValueAsString(obj);
assertNotNull(json);
}
@Test
void testDeserializeNullValue() throws IOException {
ObjectMapper mapper = ObjectMapperFactory.getShared();
String json = "{\"date\":null}";
TestObject obj = mapper.readValue(json, TestObject.class);
assertNotNull(obj);
assertNull(obj.getDate());
}
@Test
void testUnknownPropertiesIgnored() throws IOException {
ObjectMapper mapper = ObjectMapperFactory.getShared();
String json = "{\"date\":\"2024-01-15\",\"unknownField\":\"value\"}";
TestObject obj = mapper.readValue(json, TestObject.class);
assertNotNull(obj);
assertEquals(LocalDate.of(2024, 1, 15), obj.getDate());
}
@Test
void testCreateObjectMapperNotNull() {
ObjectMapper mapper = ObjectMapperFactory.createObjectMapper();
assertNotNull(mapper);
}
static class TestObject {
private LocalDate date;
TestObject() {}
TestObject(LocalDate date) {
this.date = date;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}
}
@@ -0,0 +1,421 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.integration;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import feign.RequestLine;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("integration")
class EncoderDecoderIntegrationTest {
private WireMockServer wireMockServer;
private String baseUrl;
private FineractFeignClientConfig config;
@BeforeEach
void setUp() {
wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMockServer.start();
baseUrl = "http://localhost:" + wireMockServer.port();
config = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials("testuser", "testpass")
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
}
@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
@Test
void testComplexObjectSerialization() {
wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(aResponse().withStatus(201)
.withHeader("Content-Type", "application/json").withBody("{\"id\":100,\"status\":\"created\"}")));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("complex-test");
request.setAmount(new BigDecimal("1234.56"));
request.setCreatedDate(LocalDate.of(2024, 1, 15));
request.setItems(Arrays.asList("item1", "item2", "item3"));
Map<String, String> metadata = new HashMap<>();
metadata.put("key1", "value1");
metadata.put("key2", "value2");
request.setMetadata(metadata);
NestedObject nested = new NestedObject();
nested.setNestedName("nested-value");
nested.setNestedDate(LocalDate.of(2024, 2, 20));
request.setNested(nested);
SimpleResponse response = client.createComplex(request);
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(100L);
assertThat(response.getStatus()).isEqualTo("created");
}
@Test
void testNullValueHandling() {
wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":null}")));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("null-test");
request.setAmount(null);
request.setCreatedDate(null);
request.setItems(null);
request.setMetadata(null);
request.setNested(null);
SimpleResponse response = client.createComplex(request);
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(1L);
assertThat(response.getStatus()).isNull();
}
@Test
void testDateFormats() {
String responseJson = "{\"id\":1,\"date\":\"2024-03-15\",\"dateTime\":\"2024-03-15T14:30:00\",\"timestamp\":\"2024-03-15T14:30:00.123\"}";
wireMockServer.stubFor(post(urlEqualTo("/date-test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseJson)));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("date-test");
request.setCreatedDate(LocalDate.of(2024, 3, 15));
DateResponse response = client.testDates(request);
assertThat(response).isNotNull();
assertThat(response.getDate()).isEqualTo(LocalDate.of(2024, 3, 15));
assertThat(response.getDateTime()).isEqualTo(LocalDateTime.of(2024, 3, 15, 14, 30, 0));
assertThat(response.getTimestamp()).isNotNull();
}
@Test
void testLargePayload() {
StringBuilder largeString = new StringBuilder();
for (int i = 0; i < 1000; i++) {
largeString.append("This is line ").append(i).append(". ");
}
wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"processed\"}")));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName(largeString.toString());
request.setAmount(new BigDecimal("999999.99"));
request.setCreatedDate(LocalDate.now(ZoneId.systemDefault()));
SimpleResponse response = client.createComplex(request);
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo("processed");
}
@Test
void testEmptyCollections() {
wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"ok\"}")));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("empty-test");
request.setItems(Arrays.asList());
request.setMetadata(new HashMap<>());
SimpleResponse response = client.createComplex(request);
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo("ok");
}
@Test
void testBigDecimalPrecision() {
wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"precise\"}")));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("precision-test");
request.setAmount(new BigDecimal("123456789.123456789"));
SimpleResponse response = client.createComplex(request);
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo("precise");
}
@Test
void testNestedObjectDeserialization() {
String responseJson = "{\"id\":1,\"nested\":{\"nestedName\":\"deep-value\",\"nestedDate\":\"2024-04-01\"},\"items\":[\"a\",\"b\",\"c\"]}";
wireMockServer.stubFor(post(urlEqualTo("/nested"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseJson)));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("nested-test");
NestedResponse response = client.testNested(request);
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(1L);
assertThat(response.getNested()).isNotNull();
assertThat(response.getNested().getNestedName()).isEqualTo("deep-value");
assertThat(response.getNested().getNestedDate()).isEqualTo(LocalDate.of(2024, 4, 1));
assertThat(response.getItems()).containsExactly("a", "b", "c");
}
@Test
void testSpecialCharacterHandling() {
wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"special\"}")));
ComplexApi client = config.createClient(ComplexApi.class);
ComplexRequest request = new ComplexRequest();
request.setName("Test with special chars: <>\"'&\n\t");
SimpleResponse response = client.createComplex(request);
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo("special");
}
interface ComplexApi {
@RequestLine("POST /complex")
SimpleResponse createComplex(ComplexRequest request);
@RequestLine("POST /date-test")
DateResponse testDates(ComplexRequest request);
@RequestLine("POST /nested")
NestedResponse testNested(ComplexRequest request);
}
static class ComplexRequest {
private String name;
private BigDecimal amount;
private LocalDate createdDate;
private List<String> items;
private Map<String, String> metadata;
private NestedObject nested;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public LocalDate getCreatedDate() {
return createdDate;
}
public void setCreatedDate(LocalDate createdDate) {
this.createdDate = createdDate;
}
public List<String> getItems() {
return items;
}
public void setItems(List<String> items) {
this.items = items;
}
public Map<String, String> getMetadata() {
return metadata;
}
public void setMetadata(Map<String, String> metadata) {
this.metadata = metadata;
}
public NestedObject getNested() {
return nested;
}
public void setNested(NestedObject nested) {
this.nested = nested;
}
}
static class NestedObject {
private String nestedName;
private LocalDate nestedDate;
public String getNestedName() {
return nestedName;
}
public void setNestedName(String nestedName) {
this.nestedName = nestedName;
}
public LocalDate getNestedDate() {
return nestedDate;
}
public void setNestedDate(LocalDate nestedDate) {
this.nestedDate = nestedDate;
}
}
static class SimpleResponse {
private Long id;
private String status;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
static class DateResponse {
private Long id;
private LocalDate date;
private LocalDateTime dateTime;
private LocalDateTime timestamp;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public LocalDateTime getDateTime() {
return dateTime;
}
public void setDateTime(LocalDateTime dateTime) {
this.dateTime = dateTime;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
}
static class NestedResponse {
private Long id;
private NestedObject nested;
private List<String> items;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public NestedObject getNested() {
return nested;
}
public void setNested(NestedObject nested) {
this.nested = nested;
}
public List<String> getItems() {
return items;
}
public void setItems(List<String> items) {
this.items = items;
}
}
}
@@ -0,0 +1,252 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.integration;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.containing;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import feign.RequestLine;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FeignException;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("integration")
class FineractFeignClientIntegrationTest {
private WireMockServer wireMockServer;
private String baseUrl;
private FineractFeignClientConfig config;
@BeforeEach
void setUp() {
wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMockServer.start();
baseUrl = "http://localhost:" + wireMockServer.port();
config = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials("testuser", "testpass")
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
}
@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
@Test
void testJsonEncoding() {
wireMockServer
.stubFor(post(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"message\":\"success\",\"timestamp\":\"2024-01-15T10:30:00\"}")));
TestApi client = config.createClient(TestApi.class);
TestRequest request = new TestRequest();
request.setName("test-name");
request.setDate(LocalDate.of(2024, 1, 15));
TestResponse response = client.createTest(request);
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(1L);
assertThat(response.getMessage()).isEqualTo("success");
assertThat(response.getTimestamp()).isEqualTo(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
}
@Test
void testJsonDecoding() {
wireMockServer
.stubFor(get(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":42,\"message\":\"decoded-message\",\"timestamp\":\"2024-12-25T18:45:30\"}")));
TestApi client = config.createClient(TestApi.class);
TestResponse response = client.getTest();
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(42L);
assertThat(response.getMessage()).isEqualTo("decoded-message");
assertThat(response.getTimestamp()).isEqualTo(LocalDateTime.of(2024, 12, 25, 18, 45, 30));
}
@Test
void testJava8DateTimeSerialization() {
wireMockServer.stubFor(post(urlEqualTo("/test")).withRequestBody(containing("2024-06-30"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":99,\"message\":\"date-test\",\"timestamp\":\"2024-06-30T12:00:00\"}")));
TestApi client = config.createClient(TestApi.class);
TestRequest request = new TestRequest();
request.setName("date-test");
request.setDate(LocalDate.of(2024, 6, 30));
TestResponse response = client.createTest(request);
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(99L);
assertThat(response.getTimestamp()).isNotNull();
assertThat(response.getTimestamp().toLocalDate()).isEqualTo(LocalDate.of(2024, 6, 30));
}
@Test
void testErrorDecoder() {
wireMockServer.stubFor(
get(urlEqualTo("/error")).willReturn(aResponse().withStatus(404).withHeader("Content-Type", "application/json").withBody(
"{\"developerMessage\":\"Resource not found\",\"httpStatusCode\":\"404\",\"defaultUserMessage\":\"Not Found\"}")));
TestApi client = config.createClient(TestApi.class);
assertThatThrownBy(client::getError).isInstanceOf(FeignException.class).hasMessageContaining("Resource not found");
}
@Test
void testErrorDecoderWithServerError() {
wireMockServer.stubFor(
get(urlEqualTo("/error")).willReturn(aResponse().withStatus(500).withHeader("Content-Type", "application/json").withBody(
"{\"developerMessage\":\"Internal server error occurred\",\"httpStatusCode\":\"500\",\"defaultUserMessage\":\"Server Error\"}")));
TestApi client = config.createClient(TestApi.class);
assertThatThrownBy(client::getError).isInstanceOf(FeignException.class).hasMessageContaining("Internal server error occurred");
}
@Test
void testConnectionTimeToLiveConfiguration() {
FineractFeignClientConfig ttlConfig = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials("testuser", "testpass")
.connectionTimeToLive(5, TimeUnit.MINUTES).build();
wireMockServer
.stubFor(get(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"message\":\"ttl-test\",\"timestamp\":\"2024-01-01T00:00:00\"}")));
TestApi client = ttlConfig.createClient(TestApi.class);
TestResponse response = client.getTest();
assertThat(response).isNotNull();
assertThat(response.getMessage()).isEqualTo("ttl-test");
}
@Test
void testBasicAuthentication() {
wireMockServer.stubFor(get(urlEqualTo("/test")).withHeader("Authorization", equalTo("Basic dGVzdHVzZXI6dGVzdHBhc3M="))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"message\":\"authenticated\",\"timestamp\":\"2024-01-01T00:00:00\"}")));
TestApi client = config.createClient(TestApi.class);
TestResponse response = client.getTest();
assertThat(response).isNotNull();
assertThat(response.getMessage()).isEqualTo("authenticated");
}
@Test
void testNullValueHandling() {
wireMockServer
.stubFor(post(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"message\":null,\"timestamp\":\"2024-01-01T00:00:00\"}")));
TestApi client = config.createClient(TestApi.class);
TestRequest request = new TestRequest();
request.setName("null-test");
request.setDate(null);
TestResponse response = client.createTest(request);
assertThat(response).isNotNull();
assertThat(response.getMessage()).isNull();
}
interface TestApi {
@RequestLine("GET /test")
TestResponse getTest();
@RequestLine("POST /test")
TestResponse createTest(TestRequest request);
@RequestLine("GET /error")
TestResponse getError();
}
static class TestRequest {
private String name;
private LocalDate date;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}
static class TestResponse {
private Long id;
private String message;
private LocalDateTime timestamp;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
}
}
@@ -0,0 +1,269 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.feign.performance;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import feign.RequestLine;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("performance")
class ConnectionPoolPerformanceTest {
private WireMockServer wireMockServer;
@BeforeEach
void setUp() {
wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMockServer.start();
}
@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
@Test
void testConnectionPoolTTL() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectionTimeToLive(2, TimeUnit.SECONDS).connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
TestApi api = config.createClient(TestApi.class);
String response1 = api.getTest();
assertThat(response1).isEqualTo("OK");
Thread.sleep(3000);
String response2 = api.getTest();
assertThat(response2).isEqualTo("OK");
}
@Test
void testConnectionPoolTTLWithMultipleRequests() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectionTimeToLive(1, TimeUnit.SECONDS).connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
TestApi api = config.createClient(TestApi.class);
for (int i = 0; i < 5; i++) {
String response = api.getTest();
assertThat(response).isEqualTo("OK");
Thread.sleep(1500);
}
}
@Test
void testConcurrentRequests() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"").withFixedDelay(100)));
TestApi api = config.createClient(TestApi.class);
int numRequests = 50;
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<String>> futures = new ArrayList<>();
long startTime = System.currentTimeMillis();
for (int i = 0; i < numRequests; i++) {
futures.add(executor.submit(api::getTest));
}
for (Future<String> future : futures) {
assertThat(future.get()).isEqualTo("OK");
}
long duration = System.currentTimeMillis() - startTime;
executor.shutdown();
executor.awaitTermination(30, TimeUnit.SECONDS);
assertThat(duration).isLessThan(2000);
}
@Test
void testConcurrentRequestsWithHighLoad() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"").withFixedDelay(50)));
TestApi api = config.createClient(TestApi.class);
int numRequests = 100;
ExecutorService executor = Executors.newFixedThreadPool(20);
List<Future<String>> futures = new ArrayList<>();
long startTime = System.currentTimeMillis();
for (int i = 0; i < numRequests; i++) {
futures.add(executor.submit(api::getTest));
}
int successCount = 0;
for (Future<String> future : futures) {
if ("OK".equals(future.get())) {
successCount++;
}
}
long duration = System.currentTimeMillis() - startTime;
executor.shutdown();
executor.awaitTermination(30, TimeUnit.SECONDS);
assertThat(successCount).isEqualTo(numRequests);
assertThat(duration).isLessThan(5000);
}
@Test
void testConnectionRecycling() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectionTimeToLive(500, TimeUnit.MILLISECONDS).connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
TestApi api = config.createClient(TestApi.class);
for (int i = 0; i < 10; i++) {
String response = api.getTest();
assertThat(response).isEqualTo("OK");
Thread.sleep(600);
}
}
@Test
void testDefaultConnectionTimeToLive() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").build();
wireMockServer.stubFor(get(urlEqualTo("/test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
TestApi api = config.createClient(TestApi.class);
String response = api.getTest();
assertThat(response).isEqualTo("OK");
}
@Test
void testSequentialRequests() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
TestApi api = config.createClient(TestApi.class);
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
String response = api.getTest();
assertThat(response).isEqualTo("OK");
}
long duration = System.currentTimeMillis() - startTime;
assertThat(duration).isLessThan(5000);
}
@Test
void testConnectionPoolWithDifferentEndpoints() throws Exception {
FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
.credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
wireMockServer.stubFor(get(urlEqualTo("/test"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
wireMockServer.stubFor(get(urlEqualTo("/test2"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK2\"")));
wireMockServer.stubFor(get(urlEqualTo("/test3"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK3\"")));
TestApi api = config.createClient(TestApi.class);
ExecutorService executor = Executors.newFixedThreadPool(5);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 30; i++) {
int endpoint = i % 3;
futures.add(executor.submit(() -> {
switch (endpoint) {
case 0:
return api.getTest();
case 1:
return api.getTest2();
default:
return api.getTest3();
}
}));
}
for (Future<String> future : futures) {
assertThat(future.get()).isIn("OK", "OK2", "OK3");
}
executor.shutdown();
executor.awaitTermination(30, TimeUnit.SECONDS);
}
interface TestApi {
@RequestLine("GET /test")
String getTest();
@RequestLine("GET /test2")
String getTest2();
@RequestLine("GET /test3")
String getTest3();
}
}
@@ -0,0 +1,208 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.services;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.delete;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import feign.Response;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.apache.fineract.client.feign.services.DocumentsApiFixed;
import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@Tag("integration")
class DocumentsApiFixedIntegrationTest {
@TempDir
File tempDir;
private WireMockServer wireMockServer;
private FineractFeignClientConfig config;
private File testDocumentFile;
@BeforeEach
void setUp() throws IOException {
wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMockServer.start();
config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port()).credentials("test", "test")
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
testDocumentFile = new File(tempDir, "test-doc.pdf");
Files.write(testDocumentFile.toPath(), "%PDF-1.4 test content".getBytes(StandardCharsets.UTF_8));
}
@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
@Test
void testGetDocumentsForClient() {
String responseBody = "[{\"id\":1,\"name\":\"Document1\",\"description\":\"Test document\"}]";
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
List<DocumentData> documents = api.retrieveAllDocuments("clients", 123L);
assertThat(documents).isNotNull();
assertThat(documents).hasSize(1);
assertThat(documents.get(0).getId()).isEqualTo(1L);
}
@Test
void testGetDocumentsForLoan() {
String responseBody = "[{\"id\":2,\"name\":\"LoanDocument\",\"description\":\"Loan agreement\"}]";
wireMockServer.stubFor(get(urlEqualTo("/v1/loans/456/documents"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
List<DocumentData> documents = api.retrieveAllDocuments("loans", 456L);
assertThat(documents).isNotNull();
assertThat(documents).hasSize(1);
assertThat(documents.get(0).getName()).isEqualTo("LoanDocument");
}
@Test
void testGetDocumentsEmpty() {
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/999/documents"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("[]")));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
List<DocumentData> documents = api.retrieveAllDocuments("clients", 999L);
assertThat(documents).isNotNull();
assertThat(documents).isEmpty();
}
@Test
void testGetSingleDocument() {
String responseBody = "{\"id\":1,\"name\":\"Document1\",\"description\":\"Test document\",\"fileName\":\"doc.pdf\"}";
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents/1"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
DocumentData document = api.getDocument("clients", 123L, 1L);
assertThat(document).isNotNull();
assertThat(document.getId()).isEqualTo(1L);
assertThat(document.getName()).isEqualTo("Document1");
assertThat(document.getFileName()).isEqualTo("doc.pdf");
}
@Test
void testDownloadDocument() throws IOException {
byte[] docContent = "PDF document content here".getBytes(StandardCharsets.UTF_8);
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents/456/attachment"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/pdf")
.withHeader("Content-Disposition", "attachment; filename=\"document.pdf\"").withBody(docContent)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
Response response = api.downloadFile("clients", 123L, 456L);
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers()).containsKey("Content-Type");
assertThat(response.headers().get("Content-Type")).contains("application/pdf");
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(docContent);
}
@Test
void testDeleteDocument() {
String responseBody = "{\"resourceId\":456}";
wireMockServer.stubFor(delete(urlEqualTo("/v1/clients/123/documents/456"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
DeleteEntityTypeEntityIdDocumentsResponse response = api.deleteDocument("clients", 123L, 456L);
assertThat(response).isNotNull();
assertThat(response.getResourceId()).isEqualTo(456L);
}
@Test
void testDeleteDocumentForLoan() {
String responseBody = "{\"resourceId\":999}";
wireMockServer.stubFor(delete(urlEqualTo("/v1/loans/789/documents/999"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
DeleteEntityTypeEntityIdDocumentsResponse response = api.deleteDocument("loans", 789L, 999L);
assertThat(response).isNotNull();
assertThat(response.getResourceId()).isEqualTo(999L);
}
@Test
void testMultipleEntityTypes() {
String[] entityTypes = { "clients", "loans", "savings", "groups" };
Long[] entityIds = { 100L, 200L, 300L, 400L };
for (int i = 0; i < entityTypes.length; i++) {
String entityType = entityTypes[i];
Long entityId = entityIds[i];
wireMockServer.stubFor(get(urlEqualTo("/v1/" + entityType + "/" + entityId + "/documents"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("[]")));
}
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
for (int i = 0; i < entityTypes.length; i++) {
List<DocumentData> documents = api.retrieveAllDocuments(entityTypes[i], entityIds[i]);
assertThat(documents).isNotNull();
assertThat(documents).isEmpty();
}
}
@Test
void testDownloadTextDocument() throws IOException {
String textContent = "Plain text document content";
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents/999/attachment"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "text/plain")
.withHeader("Content-Disposition", "attachment; filename=\"readme.txt\"").withBody(textContent)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
Response response = api.downloadFile("clients", 123L, 999L);
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers().get("Content-Type")).contains("text/plain");
String content = new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertThat(content).isEqualTo(textContent);
}
}
@@ -0,0 +1,180 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.services;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.delete;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import feign.Response;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.apache.fineract.client.feign.services.ImagesApi;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@Tag("integration")
class ImagesApiIntegrationTest {
@TempDir
File tempDir;
private WireMockServer wireMockServer;
private FineractFeignClientConfig config;
private File testImageFile;
@BeforeEach
void setUp() throws IOException {
wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMockServer.start();
config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port()).credentials("test", "test")
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
testImageFile = new File(tempDir, "test-image.jpg");
Files.write(testImageFile.toPath(), new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x01, 0x02, 0x03 });
}
@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
@Test
void testRetrieveClientImage() throws IOException {
byte[] imageData = new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x10, 0x11, 0x12 };
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/images"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(imageData)));
ImagesApi api = config.createClient(ImagesApi.class);
Response response = api.get("clients", 123L, new HashMap<>());
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers()).containsKey("Content-Type");
assertThat(response.headers().get("Content-Type")).contains("image/jpeg");
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(imageData);
}
@Test
void testRetrieveClientImageWithMaxDimensions() throws IOException {
byte[] resizedImage = new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x20, 0x21 };
wireMockServer.stubFor(get(urlPathEqualTo("/v1/clients/123/images")).withQueryParam("maxWidth", equalTo("100"))
.withQueryParam("maxHeight", equalTo("100"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(resizedImage)));
ImagesApi api = config.createClient(ImagesApi.class);
Map<String, Object> params = new HashMap<>();
params.put("maxWidth", 100);
params.put("maxHeight", 100);
Response response = api.get("clients", 123L, params);
assertThat(response.status()).isEqualTo(200);
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(resizedImage);
}
@Test
void testRetrieveClientImageWithMaxWidthOnly() throws IOException {
byte[] resizedImage = new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x30 };
wireMockServer.stubFor(get(urlPathEqualTo("/v1/clients/123/images")).withQueryParam("maxWidth", equalTo("200"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(resizedImage)));
ImagesApi api = config.createClient(ImagesApi.class);
Map<String, Object> params = new HashMap<>();
params.put("maxWidth", 200);
Response response = api.get("clients", 123L, params);
assertThat(response.status()).isEqualTo(200);
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(resizedImage);
}
@Test
void testDeleteClientImage() {
wireMockServer.stubFor(delete(urlEqualTo("/v1/clients/123/images"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"resourceId\":123}")));
ImagesApi api = config.createClient(ImagesApi.class);
Response response = api.delete("clients", 123L);
assertThat(response.status()).isEqualTo(200);
}
@Test
void testDeleteStaffImage() {
wireMockServer.stubFor(delete(urlEqualTo("/v1/staff/456/images"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"resourceId\":456}")));
ImagesApi api = config.createClient(ImagesApi.class);
Response response = api.delete("staff", 456L);
assertThat(response.status()).isEqualTo(200);
}
@Test
void testRetrieveImageWithPngFormat() throws IOException {
byte[] pngImage = new byte[] { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A };
wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/images"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/png").withBody(pngImage)));
ImagesApi api = config.createClient(ImagesApi.class);
Response response = api.get("clients", 123L, new HashMap<>());
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers().get("Content-Type")).contains("image/png");
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(pngImage);
}
@Test
void testMultipleEntityTypes() {
String[] entityTypes = { "clients", "staff" };
Long[] entityIds = { 100L, 200L };
for (int i = 0; i < entityTypes.length; i++) {
String entityType = entityTypes[i];
Long entityId = entityIds[i];
wireMockServer.stubFor(get(urlEqualTo("/v1/" + entityType + "/" + entityId + "/images"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(new byte[] { 0x01 })));
}
ImagesApi api = config.createClient(ImagesApi.class);
for (int i = 0; i < entityTypes.length; i++) {
Response response = api.get(entityTypes[i], entityIds[i], new HashMap<>());
assertThat(response.status()).isEqualTo(200);
}
}
}
@@ -0,0 +1,166 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.services;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import feign.Response;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.apache.fineract.client.feign.services.RunReportsApi;
import org.apache.fineract.client.models.RunReportsResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("integration")
class RunReportsApiIntegrationTest {
private WireMockServer wireMockServer;
private FineractFeignClientConfig config;
@BeforeEach
void setUp() {
wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMockServer.start();
config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port()).credentials("test", "test")
.connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
}
@AfterEach
void tearDown() {
if (wireMockServer != null) {
wireMockServer.stop();
}
}
@Test
void testRunReportGetDataWithMultipleParameters() {
wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/DateRangeReport")).withQueryParam("officeId", equalTo("1"))
.withQueryParam("fromDate", equalTo("2024-01-01")).withQueryParam("toDate", equalTo("2024-12-31")).willReturn(aResponse()
.withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"columnHeaders\":[],\"data\":[]}")));
RunReportsApi api = config.createClient(RunReportsApi.class);
Map<String, String> params = new HashMap<>();
params.put("officeId", "1");
params.put("fromDate", "2024-01-01");
params.put("toDate", "2024-12-31");
RunReportsResponse response = api.runReportGetData("DateRangeReport", params);
assertThat(response).isNotNull();
}
@Test
void testRunReportGetDataWithEmptyResult() {
wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/EmptyReport")).withQueryParam("officeId", equalTo("1")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"columnHeaders\":[],\"data\":[]}")));
RunReportsApi api = config.createClient(RunReportsApi.class);
Map<String, String> params = new HashMap<>();
params.put("officeId", "1");
RunReportsResponse response = api.runReportGetData("EmptyReport", params);
assertThat(response).isNotNull();
assertThat(response.getColumnHeaders()).isEmpty();
assertThat(response.getData()).isEmpty();
}
@Test
void testRunReportGetFileAsPdf() throws IOException {
byte[] pdfContent = "%PDF-1.4 test content".getBytes(StandardCharsets.UTF_8);
wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/LoanSummary")).withQueryParam("output-type", equalTo("PDF"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/pdf")
.withHeader("Content-Disposition", "attachment; filename=\"report.pdf\"").withBody(pdfContent)));
RunReportsApi api = config.createClient(RunReportsApi.class);
Map<String, String> params = new HashMap<>();
params.put("output-type", "PDF");
Response response = api.runReportGetFile("LoanSummary", params);
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers()).containsKey("Content-Type");
assertThat(response.headers().get("Content-Type")).contains("application/pdf");
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(pdfContent);
}
@Test
void testRunReportGetFileAsCsv() throws IOException {
String csvContent = "id,name,balance\n1,Client1,1000.00\n2,Client2,2000.00";
wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/ClientBalance")).withQueryParam("exportCSV", equalTo("true"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "text/csv")
.withHeader("Content-Disposition", "attachment; filename=\"report.csv\"").withBody(csvContent)));
RunReportsApi api = config.createClient(RunReportsApi.class);
Map<String, String> params = new HashMap<>();
params.put("exportCSV", "true");
Response response = api.runReportGetFile("ClientBalance", params);
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers()).containsKey("Content-Type");
assertThat(response.headers().get("Content-Type")).contains("text/csv");
String content = new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertThat(content).isEqualTo(csvContent);
}
@Test
void testRunReportGetFileAsExcel() throws IOException {
byte[] excelContent = "fake excel data".getBytes(StandardCharsets.UTF_8);
wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/FinancialReport")).withQueryParam("output-type", equalTo("XLS"))
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/vnd.ms-excel")
.withHeader("Content-Disposition", "attachment; filename=\"report.xls\"").withBody(excelContent)));
RunReportsApi api = config.createClient(RunReportsApi.class);
Map<String, String> params = new HashMap<>();
params.put("output-type", "XLS");
Response response = api.runReportGetFile("FinancialReport", params);
assertThat(response.status()).isEqualTo(200);
assertThat(response.headers()).containsKey("Content-Type");
assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(excelContent);
}
@Test
void testRunReportGetDataWithNoParameters() {
wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/AllClients")).willReturn(
aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"columnHeaders\":[],\"data\":[]}")));
RunReportsApi api = config.createClient(RunReportsApi.class);
RunReportsResponse response = api.runReportGetData("AllClients", new HashMap<>());
assertThat(response).isNotNull();
}
}
@@ -0,0 +1,296 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.client.util;
import static org.assertj.core.api.Assertions.assertThat;
import feign.Request;
import feign.Response;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FeignPartsTest {
@TempDir
File tempDir;
@Test
void testMediaTypeForJpeg() {
String mediaType = FeignParts.mediaType("photo.jpg");
assertThat(mediaType).isEqualTo("image/jpeg");
}
@Test
void testMediaTypeForJpegUppercase() {
String mediaType = FeignParts.mediaType("photo.JPEG");
assertThat(mediaType).isEqualTo("image/jpeg");
}
@Test
void testMediaTypeForPng() {
String mediaType = FeignParts.mediaType("image.png");
assertThat(mediaType).isEqualTo("image/png");
}
@Test
void testMediaTypeForPdf() {
String mediaType = FeignParts.mediaType("document.pdf");
assertThat(mediaType).isEqualTo("application/pdf");
}
@Test
void testMediaTypeForDocx() {
String mediaType = FeignParts.mediaType("report.docx");
assertThat(mediaType).isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
}
@Test
void testMediaTypeForDoc() {
String mediaType = FeignParts.mediaType("report.doc");
assertThat(mediaType).isEqualTo("application/msword");
}
@Test
void testMediaTypeForXlsx() {
String mediaType = FeignParts.mediaType("spreadsheet.xlsx");
assertThat(mediaType).isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}
@Test
void testMediaTypeForXls() {
String mediaType = FeignParts.mediaType("spreadsheet.xls");
assertThat(mediaType).isEqualTo("application/vnd.ms-excel");
}
@Test
void testMediaTypeForTiff() {
String mediaType = FeignParts.mediaType("scan.tiff");
assertThat(mediaType).isEqualTo("image/tiff");
}
@Test
void testMediaTypeForGif() {
String mediaType = FeignParts.mediaType("animation.gif");
assertThat(mediaType).isEqualTo("image/gif");
}
@Test
void testMediaTypeForOdt() {
String mediaType = FeignParts.mediaType("document.odt");
assertThat(mediaType).isEqualTo("application/vnd.oasis.opendocument.text");
}
@Test
void testMediaTypeForOds() {
String mediaType = FeignParts.mediaType("spreadsheet.ods");
assertThat(mediaType).isEqualTo("application/vnd.oasis.opendocument.spreadsheet");
}
@Test
void testMediaTypeForTxt() {
String mediaType = FeignParts.mediaType("readme.txt");
assertThat(mediaType).isEqualTo("text/plain");
}
@Test
void testMediaTypeForUnknownExtension() {
String mediaType = FeignParts.mediaType("file.xyz");
assertThat(mediaType).isEqualTo("application/octet-stream");
}
@Test
void testMediaTypeCaseInsensitive() {
assertThat(FeignParts.mediaType("FILE.JPG")).isEqualTo("image/jpeg");
assertThat(FeignParts.mediaType("file.PDF")).isEqualTo("application/pdf");
assertThat(FeignParts.mediaType("Document.DOCX"))
.isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
}
@Test
void testMediaTypeForNullFilename() {
String mediaType = FeignParts.mediaType(null);
assertThat(mediaType).isNull();
}
@Test
void testMediaTypeForFilenameWithoutExtension() {
String mediaType = FeignParts.mediaType("filename");
assertThat(mediaType).isNull();
}
@Test
void testFileNameExtractionFromContentDisposition() {
Response response = Response.builder().status(200)
.request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
.headers(Map.of("Content-Disposition", List.of("attachment; filename=\"test.pdf\""))).body(new byte[0]).build();
Optional<String> filename = FeignParts.fileName(response);
assertThat(filename).isPresent();
assertThat(filename.get()).isEqualTo("test.pdf");
}
@Test
void testFileNameExtractionWithComplexFilename() {
Response response = Response.builder().status(200)
.request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
.headers(Map.of("Content-Disposition", List.of("attachment; filename=\"report-2024-01-15.xlsx\""))).body(new byte[0])
.build();
Optional<String> filename = FeignParts.fileName(response);
assertThat(filename).isPresent();
assertThat(filename.get()).isEqualTo("report-2024-01-15.xlsx");
}
@Test
void testFileNameWithoutContentDisposition() {
Response response = Response.builder().status(200)
.request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null)).headers(Map.of()).body(new byte[0])
.build();
Optional<String> filename = FeignParts.fileName(response);
assertThat(filename).isEmpty();
}
@Test
void testFileNameWithNullHeaders() {
Response response = Response.builder().status(200)
.request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null)).headers(null).body(new byte[0])
.build();
Optional<String> filename = FeignParts.fileName(response);
assertThat(filename).isEmpty();
}
@Test
void testFileNameWithEmptyContentDisposition() {
Response response = Response.builder().status(200)
.request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
.headers(Map.of("Content-Disposition", Collections.emptyList())).body(new byte[0]).build();
Optional<String> filename = FeignParts.fileName(response);
assertThat(filename).isEmpty();
}
@Test
void testFileNameWithInvalidContentDisposition() {
Response response = Response.builder().status(200)
.request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
.headers(Map.of("Content-Disposition", List.of("inline"))).body(new byte[0]).build();
Optional<String> filename = FeignParts.fileName(response);
assertThat(filename).isEmpty();
}
@Test
void testProbeContentTypeForJpegFile() throws IOException {
File jpegFile = new File(tempDir, "test.jpg");
Files.write(jpegFile.toPath(), new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF });
String contentType = FeignParts.probeContentType(jpegFile);
assertThat(contentType).isIn("image/jpeg", "image/jpg");
}
@Test
void testProbeContentTypeForPdfFile() throws IOException {
File pdfFile = new File(tempDir, "test.pdf");
Files.write(pdfFile.toPath(), "%PDF-1.4".getBytes(StandardCharsets.UTF_8));
String contentType = FeignParts.probeContentType(pdfFile);
assertThat(contentType).isEqualTo("application/pdf");
}
@Test
void testProbeContentTypeFallsBackToMediaType() throws IOException {
File unknownFile = new File(tempDir, "test.docx");
Files.write(unknownFile.toPath(), "test content".getBytes(StandardCharsets.UTF_8));
String contentType = FeignParts.probeContentType(unknownFile);
assertThat(contentType).isNotNull();
assertThat(contentType).isNotEqualTo("application/octet-stream");
}
@Test
void testProbeContentTypeForUnknownFile() throws IOException {
File unknownFile = new File(tempDir, "test.xyz");
Files.write(unknownFile.toPath(), "unknown content".getBytes(StandardCharsets.UTF_8));
String contentType = FeignParts.probeContentType(unknownFile);
assertThat(contentType).isNotNull();
}
@Test
void testMediaTypeForAllSupportedImageFormats() {
assertThat(FeignParts.mediaType("image.jpg")).isEqualTo("image/jpeg");
assertThat(FeignParts.mediaType("image.jpeg")).isEqualTo("image/jpeg");
assertThat(FeignParts.mediaType("image.png")).isEqualTo("image/png");
assertThat(FeignParts.mediaType("image.gif")).isEqualTo("image/gif");
assertThat(FeignParts.mediaType("image.tif")).isEqualTo("image/tiff");
assertThat(FeignParts.mediaType("image.tiff")).isEqualTo("image/tiff");
}
@Test
void testMediaTypeForAllSupportedDocumentFormats() {
assertThat(FeignParts.mediaType("doc.pdf")).isEqualTo("application/pdf");
assertThat(FeignParts.mediaType("doc.doc")).isEqualTo("application/msword");
assertThat(FeignParts.mediaType("doc.docx")).isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
assertThat(FeignParts.mediaType("doc.odt")).isEqualTo("application/vnd.oasis.opendocument.text");
assertThat(FeignParts.mediaType("doc.txt")).isEqualTo("text/plain");
}
@Test
void testMediaTypeForAllSupportedSpreadsheetFormats() {
assertThat(FeignParts.mediaType("sheet.xls")).isEqualTo("application/vnd.ms-excel");
assertThat(FeignParts.mediaType("sheet.xlsx")).isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
assertThat(FeignParts.mediaType("sheet.ods")).isEqualTo("application/vnd.oasis.opendocument.spreadsheet");
}
}