diff --git a/.docs/README.md b/.docs/README.md index 961a61b..eaf03b6 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -24,6 +24,13 @@ extensions: ```neon guzzle: debug: %debugMode% + tracy: %debugMode% + preset: default + app: MyApp/1.0 + logger: + level: info + formatter: "[{method}] {uri} {code}" + # logger: @Psr\Log\LoggerInterface client: # config for GuzzleHttp\Client timeout: 30 ``` @@ -36,6 +43,7 @@ Everything else is in Guzzle documentation. ```php use Contributte\Guzzlette\ClientFactory; +use Contributte\Guzzlette\GuzzleBuilder; use GuzzleHttp\Client; use Nette\Application\UI\Presenter; @@ -57,5 +65,14 @@ class ExamplePresenter extends Presenter { ]); } + public function injectGuzzleBuilder(ClientFactory $factory): void + { + $this->guzzle = $factory + ->create() + ->withBaseUri('https://api.example.com') + ->withHttpAuth('john', 'doe') + ->build(); + } + } ``` diff --git a/composer.json b/composer.json index 03b55c5..84deabd 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "guzzlehttp/guzzle": "^7.5.0", "nette/di": "^3.1.2", "nette/utils": "^3.2.8 || ^4.0.0", + "psr/log": "^3.0", "tracy/tracy": "^2.9.5" }, "require-dev": { diff --git a/src/ClientFactory.php b/src/ClientFactory.php index 9c6b8a3..2f98b06 100644 --- a/src/ClientFactory.php +++ b/src/ClientFactory.php @@ -4,49 +4,166 @@ use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; +use GuzzleHttp\MessageFormatter; +use GuzzleHttp\MessageFormatterInterface; +use GuzzleHttp\Middleware; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; class ClientFactory { public const FORCE_REQUEST_COLLECTION = true; + /** @var array */ + private array $stackFns = []; + + /** @var array */ + private array $options = []; + private SnapshotStack $snapshotStack; private bool $debug; + private ?LoggerInterface $logger = null; + + private ?MessageFormatterInterface $formatter = null; + + private ?HandlerStack $handlerStack = null; + public function __construct(SnapshotStack $snapshotStack, bool $debug = false) { $this->snapshotStack = $snapshotStack; $this->debug = $debug; } + public function setFormatter(MessageFormatterInterface $formatter): void + { + $this->formatter = $formatter; + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + public function setHandlerStack(HandlerStack $handlerStack): void + { + $this->handlerStack = $handlerStack; + } + + public function withDefaults(): self + { + return $this->withTracy()->withLog(); + } + + public function withTracy(): self + { + if ($this->debug) { + $this->with(new GuzzleHandler($this->snapshotStack), 'tracy'); + } + + return $this; + } + /** - * @param mixed[] $config + * @phpstan-param LogLevel::* $level */ - public function createClient(array $config = []): Client + public function withLog(?LoggerInterface $logger = null, ?MessageFormatterInterface $formatter = null, string $level = LogLevel::DEBUG): self { - if ($this->debug) { - $handlerStack = $config['handler'] ?? null; - if (!($handlerStack instanceof HandlerStack)) { - $handlerStack = null; - } + if ($this->logger !== null || $logger !== null) { + $resolvedLogger = $logger ?? $this->logger; + assert($resolvedLogger !== null); - $config['handler'] = $this->createHandlerStack($handlerStack); + return $this->with( + Middleware::log( + $resolvedLogger, + $formatter ?? $this->formatter ?? new MessageFormatter(), + $level, + ), + 'logger', + ); } - return new Client($config); + return $this; + } + + public function with(callable $middleware, string $name): self + { + $this->stackFns[$name] = $middleware; + + return $this; + } + + public function withConfigOption(string $name, mixed $value): self + { + $this->options[$name] = $value; + + return $this; + } + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $this->options['headers'] = array_merge( + $this->options['headers'] ?? [], // @phpstan-ignore-line + $headers, + ); + + return $this; + } + + public function withUserAgent(string $agent): self + { + return $this->withHeaders(['User-Agent' => $agent]); + } + + public function withBaseUri(string $url): self + { + return $this->withConfigOption('base_uri', $url); + } + + public function withHttpAuth(string $user, string $password): self + { + return $this->withConfigOption('auth', [$user, $password]); } - private function createHandlerStack(?HandlerStack $handlerStack = null): HandlerStack + public function create(?HandlerStack $handlerStack = null): GuzzleBuilder { - if ($handlerStack === null) { - $handlerStack = HandlerStack::create(); + $stack = $handlerStack ?? $this->handlerStack ?? HandlerStack::create(); + + foreach ($this->stackFns as $name => $fn) { + $stack->push($fn, $name); + } + + if ($this->debug && !isset($this->stackFns['tracy'])) { + $stack->push(new GuzzleHandler($this->snapshotStack), 'tracy'); } - $handler = new GuzzleHandler($this->snapshotStack); - $handlerStack->push($handler); + $builder = new GuzzleBuilder($stack); + + foreach ($this->options as $name => $value) { + $builder->withConfigOption($name, $value); + } + + return $builder; + } + + /** + * @param mixed[] $config + */ + public function createClient(array $config = []): Client + { + $handlerStack = null; + + if (isset($config['handler']) && $config['handler'] instanceof HandlerStack) { + $handlerStack = $config['handler']; + unset($config['handler']); + } - return $handlerStack; + return $this->create($handlerStack)->build($config); } } diff --git a/src/DI/GuzzleExtension.php b/src/DI/GuzzleExtension.php index 07d4b79..f269a07 100644 --- a/src/DI/GuzzleExtension.php +++ b/src/DI/GuzzleExtension.php @@ -5,10 +5,16 @@ use Contributte\Guzzlette\ClientFactory; use Contributte\Guzzlette\SnapshotStack; use GuzzleHttp\Client; +use GuzzleHttp\MessageFormatter; use Nette\DI\CompilerExtension; +use Nette\DI\Definitions\ServiceDefinition; +use Nette\DI\Definitions\Statement; use Nette\PhpGenerator\ClassType; use Nette\Schema\Expect; use Nette\Schema\Schema; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Psr\Log\NullLogger; use stdClass; /** @@ -19,8 +25,24 @@ class GuzzleExtension extends CompilerExtension public function getConfigSchema(): Schema { + $expectService = Expect::anyOf( + Expect::string()->required()->assert(static fn (mixed $input): bool => is_string($input) && (str_starts_with($input, '@') || class_exists($input) || interface_exists($input))), + Expect::type(Statement::class)->required(), + ); + return Expect::structure([ 'debug' => Expect::bool(false), + 'preset' => Expect::anyOf(null, 'default')->default('default'), + 'app' => Expect::string()->nullable()->default(null), + 'logger' => Expect::structure([ + 'level' => Expect::string(LogLevel::INFO), + 'formatter' => Expect::anyOf( + clone $expectService, + Expect::string()->required(), + )->nullable()->default(null), + 'logger' => clone $expectService, + ]), + 'tracy' => Expect::bool(false), 'client' => Expect::array()->dynamic()->default([ 'timeout' => 30, ]), @@ -38,18 +60,60 @@ public function loadConfiguration(): void $builder->addDefinition($this->prefix('clientFactory')) ->setType(ClientFactory::class) - ->setArguments([$builder->getDefinition($this->prefix('snapshotStack')), $config->debug]); + ->setArguments([$builder->getDefinition($this->prefix('snapshotStack')), $config->debug || $config->tracy]); + + $factoryDef = $builder->getDefinition($this->prefix('clientFactory')); + assert($factoryDef instanceof ServiceDefinition); + + if ($config->app !== null) { + $factoryDef->addSetup('withUserAgent', [$config->app]); + } + + if ($config->logger->formatter !== null) { + if (is_string($config->logger->formatter)) { + $factoryDef->addSetup('setFormatter', [new Statement(MessageFormatter::class, [$config->logger->formatter])]); + } else { + $factoryDef->addSetup('setFormatter', [$config->logger->formatter]); + } + } + + if ($config->logger->logger !== null) { + $factoryDef->addSetup('setLogger', [is_string($config->logger->logger) ? new Statement($config->logger->logger) : $config->logger->logger]); + } $builder->addDefinition($this->prefix('client')) ->setType(Client::class) ->setFactory('@' . $this->prefix('clientFactory') . '::createClient', ['config' => $config->client]); } + public function beforeCompile(): void + { + $config = $this->config; + $builder = $this->getContainerBuilder(); + + $factoryDef = $builder->getDefinition($this->prefix('clientFactory')); + assert($factoryDef instanceof ServiceDefinition); + + if ($config->logger->logger === null) { + $loggerDef = $builder->getByType(LoggerInterface::class); + + if ($loggerDef !== null) { + $factoryDef->addSetup('setLogger', [$builder->getDefinition($loggerDef)]); + } elseif ($config->preset === 'default') { + $factoryDef->addSetup('setLogger', [new Statement(NullLogger::class)]); + } + } + + if ($config->preset === 'default') { + $factoryDef->addSetup('withDefaults'); + } + } + public function afterCompile(ClassType $class): void { $config = $this->config; - if ($config->debug) { + if ($config->debug || $config->tracy) { $initialize = $class->getMethod('initialize'); $initialize->addBody( '$this->getService(?)->addPanel(new \Contributte\Guzzlette\Tracy\Panel($this->getService(?)));', diff --git a/src/GuzzleBuilder.php b/src/GuzzleBuilder.php new file mode 100644 index 0000000..1958277 --- /dev/null +++ b/src/GuzzleBuilder.php @@ -0,0 +1,75 @@ + */ + private array $options = []; + + public function __construct(HandlerStack $stack) + { + $this->stack = $stack; + } + + public function with(callable $middleware, string $name): self + { + $this->stack->push($middleware, $name); + + return $this; + } + + public function withConfigOption(string $name, mixed $value): self + { + $this->options[$name] = $value; + + return $this; + } + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $this->options['headers'] = array_merge( + $this->options['headers'] ?? [], // @phpstan-ignore-line + $headers, + ); + + return $this; + } + + public function withUserAgent(string $agent): self + { + return $this->withHeaders(['User-Agent' => $agent]); + } + + public function withBaseUri(string $url): self + { + return $this->withConfigOption('base_uri', $url); + } + + public function withHttpAuth(string $user, string $password): self + { + return $this->withConfigOption('auth', [$user, $password]); + } + + /** + * @param array $options + */ + public function build(array $options = []): Client + { + return new Client(array_merge( + $this->options, + $options, + ['handler' => $this->stack], + )); + } + +} diff --git a/tests/Cases/GuzzleExtensionTest.phpt b/tests/Cases/GuzzleExtensionTest.phpt index 50e8c14..ba1e7ef 100644 --- a/tests/Cases/GuzzleExtensionTest.phpt +++ b/tests/Cases/GuzzleExtensionTest.phpt @@ -71,3 +71,25 @@ Toolkit::test(function (): void { Assert::count(1, $container->findByType(Client::class)); Assert::count(1, $container->findByType(ClientFactory::class)); }); + +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addConfig([ + 'guzzle' => [ + 'tracy' => true, + 'app' => 'AcmeTest/1.0', + 'logger' => [ + 'formatter' => '[{method}] {uri} {code}', + ], + ], + ]); + $compiler->addExtension('guzzle', new GuzzleExtension()); + }, [getmypid(), 4]); + + /** @var Container $container */ + $container = new $class(); + + Assert::count(1, $container->findByType(Client::class)); + Assert::count(1, $container->findByType(ClientFactory::class)); +}); diff --git a/tests/Cases/GuzzleHandlerTest.phpt b/tests/Cases/GuzzleHandlerTest.phpt index 75daf49..1a19dde 100644 --- a/tests/Cases/GuzzleHandlerTest.phpt +++ b/tests/Cases/GuzzleHandlerTest.phpt @@ -56,3 +56,25 @@ Toolkit::test(function (): void { Assert::same(0, count($snapshotStack->getSnapshots())); Assert::same(0.0, $snapshotStack->getTotalTime()); }); + +Toolkit::test(function (): void { + $snapshotStack = new SnapshotStack(); + $factory = new ClientFactory($snapshotStack); + + $mock = new MockHandler([ + new Response(200), + ]); + + $handler = HandlerStack::create($mock); + + $client = $factory + ->withUserAgent('Guzzlette/1.0') + ->withBaseUri('https://example.com') + ->withHttpAuth('john', 'doe') + ->create($handler) + ->build(); + + Assert::same('https://example.com', (string) $client->getConfig('base_uri')); + Assert::same('Guzzlette/1.0', $client->getConfig('headers')['User-Agent']); + Assert::same(['john', 'doe'], $client->getConfig('auth')); +});