DSN parsing library with support for complex expressions.

Fund package maintenance!
kbond

v0.2.0 2024-01-02 20:47 UTC

This package is auto-updated.

Last update: 2024-02-01 00:50:17 UTC


README

CI codecov

DSN parsing library with support for complex expressions:

  1. URI: http://example.com?foo=bar#baz
  2. Mailto: mailto:sam@example.com?cc=jane@example.com
  3. DSN Functions:
    1. Decorated: retry(inner://dsn)?times=5
    2. Group: round+robin(inner://dsn1 inner://dsn2)
    3. Complex: fail+over(rount+robin(inner://dsn1 inner://dsn2) inner://dsn3)

Installation

composer require zenstruck/dsn

Usage

Parsing DSNs

For basic usage, you can use Zenstruck\Dsn::parse($mydsn). This takes a string and returns one of the following objects:

The only thing in common with these returned objects is that they are all \Stringable.

If the parsing fails, a Zenstruck\Dsn\Exception\UnableToParse exception will be thrown.

Note See zenstruck/uri to view the API for Uri|Mailto.

URI

This DSN object is an instance of Zenstruck\Uri. View it's full API documentation.

$dsn = Zenstruck\Dsn::parse('https://example.com/some/dir/file.html?q=abc&flag=1#test')

/* @var Zenstruck\Uri $dsn */
$dsn->scheme()->toString(); // 'https'
$dsn->host()->toString(); // 'example.com'
$dsn->path()->toString(); // /some/dir/file.html
$dsn->query()->all(); // ['q' => 'abc', 'flag' => '1']
$dsn->fragment(); // 'test'

Mailto

This DSN object is an instance of Zenstruck\Uri\Mailto. View it's full API documentation.

$dsn = Zenstruck\Dsn::parse('mailto:kevin@example.com?cc=jane@example.com&subject=some+subject&body=some+body')

/** @var Zenstruck\Uri\Mailto $dsn */
$dsn->to(); // ['kevin@example.com']
$dsn->cc(); // ['jane@example.com']
$dsn->bcc(); // []
$dsn->subject(); // 'some subject'
$dsn->body(); // 'some body'

Decorated

This is a DSN Function that wraps a single inner DSN:

retry(inner://dsn)?times=5

The above example would parse to a Zenstruck\Dsn\Decorated object with the following properties:

  • Scheme/Function Name: retry
  • Query: ['times' => '5']
  • Inner DSN: This will be an instance of Zenstruck\Uri in this case but could be any DSN Object.
$dsn = Zenstruck\Dsn::parse('retry(inner://dsn)?times=5');

/** @var Zenstruck\Dsn\Decorated $dsn */
$dsn->scheme()->toString(); // 'retry'
$dsn->query()->all(); // ['times' => '5']

$inner = $dsn->inner();

/** @var Zenstruck\Uri $inner */
$inner->scheme()->toString(); // 'inner'
$inner->host()->toString(); // 'dsn'

Group

This is a DSN Function that wraps a multiple inner DSNs (space separated):

round+robin(inner://dsn1 inner://dsn2)?strategy=random

The above example would parse to a Zenstruck\Dsn\Group object with the following properties:

  • Scheme/Function Name: round+robin
  • Query: ['strategy' => 'random']
  • Child DSNs: This will be an array of 2 Zenstruck\Uri objects in this case but could an array of any DSN Objects.
$dsn = Zenstruck\Dsn::parse('round+robin(inner://dsn1 inner://dsn2)?strategy=random');

/** @var Zenstruck\Dsn\Group $dsn */
$dsn->scheme()->toString(); // 'round+robin'
$dsn->query()->all(); // ['strategy' => 'random']

$children = $dsn->children();

/** @var Zenstruck\Uri[] $children */
$children[0]->scheme()->toString(); // 'inner'
$children[0]->host()->toString(); // 'dsn1'

$children[1]->scheme()->toString(); // 'inner'
$children[1]->host()->toString(); // 'dsn2'

Complex DSNs

You can nest Group and Decorated DSNs to create complex expressions:

$dsn = Zenstruck\Dsn::parse('retry(round+robin(inner://dsn1 inner://dsn2)?strategy=random)?times=5');

/** @var Zenstruck\Dsn\Decorated $dsn */
$dsn->scheme()->toString(); // 'retry'
$dsn->query()->all(); // ['times' => '5']

$inner = $dsn->inner();

/** @var Zenstruck\Dsn\Group $inner */
$inner->scheme()->toString(); // 'round+robin'
$inner->query()->all(); // ['strategy' => 'random']

$children = $inner->children();

/** @var Zenstruck\Uri[] $children */
$children[0]->scheme()->toString(); // 'inner'
$children[0]->host()->toString(); // 'dsn1'

$children[1]->scheme()->toString(); // 'inner'
$children[1]->host()->toString(); // 'dsn2'

Using Parsed DSNs

Once parsed, you can use an instanceof check to determine the type of DSN that was parsed and act accordingly:

$dsn = Zenstruck\Dsn::parse($someDsnString); // throws Zenstruck\Dsn\Exception\UnableToParse on failure

switch (true) {
    case $dsn instanceof Zenstruck\Uri:
        // do something with the Uri object

    case $dsn instanceof Zenstruck\Uri\Mailto:
        // do something with the Mailto object

    case $dsn instanceof Decorated:
        // do something with the Decorated object (see api below)

    case $dsn instanceof Group:
        // do something with the Group object (see api below)
}

Usage Example

The best way to show how the parsed DSN could be used for something useful is with an example. Consider an email abstraction library that has multiple service transports (smtp, mailchimp, postmark) and special utility transports: round-robin (for distributing workload between multiple transports) and retry (for retrying failures x times before hard-failing).

You'd like end user's of this library to be able to create transports from a custom DSN syntax. The following is an example of a transport DSN factory:

use Zenstruck\Dsn\Decorated;
use Zenstruck\Dsn\Group;
use Zenstruck\Uri;

class TransportFactory
{
    public function create(\Stringable $dsn): TransportInterface
    {
        if ($dsn instanceof Uri && $dsn->scheme()->equals('smtp')) {
            return new SmtpTransport(
                host: $dsn->host()->toString(),
                user: $dsn->user(),
                password: $dsn->pass(),
                port: $dsn->port(),
            );
        }

        if ($dsn instanceof Uri && $dsn->scheme()->equals('mailchimp')) {
            return new MailchimpTransport(apiKey: $dsn->user());
        }

        if ($dsn instanceof Uri && $dsn->scheme()->equals('postmark')) {
            return new PostmarkTransport(apiKey: $dsn->user());
        }

        if ($dsn instanceof Decorated && $dsn->scheme()->equals('retry')) {
            return new RetryTransport(
                transport: $this->create($dsn->inner()), // recursively build inner transport
                times: $dsn->query()->getInt('times', 5), // default to 5 retries if not set
            );
        }

        if ($dsn instanceof Group && $dsn->scheme()->equals('round+robin')) {
            return new RoundRobinTransport(
                transports: array_map(fn($dsn) => $this->create($dsn), $dsn->children()), // recursively build inner transports
                strategy: $dsn->query()->get('strategy', 'random'), // default to "random" strategy if not set
            );
        }

        throw new \LogicException("Unable to parse transport DSN: {$dsn}.");
    }
}

The usage of this factory is as follows:

use Zenstruck\Dsn;

// SmtpTransport:
$factory->create('smtp://kevin:p4ssword@localhost');

// RetryTransport wrapping SmtpTransport:
$factory->create('retry(smtp://kevin:p4ssword@localhost)');

// RetryTransport (3 retries) wrapping RoundRobinTransport (sequential strategy) wrapping MailchimpTransport & PostmarkTransport
$factory->create('retry(round+robin(mailchimp://key@default postmark://key@default)?strategy=sequential)?times=3');

Advanced Usage

Under the hood Zenstruck\Dsn::parse() uses a parsing system for converting DSN strings to the packaged DSN objects. You can create your own parsers by having them implement the Zenstruck\Dsn\Parser interface.

Note Zenstruck\Dsn::parse() is a utility function that only uses the core parsers. In order to add your own parsers, you'll need to manually wire up a chain parser that includes them and use this for parsing DSNs.

Core Parsers

UriParser

Converts url-looking strings to Zenstruck\Uri objects.

MailtoParser

Converts mailto-looking strings to Zenstruck\Uri\Mailto objects.

WrappedParser

Converts dsn-function-looking strings to Zenstruck\Dsn\Decorated or Zenstruck\Dsn\Group objects.

Utility Parsers

ChainParser

Wraps a chain of parsers, during parse() it loops through these and attempts to find one that successfully parses a DSN string. It is considered successful if a \Stringable object is returned. If the parser throws a Zenstruck\Dsn\Exception\UnableToParse exception, the next parser in the chain is tried. Finally, if all the parsers throw UnableToParse, this is thrown.

$parser = new Zenstruck\Dsn\Parser\ChainParser([$customParser1, $customParser1]);

$parser->parse('some-dsn'); // \Stringable object

Note This parser always contains the core parsers as the last items in the chain. Custom parsers you add to the constructor are attempted before these.

CacheParser

Wraps another parser and an instance of one of these cache interfaces:

  • Symfony\Contracts\Cache\CacheInterface (Symfony cache)
  • Psr\Cache\CacheItemPoolInterface (PSR-6 cache)
  • Psr\SimpleCache\CacheInterface (PSR-16 cache)

The parsed object is cached (keyed by the DSN string) and subsequent parsing of the same string are retrieved from the cache. This gives a bit of a performance boost especially for complex DSNs.

/** @var SymfonyCache|Psr6Cache|Psr16Cache $cache */
/** @var Zenstruck\Dsn\Parser $inner */

$parser = new \Zenstruck\Dsn\Parser\CacheParser($parser, $cache);

$parser->parse('some-dsn'); // \Stringable (caches this object)

$parser->parse('some-dsn'); // \Stringable (retrieved from cache)

Custom Parsers

You can create your own parser by creating an object that implements Zenstruck\Dsn\Parser:

use Zenstruck\Dsn\Exception\UnableToParse;
use Zenstruck\Dsn\Parser;

class MyParser implements Parser
{
    public function parse(string $dsn): \Stringable
    {
        // determine if $dsn is parsable and return a \Stringable DSN object

        throw UnableToParse::value($dsn); // important when using in a chain parser
    }
}

Usage:

// standalone
$parser = new MyParser();

$parser->parse('some-dsn');

// add to ChainParser
$parser = new Zenstruck\Dsn\Parser\ChainParser([new MyParser()]);

$parser->parse('some-dsn');

Symfony Bundle

A Symfony Bundle is provided that adds an autowireable Zenstruck\Dsn\Parser service. This is an interface with a parse(string $dsn) method. It works identically to Zenstruck\Dsn::parse() but caches the created DSN object (using cache.system) for a bit of a performance boost.

To use, enable the bundle:

// config/bundles.php

return [
    // ...
    Zenstruck\Dsn\Bridge\SymfonyZenstruckDsnBundle::class => ['all' => true],
];

Zenstruck\Dsn\Parser can be autowired:

use Zenstruck\Dsn\Parser;

public function myAction(Parser $parser): Response
{
    // ...

    $dsn = $parser->parse(...);

    // ...
}

DSN Service Factory

You can use the Zenstruck\Dsn\Parser service as a service factory to create DSN service objects:

# config/services.yaml

services:
    mailer_dsn:
        factory: ['@Zenstruck\Dsn\Parser', 'parse']
        arguments: ['%env(MAILER_DSN)%']

The mailer_dsn service will be an instance of a parsed DSN object. The type depends on the value of the MAILER_DSN environment variable.

Using the mailer transport factory above, we can create the transport via a service factory that uses the mailer_dsn:

# config/services.yaml

services:
    App\Mailer\TransportFactory: ~

    App\Mailer\TransportInterface:
        factory: ['@App\Mailer\TransportFactory', 'create']
        arguments: ['@mailer_dsn']

Now, when injecting App\Mailer\TransportInterface, the transport will be created by App\Mailer\TransportFactory using your MAILER_DSN environment variable.