From d9c3b347db42a572ecc63ba43dbab58ade4afe75 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 7 Jul 2026 15:10:28 +0200 Subject: [PATCH] ref(options): backport sentry OptionsResolver from 5.x --- phpstan-baseline.neon | 5 - src/Integration/RequestIntegration.php | 12 +- src/Options.php | 544 ++++++++----------------- src/OptionsResolver.php | 299 ++++++++++++++ tests/OptionResolverTest.php | 338 +++++++++++++++ tests/OptionsTest.php | 137 ++++--- 6 files changed, 897 insertions(+), 438 deletions(-) create mode 100644 src/OptionsResolver.php create mode 100644 tests/OptionResolverTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7f2eb603b..4a34c967f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -35,11 +35,6 @@ parameters: count: 1 path: src/Event.php - - - message: "#^Property Sentry\\\\Integration\\\\RequestIntegration\\:\\:\\$options \\(array\\{pii_sanitize_headers\\: array\\\\}\\) does not accept array\\.$#" - count: 1 - path: src/Integration/RequestIntegration.php - - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" count: 1 diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index 17086afa7..8f4949bcd 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -9,12 +9,11 @@ use Sentry\Event; use Sentry\Exception\JsonException; use Sentry\Options; +use Sentry\OptionsResolver; use Sentry\SentrySdk; use Sentry\State\Scope; use Sentry\UserDataBag; use Sentry\Util\JSON; -use Symfony\Component\OptionsResolver\Options as SymfonyOptions; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * This integration collects information from the request and attaches them to @@ -93,7 +92,10 @@ public function __construct(?RequestFetcherInterface $requestFetcher = null, arr $this->configureOptions($resolver); $this->requestFetcher = $requestFetcher ?? new RequestFetcher(); - $this->options = $resolver->resolve($options); + + /** @var array{pii_sanitize_headers: string[]} $resolvedOptions */ + $resolvedOptions = $resolver->resolve($options); + $this->options = $resolvedOptions; } /** @@ -303,10 +305,10 @@ private function isRequestBodySizeWithinReadBounds(int $requestBodySize, string */ private function configureOptions(OptionsResolver $resolver): void { - $resolver->setDefault('pii_sanitize_headers', self::DEFAULT_SENSITIVE_HEADERS); $resolver->setAllowedTypes('pii_sanitize_headers', 'string[]'); - $resolver->setNormalizer('pii_sanitize_headers', static function (SymfonyOptions $options, array $value): array { + $resolver->setNormalizer('pii_sanitize_headers', static function (array $value): array { return array_map('strtolower', $value); }); + $resolver->setDefault('pii_sanitize_headers', self::DEFAULT_SENSITIVE_HEADERS); } } diff --git a/src/Options.php b/src/Options.php index 7e8afbfc6..71db61b7c 100644 --- a/src/Options.php +++ b/src/Options.php @@ -12,8 +12,6 @@ use Sentry\Logs\Log; use Sentry\Metrics\Types\Metric; use Sentry\Transport\TransportInterface; -use Symfony\Component\OptionsResolver\Options as SymfonyOptions; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * Configuration container for the Sentry client. @@ -67,7 +65,7 @@ public function __construct(array $options = []) $options['strict_trace_continuation'] = $options['strict_trace_propagation']; } - $this->options = $this->resolver->resolve($options); + $this->options = $this->resolver->resolve($options, $this->getLoggerOrNullLogger($options)); if ($this->options['enable_tracing'] === true && $this->options['traces_sample_rate'] === null) { $this->options = array_merge($this->options, ['traces_sample_rate' => 1]); @@ -93,11 +91,7 @@ public function getPrefixes(): array */ public function setPrefixes(array $prefixes): self { - $options = array_merge($this->options, ['prefixes' => $prefixes]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['prefixes' => $prefixes]); } /** @@ -117,11 +111,7 @@ public function getSampleRate(): float */ public function setSampleRate(float $sampleRate): self { - $options = array_merge($this->options, ['sample_rate' => $sampleRate]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['sample_rate' => $sampleRate]); } /** @@ -143,11 +133,7 @@ public function getTracesSampleRate(): ?float */ public function setEnableTracing(?bool $enableTracing): self { - $options = array_merge($this->options, ['enable_tracing' => $enableTracing]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['enable_tracing' => $enableTracing]); } /** @@ -169,11 +155,7 @@ public function getEnableTracing(): ?bool */ public function setEnableLogs(?bool $enableLogs): self { - $options = array_merge($this->options, ['enable_logs' => $enableLogs]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['enable_logs' => $enableLogs]); } /** @@ -203,11 +185,7 @@ public function getLogFlushThreshold(): ?int */ public function setLogFlushThreshold(?int $logFlushThreshold): self { - $options = array_merge($this->options, ['log_flush_threshold' => $logFlushThreshold]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['log_flush_threshold' => $logFlushThreshold]); } /** @@ -229,11 +207,7 @@ public function getMetricFlushThreshold(): ?int */ public function setMetricFlushThreshold(?int $metricFlushThreshold): self { - $options = array_merge($this->options, ['metric_flush_threshold' => $metricFlushThreshold]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['metric_flush_threshold' => $metricFlushThreshold]); } /** @@ -241,11 +215,7 @@ public function setMetricFlushThreshold(?int $metricFlushThreshold): self */ public function setEnableMetrics(bool $enableTracing): self { - $options = array_merge($this->options, ['enable_metrics' => $enableTracing]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['enable_metrics' => $enableTracing]); } /** @@ -269,11 +239,7 @@ public function getEnableMetrics(): bool */ public function setTracesSampleRate(?float $sampleRate): self { - $options = array_merge($this->options, ['traces_sample_rate' => $sampleRate]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['traces_sample_rate' => $sampleRate]); } public function getProfilesSampleRate(): ?float @@ -286,11 +252,7 @@ public function getProfilesSampleRate(): ?float public function setProfilesSampleRate(?float $sampleRate): self { - $options = array_merge($this->options, ['profiles_sample_rate' => $sampleRate]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['profiles_sample_rate' => $sampleRate]); } /** @@ -316,11 +278,7 @@ public function getProfilesSampler(): ?callable */ public function setProfilesSampler(?callable $sampler): self { - $options = array_merge($this->options, ['profiles_sampler' => $sampler]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['profiles_sampler' => $sampler]); } /** @@ -352,11 +310,7 @@ public function shouldAttachStacktrace(): bool */ public function setAttachStacktrace(bool $enable): self { - $options = array_merge($this->options, ['attach_stacktrace' => $enable]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['attach_stacktrace' => $enable]); } /** @@ -376,11 +330,7 @@ public function shouldAttachMetricCodeLocations(): bool */ public function setAttachMetricCodeLocations(bool $enable): self { - $options = array_merge($this->options, ['attach_metric_code_locations' => $enable]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['attach_metric_code_locations' => $enable]); } /** @@ -398,11 +348,7 @@ public function getContextLines(): ?int */ public function setContextLines(?int $contextLines): self { - $options = array_merge($this->options, ['context_lines' => $contextLines]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['context_lines' => $contextLines]); } /** @@ -420,11 +366,7 @@ public function getEnvironment(): ?string */ public function setEnvironment(?string $environment): self { - $options = array_merge($this->options, ['environment' => $environment]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['environment' => $environment]); } /** @@ -444,11 +386,7 @@ public function getInAppExcludedPaths(): array */ public function setInAppExcludedPaths(array $paths): self { - $options = array_merge($this->options, ['in_app_exclude' => $paths]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['in_app_exclude' => $paths]); } /** @@ -468,11 +406,7 @@ public function getInAppIncludedPaths(): array */ public function setInAppIncludedPaths(array $paths): self { - $options = array_merge($this->options, ['in_app_include' => $paths]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['in_app_include' => $paths]); } /** @@ -480,14 +414,27 @@ public function setInAppIncludedPaths(array $paths): self */ public function getLogger(): ?LoggerInterface { - return $this->options['logger']; + return $this->options['logger'] ?? null; } /** * Helper to always get a logger instance even if it was not set. + * + * It checks for a logger using the following order: + * 1. the passed `$options` + * 2. already configured `logger` option + * 3. `NullLogger` as fallback + * + * @param array $options */ - public function getLoggerOrNullLogger(): LoggerInterface + public function getLoggerOrNullLogger(array $options = []): LoggerInterface { + $logger = $options['logger'] ?? null; + + if ($logger instanceof LoggerInterface) { + return $logger; + } + return $this->getLogger() ?? new NullLogger(); } @@ -496,11 +443,7 @@ public function getLoggerOrNullLogger(): LoggerInterface */ public function setLogger(LoggerInterface $logger): self { - $options = array_merge($this->options, ['logger' => $logger]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['logger' => $logger]); } public function isSpotlightEnabled(): bool @@ -513,11 +456,7 @@ public function isSpotlightEnabled(): bool */ public function enableSpotlight($enable): self { - $options = array_merge($this->options, ['spotlight' => $enable]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['spotlight' => $enable]); } public function getSpotlightUrl(): string @@ -536,11 +475,7 @@ public function getSpotlightUrl(): string */ public function setSpotlightUrl(string $url): self { - $options = array_merge($this->options, ['spotlight_url' => $url]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['spotlight_url' => $url]); } /** @@ -558,11 +493,7 @@ public function getRelease(): ?string */ public function setRelease(?string $release): self { - $options = array_merge($this->options, ['release' => $release]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['release' => $release]); } /** @@ -586,11 +517,7 @@ public function getOrgId(): ?int */ public function setOrgId(int $orgId): self { - $options = array_merge($this->options, ['org_id' => $orgId]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['org_id' => $orgId]); } /** @@ -608,11 +535,7 @@ public function getServerName(): string */ public function setServerName(string $serverName): self { - $options = array_merge($this->options, ['server_name' => $serverName]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['server_name' => $serverName]); } /** @@ -634,11 +557,7 @@ public function getIgnoreExceptions(): array */ public function setIgnoreExceptions(array $ignoreErrors): self { - $options = array_merge($this->options, ['ignore_exceptions' => $ignoreErrors]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['ignore_exceptions' => $ignoreErrors]); } /** @@ -658,11 +577,7 @@ public function getIgnoreTransactions(): array */ public function setIgnoreTransactions(array $ignoreTransaction): self { - $options = array_merge($this->options, ['ignore_transactions' => $ignoreTransaction]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['ignore_transactions' => $ignoreTransaction]); } /** @@ -686,11 +601,7 @@ public function getBeforeSendCallback(): callable */ public function setBeforeSendCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_send' => $callback]); } /** @@ -714,11 +625,7 @@ public function getBeforeSendTransactionCallback(): callable */ public function setBeforeSendTransactionCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send_transaction' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_send_transaction' => $callback]); } /** @@ -742,11 +649,7 @@ public function getBeforeSendCheckInCallback(): callable */ public function setBeforeSendCheckInCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send_check_in' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_send_check_in' => $callback]); } /** @@ -770,11 +673,7 @@ public function getBeforeSendLogCallback(): callable */ public function setBeforeSendLogCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send_log' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_send_log' => $callback]); } /** @@ -812,11 +711,7 @@ public function getBeforeSendMetricCallback(): callable */ public function setBeforeSendMetricCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send_metric' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_send_metric' => $callback]); } /** @@ -831,11 +726,7 @@ public function setBeforeSendMetricCallback(callable $callback): self */ public function setBeforeSendMetricsCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send_metrics' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_send_metrics' => $callback]); } /** @@ -855,11 +746,7 @@ public function getTracePropagationTargets(): ?array */ public function setTracePropagationTargets(array $tracePropagationTargets): self { - $options = array_merge($this->options, ['trace_propagation_targets' => $tracePropagationTargets]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['trace_propagation_targets' => $tracePropagationTargets]); } /** @@ -880,11 +767,7 @@ public function isStrictTraceContinuationEnabled(): bool */ public function enableStrictTraceContinuation(bool $strictTraceContinuation): self { - $options = array_merge($this->options, ['strict_trace_continuation' => $strictTraceContinuation]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['strict_trace_continuation' => $strictTraceContinuation]); } /** @@ -924,11 +807,7 @@ public function getTags(): array */ public function setTags(array $tags): self { - $options = array_merge($this->options, ['tags' => $tags]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['tags' => $tags]); } /** @@ -946,11 +825,7 @@ public function getErrorTypes(): int */ public function setErrorTypes(int $errorTypes): self { - $options = array_merge($this->options, ['error_types' => $errorTypes]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['error_types' => $errorTypes]); } /** @@ -968,11 +843,7 @@ public function getMaxBreadcrumbs(): int */ public function setMaxBreadcrumbs(int $maxBreadcrumbs): self { - $options = array_merge($this->options, ['max_breadcrumbs' => $maxBreadcrumbs]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['max_breadcrumbs' => $maxBreadcrumbs]); } /** @@ -998,11 +869,7 @@ public function getBeforeBreadcrumbCallback(): callable */ public function setBeforeBreadcrumbCallback(callable $callback): self { - $options = array_merge($this->options, ['before_breadcrumb' => $callback]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['before_breadcrumb' => $callback]); } /** @@ -1014,11 +881,7 @@ public function setBeforeBreadcrumbCallback(callable $callback): self */ public function setIntegrations($integrations): self { - $options = array_merge($this->options, ['integrations' => $integrations]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['integrations' => $integrations]); } /** @@ -1033,11 +896,7 @@ public function getIntegrations() public function setTransport(TransportInterface $transport): self { - $options = array_merge($this->options, ['transport' => $transport]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['transport' => $transport]); } public function getTransport(): ?TransportInterface @@ -1047,11 +906,7 @@ public function getTransport(): ?TransportInterface public function setHttpClient(HttpClientInterface $httpClient): self { - $options = array_merge($this->options, ['http_client' => $httpClient]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_client' => $httpClient]); } public function getHttpClient(): ?HttpClientInterface @@ -1074,11 +929,7 @@ public function shouldSendDefaultPii(): bool */ public function setSendDefaultPii(bool $enable): self { - $options = array_merge($this->options, ['send_default_pii' => $enable]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['send_default_pii' => $enable]); } /** @@ -1096,11 +947,7 @@ public function hasDefaultIntegrations(): bool */ public function setDefaultIntegrations(bool $enable): self { - $options = array_merge($this->options, ['default_integrations' => $enable]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['default_integrations' => $enable]); } /** @@ -1118,11 +965,7 @@ public function getMaxValueLength(): int */ public function setMaxValueLength(int $maxValueLength): self { - $options = array_merge($this->options, ['max_value_length' => $maxValueLength]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['max_value_length' => $maxValueLength]); } /** @@ -1140,11 +983,7 @@ public function getHttpProxy(): ?string */ public function setHttpProxy(?string $httpProxy): self { - $options = array_merge($this->options, ['http_proxy' => $httpProxy]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_proxy' => $httpProxy]); } public function getHttpProxyAuthentication(): ?string @@ -1154,11 +993,7 @@ public function getHttpProxyAuthentication(): ?string public function setHttpProxyAuthentication(?string $httpProxy): self { - $options = array_merge($this->options, ['http_proxy_authentication' => $httpProxy]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_proxy_authentication' => $httpProxy]); } /** @@ -1176,11 +1011,7 @@ public function getHttpConnectTimeout(): float */ public function setHttpConnectTimeout(float $httpConnectTimeout): self { - $options = array_merge($this->options, ['http_connect_timeout' => $httpConnectTimeout]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_connect_timeout' => $httpConnectTimeout]); } /** @@ -1200,11 +1031,7 @@ public function getHttpTimeout(): float */ public function setHttpTimeout(float $httpTimeout): self { - $options = array_merge($this->options, ['http_timeout' => $httpTimeout]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_timeout' => $httpTimeout]); } public function getHttpSslVerifyPeer(): bool @@ -1214,11 +1041,7 @@ public function getHttpSslVerifyPeer(): bool public function setHttpSslVerifyPeer(bool $httpSslVerifyPeer): self { - $options = array_merge($this->options, ['http_ssl_verify_peer' => $httpSslVerifyPeer]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_ssl_verify_peer' => $httpSslVerifyPeer]); } public function getHttpSslNativeCa(): bool @@ -1228,11 +1051,7 @@ public function getHttpSslNativeCa(): bool public function setHttpSslNativeCa(bool $httpSslNativeCa): self { - $options = array_merge($this->options, ['http_ssl_native_ca' => $httpSslNativeCa]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_ssl_native_ca' => $httpSslNativeCa]); } /** @@ -1248,11 +1067,7 @@ public function isHttpCompressionEnabled(): bool */ public function setEnableHttpCompression(bool $enabled): self { - $options = array_merge($this->options, ['http_compression' => $enabled]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_compression' => $enabled]); } /** @@ -1279,11 +1094,7 @@ public function isShareHandleEnabled(): bool */ public function setEnableShareHandle(bool $enabled): self { - $options = array_merge($this->options, ['http_enable_curl_share_handle' => $enabled]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['http_enable_curl_share_handle' => $enabled]); } /** @@ -1305,11 +1116,7 @@ public function shouldCaptureSilencedErrors(): bool */ public function setCaptureSilencedErrors(bool $shouldCapture): self { - $options = array_merge($this->options, ['capture_silenced_errors' => $shouldCapture]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['capture_silenced_errors' => $shouldCapture]); } /** @@ -1341,11 +1148,7 @@ public function getMaxRequestBodySize(): string */ public function setMaxRequestBodySize(string $maxRequestBodySize): self { - $options = array_merge($this->options, ['max_request_body_size' => $maxRequestBodySize]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['max_request_body_size' => $maxRequestBodySize]); } /** @@ -1368,11 +1171,7 @@ public function getClassSerializers(): array */ public function setClassSerializers(array $serializers): self { - $options = array_merge($this->options, ['class_serializers' => $serializers]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['class_serializers' => $serializers]); } /** @@ -1395,23 +1194,96 @@ public function getTracesSampler(): ?callable */ public function setTracesSampler(?callable $sampler): self { - $options = array_merge($this->options, ['traces_sampler' => $sampler]); - - $this->options = $this->resolver->resolve($options); - - return $this; + return $this->updateOptions(['traces_sampler' => $sampler]); } /** * Configures the options of the client. * * @param OptionsResolver $resolver The resolver for the options - * - * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException - * @throws \Symfony\Component\OptionsResolver\Exception\AccessException */ private function configureOptions(OptionsResolver $resolver): void { + $resolver->setAllowedTypes('prefixes', 'string[]'); + $resolver->setAllowedTypes('sample_rate', ['int', 'float']); + $resolver->setAllowedTypes('enable_tracing', ['null', 'bool']); + $resolver->setAllowedTypes('enable_logs', 'bool'); + $resolver->setAllowedTypes('log_flush_threshold', ['null', 'int']); + $resolver->setAllowedTypes('enable_metrics', 'bool'); + $resolver->setAllowedTypes('metric_flush_threshold', ['null', 'int']); + $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); + $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); + $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); + $resolver->setAllowedTypes('profiles_sampler', ['null', 'callable']); + $resolver->setAllowedTypes('attach_stacktrace', 'bool'); + $resolver->setAllowedTypes('attach_metric_code_locations', 'bool'); + $resolver->setAllowedTypes('context_lines', ['null', 'int']); + $resolver->setAllowedTypes('environment', ['null', 'string']); + $resolver->setAllowedTypes('in_app_exclude', 'string[]'); + $resolver->setAllowedTypes('in_app_include', 'string[]'); + $resolver->setAllowedTypes('logger', ['null', LoggerInterface::class]); + $resolver->setAllowedTypes('spotlight', ['bool', 'string', 'null']); + $resolver->setAllowedTypes('spotlight_url', 'string'); + $resolver->setAllowedTypes('release', ['null', 'string']); + $resolver->setAllowedTypes('dsn', ['null', 'string', 'bool', Dsn::class]); + $resolver->setAllowedTypes('org_id', ['null', 'int']); + $resolver->setAllowedTypes('server_name', 'string'); + $resolver->setAllowedTypes('before_send', ['callable']); + $resolver->setAllowedTypes('before_send_transaction', ['callable']); + $resolver->setAllowedTypes('before_send_log', 'callable'); + $resolver->setAllowedTypes('before_send_metric', ['callable']); + $resolver->setAllowedTypes('ignore_exceptions', 'string[]'); + $resolver->setAllowedTypes('ignore_transactions', 'string[]'); + $resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']); + $resolver->setAllowedTypes('strict_trace_continuation', 'bool'); + $resolver->setAllowedTypes('strict_trace_propagation', 'bool'); + $resolver->setAllowedTypes('tags', 'string[]'); + $resolver->setAllowedTypes('error_types', ['null', 'int']); + $resolver->setAllowedTypes('max_breadcrumbs', 'int'); + $resolver->setAllowedTypes('before_breadcrumb', ['callable']); + $resolver->setAllowedTypes('integrations', ['Sentry\\Integration\\IntegrationInterface[]', 'callable']); + $resolver->setAllowedTypes('send_default_pii', 'bool'); + $resolver->setAllowedTypes('default_integrations', 'bool'); + $resolver->setAllowedTypes('max_value_length', 'int'); + $resolver->setAllowedTypes('transport', ['null', TransportInterface::class]); + $resolver->setAllowedTypes('http_client', ['null', HttpClientInterface::class]); + $resolver->setAllowedTypes('http_proxy', ['null', 'string']); + $resolver->setAllowedTypes('http_proxy_authentication', ['null', 'string']); + $resolver->setAllowedTypes('http_connect_timeout', ['int', 'float']); + $resolver->setAllowedTypes('http_timeout', ['int', 'float']); + $resolver->setAllowedTypes('http_ssl_verify_peer', 'bool'); + $resolver->setAllowedTypes('http_ssl_native_ca', 'bool'); + $resolver->setAllowedTypes('http_compression', 'bool'); + $resolver->setAllowedTypes('http_enable_curl_share_handle', 'bool'); + $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); + $resolver->setAllowedTypes('max_request_body_size', 'string'); + $resolver->setAllowedTypes('class_serializers', 'array'); + + $resolver->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); + $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); + $resolver->setAllowedValues('max_breadcrumbs', \Closure::fromCallable([$this, 'validateMaxBreadcrumbsOptions'])); + $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); + $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); + $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); + $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); + + $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); + + $resolver->setNormalizer('prefixes', function (array $value) { + return array_map([$this, 'normalizeAbsolutePath'], $value); + }); + + $resolver->setNormalizer('spotlight_url', \Closure::fromCallable([$this, 'normalizeSpotlightUrl'])); + $resolver->setNormalizer('spotlight', \Closure::fromCallable([$this, 'normalizeBooleanOrUrl'])); + + $resolver->setNormalizer('in_app_exclude', function (array $value) { + return array_map([$this, 'normalizeAbsolutePath'], $value); + }); + + $resolver->setNormalizer('in_app_include', function (array $value) { + return array_map([$this, 'normalizeAbsolutePath'], $value); + }); + $resolver->setDefaults([ 'integrations' => [], 'default_integrations' => true, @@ -1494,86 +1366,6 @@ private function configureOptions(OptionsResolver $resolver): void 'max_request_body_size' => 'medium', 'class_serializers' => [], ]); - - $resolver->setAllowedTypes('prefixes', 'string[]'); - $resolver->setAllowedTypes('sample_rate', ['int', 'float']); - $resolver->setAllowedTypes('enable_tracing', ['null', 'bool']); - $resolver->setAllowedTypes('enable_logs', 'bool'); - $resolver->setAllowedTypes('log_flush_threshold', ['null', 'int']); - $resolver->setAllowedTypes('enable_metrics', 'bool'); - $resolver->setAllowedTypes('metric_flush_threshold', ['null', 'int']); - $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); - $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); - $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); - $resolver->setAllowedTypes('profiles_sampler', ['null', 'callable']); - $resolver->setAllowedTypes('attach_stacktrace', 'bool'); - $resolver->setAllowedTypes('attach_metric_code_locations', 'bool'); - $resolver->setAllowedTypes('context_lines', ['null', 'int']); - $resolver->setAllowedTypes('environment', ['null', 'string']); - $resolver->setAllowedTypes('in_app_exclude', 'string[]'); - $resolver->setAllowedTypes('in_app_include', 'string[]'); - $resolver->setAllowedTypes('logger', ['null', LoggerInterface::class]); - $resolver->setAllowedTypes('spotlight', ['bool', 'string', 'null']); - $resolver->setAllowedTypes('spotlight_url', 'string'); - $resolver->setAllowedTypes('release', ['null', 'string']); - $resolver->setAllowedTypes('dsn', ['null', 'string', 'bool', Dsn::class]); - $resolver->setAllowedTypes('org_id', ['null', 'int']); - $resolver->setAllowedTypes('server_name', 'string'); - $resolver->setAllowedTypes('before_send', ['callable']); - $resolver->setAllowedTypes('before_send_transaction', ['callable']); - $resolver->setAllowedTypes('before_send_log', 'callable'); - $resolver->setAllowedTypes('before_send_metric', ['callable']); - $resolver->setAllowedTypes('ignore_exceptions', 'string[]'); - $resolver->setAllowedTypes('ignore_transactions', 'string[]'); - $resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']); - $resolver->setAllowedTypes('strict_trace_continuation', 'bool'); - $resolver->setAllowedTypes('strict_trace_propagation', 'bool'); - $resolver->setAllowedTypes('tags', 'string[]'); - $resolver->setAllowedTypes('error_types', ['null', 'int']); - $resolver->setAllowedTypes('max_breadcrumbs', 'int'); - $resolver->setAllowedTypes('before_breadcrumb', ['callable']); - $resolver->setAllowedTypes('integrations', ['Sentry\\Integration\\IntegrationInterface[]', 'callable']); - $resolver->setAllowedTypes('send_default_pii', 'bool'); - $resolver->setAllowedTypes('default_integrations', 'bool'); - $resolver->setAllowedTypes('max_value_length', 'int'); - $resolver->setAllowedTypes('transport', ['null', TransportInterface::class]); - $resolver->setAllowedTypes('http_client', ['null', HttpClientInterface::class]); - $resolver->setAllowedTypes('http_proxy', ['null', 'string']); - $resolver->setAllowedTypes('http_proxy_authentication', ['null', 'string']); - $resolver->setAllowedTypes('http_connect_timeout', ['int', 'float']); - $resolver->setAllowedTypes('http_timeout', ['int', 'float']); - $resolver->setAllowedTypes('http_ssl_verify_peer', 'bool'); - $resolver->setAllowedTypes('http_ssl_native_ca', 'bool'); - $resolver->setAllowedTypes('http_compression', 'bool'); - $resolver->setAllowedTypes('http_enable_curl_share_handle', 'bool'); - $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); - $resolver->setAllowedTypes('max_request_body_size', 'string'); - $resolver->setAllowedTypes('class_serializers', 'array'); - - $resolver->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); - $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); - $resolver->setAllowedValues('max_breadcrumbs', \Closure::fromCallable([$this, 'validateMaxBreadcrumbsOptions'])); - $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); - $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); - $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); - $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); - - $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); - - $resolver->setNormalizer('prefixes', function (SymfonyOptions $options, array $value) { - return array_map([$this, 'normalizeAbsolutePath'], $value); - }); - - $resolver->setNormalizer('spotlight_url', \Closure::fromCallable([$this, 'normalizeSpotlightUrl'])); - $resolver->setNormalizer('spotlight', \Closure::fromCallable([$this, 'normalizeBooleanOrUrl'])); - - $resolver->setNormalizer('in_app_exclude', function (SymfonyOptions $options, array $value) { - return array_map([$this, 'normalizeAbsolutePath'], $value); - }); - - $resolver->setNormalizer('in_app_include', function (SymfonyOptions $options, array $value) { - return array_map([$this, 'normalizeAbsolutePath'], $value); - }); } /** @@ -1593,16 +1385,18 @@ private function normalizeAbsolutePath(string $value): string } /** + * @param bool|string|null $booleanOrUrl + * * @return bool|string */ - private function normalizeBooleanOrUrl(SymfonyOptions $options, ?string $booleanOrUrl) + private function normalizeBooleanOrUrl($booleanOrUrl) { if (empty($booleanOrUrl)) { return false; } if (filter_var($booleanOrUrl, \FILTER_VALIDATE_URL)) { - return $this->normalizeSpotlightUrl($options, $booleanOrUrl); + return $this->normalizeSpotlightUrl((string) $booleanOrUrl); } return filter_var($booleanOrUrl, \FILTER_VALIDATE_BOOLEAN); @@ -1611,7 +1405,7 @@ private function normalizeBooleanOrUrl(SymfonyOptions $options, ?string $boolean /** * Normalizes the spotlight URL by removing the `/stream` at the end if present. */ - private function normalizeSpotlightUrl(SymfonyOptions $options, string $url): string + private function normalizeSpotlightUrl(string $url): string { if (substr_compare($url, '/stream', -7, 7) === 0) { return substr($url, 0, -7); @@ -1624,10 +1418,9 @@ private function normalizeSpotlightUrl(SymfonyOptions $options, string $url): st * Normalizes the DSN option by parsing the host, public and secret keys and * an optional path. * - * @param SymfonyOptions $options The configuration options - * @param string|bool|Dsn|null $value The actual value of the option to normalize + * @param string|bool|Dsn|null $value The actual value of the option to normalize */ - private function normalizeDsnOption(SymfonyOptions $options, $value): ?Dsn + private function normalizeDsnOption($value): ?Dsn { if ($value === null || \is_bool($value)) { return null; @@ -1704,8 +1497,8 @@ private function validateMaxBreadcrumbsOptions(int $value): bool */ private function validateClassSerializersOption(array $serializers): bool { - foreach ($serializers as $class => $serializer) { - if (!\is_string($class) || !\is_callable($serializer)) { + foreach (array_keys($serializers) as $class) { + if (!\is_string($class) || !\is_callable($serializers[$class])) { return false; } } @@ -1742,4 +1535,23 @@ private function validateMetricFlushThresholdOption(?int $metricFlushThreshold): { return $metricFlushThreshold === null || $metricFlushThreshold > 0; } + + /** + * Merges the passed options with the current options and resolves them. + * The result is stored back onto the class field. + * + * @param array $override + * + * @return $this + * + * @internal + */ + public function updateOptions(array $override = []): self + { + $resolved = $this->resolver->resolveOnly($override, $this->getLoggerOrNullLogger($override)); + + $this->options = array_merge($this->options, $resolved); + + return $this; + } } diff --git a/src/OptionsResolver.php b/src/OptionsResolver.php new file mode 100644 index 000000000..54f2183c0 --- /dev/null +++ b/src/OptionsResolver.php @@ -0,0 +1,299 @@ + + */ + private $defaults = []; + + /** + * List of allowed types for each top level array key. + * + * @var array + */ + private $allowedTypes = []; + + /** + * List of valid values or a validation callback for each top level array key. + * + * @var array + */ + private $allowedValues = []; + + /** + * Stores normalizers for each top level array key. Normalizers are executed for defaults and user provided options. + * + * @var array + */ + private $normalizers = []; + + /** + * @param array $defaults + */ + public function setDefaults(array $defaults): void + { + $processed = []; + + foreach (array_keys($defaults) as $option) { + $validation = $this->normalizeAndValidate($option, $defaults[$option]); + + if (!$validation[0]) { + // Since defaults are used as fallback values if passed options are invalid, we want to + // get hard errors here to make sure we have something to fall back to. + throw new \InvalidArgumentException(\sprintf('Invalid default for option "%s"', $option)); + } + + $processed[$option] = $validation[1]; + } + + $this->defaults = $processed; + } + + /** + * @param mixed $value + */ + public function setDefault(string $name, $value): void + { + $validation = $this->normalizeAndValidate($name, $value); + + if (!$validation[0]) { + // Since defaults are used as fallback values if passed options are invalid, we want to + // get hard errors here to make sure we have something to fall back to. + throw new \InvalidArgumentException(\sprintf('Invalid default for option "%s"', $name)); + } + + $this->defaults[$name] = $validation[1]; + } + + /** + * @param mixed $types + */ + public function setAllowedTypes(string $name, $types): void + { + if (\is_string($types)) { + $this->allowedTypes[$name] = [$types]; + + return; + } + + if (!\is_array($types)) { + throw new \InvalidArgumentException('Allowed types must be a string or an array of strings.'); + } + + $typeSpecs = []; + + foreach (array_keys($types) as $key) { + if (!\is_string($types[$key])) { + throw new \InvalidArgumentException('Allowed types must be strings.'); + } + + $typeSpecs[] = $types[$key]; + } + + $this->allowedTypes[$name] = $typeSpecs; + } + + /** + * @param mixed[]|callable $values + */ + public function setAllowedValues(string $path, $values): void + { + $this->allowedValues[$path] = $values; + } + + public function setNormalizer(string $path, callable $normalizer): void + { + $this->normalizers[$path] = $normalizer; + } + + /** + * Resolves the passed options against the configured defaults. + * If a value does not have a default value, it will be ignored. + * If a value is invalid but has a default, it will fall back to using the default value. + * + * If a value doesn't exist or is invalid, a DEBUG log is generated using the configured logger. + * + * @param array $options + * + * @return array + */ + public function resolve(array $options = [], ?LoggerInterface $logger = null): array + { + return array_merge($this->defaults, $this->resolveOnly($options, $logger)); + } + + /** + * Resolves passed options against the defaults but in contrast to {@see self::resolve}, it will not merge + * defaults into the result. This means that the returning array will always be equal or smaller than + * the input array. + * + * @param array $options + * + * @return array + */ + public function resolveOnly(array $options = [], ?LoggerInterface $logger = null): array + { + $result = []; + + foreach (array_keys($options) as $option) { + if (!\array_key_exists($option, $this->defaults)) { + if ($logger !== null) { + $logger->debug(\sprintf('Option "%s" does not exist and will be ignored', $option)); + } + continue; + } + + $validation = $this->normalizeAndValidate($option, $options[$option]); + if (!$validation[0]) { + if ($logger !== null) { + $logger->debug(\sprintf('Invalid value for option "%s". Using default value.', $option)); + } + $result[$option] = $this->defaults[$option]; + continue; + } + + $result[$option] = $validation[1]; + } + + return $result; + } + + /** + * Normalizes and validates a value for a given path. + * + * @param mixed $value + * + * @return array{0: bool, 1: mixed} [isValid, normalizedValue] + */ + private function normalizeAndValidate(string $name, $value): array + { + if (!$this->validateType($name, $value)) { + return [false, $value]; + } + + if (!$this->validateValue($name, $value)) { + return [false, $value]; + } + + // If there's no normalizer for this path, or normalization is a no-op, skip re-validation + $normalizer = $this->normalizers[$name] ?? null; + if ($normalizer === null) { + return [true, $value]; + } + + // Normalize, then validate again only if the value actually changed + /** @mago-ignore analysis:mixed-assignment */ + $normalized = $normalizer($value); + if ($normalized === $value) { + return [true, $value]; + } + + if (!$this->validateType($name, $normalized)) { + return [false, $normalized]; + } + + if (!$this->validateValue($name, $normalized)) { + return [false, $normalized]; + } + + return [true, $normalized]; + } + + /** + * @param mixed $value + */ + private function validateType(string $name, $value): bool + { + $allowedTypes = $this->allowedTypes[$name] ?? null; + if ($allowedTypes === null) { + return true; + } + + foreach ($allowedTypes as $typeSpec) { + if ($this->valueMatchesType($value, $typeSpec)) { + return true; + } + } + + return false; + } + + /** + * Checks whether a value matches a given type specification. + * Supports built-ins, FQCNs/interfaces and typed arrays like "string[]" or Foo\Bar\Baz[]. + * + * @param mixed $value + */ + private function valueMatchesType($value, string $typeSpec): bool + { + if (substr($typeSpec, -2) === '[]') { + $elementType = substr($typeSpec, 0, -2); + + if (!\is_array($value)) { + return false; + } + + foreach (array_keys($value) as $key) { + if (!$this->valueMatchesType($value[$key], $elementType)) { + return false; + } + } + + return true; + } + + switch ($typeSpec) { + case 'string': + return \is_string($value); + case 'int': + case 'integer': + return \is_int($value); + case 'float': + case 'double': + return \is_float($value); + case 'boolean': + case 'bool': + return \is_bool($value); + case 'array': + return \is_array($value); + case 'object': + return \is_object($value); + case 'callable': + return \is_callable($value); + case 'null': + return $value === null; + } + + if (\is_object($value)) { + return $value instanceof $typeSpec; + } + + return false; + } + + /** + * @param mixed $value + */ + private function validateValue(string $name, $value): bool + { + $allowedValue = $this->allowedValues[$name] ?? null; + if ($allowedValue === null) { + return true; + } + if (\is_callable($allowedValue)) { + return $allowedValue($value) === true; + } + + return \in_array($value, $allowedValue, true); + } +} diff --git a/tests/OptionResolverTest.php b/tests/OptionResolverTest.php new file mode 100644 index 000000000..5c6ec630c --- /dev/null +++ b/tests/OptionResolverTest.php @@ -0,0 +1,338 @@ +setDefaults([ + 'foo' => 'bar', + 'test' => 10, + 'bla' => 'blu', + ]); + $result = $resolver->resolve(['foo' => 'example', 'test' => 200]); + $this->assertEquals(['foo' => 'example', 'test' => 200, 'bla' => 'blu'], $result); + } + + public function testNestedOptionResolve(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => ['bar' => 'baz'], 'a' => 'b']); + $result = $resolver->resolve(['foo' => ['bar' => 'test']]); + $this->assertEquals(['foo' => ['bar' => 'test'], 'a' => 'b'], $result); + } + + public function testArrayValues(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => ['bar', 'baz'], 'a' => 'b']); + $result = $resolver->resolve(['foo' => ['php'], 'a' => 'b']); + $this->assertEquals(['foo' => ['php'], 'a' => 'b'], $result); + } + + public function testAllowedTypeIntValid(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => ['bar', 'baz'], 'a' => 20]); + $resolver->setAllowedTypes('a', ['integer']); + $result = $resolver->resolve(['foo' => ['bar', 'baz'], 'a' => 100]); + $this->assertEquals(['foo' => ['bar', 'baz'], 'a' => 100], $result); + } + + public function testAllowedTypeIntInvalid(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => ['bar', 'baz'], 'a' => 20]); + $resolver->setAllowedTypes('a', ['integer']); + $result = $resolver->resolve(['foo' => ['bar', 'baz'], 'a' => '100']); + $this->assertEquals(['foo' => ['bar', 'baz'], 'a' => 20], $result); + } + + /** + * @dataProvider allowedTypeTestProvider + */ + public function testAllowedTypes(array $defaults, array $options, array $allowedTypes, array $expectedResult): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults($defaults); + foreach ($allowedTypes as $path => $type) { + $resolver->setAllowedTypes($path, $type); + } + $result = $resolver->resolve($options); + $this->assertEquals($result, $expectedResult); + } + + /** + * @dataProvider allowedValueTestProvider + */ + public function testAllowedValues(array $defaults, array $options, array $allowedValues, array $expectedResult): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults($defaults); + foreach ($allowedValues as $path => $values) { + $resolver->setAllowedValues($path, $values); + } + $result = $resolver->resolve($options); + $this->assertEquals($result, $expectedResult); + } + + /** + * @dataProvider normalizerTestProvider + */ + public function testNormalizers(array $defaults, array $options, array $normalizers, array $expectedResult): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults($defaults); + foreach ($normalizers as $path => $type) { + $resolver->setNormalizer($path, $type); + } + $result = $resolver->resolve($options); + $this->assertEquals($result, $expectedResult); + } + + /** + * @dataProvider resolveOnlyTestProvider + */ + public function testResolveOnly(array $defaults, array $options, array $expectedResult): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults($defaults); + $result = $resolver->resolveOnly($options); + $this->assertEquals($expectedResult, $result); + } + + public function testNormalizerReturnsInvalidType(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => 'bar']); + $resolver->setAllowedTypes('foo', ['string']); + $resolver->setNormalizer('foo', static function ($value) { + return 8; + }); + $result = $resolver->resolve(['foo' => 'test']); + $this->assertEquals(['foo' => 'bar'], $result); + } + + public function testNormalizerReturnsInvalidValue(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => 'b']); + $resolver->setAllowedValues('foo', ['a', 'b', 'c']); + $resolver->setNormalizer('foo', static function ($value) { + return 'z'; + }); + $result = $resolver->resolve(['foo' => 'a']); + $this->assertEquals(['foo' => 'b'], $result); + } + + public function testNormalizerResultFailsValidation(): void + { + $resolver = new OptionsResolver(); + $resolver->setDefaults(['foo' => 'b']); + $resolver->setAllowedValues('foo', ['a', 'b', 'c']); + $resolver->setNormalizer('foo', static function ($value) { + return false; + }); + $result = $resolver->resolve(['foo' => 'a']); + $this->assertEquals(['foo' => 'b'], $result); + } + + public function allowedTypeTestProvider(): \Generator + { + yield 'Integer allowed type passes validation' => [ + ['a' => 'b', 'c' => 10], + ['c' => 20], + ['c' => ['int']], + ['a' => 'b', 'c' => 20], + ]; + + yield 'Integer allowed type fails validation and default value is used' => [ + ['a' => 'b', 'c' => 10], + ['c' => 'foo'], + ['c' => ['int']], + ['a' => 'b', 'c' => 10], + ]; + + yield 'Float allowed type passes validation' => [ + ['a' => 'b', 'c' => 10.0], + ['c' => 20.0], + ['c' => ['float']], + ['a' => 'b', 'c' => 20.0], + ]; + + yield 'Float allowed type fails validation and default value is used' => [ + ['a' => 'b', 'c' => 10.0], + ['c' => 'foo'], + ['c' => ['float']], + ['a' => 'b', 'c' => 10.0], + ]; + + yield 'String allowed type passes validation' => [ + ['a' => 'b', 'c' => 'hello'], + ['c' => 'world'], + ['c' => ['string']], + ['a' => 'b', 'c' => 'world'], + ]; + + yield 'String allowed type fails validation and default value is used' => [ + ['a' => 'b', 'c' => 'hello'], + ['c' => 42], + ['c' => ['string']], + ['a' => 'b', 'c' => 'hello'], + ]; + + yield 'Boolean allowed type passes validation' => [ + ['a' => 'b', 'c' => true], + ['c' => false], + ['c' => ['bool']], + ['a' => 'b', 'c' => false], + ]; + + yield 'Boolean allowed type fails validation and default value is used' => [ + ['a' => 'b', 'c' => true], + ['c' => 'false'], + ['c' => ['bool']], + ['a' => 'b', 'c' => true], + ]; + + yield 'Array allowed type passes validation' => [ + ['a' => 'b', 'c' => ['foo' => 'bar']], + ['c' => ['foo' => 'bar']], + ['c' => ['array']], + ['a' => 'b', 'c' => ['foo' => 'bar']], + ]; + + yield 'Array allowed type fails validation and default value is used' => [ + ['a' => 'b', 'c' => ['foo' => 'bar']], + ['c' => 'test'], + ['c' => ['array']], + ['a' => 'b', 'c' => ['foo' => 'bar']], + ]; + } + + public function allowedValueTestProvider(): \Generator + { + yield 'String in array of strings' => [ + ['a' => 'b'], + ['a' => 'd'], + ['a' => ['a', 'b', 'c', 'd']], + ['a' => 'd'], + ]; + + yield 'String not in array of strings' => [ + ['a' => 'b'], + ['a' => 'z'], + ['a' => ['a', 'b', 'c', 'd']], + ['a' => 'b'], + ]; + + yield 'Callback validates successfully' => [ + ['count' => 50], + ['count' => 10], + ['count' => static function ($value) { + return $value >= 0 && $value <= 100; + }], + ['count' => 10], + ]; + + yield 'Callback validation fails' => [ + ['count' => 50], + ['count' => 200], + ['count' => static function ($value) { + return $value >= 0 && $value <= 100; + }], + ['count' => 50], + ]; + } + + public function resolveOnlyTestProvider(): \Generator + { + $defaults = ['foo' => 'bar', 'test' => 'example', 'a' => 'b']; + + yield 'Result only contains passed values' => [ + $defaults, + ['foo' => 'test'], + ['foo' => 'test'], + ]; + + yield 'Result does not contain invalid key' => [ + $defaults, + ['foo' => 'test', 'example' => 'abcde'], + ['foo' => 'test'], + ]; + + yield 'Empty input returns empty output' => [ + $defaults, + [], + [], + ]; + } + + public function normalizerTestProvider(): \Generator + { + yield 'Normalizes successful' => [ + ['a' => 'b'], + ['a' => ' c '], + ['a' => static function ($value) { + return trim($value); + }], + ['a' => 'c'], + ]; + } + + public function testDebugLogsProduced(): void + { + $logger = new TestLogger(); + $resolver = new OptionsResolver(); + $resolver->setAllowedValues('test', ['foo']); + $resolver->setDefaults([ + 'test' => 'foo', + ]); + $resolver->resolve(['example' => 'abc'], $logger); + $this->assertCount(1, $logger->getLogs()); + $this->assertEquals('Option "example" does not exist and will be ignored', $logger->getLogs()[0]); + + $resolver->resolve(['test' => 'abc'], $logger); + $this->assertCount(2, $logger->getLogs()); + $this->assertEquals('Invalid value for option "test". Using default value.', $logger->getLogs()[1]); + } + + public function testDebugLogsProducedForResolveOnly(): void + { + $logger = new TestLogger(); + $resolver = new OptionsResolver(); + $resolver->setAllowedValues('test', ['foo']); + $resolver->setDefaults(['test' => 'foo', 'abc' => 'def', 'bar' => 'baz']); + + $resolver->resolveOnly(['example' => 'test'], $logger); + $this->assertCount(1, $logger->getLogs()); + $this->assertEquals('Option "example" does not exist and will be ignored', $logger->getLogs()[0]); + + $resolver->resolveOnly(['test' => 'abc'], $logger); + $this->assertCount(2, $logger->getLogs()); + $this->assertEquals('Invalid value for option "test". Using default value.', $logger->getLogs()[1]); + } +} + +class TestLogger extends AbstractLogger +{ + private $logs = []; + + public function log($level, $message, array $context = []): void + { + $this->logs[] = $message; + } + + public function getLogs(): array + { + return $this->logs; + } +} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 9b48eb8bd..6d43cb571 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -5,13 +5,13 @@ namespace Sentry\Tests; use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; use Psr\Log\NullLogger; use Sentry\Dsn; use Sentry\HttpClient\HttpClient; use Sentry\Options; use Sentry\Serializer\PayloadSerializer; use Sentry\Transport\HttpTransport; -use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; final class OptionsTest extends TestCase { @@ -548,26 +548,23 @@ public static function dsnOptionDataProvider(): \Generator } /** - * @dataProvider dsnOptionThrowsOnInvalidValueDataProvider + * @dataProvider dsnOptionInvalidValueDataProvider */ - public function testDsnOptionThrowsOnInvalidValue($value, string $expectedExceptionMessage): void + public function testDsnOptionInvalidValueFallsBackToDefault($value): void { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage($expectedExceptionMessage); + $options = new Options(['dsn' => $value]); - new Options(['dsn' => $value]); + $this->assertNull($options->getDsn()); } - public static function dsnOptionThrowsOnInvalidValueDataProvider(): \Generator + public static function dsnOptionInvalidValueDataProvider(): \Generator { - yield [ + yield '"true" is not a valid DSN' => [ true, - 'The option "dsn" with value true is invalid.', ]; - yield [ + yield '"foo" is not a valid DSN' => [ 'foo', - 'The option "dsn" with value "foo" is invalid.', ]; } @@ -614,60 +611,51 @@ public function includedPathProviders(): array /** * @dataProvider maxBreadcrumbsOptionIsValidatedCorrectlyDataProvider */ - public function testMaxBreadcrumbsOptionIsValidatedCorrectly(bool $isValid, $value): void + public function testMaxBreadcrumbsOptionIsValidatedCorrectly($value, int $expectedValue): void { - if (!$isValid) { - $this->expectException(InvalidOptionsException::class); - } - $options = new Options(['max_breadcrumbs' => $value]); - $this->assertSame($value, $options->getMaxBreadcrumbs()); + $this->assertSame($expectedValue, $options->getMaxBreadcrumbs()); } public static function maxBreadcrumbsOptionIsValidatedCorrectlyDataProvider(): array { return [ - [false, -1], - [true, 0], - [true, 1], - [true, Options::DEFAULT_MAX_BREADCRUMBS], - [true, Options::DEFAULT_MAX_BREADCRUMBS + 1], - [false, 'string'], - [false, '1'], + [-1, Options::DEFAULT_MAX_BREADCRUMBS], + [0, 0], + [1, 1], + [Options::DEFAULT_MAX_BREADCRUMBS, Options::DEFAULT_MAX_BREADCRUMBS], + [Options::DEFAULT_MAX_BREADCRUMBS + 1, Options::DEFAULT_MAX_BREADCRUMBS + 1], + ['string', Options::DEFAULT_MAX_BREADCRUMBS], + ['1', Options::DEFAULT_MAX_BREADCRUMBS], ]; } /** * @dataProvider contextLinesOptionValidatesInputValueDataProvider */ - public function testContextLinesOptionValidatesInputValue(?int $value, ?string $expectedExceptionMessage): void + public function testContextLinesOptionValidatesInputValue(?int $value, ?int $expectedValue): void { - if ($expectedExceptionMessage !== null) { - $this->expectException(InvalidOptionsException::class); - $this->expectExceptionMessage($expectedExceptionMessage); - } else { - $this->expectNotToPerformAssertions(); - } + $options = new Options(['context_lines' => $value]); - new Options(['context_lines' => $value]); + $this->assertSame($expectedValue, $options->getContextLines()); } public static function contextLinesOptionValidatesInputValueDataProvider(): \Generator { yield [ -1, - 'The option "context_lines" with value -1 is invalid.', + 5, ]; yield [ 0, - null, + 0, ]; yield [ 1, - null, + 1, ]; yield [ @@ -679,55 +667,80 @@ public static function contextLinesOptionValidatesInputValueDataProvider(): \Gen /** * @dataProvider logFlushThresholdOptionIsValidatedCorrectlyDataProvider */ - public function testLogFlushThresholdOptionIsValidatedCorrectly(bool $isValid, $value): void + public function testLogFlushThresholdOptionIsValidatedCorrectly($value, ?int $expectedValue): void { - if (!$isValid) { - $this->expectException(InvalidOptionsException::class); - } - $options = new Options(['log_flush_threshold' => $value]); - $this->assertSame($value, $options->getLogFlushThreshold()); + $this->assertSame($expectedValue, $options->getLogFlushThreshold()); } public static function logFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array { return [ - [false, -1], - [false, 0], - [true, 1], - [true, 10], - [true, null], - [false, 'string'], - [false, '1'], + [-1, null], + [0, null], + [1, 1], + [10, 10], + [null, null], + ['string', null], + ['1', null], ]; } /** * @dataProvider metricFlushThresholdOptionIsValidatedCorrectlyDataProvider */ - public function testMetricFlushThresholdOptionIsValidatedCorrectly(bool $isValid, $value): void + public function testMetricFlushThresholdOptionIsValidatedCorrectly($value, ?int $expectedValue): void { - if (!$isValid) { - $this->expectException(InvalidOptionsException::class); - } - $options = new Options(['metric_flush_threshold' => $value]); - $this->assertSame($value, $options->getMetricFlushThreshold()); + $this->assertSame($expectedValue, $options->getMetricFlushThreshold()); } public static function metricFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array { return [ - [false, -1], - [false, 0], - [true, 1], - [true, 10], - [true, null], - [false, 'string'], - [false, '1'], - ]; + [-1, null], + [0, null], + [1, 1], + [10, 10], + [null, null], + ['string', null], + ['1', null], + ]; + } + + public function testUpdateOptionsLogsInvalidValuesAndFallsBackToDefault(): void + { + $logger = new class extends AbstractLogger { + /** + * @var string[] + */ + private $logs = []; + + public function log($level, $message, array $context = []): void + { + $this->logs[] = $message; + } + + /** + * @return string[] + */ + public function getLogs(): array + { + return $this->logs; + } + }; + + $options = new Options([ + 'logger' => $logger, + 'sample_rate' => 0.5, + ]); + + $options->updateOptions(['sample_rate' => 'invalid']); + + $this->assertSame(1.0, $options->getSampleRate()); + $this->assertSame(['Invalid value for option "sample_rate". Using default value.'], $logger->getLogs()); } /**