diff --git a/src/DSN/DSN.php b/src/DSN/DSN.php index fd1111b..1256c39 100644 --- a/src/DSN/DSN.php +++ b/src/DSN/DSN.php @@ -53,29 +53,80 @@ class DSN */ public function __construct(string $dsn) { - $parts = \parse_url($dsn); - - if (!$parts) { - throw new \InvalidArgumentException("Unable to parse DSN: $dsn"); - } + $parts = $this->parseDsn($dsn); if (empty($parts['scheme'])) { throw new \InvalidArgumentException('Unable to parse DSN: scheme is required'); } - if (empty($parts['host'])) { - throw new \InvalidArgumentException('Unable to parse DSN: host is required'); + $hasAuthority = (($parts['host'] ?? '') !== '') || isset($parts['user']) || isset($parts['pass']) || isset($parts['port']); + $hasPath = array_key_exists('path', $parts) && $parts['path'] !== ''; + $hasQuery = array_key_exists('query', $parts) && $parts['query'] !== ''; + + if (! $hasAuthority && ! $hasPath && ! $hasQuery) { + throw new \InvalidArgumentException('Unable to parse DSN: missing connection information'); } $this->scheme = $parts['scheme']; $this->user = isset($parts['user']) ? \urldecode($parts['user']) : null; $this->password = isset($parts['pass']) ? \urldecode($parts['pass']) : null; - $this->host = $parts['host']; + $this->host = $parts['host'] ?? ''; $this->port = $parts['port'] ?? null; $this->path = isset($parts['path']) ? ltrim((string) $parts['path'], '/') : ''; $this->query = $parts['query'] ?? null; } + /** + * Parse a DSN string while tolerating missing host values. + * + * @return array + */ + protected function parseDsn(string $dsn): array + { + $parts = \parse_url($dsn); + + if ($parts !== false) { + return $parts; + } + + if (! \preg_match('/^(?[a-z][a-z0-9+\.\-]*):\/\/(?.*)$/i', $dsn, $matches)) { + throw new \InvalidArgumentException("Unable to parse DSN: $dsn"); + } + + $placeholder = '__utopia_dsn_placeholder__'; + $rest = $matches['rest']; + + $authorityEnd = strcspn($rest, '/?#'); + $authority = substr($rest, 0, $authorityEnd); + $suffix = substr($rest, $authorityEnd); + + $hostMissing = $authority === ''; + + if (! $hostMissing && \str_contains($authority, '@')) { + $afterAt = substr($authority, strrpos($authority, '@') + 1); + $hostMissing = $afterAt === ''; + $authority = rtrim($authority, '@'); + } + + if ($hostMissing) { + $authority = $authority === '' ? $placeholder : $authority . '@' . $placeholder; + } + + $normalized = $matches['scheme'] . '://' . $authority . $suffix; + + $parts = \parse_url($normalized); + + if ($parts === false) { + throw new \InvalidArgumentException("Unable to parse DSN: $dsn"); + } + + if (($parts['host'] ?? null) === $placeholder) { + $parts['host'] = ''; + } + + return $parts; + } + /** * Return the scheme. * diff --git a/tests/DSN/DSNTest.php b/tests/DSN/DSNTest.php index 4521f37..aaedd82 100644 --- a/tests/DSN/DSNTest.php +++ b/tests/DSN/DSNTest.php @@ -138,6 +138,43 @@ public function testSuccess(): void $this->assertEquals("value=$encoded", $dsn->getQuery()); } + public function testPartial(): void + { + $dsn = new DSN('redis://user@'); + $this->assertEquals('redis', $dsn->getScheme()); + $this->assertEquals('user', $dsn->getUser()); + $this->assertNull($dsn->getPassword()); + $this->assertSame('', $dsn->getHost()); + $this->assertNull($dsn->getPort()); + $this->assertEmpty($dsn->getPath()); + $this->assertNull($dsn->getQuery()); + + $dsn = new DSN('redis://:secret@'); + $this->assertEmpty($dsn->getUser()); + $this->assertEquals('secret', $dsn->getPassword()); + $this->assertSame('', $dsn->getHost()); + $this->assertNull($dsn->getPort()); + $this->assertEmpty($dsn->getPath()); + $this->assertNull($dsn->getQuery()); + + $dsn = new DSN('redis:///cache'); + $this->assertNull($dsn->getUser()); + $this->assertNull($dsn->getPassword()); + $this->assertSame('', $dsn->getHost()); + $this->assertNull($dsn->getPort()); + $this->assertEquals('cache', $dsn->getPath()); + $this->assertNull($dsn->getQuery()); + + $dsn = new DSN('redis://?timeout=5'); + $this->assertNull($dsn->getUser()); + $this->assertNull($dsn->getPassword()); + $this->assertSame('', $dsn->getHost()); + $this->assertNull($dsn->getPort()); + $this->assertEmpty($dsn->getPath()); + $this->assertEquals('timeout=5', $dsn->getQuery()); + $this->assertEquals('5', $dsn->getParam('timeout')); + } + public function testGetParam(): void { $dsn = new DSN('mariadb://user:password@localhost:3306/database?charset=utf8&timezone=UTC');