diff --git a/db-async-common/src/main/scala/com/github/mauricio/async/db/Configuration.scala b/db-async-common/src/main/scala/com/github/mauricio/async/db/Configuration.scala index b032ac02..cde267cf 100644 --- a/db-async-common/src/main/scala/com/github/mauricio/async/db/Configuration.scala +++ b/db-async-common/src/main/scala/com/github/mauricio/async/db/Configuration.scala @@ -25,6 +25,8 @@ import scala.concurrent.duration._ object Configuration { val DefaultCharset = CharsetUtil.UTF_8 + + @deprecated("Use com.github.mauricio.async.db.postgresql.util.URLParser.DEFAULT or com.github.mauricio.async.db.mysql.util.URLParser.DEFAULT.", since = "0.2.20") val Default = new Configuration("postgres") } diff --git a/db-async-common/src/main/scala/com/github/mauricio/async/db/exceptions/UnableToParseURLException.scala b/db-async-common/src/main/scala/com/github/mauricio/async/db/exceptions/UnableToParseURLException.scala new file mode 100644 index 00000000..0d2799df --- /dev/null +++ b/db-async-common/src/main/scala/com/github/mauricio/async/db/exceptions/UnableToParseURLException.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2016 Maurício Linhares + * + * Maurício Linhares 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 com.github.mauricio.async.db.exceptions + +/** + * Thrown to indicate that a URL Parser could not understand the provided URL. + */ +class UnableToParseURLException(message: String, base: Throwable) extends RuntimeException(message, base) { + def this(message: String) = this(message, null) +} \ No newline at end of file diff --git a/db-async-common/src/main/scala/com/github/mauricio/async/db/util/AbstractURIParser.scala b/db-async-common/src/main/scala/com/github/mauricio/async/db/util/AbstractURIParser.scala new file mode 100644 index 00000000..e18de6e1 --- /dev/null +++ b/db-async-common/src/main/scala/com/github/mauricio/async/db/util/AbstractURIParser.scala @@ -0,0 +1,175 @@ +/* + * Copyright 2016 Maurício Linhares + * + * Maurício Linhares 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 com.github.mauricio.async.db.util + +import java.net.{URI, URISyntaxException, URLDecoder} +import java.nio.charset.Charset + +import com.github.mauricio.async.db.exceptions.UnableToParseURLException +import com.github.mauricio.async.db.{Configuration, SSLConfiguration} +import org.slf4j.LoggerFactory + +import scala.util.matching.Regex + +/** + * Common parser assisting methods for PG and MySQL URI parsers. + */ +abstract class AbstractURIParser { + import AbstractURIParser._ + + protected val logger = LoggerFactory.getLogger(getClass) + + /** + * Parses out userInfo into a tuple of optional username and password + * + * @param userInfo the optional user info string + * @return a tuple of optional username and password + */ + final protected def parseUserInfo(userInfo: Option[String]): (Option[String], Option[String]) = userInfo.map(_.split(":", 2).toList) match { + case Some(user :: pass :: Nil) ⇒ (Some(user), Some(pass)) + case Some(user :: Nil) ⇒ (Some(user), None) + case _ ⇒ (None, None) + } + + /** + * A Regex that will match the base name of the driver scheme, minus jdbc:. + * Eg: postgres(?:ul)? + */ + protected val SCHEME: Regex + + /** + * The default for this particular URLParser, ie: appropriate and specific to PG or MySQL accordingly + */ + val DEFAULT: Configuration + + + /** + * Parses the provided url and returns a Configuration based upon it. On an error, + * @param url the URL to parse. + * @param charset the charset to use. + * @return a Configuration. + */ + @throws[UnableToParseURLException]("if the URL does not match the expected type, or cannot be parsed for any reason") + def parseOrDie(url: String, + charset: Charset = DEFAULT.charset): Configuration = { + try { + val properties = parse(new URI(url).parseServerAuthority) + + assembleConfiguration(properties, charset) + } catch { + case e: URISyntaxException => + throw new UnableToParseURLException(s"Failed to parse URL: $url", e) + } + } + + + /** + * Parses the provided url and returns a Configuration based upon it. On an error, + * a default configuration is returned. + * @param url the URL to parse. + * @param charset the charset to use. + * @return a Configuration. + */ + def parse(url: String, + charset: Charset = DEFAULT.charset + ): Configuration = { + try { + parseOrDie(url, charset) + } catch { + case e: Exception => + logger.warn(s"Connection url '$url' could not be parsed.", e) + // Fallback to default to maintain current behavior + DEFAULT + } + } + + /** + * Assembles a configuration out of the provided property map. This is the generic form, subclasses may override to + * handle additional properties. + * @param properties the extracted properties from the URL. + * @param charset the charset passed in to parse or parseOrDie. + * @return + */ + protected def assembleConfiguration(properties: Map[String, String], charset: Charset): Configuration = { + DEFAULT.copy( + username = properties.getOrElse(USERNAME, DEFAULT.username), + password = properties.get(PASSWORD), + database = properties.get(DBNAME), + host = properties.getOrElse(HOST, DEFAULT.host), + port = properties.get(PORT).map(_.toInt).getOrElse(DEFAULT.port), + ssl = SSLConfiguration(properties), + charset = charset + ) + } + + + protected def parse(uri: URI): Map[String, String] = { + uri.getScheme match { + case SCHEME() => + val userInfo = parseUserInfo(Option(uri.getUserInfo)) + + val port = Some(uri.getPort).filter(_ > 0) + val db = Option(uri.getPath).map(_.stripPrefix("/")).filterNot(_.isEmpty) + val host = Option(uri.getHost) + + val builder = Map.newBuilder[String, String] + builder ++= userInfo._1.map(USERNAME -> _) + builder ++= userInfo._2.map(PASSWORD -> _) + builder ++= port.map(PORT -> _.toString) + builder ++= db.map(DBNAME -> _) + builder ++= host.map(HOST -> unwrapIpv6address(_)) + + // Parse query string parameters and just append them, overriding anything previously set + builder ++= (for { + qs <- Option(uri.getQuery).toSeq + parameter <- qs.split('&') + Array(name, value) = parameter.split('=') + if name.nonEmpty && value.nonEmpty + } yield URLDecoder.decode(name, "UTF-8") -> URLDecoder.decode(value, "UTF-8")) + + + builder.result + case "jdbc" => + handleJDBC(uri) + case _ => + throw new UnableToParseURLException("Unrecognized URI scheme") + } + } + + /** + * This method breaks out handling of the jdbc: prefixed uri's, allowing them to be handled differently + * without reimplementing all of parse. + */ + protected def handleJDBC(uri: URI): Map[String, String] = parse(new URI(uri.getSchemeSpecificPart)) + + + final protected def unwrapIpv6address(server: String): String = { + if (server.startsWith("[")) { + server.substring(1, server.length() - 1) + } else server + } + +} + +object AbstractURIParser { + // Constants and value names + val PORT = "port" + val DBNAME = "database" + val HOST = "host" + val USERNAME = "user" + val PASSWORD = "password" +} + diff --git a/mysql-async/src/main/scala/com/github/mauricio/async/db/mysql/util/URLParser.scala b/mysql-async/src/main/scala/com/github/mauricio/async/db/mysql/util/URLParser.scala new file mode 100644 index 00000000..ba9c0333 --- /dev/null +++ b/mysql-async/src/main/scala/com/github/mauricio/async/db/mysql/util/URLParser.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2016 Maurício Linhares + * + * Maurício Linhares 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 com.github.mauricio.async.db.mysql.util + +import com.github.mauricio.async.db.util.AbstractURIParser +import com.github.mauricio.async.db.Configuration + +/** + * The MySQL URL parser. + */ +object URLParser extends AbstractURIParser { + + /** + * The default configuration for MySQL. + */ + override val DEFAULT = Configuration( + username = "root", + host = "127.0.0.1", //Matched JDBC default + port = 3306, + password = None, + database = None + ) + + override protected val SCHEME = "^mysql$".r + +} diff --git a/mysql-async/src/test/scala/com/github/mauricio/async/db/mysql/util/URLParserSpec.scala b/mysql-async/src/test/scala/com/github/mauricio/async/db/mysql/util/URLParserSpec.scala new file mode 100644 index 00000000..b15ab779 --- /dev/null +++ b/mysql-async/src/test/scala/com/github/mauricio/async/db/mysql/util/URLParserSpec.scala @@ -0,0 +1,264 @@ +/* + * Copyright 2016 Maurício Linhares + * + * Maurício Linhares 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 com.github.mauricio.async.db.mysql.util + +import java.nio.charset.Charset + +import com.github.mauricio.async.db.{Configuration, SSLConfiguration} +import com.github.mauricio.async.db.exceptions.UnableToParseURLException +import io.netty.buffer.{ByteBufAllocator, PooledByteBufAllocator} +import org.specs2.mutable.Specification + +import scala.concurrent.duration.Duration + +class URLParserSpec extends Specification { + + "mysql URLParser" should { + import URLParser.{DEFAULT, parse, parseOrDie} + + + "have a reasonable default" in { + // This is a deliberate extra step, protecting the DEFAULT from frivilous changes. + // Any change to DEFAULT should require a change to this test. + + DEFAULT === Configuration( + username = "root", + host = "127.0.0.1", //Matched JDBC default + port = 3306, + password = None, + database = None + ) + } + + + // Divided into sections + // =========== jdbc:mysql =========== + + "create a jdbc:mysql connection with the available fields" in { + val connectionUri = "jdbc:mysql://128.167.54.90:9987/my_database?user=john&password=doe" + + parse(connectionUri) === DEFAULT.copy( + username = "john", + password = Some("doe"), + database = Some("my_database"), + host = "128.167.54.90", + port = 9987 + ) + } + + "create a connection without port" in { + val connectionUri = "jdbc:mysql://128.167.54.90/my_database?user=john&password=doe" + + parse(connectionUri) === DEFAULT.copy( + username = "john", + password = Some("doe"), + database = Some("my_database"), + host = "128.167.54.90" + ) + } + + + "create a connection without username and password" in { + val connectionUri = "jdbc:mysql://128.167.54.90:9987/my_database" + + parse(connectionUri) === DEFAULT.copy( + database = Some("my_database"), + host = "128.167.54.90", + port = 9987 + ) + } + + "create a connection from a heroku like URL using 'mysql' protocol" in { + val connectionUri = "mysql://john:doe@128.167.54.90:9987/my_database" + + parse(connectionUri) === DEFAULT.copy( + username = "john", + password = Some("doe"), + database = Some("my_database"), + host = "128.167.54.90", + port = 9987 + ) + } + + "create a connection with the available fields and named server" in { + val connectionUri = "jdbc:mysql://localhost:9987/my_database?user=john&password=doe" + + parse(connectionUri) === DEFAULT.copy( + username = "john", + password = Some("doe"), + database = Some("my_database"), + host = "localhost", + port = 9987 + ) + } + + "create a connection from a heroku like URL with named server" in { + val connectionUri = "mysql://john:doe@psql.heroku.com:9987/my_database" + + val configuration = parse(connectionUri) + configuration.username === "john" + configuration.password === Some("doe") + configuration.database === Some("my_database") + configuration.host === "psql.heroku.com" + configuration.port === 9987 + } + + "create a connection with the available fields and ipv6" in { + val connectionUri = "jdbc:mysql://[::1]:9987/my_database?user=john&password=doe" + + val configuration = parse(connectionUri) + + configuration.username === "john" + configuration.password === Some("doe") + configuration.database === Some("my_database") + configuration.host === "::1" + configuration.port === 9987 + } + + "create a connection from a heroku like URL and with ipv6" in { + val connectionUri = "mysql://john:doe@[::1]:9987/my_database" + + val configuration = parse(connectionUri) + configuration.username === "john" + configuration.password === Some("doe") + configuration.database === Some("my_database") + configuration.host === "::1" + configuration.port === 9987 + } + + "create a connection with a missing hostname" in { + val connectionUri = "jdbc:mysql:/my_database?user=john&password=doe" + + parse(connectionUri) === DEFAULT.copy( + username = "john", + password = Some("doe"), + database = Some("my_database") + ) + } + + "create a connection with a missing database name" in { + val connectionUri = "jdbc:mysql://[::1]:9987/?user=john&password=doe" + + val configuration = parse(connectionUri) + + configuration.username === "john" + configuration.password === Some("doe") + configuration.database === None + configuration.host === "::1" + configuration.port === 9987 + } + + "create a connection with all default fields" in { + val connectionUri = "jdbc:mysql:" + + val configuration = parse(connectionUri) + + configuration.username === "root" + configuration.password === None + configuration.database === None + configuration.host === "127.0.0.1" + configuration.port === 3306 + } + + "create a connection with an empty (invalid) url" in { + val connectionUri = "" + + val configuration = parse(connectionUri) + + configuration.username === "root" + configuration.password === None + configuration.database === None + configuration.host === "127.0.0.1" + configuration.port === 3306 + } + + + "recognise a mysql:// uri" in { + parse("mysql://localhost:425/dbname") mustEqual DEFAULT.copy( + username = "root", + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "recognize a jdbc:mysql:// uri" in { + parse("jdbc:mysql://localhost:425/dbname") mustEqual DEFAULT.copy( + username = "root", + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "pull the username and password from URI credentials" in { + parse("jdbc:mysql://user:password@localhost:425/dbname") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "pull the username and password from query string" in { + parse("jdbc:mysql://localhost:425/dbname?user=user&password=password") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + // Included for consistency, so later changes aren't allowed to change behavior + "use the query string parameters to override URI credentials" in { + parse("jdbc:mysql://baduser:badpass@localhost:425/dbname?user=user&password=password") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "successfully default the port to the mysql port" in { + parse("jdbc:mysql://baduser:badpass@localhost/dbname?user=user&password=password") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 3306, + host = "localhost" + ) + } + + "reject malformed ip addresses" in { + val connectionUri = "mysql://john:doe@128.567.54.90:9987/my_database" + + val configuration = parse(connectionUri) + configuration.username === "root" + configuration.password === None + configuration.database === None + configuration.host === "127.0.0.1" + configuration.port === 3306 + + parseOrDie(connectionUri) must throwA[UnableToParseURLException] + } + + } + +} diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/PostgreSQLConnection.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/PostgreSQLConnection.scala index 8c58076b..ec89660c 100644 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/PostgreSQLConnection.scala +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/PostgreSQLConnection.scala @@ -17,8 +17,8 @@ package com.github.mauricio.async.db.postgresql import com.github.mauricio.async.db.QueryResult -import com.github.mauricio.async.db.column.{ColumnEncoderRegistry, ColumnDecoderRegistry} -import com.github.mauricio.async.db.exceptions.{InsufficientParametersException, ConnectionStillRunningQueryException} +import com.github.mauricio.async.db.column.{ColumnDecoderRegistry, ColumnEncoderRegistry} +import com.github.mauricio.async.db.exceptions.{ConnectionStillRunningQueryException, InsufficientParametersException} import com.github.mauricio.async.db.general.MutableResultSet import com.github.mauricio.async.db.pool.TimeoutScheduler import com.github.mauricio.async.db.postgresql.codec.{PostgreSQLConnectionDelegate, PostgreSQLConnectionHandler} @@ -26,14 +26,17 @@ import com.github.mauricio.async.db.postgresql.column.{PostgreSQLColumnDecoderRe import com.github.mauricio.async.db.postgresql.exceptions._ import com.github.mauricio.async.db.util._ import com.github.mauricio.async.db.{Configuration, Connection} -import java.util.concurrent.atomic.{AtomicLong,AtomicInteger,AtomicReference} +import java.util.concurrent.atomic.{AtomicInteger, AtomicLong, AtomicReference} + import messages.backend._ import messages.frontend._ -import scala.Some + import scala.concurrent._ import io.netty.channel.EventLoopGroup import java.util.concurrent.CopyOnWriteArrayList +import com.github.mauricio.async.db.postgresql.util.URLParser + object PostgreSQLConnection { final val Counter = new AtomicLong() final val ServerVersionKey = "server_version" @@ -42,7 +45,7 @@ object PostgreSQLConnection { class PostgreSQLConnection ( - configuration: Configuration = Configuration.Default, + configuration: Configuration = URLParser.DEFAULT, encoderRegistry: ColumnEncoderRegistry = PostgreSQLColumnEncoderRegistry.Instance, decoderRegistry: ColumnDecoderRegistry = PostgreSQLColumnDecoderRegistry.Instance, group : EventLoopGroup = NettyUtils.DefaultEventLoopGroup, diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/ParserURL.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/ParserURL.scala deleted file mode 100644 index 8172877e..00000000 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/ParserURL.scala +++ /dev/null @@ -1,65 +0,0 @@ -/** - * - */ -package com.github.mauricio.async.db.postgresql.util - -import org.slf4j.LoggerFactory - -/** - * @author gciuloaica - * - */ -object ParserURL { - - private val logger = LoggerFactory.getLogger(ParserURL.getClass()) - - val PGPORT = "port" - val PGDBNAME = "database" - val PGHOST = "host" - val PGUSERNAME = "user" - val PGPASSWORD = "password" - - val DEFAULT_PORT = "5432" - - private val pgurl1 = """(jdbc:postgresql):(?://([^/:]*|\[.+\])(?::(\d+))?)?(?:/([^/?]*))?(?:\?(.*))?""".r - private val pgurl2 = """(postgres|postgresql)://(.*):(.*)@(.*):(\d+)/([^/?]*)(?:\?(.*))?""".r - - def parse(connectionURL: String): Map[String, String] = { - val properties: Map[String, String] = Map() - - def parseOptions(optionsStr: String): Map[String, String] = - optionsStr.split("&").map { o => - o.span(_ != '=') match { - case (name, value) => name -> value.drop(1) - } - }.toMap - - connectionURL match { - case pgurl1(protocol, server, port, dbname, params) => { - var result = properties - if (server != null) result += (PGHOST -> unwrapIpv6address(server)) - if (dbname != null && dbname.nonEmpty) result += (PGDBNAME -> dbname) - if (port != null) result += (PGPORT -> port) - if (params != null) result ++= parseOptions(params) - result - } - case pgurl2(protocol, username, password, server, port, dbname, params) => { - var result = properties + (PGHOST -> unwrapIpv6address(server)) + (PGPORT -> port) + (PGDBNAME -> dbname) + (PGUSERNAME -> username) + (PGPASSWORD -> password) - if (params != null) result ++= parseOptions(params) - result - } - case _ => { - logger.warn(s"Connection url '$connectionURL' could not be parsed.") - properties - } - } - - } - - private def unwrapIpv6address(server: String): String = { - if (server.startsWith("[")) { - server.substring(1, server.length() - 1) - } else server - } - -} diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/URLParser.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/URLParser.scala index debcb6d9..fcb9b3cf 100644 --- a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/URLParser.scala +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/URLParser.scala @@ -1,46 +1,72 @@ -/* - * Copyright 2013 Maurício Linhares +/** * - * Maurício Linhares 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 com.github.mauricio.async.db.postgresql.util -import com.github.mauricio.async.db.{Configuration, SSLConfiguration} +import java.net.URI import java.nio.charset.Charset -object URLParser { +import com.github.mauricio.async.db.{Configuration, SSLConfiguration} +import com.github.mauricio.async.db.util.AbstractURIParser - import Configuration.Default +/** + * The PostgreSQL URL parser. + */ +object URLParser extends AbstractURIParser { + import AbstractURIParser._ - def parse(url: String, - charset: Charset = Default.charset - ): Configuration = { + // Alias these for anyone still making use of them + @deprecated("Use com.github.mauricio.async.db.AbstractURIParser.PORT", since = "0.2.20") + val PGPORT = PORT - val properties = ParserURL.parse(url) + @deprecated("Use com.github.mauricio.async.db.AbstractURIParser.DBNAME", since = "0.2.20") + val PGDBNAME = DBNAME - val port = properties.get(ParserURL.PGPORT).getOrElse(ParserURL.DEFAULT_PORT).toInt + @deprecated("Use com.github.mauricio.async.db.AbstractURIParser.HOST", since = "0.2.20") + val PGHOST = HOST - new Configuration( - username = properties.get(ParserURL.PGUSERNAME).getOrElse(Default.username), - password = properties.get(ParserURL.PGPASSWORD), - database = properties.get(ParserURL.PGDBNAME), - host = properties.getOrElse(ParserURL.PGHOST, Default.host), - port = port, - ssl = SSLConfiguration(properties), - charset = charset - ) + @deprecated("Use com.github.mauricio.async.db.AbstractURIParser.USERNAME", since = "0.2.20") + val PGUSERNAME = USERNAME + @deprecated("Use com.github.mauricio.async.db.AbstractURIParser.PASSWORD", since = "0.2.20") + val PGPASSWORD = PASSWORD + + @deprecated("Use com.github.mauricio.async.db.postgresql.util.URLParser.DEFAULT.port", since = "0.2.20") + val DEFAULT_PORT = "5432" + + /** + * The default configuration for PostgreSQL. + */ + override val DEFAULT = Configuration( + username = "postgres", + host = "localhost", + port = 5432, + password = None, + database = None, + ssl = SSLConfiguration() + ) + + override protected val SCHEME = "^postgres(?:ql)?$".r + + private val simplePGDB = "^postgresql:(\\w+)$".r + + override protected def handleJDBC(uri: URI): Map[String, String] = uri.getSchemeSpecificPart match { + case simplePGDB(db) => Map(DBNAME -> db) + case x => parse(new URI(x)) } + /** + * Assembles a configuration out of the provided property map. This is the generic form, subclasses may override to + * handle additional properties. + * + * @param properties the extracted properties from the URL. + * @param charset the charset passed in to parse or parseOrDie. + * @return + */ + override protected def assembleConfiguration(properties: Map[String, String], charset: Charset): Configuration = { + // Add SSL Configuration + super.assembleConfiguration(properties, charset).copy( + ssl = SSLConfiguration(properties) + ) + } } diff --git a/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/package.scala b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/package.scala new file mode 100644 index 00000000..5d321170 --- /dev/null +++ b/postgresql-async/src/main/scala/com/github/mauricio/async/db/postgresql/util/package.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2016 Maurício Linhares + * + * Maurício Linhares 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 com.github.mauricio.async.db.postgresql + +/** + * Contains package level aliases and type renames. + */ +package object util { + + /** + * Alias to help compatibility. + */ + @deprecated("Use com.github.mauricio.async.db.postgresql.util.URLParser", since = "0.2.20") + val ParserURL = URLParser + +} diff --git a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/util/URLParserSpec.scala b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/util/URLParserSpec.scala index d0df6eaa..9d2d2828 100644 --- a/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/util/URLParserSpec.scala +++ b/postgresql-async/src/test/scala/com/github/mauricio/async/db/postgresql/util/URLParserSpec.scala @@ -17,79 +17,93 @@ package com.github.mauricio.async.db.postgresql.util import org.specs2.mutable.Specification -import com.github.mauricio.async.db.Configuration -import com.github.mauricio.async.db.SSLConfiguration import com.github.mauricio.async.db.SSLConfiguration.Mode +import com.github.mauricio.async.db.exceptions.UnableToParseURLException class URLParserSpec extends Specification { - "parser" should { + "postgresql URLParser" should { + import URLParser.{parse, parseOrDie, DEFAULT} - "create a connection with the available fields" in { - val connectionUri = "jdbc:postgresql://128.567.54.90:9987/my_database?user=john&password=doe" + // Divided into sections + // =========== jdbc:postgresql =========== - val configuration = URLParser.parse(connectionUri) + // https://jdbc.postgresql.org/documentation/80/connect.html + "recognize a jdbc:postgresql:dbname uri" in { + val connectionUri = "jdbc:postgresql:dbname" + + parse(connectionUri) mustEqual DEFAULT.copy( + database = Some("dbname") + ) + } + + "create a jdbc:postgresql connection with the available fields" in { + val connectionUri = "jdbc:postgresql://128.167.54.90:9987/my_database?user=john&password=doe" + + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") - configuration.host === "128.567.54.90" + configuration.host === "128.167.54.90" configuration.port === 9987 } "create a connection without port" in { - val connectionUri = "jdbc:postgresql://128.567.54.90/my_database?user=john&password=doe" + val connectionUri = "jdbc:postgresql://128.167.54.90/my_database?user=john&password=doe" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") - configuration.host === "128.567.54.90" + configuration.host === "128.167.54.90" configuration.port === 5432 } "create a connection without username and password" in { - val connectionUri = "jdbc:postgresql://128.567.54.90:9987/my_database" + val connectionUri = "jdbc:postgresql://128.167.54.90:9987/my_database" - val configuration = URLParser.parse(connectionUri) - configuration.username === Configuration.Default.username + val configuration = parse(connectionUri) + configuration.username === DEFAULT.username configuration.password === None configuration.database === Some("my_database") - configuration.host === "128.567.54.90" + configuration.host === "128.167.54.90" configuration.port === 9987 } + //========== postgresql:// ============== + "create a connection from a heroku like URL using 'postgresql' protocol" in { - val connectionUri = "postgresql://john:doe@128.567.54.90:9987/my_database" + val connectionUri = "postgresql://john:doe@128.167.54.90:9987/my_database" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") - configuration.host === "128.567.54.90" + configuration.host === "128.167.54.90" configuration.port === 9987 } "create a connection with SSL enabled" in { - val connectionUri = "jdbc:postgresql://128.567.54.90:9987/my_database?sslmode=verify-full" + val connectionUri = "jdbc:postgresql://128.167.54.90:9987/my_database?sslmode=verify-full" - val configuration = URLParser.parse(connectionUri) - configuration.username === Configuration.Default.username + val configuration = parse(connectionUri) + configuration.username === DEFAULT.username configuration.password === None configuration.database === Some("my_database") - configuration.host === "128.567.54.90" + configuration.host === "128.167.54.90" configuration.port === 9987 configuration.ssl.mode === Mode.VerifyFull } "create a connection with SSL enabled and root CA from a heroku like URL using 'postgresql' protocol" in { - val connectionUri = "postgresql://john:doe@128.567.54.90:9987/my_database?sslmode=verify-ca&sslrootcert=server.crt" + val connectionUri = "postgresql://john:doe@128.167.54.90:9987/my_database?sslmode=verify-ca&sslrootcert=server.crt" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") - configuration.host === "128.567.54.90" + configuration.host === "128.167.54.90" configuration.port === 9987 configuration.ssl.mode === Mode.VerifyCA configuration.ssl.rootCert.map(_.getPath) === Some("server.crt") @@ -98,7 +112,7 @@ class URLParserSpec extends Specification { "create a connection with the available fields and named server" in { val connectionUri = "jdbc:postgresql://localhost:9987/my_database?user=john&password=doe" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") @@ -109,7 +123,7 @@ class URLParserSpec extends Specification { "create a connection from a heroku like URL with named server" in { val connectionUri = "postgresql://john:doe@psql.heroku.com:9987/my_database" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") @@ -120,7 +134,7 @@ class URLParserSpec extends Specification { "create a connection with the available fields and ipv6" in { val connectionUri = "jdbc:postgresql://[::1]:9987/my_database?user=john&password=doe" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") @@ -132,7 +146,7 @@ class URLParserSpec extends Specification { "create a connection from a heroku like URL and with ipv6" in { val connectionUri = "postgresql://john:doe@[::1]:9987/my_database" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") configuration.database === Some("my_database") @@ -143,7 +157,7 @@ class URLParserSpec extends Specification { "create a connection with a missing hostname" in { val connectionUri = "jdbc:postgresql:/my_database?user=john&password=doe" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") @@ -155,7 +169,7 @@ class URLParserSpec extends Specification { "create a connection with a missing database name" in { val connectionUri = "jdbc:postgresql://[::1]:9987/?user=john&password=doe" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "john" configuration.password === Some("doe") @@ -167,7 +181,7 @@ class URLParserSpec extends Specification { "create a connection with all default fields" in { val connectionUri = "jdbc:postgresql:" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "postgres" configuration.password === None @@ -179,7 +193,7 @@ class URLParserSpec extends Specification { "create a connection with an empty (invalid) url" in { val connectionUri = "" - val configuration = URLParser.parse(connectionUri) + val configuration = parse(connectionUri) configuration.username === "postgres" configuration.password === None @@ -188,6 +202,88 @@ class URLParserSpec extends Specification { configuration.port === 5432 } + + "recognise a postgresql:// uri" in { + parse("postgresql://localhost:425/dbname") mustEqual DEFAULT.copy( + username = "postgres", + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "recognise a postgres:// uri" in { + parse("postgres://localhost:425/dbname") mustEqual DEFAULT.copy( + username = "postgres", + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "recognize a jdbc:postgresql:// uri" in { + parse("jdbc:postgresql://localhost:425/dbname") mustEqual DEFAULT.copy( + username = "postgres", + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "pull the username and password from URI credentials" in { + parse("jdbc:postgresql://user:password@localhost:425/dbname") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "pull the username and password from query string" in { + parse("jdbc:postgresql://localhost:425/dbname?user=user&password=password") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + // Included for consistency, so later changes aren't allowed to change behavior + "use the query string parameters to override URI credentials" in { + parse("jdbc:postgresql://baduser:badpass@localhost:425/dbname?user=user&password=password") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 425, + host = "localhost" + ) + } + + "successfully default the port to the PostgreSQL port" in { + parse("jdbc:postgresql://baduser:badpass@localhost/dbname?user=user&password=password") mustEqual DEFAULT.copy( + username = "user", + password = Some("password"), + database = Some("dbname"), + port = 5432, + host = "localhost" + ) + } + + "reject malformed ip addresses" in { + val connectionUri = "postgresql://john:doe@128.567.54.90:9987/my_database" + + val configuration = parse(connectionUri) + configuration.username === "postgres" + configuration.password === None + configuration.database === None + configuration.host === "localhost" + configuration.port === 5432 + + parseOrDie(connectionUri) must throwA[UnableToParseURLException] + } + } }