Encode auth parameters into authParams property
- Replace `authParamsMap` with `authentication` Fixes #95
This commit is contained in:
committed by
Chris Bono
parent
c59db85990
commit
ee194f4a6d
91
spring-pulsar-docs/src/main/asciidoc/authentication.adoc
Normal file
91
spring-pulsar-docs/src/main/asciidoc/authentication.adoc
Normal file
@@ -0,0 +1,91 @@
|
||||
[appendix]
|
||||
[[appendix.authentication]]
|
||||
= Authentication
|
||||
|
||||
[[Athenz]]
|
||||
== Athenz
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: org.apache.pulsar.client.impl.auth.AuthenticationAthenz
|
||||
spring.pulsar.client.authentication.tenant-domain: ...
|
||||
spring.pulsar.client.authentication.tenant-service: ...
|
||||
spring.pulsar.client.authentication.provider-domain: ...
|
||||
spring.pulsar.client.authentication.private-key: ...
|
||||
spring.pulsar.client.authentication.private-key-path: ...
|
||||
spring.pulsar.client.authentication.key-id: ...
|
||||
spring.pulsar.client.authentication.auto-prefetch-enabled: ...
|
||||
spring.pulsar.client.authentication.athenz-conf-path: ...
|
||||
spring.pulsar.client.authentication.principal-header: ...
|
||||
spring.pulsar.client.authentication.role-header: ...
|
||||
spring.pulsar.client.authentication.zts-url: ...
|
||||
----
|
||||
====
|
||||
|
||||
[[Basic]]
|
||||
== Basic
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: org.apache.pulsar.client.impl.auth.AuthenticationBasic
|
||||
spring.pulsar.client.authentication.user-id: ...
|
||||
spring.pulsar.client.authentication.password: ...
|
||||
----
|
||||
====
|
||||
|
||||
[[KeyStoreTls]]
|
||||
== KeyStoreTls
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls
|
||||
spring.pulsar.client.authentication.key-store-type: ...
|
||||
spring.pulsar.client.authentication.key-store-path: ...
|
||||
spring.pulsar.client.authentication.key-store-password: ...
|
||||
----
|
||||
====
|
||||
|
||||
[[OAuth2]]
|
||||
== OAuth2
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: ...
|
||||
spring.pulsar.client.authentication.issuer-url: ...
|
||||
spring.pulsar.client.authentication.private-key: ...
|
||||
spring.pulsar.client.authentication.audience: ...
|
||||
spring.pulsar.client.authentication.scope: ...
|
||||
----
|
||||
====
|
||||
|
||||
[[Sasl]]
|
||||
== Sasl
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: org.apache.pulsar.client.impl.auth.AuthenticationSasl
|
||||
spring.pulsar.client.authentication.sasl-jaas-client-section-name: ...
|
||||
spring.pulsar.client.authentication.server-type: ...
|
||||
----
|
||||
====
|
||||
|
||||
[[Tls]]
|
||||
== Tls
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: org.apache.pulsar.client.impl.auth.AuthenticationTls
|
||||
spring.pulsar.client.authentication.tls-cert-file: ...
|
||||
spring.pulsar.client.authentication.tls-key-file: ...
|
||||
----
|
||||
====
|
||||
|
||||
[[Token]]
|
||||
== Token
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
spring.pulsar.client.auth-plugin-class-name: org.apache.pulsar.client.impl.auth.AuthenticationToken
|
||||
spring.pulsar.client.authentication.token: ...
|
||||
----
|
||||
====
|
||||
@@ -19,6 +19,40 @@ This is done through a factory bean called `PulsarClientFactoryBean`, which take
|
||||
include::application-properties/pulsar-client.adoc[lines=3..-1]
|
||||
====
|
||||
|
||||
To connect against a Pulsar cluster requiring authentication, you need to set the `authPluginClassName` and parameters for your authentication scheme.
|
||||
To set the authentication parameters you can either pass them as a json-encoded string or as a nested map. Please see the examples below for each option:
|
||||
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
# using json-encoded string
|
||||
spring:
|
||||
pulsar:
|
||||
client:
|
||||
auth-plugin-class-name: org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2
|
||||
auth-params: "{\"privateKey\":\"file:///Users/some-key.json\",\"issuerUrl\":\"https://auth.server.cloud/", \"audience\":\"urn:sn:acme:dev:my-instance"}"
|
||||
----
|
||||
====
|
||||
|
||||
====
|
||||
[source, yaml]
|
||||
----
|
||||
# using nested parameter map
|
||||
spring:
|
||||
pulsar:
|
||||
client:
|
||||
auth-plugin-class-name: org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2
|
||||
authentication.issuer-url: https://auth.server.cloud/
|
||||
authentication.private-key: file:///Users/some-key.json
|
||||
authentication.audience: urn:sn:acme:dev:my-instance
|
||||
----
|
||||
====
|
||||
|
||||
.[.underline]#Click ##here## to view how to configure the different **authentication schemes**#.
|
||||
[%collapsible]
|
||||
====
|
||||
include::authentication.adoc[lines=3..-1]
|
||||
====
|
||||
|
||||
[[pulsar-producer]]
|
||||
==== Pulsar Producer
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.springframework.pulsar.autoconfigure;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pulsar.common.util.ObjectMapperFactory;
|
||||
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
/**
|
||||
* Utility methods for Pulsar authentication parameters.
|
||||
*
|
||||
* @author Alexander Preuß
|
||||
*/
|
||||
final class AuthParameterUtils {
|
||||
|
||||
private static final Pattern KEBAB_CASE_PATTERN = Pattern.compile("-(.)");
|
||||
|
||||
private AuthParameterUtils() {
|
||||
|
||||
}
|
||||
|
||||
private static String convertKebabCaseToCamelCase(String kebabString) {
|
||||
return KEBAB_CASE_PATTERN.matcher(kebabString).replaceAll(mr -> mr.group(1).toUpperCase());
|
||||
}
|
||||
|
||||
private static Map<String, String> convertWellKnownLowerCaseKeysToCamelCase(Map<String, String> params) {
|
||||
return params.entrySet().stream().collect(Collectors
|
||||
.toMap(entry -> WellKnownAuthParameters.getCamelCaseKey(entry.getKey()), Map.Entry::getValue));
|
||||
}
|
||||
|
||||
private static Map<String, String> convertKebabCaseKeysToCamelCase(Map<String, String> params) {
|
||||
return params.entrySet().stream()
|
||||
.collect(Collectors.toMap(entry -> convertKebabCaseToCamelCase(entry.getKey()), Map.Entry::getValue));
|
||||
}
|
||||
|
||||
static String maybeConvertToEncodedParamString(Map<String, String> params) {
|
||||
if (CollectionUtils.isEmpty(params)) {
|
||||
return null;
|
||||
}
|
||||
// env vars are bound like this ISSUER_ID -> issuerid, have to be camel-cased to
|
||||
// work
|
||||
params = convertWellKnownLowerCaseKeysToCamelCase(params);
|
||||
params = convertKebabCaseKeysToCamelCase(params);
|
||||
params = new TreeMap<>(params); // sort keys for testing and readability
|
||||
try {
|
||||
return ObjectMapperFactory.create().writeValueAsString(params);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Could not convert parameters to encoded string", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import org.apache.pulsar.common.schema.SchemaType;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.PropertyMapper;
|
||||
import org.springframework.pulsar.listener.AckMode;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Configuration properties for Spring for Apache Pulsar.
|
||||
@@ -753,7 +755,7 @@ public class PulsarProperties {
|
||||
/**
|
||||
* Authentication parameter map of the client.
|
||||
*/
|
||||
private Map<String, String> authParamsMap;
|
||||
private Map<String, String> authentication;
|
||||
|
||||
/**
|
||||
* Client operation timeout in milliseconds.
|
||||
@@ -972,12 +974,12 @@ public class PulsarProperties {
|
||||
this.authParams = authParams;
|
||||
}
|
||||
|
||||
public Map<String, String> getAuthParamsMap() {
|
||||
return this.authParamsMap;
|
||||
public Map<String, String> getAuthentication() {
|
||||
return this.authentication;
|
||||
}
|
||||
|
||||
public void setAuthParamsMap(Map<String, String> authParamsMap) {
|
||||
this.authParamsMap = authParamsMap;
|
||||
public void setAuthentication(Map<String, String> authentication) {
|
||||
this.authentication = authentication;
|
||||
}
|
||||
|
||||
public long getOperationTimeoutMs() {
|
||||
@@ -1253,6 +1255,11 @@ public class PulsarProperties {
|
||||
}
|
||||
|
||||
public Map<String, Object> buildProperties() {
|
||||
if (StringUtils.hasText(this.getAuthParams()) && !CollectionUtils.isEmpty(this.getAuthentication())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot set both spring.pulsar.client.authParams and spring.pulsar.client.authentication.*");
|
||||
}
|
||||
|
||||
PulsarProperties.Properties properties = new Properties();
|
||||
|
||||
PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
|
||||
@@ -1261,7 +1268,8 @@ public class PulsarProperties {
|
||||
map.from(this::getListenerName).to(properties.in("listenerName"));
|
||||
map.from(this::getAuthPluginClassName).to(properties.in("authPluginClassName"));
|
||||
map.from(this::getAuthParams).to(properties.in("authParams"));
|
||||
map.from(this::getAuthParamsMap).to(properties.in("authParamMap"));
|
||||
map.from(AuthParameterUtils.maybeConvertToEncodedParamString(this.getAuthentication()))
|
||||
.to(properties.in("authParams"));
|
||||
map.from(this::getOperationTimeoutMs).to(properties.in("operationTimeoutMs"));
|
||||
map.from(this::getLookupTimeoutMs).to(properties.in("lookupTimeoutMs"));
|
||||
map.from(this::getNumIoThreads).to(properties.in("numIoThreads"));
|
||||
@@ -1353,7 +1361,7 @@ public class PulsarProperties {
|
||||
/**
|
||||
* Authentication parameter map of the client.
|
||||
*/
|
||||
private Map<String, String> authParamMap;
|
||||
private Map<String, String> authentication;
|
||||
|
||||
/**
|
||||
* Path to the trusted TLS certificate file.
|
||||
@@ -1434,12 +1442,12 @@ public class PulsarProperties {
|
||||
this.authParams = authParams;
|
||||
}
|
||||
|
||||
public Map<String, String> getAuthParamMap() {
|
||||
return this.authParamMap;
|
||||
public Map<String, String> getAuthentication() {
|
||||
return this.authentication;
|
||||
}
|
||||
|
||||
public void setAuthParamMap(Map<String, String> authParamMap) {
|
||||
this.authParamMap = authParamMap;
|
||||
public void setAuthentication(Map<String, String> authentication) {
|
||||
this.authentication = authentication;
|
||||
}
|
||||
|
||||
public String getTlsTrustCertsFilePath() {
|
||||
@@ -1523,6 +1531,10 @@ public class PulsarProperties {
|
||||
}
|
||||
|
||||
public Map<String, Object> buildProperties() {
|
||||
if (!StringUtils.hasText(this.getAuthParams()) && !CollectionUtils.isEmpty(this.getAuthentication())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot set both spring.pulsar.admin.authParams and spring.pulsar.admin.authentication.*");
|
||||
}
|
||||
PulsarProperties.Properties properties = new Properties();
|
||||
|
||||
PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
|
||||
@@ -1530,7 +1542,8 @@ public class PulsarProperties {
|
||||
map.from(this::getServiceUrl).to(properties.in("serviceUrl"));
|
||||
map.from(this::getAuthPluginClassName).to(properties.in("authPluginClassName"));
|
||||
map.from(this::getAuthParams).to(properties.in("authParams"));
|
||||
map.from(this::getAuthParamMap).to(properties.in("authParamMap"));
|
||||
map.from(AuthParameterUtils.maybeConvertToEncodedParamString(this.getAuthentication()))
|
||||
.to(properties.in("authParams"));
|
||||
map.from(this::getTlsTrustCertsFilePath).to(properties.in("tlsTrustCertsFilePath"));
|
||||
map.from(this::isTlsAllowInsecureConnection).to(properties.in("tlsAllowInsecureConnection"));
|
||||
map.from(this::isTlsHostnameVerificationEnable).to(properties.in("tlsHostnameVerificationEnable"));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.springframework.pulsar.autoconfigure;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Utility class to map Pulsar auth parameters to well-known keys.
|
||||
*
|
||||
* @author Alexander Preuß
|
||||
*/
|
||||
public final class WellKnownAuthParameters {
|
||||
|
||||
private static final Map<String, String> lowerCaseToCamelCase = new HashMap<>();
|
||||
|
||||
static {
|
||||
// Athenz
|
||||
lowerCaseToCamelCase.put("tenantdomain", "tenantDomain");
|
||||
lowerCaseToCamelCase.put("tenantservice", "tenantService");
|
||||
lowerCaseToCamelCase.put("providerdomain", "providerDomain");
|
||||
lowerCaseToCamelCase.put("privatekey", "privateKey");
|
||||
lowerCaseToCamelCase.put("privatekeypath", "privateKeyPath");
|
||||
lowerCaseToCamelCase.put("keyid", "keyId");
|
||||
lowerCaseToCamelCase.put("autoprefetchenabled", "autoPrefetchEnabled");
|
||||
lowerCaseToCamelCase.put("athenzconfpath", "athenzConfPath");
|
||||
lowerCaseToCamelCase.put("principalheader", "principalHeader");
|
||||
lowerCaseToCamelCase.put("roleheader", "roleHeader");
|
||||
lowerCaseToCamelCase.put("ztsurl", "ztsUrl");
|
||||
// Basic
|
||||
lowerCaseToCamelCase.put("userid", "userId");
|
||||
lowerCaseToCamelCase.put("password", "password");
|
||||
// KeyStoreTls
|
||||
lowerCaseToCamelCase.put("keystoretype", "keyStoreType");
|
||||
lowerCaseToCamelCase.put("keystorepath", "keyStorePath");
|
||||
lowerCaseToCamelCase.put("keystorepassword", "keyStorePassword");
|
||||
// OAuth2
|
||||
lowerCaseToCamelCase.put("type", "type");
|
||||
lowerCaseToCamelCase.put("issuerurl", "issuerUrl");
|
||||
lowerCaseToCamelCase.put("privatekey", "privateKey");
|
||||
lowerCaseToCamelCase.put("audience", "audience");
|
||||
lowerCaseToCamelCase.put("scope", "scope");
|
||||
// Sasl
|
||||
lowerCaseToCamelCase.put("sasljaasclientsectionname", "saslJaasClientSectionName");
|
||||
lowerCaseToCamelCase.put("servertype", "serverType");
|
||||
// Tls
|
||||
lowerCaseToCamelCase.put("tlscertfile", "tlsCertFile");
|
||||
lowerCaseToCamelCase.put("tlskeyfile", "tlsKeyFile");
|
||||
// Token
|
||||
lowerCaseToCamelCase.put("token", "token");
|
||||
}
|
||||
|
||||
private WellKnownAuthParameters() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the camel-cased version a Pulsar auth parameter or the given key in case it
|
||||
* is not part of the well-known ones.
|
||||
* @param lowerCaseKey the lower-cased auth parameter
|
||||
* @return the camel-cased auth parameter, or the lowerCaseKey if the parameter is not
|
||||
* found.
|
||||
*/
|
||||
public static String getCamelCaseKey(String lowerCaseKey) {
|
||||
return lowerCaseToCamelCase.getOrDefault(lowerCaseKey, lowerCaseKey);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2022 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.springframework.pulsar.autoconfigure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.params.provider.Arguments.arguments;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
/**
|
||||
* Tests for {@link AuthParameterUtils}.
|
||||
*
|
||||
* @author Alexander Preuß
|
||||
*/
|
||||
public class AuthParameterUtilsTests {
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("encodedParamStringConversionProvider")
|
||||
void encodedParamStringConversion(String testName, Map<String, String> authParamsMap) {
|
||||
String encodedAuthParamString = AuthParameterUtils.maybeConvertToEncodedParamString(authParamsMap);
|
||||
if (authParamsMap == null || authParamsMap.isEmpty()) {
|
||||
assertThat(encodedAuthParamString).isNull();
|
||||
}
|
||||
else {
|
||||
assertThat(encodedAuthParamString).isEqualTo("{\"audience\":\"urn:sn:pulsar:abc:xyz\","
|
||||
+ "\"issuerUrl\":\"https://auth.server.cloud\",\"privateKey\":\"file://Users/xyz/key.json\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream<Arguments> encodedParamStringConversionProvider() {
|
||||
return Stream.of(arguments("null", null), arguments("empty", Collections.emptyMap()),
|
||||
arguments("camelCase",
|
||||
Map.of("issuerUrl", "https://auth.server.cloud", "privateKey", "file://Users/xyz/key.json",
|
||||
"audience", "urn:sn:pulsar:abc:xyz")),
|
||||
arguments("kebabCase",
|
||||
Map.of("issuer-url", "https://auth.server.cloud", "private-key", "file://Users/xyz/key.json",
|
||||
"audience", "urn:sn:pulsar:abc:xyz")),
|
||||
arguments("lowerCase",
|
||||
Map.of("issuerurl", "https://auth.server.cloud", "privatekey", "file://Users/xyz/key.json",
|
||||
"audience", "urn:sn:pulsar:abc:xyz")),
|
||||
arguments("mixed", Map.of("issuerurl", "https://auth.server.cloud", "private-key",
|
||||
"file://Users/xyz/key.json", "audience", "urn:sn:pulsar:abc:xyz")));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -201,6 +201,23 @@ class PulsarAutoConfigurationTests {
|
||||
InterceptorTestConfiguration.interceptorFoo)));
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ClientAutoConfigurationTests {
|
||||
|
||||
@Test
|
||||
void authParamMapConvertedToEncodedParamString() {
|
||||
contextRunner.withPropertyValues(
|
||||
"spring.pulsar.client.auth-plugin-class-name=org.apache.pulsar.client.impl.auth.AuthenticationBasic",
|
||||
"spring.pulsar.client.authentication.userId=username",
|
||||
"spring.pulsar.client.authentication.password=topsecret")
|
||||
.run((context -> assertThat(context).hasNotFailed().getBean(PulsarClientConfiguration.class)
|
||||
.extracting("configs", InstanceOfAssertFactories.map(String.class, Object.class))
|
||||
.doesNotContainKey("authParamMap").doesNotContainKey("userId").doesNotContainKey("password")
|
||||
.containsEntry("authParams", "{\"password\":\"topsecret\",\"userId\":\"username\"}")));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ProducerFactoryAutoConfigurationTests {
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ dependencies {
|
||||
api ('org.springframework.retry:spring-retry') {
|
||||
exclude group: 'org.springframework'
|
||||
}
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.google.code.findbugs:jsr305'
|
||||
optional 'com.fasterxml.jackson.core:jackson-core'
|
||||
optional 'com.fasterxml.jackson.core:jackson-databind'
|
||||
optional 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8'
|
||||
optional 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
optional 'com.fasterxml.jackson.datatype:jackson-datatype-joda'
|
||||
|
||||
Reference in New Issue
Block a user