diff --git a/composer.json b/composer.json index f00bd7f..218d1fa 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ ], "require": { "php": "^7.4|^8.0", + "ext-json": "*", "guzzlehttp/guzzle": "^7.2", "illuminate/http": "^8.0", "illuminate/support": "^8.0", diff --git a/src/MessageAccessor.php b/src/MessageAccessor.php new file mode 100644 index 0000000..7ca8a4d --- /dev/null +++ b/src/MessageAccessor.php @@ -0,0 +1,167 @@ +values = $values; + $this->queryFilters = $queryFilters; + $this->headersFilters = $headersFilters; + $this->jsonFilters = $jsonFilers; + $this->replace = $replace; + } + + public function getUri(RequestInterface $request): UriInterface + { + $uri = $request->getUri(); + parse_str($uri->getQuery(), $query); + + return $uri + ->withUserInfo($this->replace($this->values, $this->replace, $uri->getUserInfo())) + ->withHost($this->replace($this->values, $this->replace, $uri->getHost())) + ->withPath($this->replace($this->values, $this->replace, $uri->getPath())) + ->withQuery(Arr::query($this->replaceParameters($query, $this->queryFilters, $this->values, $this->replace))); + } + + public function getBase(RequestInterface $request): string + { + $uri = $this->getUri($request); + + $base = ''; + if ($uri->getScheme()) { + $base .= $uri->getScheme().'://'; + } + if ($uri->getUserInfo()) { + $base .= $uri->getUserInfo().'@'; + } + if ($uri->getHost()) { + $base .= $uri->getHost(); + } + if ($uri->getPort()) { + $base .= ':'.$uri->getPort(); + } + + return $base; + } + + public function getQuery(RequestInterface $request): array + { + parse_str($this->getUri($request)->getQuery(), $query); + + return $query; + } + + public function getHeaders(MessageInterface $message): array + { + foreach ($this->headersFilters as $headersFilter) { + if ($message->hasHeader($headersFilter)) { + $message = $message->withHeader($headersFilter, $this->replace); + } + } + + // Header filter applied above as this is an array with two layers + return $this->replaceParameters($message->getHeaders(), [], $this->values, $this->replace, false); + } + + /** + * Determine if the request is JSON. + * + * @see vendor/laravel/framework/src/Illuminate/Http/Client/Request.php + * + * @param MessageInterface $message + * + * @return bool + */ + public function isJson(MessageInterface $message): bool + { + return $message->hasHeader('Content-Type') && + Str::contains($message->getHeaderLine('Content-Type'), 'json'); + } + + public function getJson(MessageInterface $message): ?array + { + return $this->replaceParameters( + json_decode($message->getBody()->__toString(), true), + $this->jsonFilters, + $this->values, + $this->replace + ); + } + + public function getContent(MessageInterface $message): string + { + if ($this->isJson($message)) { + $body = json_encode($this->getJson($message)); + } else { + $body = $message->getBody()->__toString(); + foreach ($this->values as $value) { + $body = str_replace($value, $this->replace, $body); + } + } + + return $body; + } + + public function filter(MessageInterface $message): MessageInterface + { + $body = $this->getContent($message); + + foreach ($this->getHeaders($message) as $header => $values) { + $message = $message->withHeader($header, $values); + } + + return $message->withBody(Utils::streamFor($body)); + } + + protected function replaceParameters(array $array, array $parameters, array $values, string $replace, $strict = true): array + { + foreach ($parameters as $parameter) { + if (data_get($array, $parameter, null)) { + data_set($array, $parameter, $replace); + } + } + + array_walk_recursive($array, function (&$item) use ($values, $replace, $strict) { + foreach ($values as $value) { + if (!$strict && str_contains($item, $value)) { + $item = str_replace($value, $replace, $item); + } elseif ($strict && $value === $item) { + $item = $replace; + } + } + + return $item; + }); + + return $array; + } + + protected function replace($search, $replace, ?string $subject): ?string + { + if (is_null($subject)) { + return null; + } + + return str_replace($search, $replace, $subject); + } +} diff --git a/tests/MessageAccessorTest.php b/tests/MessageAccessorTest.php new file mode 100644 index 0000000..cb11819 --- /dev/null +++ b/tests/MessageAccessorTest.php @@ -0,0 +1,157 @@ +messageAccessor = new MessageAccessor( + ['data.baz.*.password'], + ['search', 'filter.field2'], + ['Authorization'], + ['secret'], + ); + + $this->request = new Request( + 'POST', + 'https://user:secret@secret.example.com:9000/some-path/secret/should-not-be-removed?test=true&search=foo&filter[field1]=A&filter[field2]=B#anchor', + [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer 1234567890', + ], + json_encode([ + 'data' => [ + 'foo' => 'bar', + 'baz' => [ + [ + 'field_1' => 'value1', + 'field_2' => 'value2', + 'password' => '123456', + 'secret' => 'this is not for everyone', + ], + ], + ], + ]) + ); + } + + public function test_get_uri() + { + $uri = $this->messageAccessor->getUri($this->request); + + $this->assertEquals('https', $uri->getScheme()); + $this->assertEquals('user%3A********@********.example.com:9000', $uri->getAuthority()); + $this->assertEquals('user%3A********', $uri->getUserInfo()); + $this->assertEquals('********.example.com', $uri->getHost()); + $this->assertEquals('9000', $uri->getPort()); + $this->assertEquals('/some-path/********/should-not-be-removed', $uri->getPath()); + $this->assertEquals('test=true&search=********&filter[field1]=A&filter[field2]=********', urldecode($uri->getQuery())); + $this->assertEquals('anchor', $uri->getFragment()); + } + + public function test_get_base() + { + $this->assertEquals( + 'https://user:********@********.example.com:9000', + urldecode($this->messageAccessor->getBase($this->request)) + ); + } + + public function test_get_query() + { + $query = $this->messageAccessor->getQuery($this->request); + + $this->assertIsArray($query); + $this->assertEquals([ + 'test' => 'true', + 'search' => '********', + 'filter' => [ + 'field1' => 'A', + 'field2' => '********', + ], + ], $query); + } + + public function test_get_headers() + { + $headers = $this->messageAccessor->getHeaders($this->request); + + $this->assertIsArray($headers); + $this->assertEquals([ + 'Accept' => ['application/json'], + 'Content-Type' => ['application/json'], + 'Authorization' => ['********'], + 'Host' => ['********.example.com:9000'], + ], $headers); + } + + public function test_is_json() + { + $this->assertTrue($this->messageAccessor->isJson($this->request)); + $this->assertFalse($this->messageAccessor->isJson(new Response(200, ['Content-Type' => 'text/html'], ''))); + } + + public function test_get_json() + { + $json = $this->messageAccessor->getJson($this->request); + + $this->assertIsArray($json); + $this->assertEquals([ + 'data' => [ + 'foo' => 'bar', + 'baz' => [ + [ + 'field_1' => 'value1', + 'field_2' => 'value2', + 'password' => '********', + 'secret' => 'this is not for everyone', // Note that keys are NOT filtered + ], + ], + ], + ], $json); + } + + public function test_get_content() + { + $content = $this->messageAccessor->getContent($this->request); + + $this->assertEquals(json_encode([ + 'data' => [ + 'foo' => 'bar', + 'baz' => [ + [ + 'field_1' => 'value1', + 'field_2' => 'value2', + 'password' => '********', + 'secret' => 'this is not for everyone', // Note that keys are NOT filtered + ], + ], + ], + ]), $content); + } + + public function test_filter() + { + $request = $this->messageAccessor->filter($this->request); + + // Note that it is required to use double quotes for the Carriage Return (\r) to work and have it on one line to pass on Windows + $output = "POST /some-path/secret/should-not-be-removed?test=true&search=foo&filter%5Bfield1%5D=A&filter%5Bfield2%5D=B HTTP/1.1\r\nHost: ********.example.com:9000\r\nAccept: application/json\r\nContent-Type: application/json\r\nAuthorization: ********\r\n\r\n{\"data\":{\"foo\":\"bar\",\"baz\":[{\"field_1\":\"value1\",\"field_2\":\"value2\",\"password\":\"********\",\"secret\":\"this is not for everyone\"}]}}"; + + $this->assertEquals($output, Message::toString($request)); + } +}