1<?php 2 3declare(strict_types=1); 4 5namespace Nyholm\Dsn; 6 7use Nyholm\Dsn\Configuration\Dsn; 8use Nyholm\Dsn\Configuration\DsnFunction; 9use Nyholm\Dsn\Configuration\Path; 10use Nyholm\Dsn\Configuration\Url; 11use Nyholm\Dsn\Exception\DsnTypeNotSupported; 12use Nyholm\Dsn\Exception\FunctionsNotAllowedException; 13use Nyholm\Dsn\Exception\SyntaxException; 14 15/** 16 * A factory class to parse a string and create a DsnFunction. 17 * 18 * @author Tobias Nyholm <tobias.nyholm@gmail.com> 19 */ 20class DsnParser 21{ 22 private const FUNCTION_REGEX = '#^([a-zA-Z0-9\+-]+):?\((.*)\)(?:\?(.*))?$#'; 23 private const ARGUMENTS_REGEX = '#([^\s,]+\([^)]+\)(?:\?[^\s,]*)?|[^\s,]+)#'; 24 private const UNRESERVED = 'a-zA-Z0-9-\._~'; 25 private const SUB_DELIMS = '!\$&\'\(\}\*\+,;='; 26 27 /** 28 * Parse A DSN thay may contain functions. If no function is present in the 29 * string, then a "dsn()" function will be added. 30 * 31 * @throws SyntaxException 32 */ 33 public static function parseFunc(string $dsn): DsnFunction 34 { 35 // Detect a function or add default function 36 $parameters = []; 37 if (1 === preg_match(self::FUNCTION_REGEX, $dsn, $matches)) { 38 $functionName = $matches[1]; 39 $arguments = $matches[2]; 40 parse_str($matches[3] ?? '', $parameters); 41 } else { 42 $functionName = 'dsn'; 43 $arguments = $dsn; 44 } 45 46 if (empty($arguments)) { 47 throw new SyntaxException($dsn, 'dsn' === $functionName ? 'The DSN is empty' : 'A function must have arguments, an empty string was provided.'); 48 } 49 50 // explode arguments and respect function parentheses 51 if (preg_match_all(self::ARGUMENTS_REGEX, $arguments, $matches)) { 52 $arguments = $matches[1]; 53 } 54 55 return new DsnFunction($functionName, array_map(\Closure::fromCallable([self::class, 'parseArguments']), $arguments), $parameters); 56 } 57 58 /** 59 * Parse a DSN without functions. 60 * 61 * @throws FunctionsNotAllowedException if the DSN contains a function 62 * @throws SyntaxException 63 */ 64 public static function parse(string $dsn): Dsn 65 { 66 if (1 === preg_match(self::FUNCTION_REGEX, $dsn, $matches)) { 67 if ('dsn' === $matches[1]) { 68 return self::parse($matches[2]); 69 } 70 throw new FunctionsNotAllowedException($dsn); 71 } 72 73 return self::getDsn($dsn); 74 } 75 76 public static function parseUrl(string $dsn): Url 77 { 78 $dsn = self::parse($dsn); 79 if (!$dsn instanceof Url) { 80 throw DsnTypeNotSupported::onlyUrl($dsn); 81 } 82 83 return $dsn; 84 } 85 86 public static function parsePath(string $dsn): Path 87 { 88 $dsn = self::parse($dsn); 89 if (!$dsn instanceof Path) { 90 throw DsnTypeNotSupported::onlyPath($dsn); 91 } 92 93 return $dsn; 94 } 95 96 /** 97 * @return DsnFunction|Dsn 98 */ 99 private static function parseArguments(string $dsn) 100 { 101 // Detect a function exists 102 if (1 === preg_match(self::FUNCTION_REGEX, $dsn)) { 103 return self::parseFunc($dsn); 104 } 105 106 // Assert: $dsn does not contain any functions. 107 return self::getDsn($dsn); 108 } 109 110 /** 111 * @throws SyntaxException 112 */ 113 private static function getDsn(string $dsn): Dsn 114 { 115 // Find the scheme if it exists and trim the double slash. 116 if (!preg_match('#^(?:(?<alt>['.self::UNRESERVED.self::SUB_DELIMS.'%]+:[0-9]+(?:[/?].*)?)|(?<scheme>[a-zA-Z0-9\+-\.]+):(?://)?(?<dsn>.*))$#', $dsn, $matches)) { 117 throw new SyntaxException($dsn, 'A DSN must contain a scheme [a-zA-Z0-9\+-\.]+ and a colon.'); 118 } 119 $scheme = null; 120 $dsn = $matches['alt']; 121 if (!empty($matches['scheme'])) { 122 $scheme = $matches['scheme']; 123 $dsn = $matches['dsn']; 124 } 125 126 if ('' === $dsn) { 127 return new Dsn($scheme); 128 } 129 130 // Parse user info 131 if (!preg_match('#^(?:(['.self::UNRESERVED.self::SUB_DELIMS.'%]+)?(?::(['.self::UNRESERVED.self::SUB_DELIMS.'%]*))?@)?([^\s@]+)$#', $dsn, $matches)) { 132 throw new SyntaxException($dsn, 'The provided DSN is not valid. Maybe you need to url-encode the user/password?'); 133 } 134 135 $authentication = [ 136 'user' => empty($matches[1]) ? null : urldecode($matches[1]), 137 'password' => empty($matches[2]) ? null : urldecode($matches[2]), 138 ]; 139 140 if ('?' === $matches[3][0]) { 141 $parts = self::explodeUrl('http://localhost'.$matches[3], $dsn); 142 143 return new Dsn($scheme, self::getQuery($parts)); 144 } 145 146 if ('/' === $matches[3][0]) { 147 $parts = self::explodeUrl($matches[3], $dsn); 148 149 return new Path($scheme, $parts['path'], self::getQuery($parts), $authentication); 150 } 151 152 $parts = self::explodeUrl('http://'.$matches[3], $dsn); 153 154 return new Url($scheme, $parts['host'], $parts['port'] ?? null, $parts['path'] ?? null, self::getQuery($parts), $authentication); 155 } 156 157 /** 158 * Parse URL and throw exception if the URL is not valid. 159 * 160 * @throws SyntaxException 161 */ 162 private static function explodeUrl(string $url, string $dsn): array 163 { 164 $url = parse_url($url); 165 if (false === $url) { 166 throw new SyntaxException($dsn, 'The provided DSN is not valid.'); 167 } 168 169 return $url; 170 } 171 172 /** 173 * Parse query params into an array. 174 */ 175 private static function getQuery(array $parts): array 176 { 177 $query = []; 178 if (isset($parts['query'])) { 179 parse_str($parts['query'], $query); 180 } 181 182 return $query; 183 } 184} 185