Skip to content

Commit

Permalink
Merge pull request #3 from EasyPost/issue-2
Browse files Browse the repository at this point in the history
implement go-style DATABASE_DSN support in rmmm
  • Loading branch information
Roguelazer authored Mar 24, 2022
2 parents 4974dea + 203e8f1 commit 7ddf97d
Showing 6 changed files with 323 additions and 26 deletions.
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
NEXT
====
- Add support for go-style `$DATABASE_DSN` in addition to `$DATABASE_URL`

0.2.1
=====
- Pin some more specific versions
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ categories = ["command-line-utilities", "database", "development-tools"]
[dependencies]
anyhow = "1"
chrono = "0.4"
clap = { version = "3", features=["std", "color", "suggestions", "cargo", "env", "wrap_help"] }
clap = { version = "3.1", features=["std", "color", "suggestions", "cargo", "env", "wrap_help"] }
derive_more = "0.99"
fern = {version = "0.6", features=["colored"]}
itertools = "0.10"
@@ -23,8 +23,9 @@ mysql = { version = "22", default_features = false }
# these next two are only in here to set features used by mysql
flate2 = { version = "1", default_features = false, features = ["zlib"] }
mysql_common = { version = "^0.28", default_features = false, features = ["time03"]}
once_cell = "1"
regex = "1"
tabled = "0.4"
tabled = "0.5"
tempfile = "3"

[features]
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -26,8 +26,11 @@ Configuration is typically through environment variables:
| Environment Variable | Meaning |
|----------------------|---------|
| `$DATABASE_URL` | URL (`mysql://`) to connect to MySQL |
| `$DATABASE_DSN` | DSN (as per [go-sql-driver](https://github.com/go-sql-driver/mysql/#user-content-dsn-data-source-name)) to connect to MySQL |
| `$MIGRATION_PATH` | Path to store state (defaults to `./db`) |

Either `$DATABASE_URL` or `$DATABASE_DSN` must be passed. They can also be passed to the program as `--database-dsn` or `--database-url`.

This work is licensed under the ISC license, a copy of which can be found in [LICENSE.txt](LICENSE.txt).

Features
239 changes: 239 additions & 0 deletions src/go_database_dsn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
use std::convert::TryInto;
use std::net::IpAddr;
use std::str::FromStr;

use anyhow::Context;
use once_cell::sync::Lazy;
use regex::Regex;

const DEFAULT_PORT: u16 = 3306;

#[derive(Debug, PartialEq, Eq)]
enum AddressName {
Address(IpAddr),
Name(String),
}

impl AddressName {
fn into_mysql_string(self) -> String {
match self {
Self::Name(s) => s,
Self::Address(IpAddr::V4(i)) => i.to_string(),
Self::Address(IpAddr::V6(i)) => format!("[{}]", i),
}
}
}

impl FromStr for AddressName {
type Err = std::convert::Infallible;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.parse() {
Ok(ip_addr) => AddressName::Address(ip_addr),
Err(_) => AddressName::Name(s.into()),
})
}
}

#[derive(Debug, PartialEq, Eq)]
struct Address {
name: AddressName,
port: u16,
}

impl FromStr for Address {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(addr) = s.strip_prefix('[') {
// IPv6 literal
let (addr, rest) = addr
.split_once(']')
.ok_or_else(|| anyhow::anyhow!("invalid IPv6 literal in {}", s))?;
let addr = AddressName::Address(addr.parse().context("invalid IPv6 literal")?);
if let Some((_, port)) = rest.rsplit_once(':') {
Ok(Address {
name: addr,
port: port.parse()?,
})
} else {
Ok(Address {
name: addr,
port: DEFAULT_PORT,
})
}
} else if let Some((address, port)) = s.rsplit_once(':') {
Ok(Address {
name: address.parse()?,
port: port.parse()?,
})
} else {
Ok(Address {
name: s.parse()?,
port: DEFAULT_PORT,
})
}
}
}

static DSN_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?x)
(?:
(?P<username>[^:@]*)
(?: : (?P<password>[^@]*) )?
@
)?
(?P<protocol>[a-z]+)
\(
(?P<address>[^)]+)
\)
/
(?P<dbname>[^?]+)
(?:
\?
(?P<params>.*)
)?
",
)
.unwrap()
});

#[derive(Debug)]
pub(crate) struct GoDatabaseDsn {
username: Option<String>,
password: Option<String>,
address: Address,
database: String,
}

impl FromStr for GoDatabaseDsn {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let caps = DSN_REGEX
.captures(s)
.ok_or_else(|| anyhow::anyhow!("Invalid DSN {}", s))?;
let username = caps.name("username").map(|s| s.as_str().to_owned());
let password = caps.name("password").map(|s| s.as_str().to_owned());
match caps.name("protocol").map(|s| s.as_str()) {
Some("tcp") => {}
Some(other) => anyhow::bail!("unhandled DSN protocol {}", other),
None => {}
}
let address = caps
.name("address")
.ok_or_else(|| anyhow::anyhow!("no address in DSN {}", s))?
.as_str()
.parse()?;
let database = caps
.name("dbname")
.ok_or_else(|| anyhow::anyhow!("no dbname in DSN {}", s))?
.as_str()
.to_owned();
Ok(GoDatabaseDsn {
username,
password,
address,
database,
})
}
}

impl TryInto<mysql::Opts> for GoDatabaseDsn {
type Error = anyhow::Error;

fn try_into(self) -> Result<mysql::Opts, Self::Error> {
Ok(mysql::OptsBuilder::new()
.user(self.username)
.pass(self.password)
.db_name(Some(self.database))
.tcp_port(self.address.port)
.ip_or_hostname(Some(self.address.name.into_mysql_string()))
.into())
}
}

#[cfg(test)]
mod tests {
use super::{Address, AddressName, GoDatabaseDsn};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

use anyhow::Context;

#[test]
fn test_address_name_parser() {
assert_eq!(
"127.0.0.1".parse(),
Ok(AddressName::Address(IpAddr::V4(Ipv4Addr::new(
127, 0, 0, 1
))))
);
assert_eq!(
"::1".parse(),
Ok(AddressName::Address(IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0, 1
))))
);
}

#[test]
fn test_address_parser() {
assert_eq!(
"127.0.0.1".parse::<Address>().unwrap(),
Address {
name: AddressName::Address(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
port: 3306,
}
);
assert_eq!(
"127.0.0.1:6603".parse::<Address>().unwrap(),
Address {
name: AddressName::Address(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
port: 6603,
}
);
assert_eq!(
"[::2]".parse::<Address>().unwrap(),
Address {
name: AddressName::Address(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 2))),
port: 3306,
}
);
assert_eq!(
"[::4]:3307".parse::<Address>().unwrap(),
Address {
name: AddressName::Address(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 4))),
port: 3307,
}
);
}

#[test]
fn test_parse() {
let parsed: GoDatabaseDsn = "foo:bar@tcp(127.0.0.1:33606)/foodb?ignored=true"
.parse()
.expect("should parse");
assert_eq!(
parsed.address,
Address {
name: AddressName::Address("127.0.0.1".parse().unwrap()),
port: 33606
}
);
assert_eq!(parsed.username.as_deref(), Some("foo"));
assert_eq!(parsed.password.as_deref(), Some("bar"));
assert_eq!(parsed.database, "foodb".to_string());
for s in &[
"foo:bar@tcp([::1])/foo",
"foo:bar@tcp([::1]:3300)/foo",
"foo@tcp([::1])/foo",
"tcp(127.0.0.1)/baz",
"usps:sekret@tcp(dblb.local.easypo.net:36060)/usps",
] {
s.parse::<GoDatabaseDsn>()
.context(format!("attempting to parse {}", s))
.expect("should parse");
}
}
}
Loading

0 comments on commit 7ddf97d

Please sign in to comment.