com.mysql.cj.conf.ConnectionUrlParser Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of mysql-connector-j Show documentation
Show all versions of mysql-connector-j Show documentation
JDBC Type 4 driver for MySQL.
/*
* Copyright (c) 2016, 2024, Oracle and/or its affiliates.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License, version 2.0, as published by
* the Free Software Foundation.
*
* This program is designed to work with certain software that is licensed under separate terms, as designated in a particular file or component or in
* included license documentation. The authors of MySQL hereby grant you an additional permission to link the program and your derivative works with the
* separately licensed software that they have either included with the program or referenced in the documentation.
*
* Without limiting anything contained in the foregoing, this file, which is part of MySQL Connector/J, is also subject to the Universal FOSS Exception,
* version 1.0, a copy of which can be found at http://oss.oracle.com/licenses/universal-foss-exception.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License, version 2.0, for more details.
*
* You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
package com.mysql.cj.conf;
import static com.mysql.cj.util.StringUtils.isNullOrEmpty;
import static com.mysql.cj.util.StringUtils.safeTrim;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.mysql.cj.Messages;
import com.mysql.cj.conf.ConnectionUrl.Type;
import com.mysql.cj.exceptions.ExceptionFactory;
import com.mysql.cj.exceptions.UnsupportedConnectionStringException;
import com.mysql.cj.exceptions.WrongArgumentException;
import com.mysql.cj.util.SearchMode;
import com.mysql.cj.util.StringUtils;
/**
* This class parses a connection string using the general URI structure defined in RFC 3986. Instead of using a URI instance to ensure the correct syntax of
* the connection string, this implementation uses regular expressions which is faster but also less strict in terms of validations. This actually works better
* because database URLs don't exactly stick to the RFC 3986 rules.
*
* scheme://authority/path?query#fragment
*
* This results in splitting the connection string URL and processing its internal parts:
*
* - scheme
* - The protocol and subprotocol identification. Usually "jdbc:mysql:" or "mysqlx:".
* - authority
* - Contains information about the user credentials and/or the host and port information. Unlike its definition in the RFC 3986 specification, there can be
* multiple authority sections separated by a single comma (,) in a connection string. It is also possible to use an alternative syntax for the user and/or host
* identification, that also allows setting per host connection properties, in the form of
* "[user[:password]@]address=(key1=value)[(key2=value)]...[,address=(key3=value)[(key4=value)]...]...".
* - path
* - Corresponds to the database identification.
* - query
* - The connection properties, written as "propertyName1[=[propertyValue1]][&propertyName2[=[propertyValue2]]]..."
* - fragment
* - The fragment section is ignored in Connector/J connection strings.
*
*/
public class ConnectionUrlParser implements DatabaseUrlContainer {
private static final String DUMMY_SCHEMA = "cj://";
private static final String USER_PASS_SEPARATOR = ":";
private static final String USER_HOST_SEPARATOR = "@";
private static final String HOSTS_SEPARATOR = ",";
private static final String KEY_VALUE_HOST_INFO_OPENING_MARKER = "(";
private static final String KEY_VALUE_HOST_INFO_CLOSING_MARKER = ")";
private static final String HOSTS_LIST_OPENING_MARKERS = "[(";
private static final String HOSTS_LIST_CLOSING_MARKERS = "])";
private static final String ADDRESS_EQUALS_HOST_INFO_PREFIX = "ADDRESS=";
private static final Pattern CONNECTION_STRING_PTRN = Pattern.compile("(?[\\w\\+:%]+)\\s*" // scheme: required; alphanumeric, plus, colon or percent
+ "(?://(?[^/?#]*))?\\s*" // authority: optional; starts with "//" followed by any char except "/", "?" and "#"
+ "(?:/(?!\\s*/)(?[^?#]*))?" // path: optional; starts with "/" but not followed by "/", and then followed by by any char except "?" and "#"
+ "(?:\\?(?!\\s*\\?)(?[^#]*))?" // query: optional; starts with "?" but not followed by "?", and then followed by by any char except "#"
+ "(?:\\s*#(?.*))?"); // fragment: optional; starts with "#", and then followed by anything
private static final Pattern SCHEME_PTRN = Pattern.compile("(?[\\w\\+:%]+).*");
private static final Pattern HOST_LIST_PTRN = Pattern.compile("^\\[(?.*)\\]$");
private static final Pattern GENERIC_HOST_PTRN = Pattern.compile("^(?.*?)(?::(?[^:]*))?$");
private static final Pattern KEY_VALUE_HOST_PTRN = Pattern.compile("[,\\s]*(?[\\w\\.\\-\\s%]*)(?:=(?[^,]*))?");
private static final Pattern ADDRESS_EQUALS_HOST_PTRN = Pattern.compile("\\s*\\(\\s*(?[\\w\\.\\-%]+)?\\s*(?:=(?[^)]*))?\\)\\s*");
private static final Pattern PROPERTIES_PTRN = Pattern.compile("[&\\s]*(?[\\w\\.\\-\\s%]*)(?:=(?[^&]*))?");
private final String baseConnectionString;
private String scheme;
private String authority;
private String path;
private String query;
private List parsedHosts = null;
private Map parsedProperties = null;
/**
* Static factory method for constructing instances of this class.
*
* @param connString
* The connection string to parse.
* @return an instance of {@link ConnectionUrlParser}
*/
public static ConnectionUrlParser parseConnectionString(String connString) {
return new ConnectionUrlParser(connString);
}
/**
* Constructs a connection string parser for the given connection string.
*
* @param connString
* the connection string to parse
*/
private ConnectionUrlParser(String connString) {
if (connString == null) {
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.0"));
}
if (!isConnectionStringSupported(connString)) {
throw ExceptionFactory.createException(UnsupportedConnectionStringException.class,
Messages.getString("ConnectionString.17", new String[] { connString }));
}
this.baseConnectionString = connString;
parseConnectionString();
}
/**
* Checks if the scheme part of given connection string matches one of the {@link Type}s supported by Connector/J.
* Throws {@link WrongArgumentException} if connString is null.
*
* @param connString
* connection string
* @return true if supported
*/
public static boolean isConnectionStringSupported(String connString) {
if (connString == null) {
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.0"));
}
Matcher matcher = SCHEME_PTRN.matcher(connString);
return matcher.matches() && Type.isSupported(decodeSkippingPlusSign(matcher.group("scheme")));
}
/**
* Splits the connection string in its main sections.
*/
private void parseConnectionString() {
String connString = this.baseConnectionString;
Matcher matcher = CONNECTION_STRING_PTRN.matcher(connString);
if (!matcher.matches()) {
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.1"));
}
this.scheme = decodeSkippingPlusSign(matcher.group("scheme"));
this.authority = matcher.group("authority"); // Don't decode just yet.
this.path = matcher.group("path") == null ? null : decode(matcher.group("path")).trim();
this.query = matcher.group("query"); // Don't decode just yet.
}
/**
* Parses the authority section (user and/or host identification) of the connection string URI.
*/
private void parseAuthoritySection() {
if (isNullOrEmpty(this.authority)) {
// Add an empty, default, host.
this.parsedHosts.add(new HostInfo());
return;
}
List authoritySegments = StringUtils.split(this.authority, HOSTS_SEPARATOR, HOSTS_LIST_OPENING_MARKERS, HOSTS_LIST_CLOSING_MARKERS, true,
SearchMode.__MRK_WS);
for (String hi : authoritySegments) {
parseAuthoritySegment(hi);
}
}
/**
* Parses the given sub authority segment, which can take one of the following syntaxes:
*
* - _user_:_password_@_host_:_port_
*
- _user_:_password_@(key1=value1,key2=value2,...)
*
- _user_:_password_@address=(key1=value1)(key2=value2)...
*
- _user_:_password_@[_any_of_the_above_1_,_any_of_the_above_2_,...]
*
* Most of the above placeholders can be omitted, representing a null, empty, or default value.
* The placeholder _host_, can be a host name, IPv4 or IPv6. This parser doesn't check IP syntax. IPv6 addresses are enclosed by square brackets ([::1]).
* The placeholder _any_of_the_above_?_ can be any of the above except for the user information part (_user_:_password_@).
* When the symbol ":" is not used, it means an null/empty password or a default (HostInfo.NO_PORT) port, respectively.
* When the symbol "@" is not used, it means that the authority part doesn't contain user information (depending on the scheme type can still be provided
* via key=value pairs).
*
* @param authSegment
* the string containing the authority segment
*/
private void parseAuthoritySegment(String authSegment) {
/*
* Start by splitting the user and host information parts from the authority segment and process the user information, if any.
*/
Pair userHostInfoSplit = splitByUserInfoAndHostInfo(authSegment);
String userInfo = safeTrim(userHostInfoSplit.left);
String user = null;
String password = null;
if (!isNullOrEmpty(userInfo)) {
Pair userInfoPair = parseUserInfo(userInfo);
user = decode(safeTrim(userInfoPair.left));
password = decode(safeTrim(userInfoPair.right));
}
String hostInfo = safeTrim(userHostInfoSplit.right);
/*
* Handle an authority part without host information.
*/
HostInfo hi = buildHostInfoForEmptyHost(user, password, hostInfo);
if (hi != null) {
this.parsedHosts.add(hi);
return;
}
/*
* Try using a java.net.URI instance to parse the host information. This helps dealing with the IPv6 syntax.
*/
hi = buildHostInfoResortingToUriParser(user, password, authSegment);
if (hi != null) {
this.parsedHosts.add(hi);
return;
}
/*
* Using a URI didn't work, now check if the host part is composed by a sub list of hosts and process them, one by one if so.
*/
List hiList = buildHostInfoResortingToSubHostsListParser(user, password, hostInfo);
if (hiList != null) {
this.parsedHosts.addAll(hiList);
return;
}
/*
* The hosts list syntax didn't work, now check if the host information is written in the alternate syntax "(Key1=value1,key2=value2)".
*/
hi = buildHostInfoResortingToKeyValueSyntaxParser(user, password, hostInfo);
if (hi != null) {
this.parsedHosts.add(hi);
return;
}
/*
* Key/value syntax didn't work either, now check if the host information is written in the alternate syntax "address=(...)".
* This parser needs to run after the key/value one because a key named "address" could invalidate it.
*/
hi = buildHostInfoResortingToAddressEqualsSyntaxParser(user, password, hostInfo);
if (hi != null) {
this.parsedHosts.add(hi);
return;
}
/*
* Alternate syntax also failed, let's wind up the corner cases the URI couldn't handle.
*/
hi = buildHostInfoResortingToGenericSyntaxParser(user, password, hostInfo);
if (hi != null) {
this.parsedHosts.add(hi);
return;
}
/*
* Failed parsing the authority segment.
*/
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.2", new Object[] { authSegment }));
}
/**
* Builds an {@link HostInfo} instance for empty host authority segments.
*
* @param user
* the user to include in the final {@link HostInfo}
* @param password
* the password to include in the final {@link HostInfo}
* @param hostInfo
* the string containing the host information part
* @return the {@link HostInfo} instance containing the parsed information or null
if the host part is not empty
*/
private HostInfo buildHostInfoForEmptyHost(String user, String password, String hostInfo) {
if (isNullOrEmpty(hostInfo)) {
return new HostInfo(this, null, HostInfo.NO_PORT, user, password);
}
return null;
}
/**
* Parses the host information resorting to a URI object. This process handles most single-host well formed addresses.
*
* @param user
* the user to include in the final {@link HostInfo}
* @param password
* the password to include in the final {@link HostInfo}
* @param hostInfo
* the string containing the host information part
*
* @return the {@link HostInfo} instance containing the parsed information or null
if unable to parse the host information
*/
private HostInfo buildHostInfoResortingToUriParser(String user, String password, String hostInfo) {
String host = null;
int port = HostInfo.NO_PORT;
try {
URI uri = URI.create(DUMMY_SCHEMA + hostInfo);
if (uri.getHost() != null) {
host = decode(uri.getHost());
}
if (uri.getPort() != -1) { // getPort() returns -1 if the port is undefined.
port = uri.getPort();
}
if (uri.getUserInfo() != null) {
// Can't have another one. The user information should have been handled already.
return null;
}
} catch (IllegalArgumentException e) {
// The URI failed to parse the host information.
return null;
}
if (host != null || port != HostInfo.NO_PORT) {
// The host info parsing succeeded.
return new HostInfo(this, host, port, user, password);
}
return null;
}
/**
* Parses the host information using the alternate sub hosts lists syntax "[host1, host2, ...]".
*
* @param user
* the user to include in all the resulting {@link HostInfo}
* @param password
* the password to include in all the resulting {@link HostInfo}
* @param hostInfo
* the string containing the host information part
* @return a list with all {@link HostInfo} instances containing the parsed information or null
if unable to parse the host information
*/
private List buildHostInfoResortingToSubHostsListParser(String user, String password, String hostInfo) {
Matcher matcher = HOST_LIST_PTRN.matcher(hostInfo);
if (matcher.matches()) {
String hosts = matcher.group("hosts");
List hostsList = StringUtils.split(hosts, HOSTS_SEPARATOR, HOSTS_LIST_OPENING_MARKERS, HOSTS_LIST_CLOSING_MARKERS, true,
SearchMode.__MRK_WS);
// One single element could, in fact, be an IPv6 stripped from its delimiters.
boolean maybeIPv6 = hostsList.size() == 1 && hostsList.get(0).matches("(?i)^[\\dabcdef:]+$");
List hostInfoList = new ArrayList<>();
for (String h : hostsList) {
HostInfo hi;
if ((hi = buildHostInfoForEmptyHost(user, password, h)) != null) {
hostInfoList.add(hi);
} else if ((hi = buildHostInfoResortingToUriParser(user, password, h)) != null
|| maybeIPv6 && (hi = buildHostInfoResortingToUriParser(user, password, "[" + h + "]")) != null) {
hostInfoList.add(hi);
} else if ((hi = buildHostInfoResortingToKeyValueSyntaxParser(user, password, h)) != null) {
hostInfoList.add(hi);
} else if ((hi = buildHostInfoResortingToAddressEqualsSyntaxParser(user, password, h)) != null) {
hostInfoList.add(hi);
} else if ((hi = buildHostInfoResortingToGenericSyntaxParser(user, password, h)) != null) {
hostInfoList.add(hi);
} else {
return null;
}
}
return hostInfoList;
}
return null;
}
/**
* Parses the host information using the alternate syntax "(key1=value1, key2=value2, ...)".
*
* @param user
* the user to include in the resulting {@link HostInfo}
* @param password
* the password to include in the resulting {@link HostInfo}
* @param hostInfo
* the string containing the host information part
* @return the {@link HostInfo} instance containing the parsed information or null
if unable to parse the host information
*/
private HostInfo buildHostInfoResortingToKeyValueSyntaxParser(String user, String password, String hostInfo) {
if (!hostInfo.startsWith(KEY_VALUE_HOST_INFO_OPENING_MARKER) || !hostInfo.endsWith(KEY_VALUE_HOST_INFO_CLOSING_MARKER)) {
// This pattern won't work.
return null;
}
hostInfo = hostInfo.substring(KEY_VALUE_HOST_INFO_OPENING_MARKER.length(), hostInfo.length() - KEY_VALUE_HOST_INFO_CLOSING_MARKER.length());
return new HostInfo(this, null, HostInfo.NO_PORT, user, password, processKeyValuePattern(KEY_VALUE_HOST_PTRN, hostInfo));
}
/**
* Parses the host information using the alternate syntax "address=(key1=value1)(key2=value2)...".
*
* @param user
* the user to include in the resulting {@link HostInfo}
* @param password
* the password to include in the resulting {@link HostInfo}
* @param hostInfo
* the string containing the host information part
* @return the {@link HostInfo} instance containing the parsed information or null
if unable to parse the host information
*/
private HostInfo buildHostInfoResortingToAddressEqualsSyntaxParser(String user, String password, String hostInfo) {
int p = StringUtils.indexOfIgnoreCase(hostInfo, ADDRESS_EQUALS_HOST_INFO_PREFIX);
if (p != 0) {
// This pattern won't work.
return null;
}
hostInfo = hostInfo.substring(p + ADDRESS_EQUALS_HOST_INFO_PREFIX.length()).trim();
return new HostInfo(this, null, HostInfo.NO_PORT, user, password, processKeyValuePattern(ADDRESS_EQUALS_HOST_PTRN, hostInfo));
}
/**
* Parses the host information using the generic syntax "host:port".
*
* @param user
* the user to include in the resulting {@link HostInfo}
* @param password
* the password to include in the resulting {@link HostInfo}
* @param hostInfo
* the string containing the host information part
* @return the {@link HostInfo} instance containing the parsed information or null
if unable to parse the host information
*/
private HostInfo buildHostInfoResortingToGenericSyntaxParser(String user, String password, String hostInfo) {
if (splitByUserInfoAndHostInfo(hostInfo).left != null) {
// This host information is invalid if contains another user information part.
return null;
}
Pair hostPortPair = parseHostPortPair(hostInfo);
String host = decode(safeTrim(hostPortPair.left));
Integer port = hostPortPair.right;
return new HostInfo(this, isNullOrEmpty(host) ? null : host, port, user, password);
}
/**
* Splits the given authority segment in the user information part and the host part.
*
* @param authSegment
* the string containing the authority segment, i.e., the user and host information parts
* @return
* a {@link Pair} containing the user information in the left side and the host information in the right
*/
private Pair splitByUserInfoAndHostInfo(String authSegment) {
String userInfoPart = null;
String hostInfoPart = authSegment;
int p = authSegment.indexOf(USER_HOST_SEPARATOR);
if (p >= 0) {
userInfoPart = authSegment.substring(0, p);
hostInfoPart = authSegment.substring(p + USER_HOST_SEPARATOR.length());
}
return new Pair<>(userInfoPart, hostInfoPart);
}
/**
* Parses the given user information which is formed by the parts [user][:password].
*
* @param userInfo
* the string containing the user information
* @return a {@link Pair} containing the user and password information or null if the user information can't be parsed
*/
public static Pair parseUserInfo(String userInfo) {
if (isNullOrEmpty(userInfo)) {
return null;
}
String[] userInfoParts = userInfo.split(USER_PASS_SEPARATOR, 2);
String userName = userInfoParts[0];
String password = userInfoParts.length > 1 ? userInfoParts[1] : null;
return new Pair<>(userName, password);
}
/**
* Parses a host:port pair and returns the two elements in a {@link Pair}
*
* @param hostInfo
* the host:pair to parse
* @return a {@link Pair} containing the host and port information or null if the host information can't be parsed
*/
public static Pair parseHostPortPair(String hostInfo) {
if (isNullOrEmpty(hostInfo)) {
return null;
}
Matcher matcher = GENERIC_HOST_PTRN.matcher(hostInfo);
if (matcher.matches()) {
String host = matcher.group("host");
String portAsString = decode(safeTrim(matcher.group("port")));
Integer portAsInteger = HostInfo.NO_PORT;
if (!isNullOrEmpty(portAsString)) {
try {
portAsInteger = Integer.parseInt(portAsString);
} catch (NumberFormatException e) {
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.3", new Object[] { hostInfo }),
e);
}
}
return new Pair<>(host, portAsInteger);
}
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.3", new Object[] { hostInfo }));
}
/**
* Parses the connection properties section and stores the extracted key/value pairs into a local map.
*/
private void parseQuerySection() {
if (isNullOrEmpty(this.query)) {
this.parsedProperties = new HashMap<>();
return;
}
this.parsedProperties = processKeyValuePattern(PROPERTIES_PTRN, this.query);
}
/**
* Takes a two-matching-groups (respectively named "key" and "value") pattern which is successively tested against the given string and produces a key/value
* map with the matched values. The given pattern must ensure that there are no leftovers between successive tests, i.e., the end of the previous match must
* coincide with the beginning of the next.
*
* @param pattern
* the regular expression pattern to match against to
* @param input
* the input string
* @return a key/value map containing the matched values
*/
private Map processKeyValuePattern(Pattern pattern, String input) {
Matcher matcher = pattern.matcher(input);
int p = 0;
Map kvMap = new HashMap<>();
while (matcher.find()) {
if (matcher.start() != p) {
throw ExceptionFactory.createException(WrongArgumentException.class,
Messages.getString("ConnectionString.4", new Object[] { input.substring(p) }));
}
String key = decode(safeTrim(matcher.group("key")));
String value = decode(safeTrim(matcher.group("value")));
if (!isNullOrEmpty(key)) {
kvMap.put(key, value);
} else if (!isNullOrEmpty(value)) {
throw ExceptionFactory.createException(WrongArgumentException.class,
Messages.getString("ConnectionString.4", new Object[] { input.substring(p) }));
}
p = matcher.end();
}
if (p != input.length()) {
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.4", new Object[] { input.substring(p) }));
}
return kvMap;
}
/**
* URL-decode the given string.
*
* @param text
* the string to decode
* @return
* the decoded string
*/
private static String decode(String text) {
if (isNullOrEmpty(text)) {
return text;
}
try {
return URLDecoder.decode(text, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
// Won't happen.
}
return "";
}
/**
* URL-decode the given string skipping all occurrences of the plus sign.
*
* @param text
* the string to decode
* @return
* the decoded string
*/
private static String decodeSkippingPlusSign(String text) {
if (isNullOrEmpty(text)) {
return text;
}
text = text.replace("+", "%2B"); // Percent encode for "+" is "%2B".
try {
return URLDecoder.decode(text, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
// Won't happen.
}
return "";
}
/**
* Returns the original database URL that produced this connection string parser.
*
* @return the original database URL
*/
@Override
public String getDatabaseUrl() {
return this.baseConnectionString;
}
/**
* Returns the scheme section.
*
* @return the scheme section
*/
public String getScheme() {
return this.scheme;
}
/**
* Returns the authority section.
*
* @return the authority section
*/
public String getAuthority() {
return this.authority;
}
/**
* Returns the path section.
*
* @return the path section
*/
public String getPath() {
return this.path;
}
/**
* Returns the query section.
*
* @return the query section
*/
public String getQuery() {
return this.query;
}
/**
* Returns the hosts information.
*
* @return the hosts information
*/
public List getHosts() {
if (this.parsedHosts == null) {
this.parsedHosts = new ArrayList<>();
parseAuthoritySection();
}
return this.parsedHosts;
}
/**
* Returns the properties map contained in this connection string.
*
* @return the properties map
*/
public Map getProperties() {
if (this.parsedProperties == null) {
parseQuerySection();
}
return Collections.unmodifiableMap(this.parsedProperties);
}
/**
* Returns a string representation of this object.
*
* @return a string representation of this object
*/
@Override
public String toString() {
StringBuilder asStr = new StringBuilder(super.toString());
asStr.append(String.format(" :: {scheme: \"%s\", authority: \"%s\", path: \"%s\", query: \"%s\", parsedHosts: %s, parsedProperties: %s}", this.scheme,
this.authority, this.path, this.query, this.parsedHosts, this.parsedProperties));
return asStr.toString();
}
/**
* This class is a simple container for two elements.
*
* @param
* left part type
* @param
* right part type
*/
public static class Pair {
public final T left;
public final U right;
public Pair(T left, U right) {
this.left = left;
this.right = right;
}
@Override
public String toString() {
StringBuilder asStr = new StringBuilder(super.toString());
asStr.append(String.format(" :: { left: %s, right: %s }", this.left, this.right));
return asStr.toString();
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy