From 402c0bb5afccc38a7914d1157857eb72956024a3 Mon Sep 17 00:00:00 2001 From: Nasrul Hazim Bin Mohamad Date: Tue, 11 Nov 2025 08:14:33 +0800 Subject: [PATCH] Added Http Collector --- src/DataCollector/HttpCollector.php | 303 +++++++++++++++++ tests/DataCollector/HttpCollectorTest.php | 380 ++++++++++++++++++++++ 2 files changed, 683 insertions(+) create mode 100644 src/DataCollector/HttpCollector.php create mode 100644 tests/DataCollector/HttpCollectorTest.php diff --git a/src/DataCollector/HttpCollector.php b/src/DataCollector/HttpCollector.php new file mode 100644 index 00000000..2b6e2944 --- /dev/null +++ b/src/DataCollector/HttpCollector.php @@ -0,0 +1,303 @@ +request = $request; + $this->response = $response; + $this->startTime = $startTime ?? (defined('LARAVEL_START') ? LARAVEL_START : $request->server->get('REQUEST_TIME_FLOAT')); + $this->hiddenRequestHeaders = $hiddenRequestHeaders; + $this->hiddenParameters = $hiddenParameters; + $this->hiddenResponseParameters = $hiddenResponseParameters; + $this->ignoredStatusCodes = $ignoredStatusCodes; + $this->sizeLimit = $sizeLimit; + } + + /** + * {@inheritDoc} + */ + public function getName() + { + return 'http'; + } + + /** + * {@inheritDoc} + */ + public function collect() + { + $uri = str_replace($this->request->root(), '', $this->request->fullUrl()) ?: '/'; + $statusCode = $this->response->getStatusCode(); + $method = $this->request->method(); + + // Check if status code should be ignored + if (in_array($statusCode, $this->ignoredStatusCodes)) { + return []; + } + + return [ + 'title' => "$uri returned HTTP Status Code $statusCode", + 'description' => "$uri for $method request returned HTTP Status Code $statusCode", + 'uri' => $uri, + 'method' => $method, + 'controller_action' => optional($this->request->route())->getActionName(), + 'middleware' => array_values(optional($this->request->route())->gatherMiddleware() ?? []), + 'headers' => $this->formatHeaders($this->request->headers->all()), + 'payload' => $this->formatPayload($this->extractInput($this->request)), + 'session' => $this->formatPayload($this->extractSessionVariables($this->request)), + 'response_status' => $statusCode, + 'response' => $this->formatResponse($this->response), + 'duration' => $this->startTime ? floor((microtime(true) - $this->startTime) * 1000) : null, + 'memory' => round(memory_get_peak_usage(true) / 1024 / 1024, 1), + ]; + } + + /** + * {@inheritDoc} + */ + public function getWidgets() + { + return [ + 'http' => [ + 'icon' => 'globe', + 'widget' => 'PhpDebugBar.Widgets.HtmlVariableListWidget', + 'map' => 'http', + 'default' => '{}', + ], + 'http:badge' => [ + 'map' => 'http.response_status', + 'default' => 'null', + ], + ]; + } + + /** + * Format the given headers. + * + * @param array $headers + * @return array + */ + protected function formatHeaders(array $headers) + { + $headers = collect($headers)->map(function ($header) { + return $header[0] ?? $header; + })->toArray(); + + return $this->hideParameters($headers, $this->hiddenRequestHeaders); + } + + /** + * Format the given payload. + * + * @param array $payload + * @return array + */ + protected function formatPayload(array $payload) + { + return $this->hideParameters($payload, $this->hiddenParameters); + } + + /** + * Hide the given parameters. + * + * @param array $data + * @param array $hidden + * @return array + */ + protected function hideParameters(array $data, array $hidden) + { + foreach ($hidden as $parameter) { + if (Arr::get($data, $parameter)) { + Arr::set($data, $parameter, '********'); + } + } + + return $data; + } + + /** + * Extract the session variables from the given request. + * + * @param Request $request + * @return array + */ + protected function extractSessionVariables(Request $request) + { + return $request->hasSession() ? $request->session()->all() : []; + } + + /** + * Extract the input from the given request. + * + * @param Request $request + * @return array + */ + protected function extractInput(Request $request) + { + $files = $request->files->all(); + + array_walk_recursive($files, function (&$file) { + $file = [ + 'name' => $file->getClientOriginalName(), + 'size' => $file->isFile() ? ($file->getSize() / 1000) . 'KB' : '0', + ]; + }); + + return array_replace_recursive($request->input(), $files); + } + + /** + * Format the given response object. + * + * @param Response $response + * @return array|string + */ + protected function formatResponse(Response $response) + { + $content = $response->getContent(); + + if (is_string($content)) { + // Handle JSON responses + if (is_array(json_decode($content, true)) && json_last_error() === JSON_ERROR_NONE) { + return $this->contentWithinLimits($content) + ? $this->hideParameters(json_decode($content, true), $this->hiddenResponseParameters) + : 'Purged By Debugbar (Response too large)'; + } + + // Handle plain text responses + if (Str::startsWith(strtolower($response->headers->get('Content-Type') ?? ''), 'text/plain')) { + return $this->contentWithinLimits($content) ? $content : 'Purged By Debugbar (Response too large)'; + } + } + + // Handle redirect responses + if ($response instanceof RedirectResponse) { + return 'Redirected to ' . $response->getTargetUrl(); + } + + // Handle view responses + if ($response instanceof IlluminateResponse && $response->getOriginalContent() instanceof View) { + return [ + 'view' => $response->getOriginalContent()->getPath(), + 'data' => $this->extractDataFromView($response->getOriginalContent()), + ]; + } + + return 'HTML Response'; + } + + /** + * Determine if the content is within the set limits. + * + * @param string $content + * @return bool + */ + protected function contentWithinLimits($content) + { + return mb_strlen($content) / 1000 <= $this->sizeLimit; + } + + /** + * Extract the data from the given view in array form. + * + * @param View $view + * @return array + */ + protected function extractDataFromView(View $view) + { + return collect($view->getData())->map(function ($value) { + if ($value instanceof Model) { + return $this->formatModel($value); + } elseif (is_object($value)) { + return [ + 'class' => get_class($value), + 'properties' => json_decode(json_encode($value), true), + ]; + } else { + return json_decode(json_encode($value), true); + } + })->toArray(); + } + + /** + * Format a model instance. + * + * @param Model $model + * @return array + */ + protected function formatModel(Model $model) + { + return [ + 'class' => get_class($model), + 'key' => $model->getKey(), + 'attributes' => $model->getAttributes(), + 'relations' => collect($model->getRelations())->map(function ($relation) { + if ($relation instanceof Model) { + return [ + 'class' => get_class($relation), + 'key' => $relation->getKey(), + ]; + } + return gettype($relation); + })->toArray(), + ]; + } +} diff --git a/tests/DataCollector/HttpCollectorTest.php b/tests/DataCollector/HttpCollectorTest.php new file mode 100644 index 00000000..8d00c6a8 --- /dev/null +++ b/tests/DataCollector/HttpCollectorTest.php @@ -0,0 +1,380 @@ +json(['message' => 'Hello', 'secret' => 'password123']); + }); + + Route::get('test/redirect', function () { + return redirect('/test/destination'); + }); + + Route::get('test/view', function () { + return view('dashboard'); + }); + + Route::post('test/with-input', function (Request $request) { + return response()->json(['received' => $request->all()]); + }); + + Route::get('test/plain-text', function () { + return response('Plain text response', 200, ['Content-Type' => 'text/plain']); + }); + + Route::get('test/large-response', function () { + return response()->json(['data' => str_repeat('x', 100000)]); + }); + } + + public function testCollectorName() + { + $collector = $this->createCollector(); + $this->assertEquals('http', $collector->getName()); + } + + public function testCollectorWidgets() + { + $collector = $this->createCollector(); + $widgets = $collector->getWidgets(); + + $this->assertArrayHasKey('http', $widgets); + $this->assertArrayHasKey('http:badge', $widgets); + $this->assertEquals('globe', $widgets['http']['icon']); + $this->assertEquals('PhpDebugBar.Widgets.HtmlVariableListWidget', $widgets['http']['widget']); + } + + public function testItCollectsBasicRequestInformation() + { + $response = $this->get('web/html'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('uri', $data); + $this->assertArrayHasKey('method', $data); + $this->assertArrayHasKey('response_status', $data); + $this->assertEquals('GET', $data['method']); + $this->assertEquals(200, $data['response_status']); + } + + public function testItCollectsControllerAction() + { + $response = $this->get('web/show'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('controller_action', $data); + $this->assertNotNull($data['controller_action']); + } + + public function testItCollectsMiddleware() + { + $response = $this->post('web/mw'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('middleware', $data); + $this->assertIsArray($data['middleware']); + } + + public function testItCollectsRequestHeaders() + { + $response = $this->withHeaders([ + 'X-Custom-Header' => 'test-value', + 'Accept' => 'application/json', + ])->get('web/plain'); + + $collector = $this->createCollectorFromResponse($response); + $data = $collector->collect(); + + $this->assertArrayHasKey('headers', $data); + $this->assertIsArray($data['headers']); + } + + public function testItHidesRequestHeaders() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer secret-token', + ])->get('web/plain'); + + $collector = new HttpCollector( + $this->app['request'], + $response->baseResponse, + null, + ['authorization'], // Hidden headers + [], + [], + [] + ); + + $data = $collector->collect(); + + if (isset($data['headers']['authorization'])) { + $this->assertEquals('********', $data['headers']['authorization']); + } + } + + public function testItCollectsRequestPayload() + { + $response = $this->post('test/with-input', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $collector = $this->createCollectorFromResponse($response); + $data = $collector->collect(); + + $this->assertArrayHasKey('payload', $data); + $this->assertIsArray($data['payload']); + } + + public function testItHidesRequestParameters() + { + $response = $this->post('test/with-input', [ + 'name' => 'John Doe', + 'password' => 'secret123', + ]); + + $collector = new HttpCollector( + $this->app['request'], + $response->baseResponse, + null, + [], + ['password'], // Hidden parameters + [], + [] + ); + + $data = $collector->collect(); + + $this->assertEquals('********', $data['payload']['password']); + } + + public function testItCollectsSessionData() + { + $response = $this->withSession(['user_id' => 1, 'token' => 'abc123'])->get('web/plain'); + + $collector = $this->createCollectorFromResponse($response); + $data = $collector->collect(); + + $this->assertArrayHasKey('session', $data); + $this->assertIsArray($data['session']); + } + + public function testItCollectsDurationAndMemory() + { + $startTime = microtime(true) - 0.1; // Subtract 0.1 seconds to ensure measurable duration + $response = $this->get('web/html'); + + $collector = new HttpCollector( + $this->app['request'], + $response->baseResponse, + $startTime + ); + + $data = $collector->collect(); + + $this->assertArrayHasKey('duration', $data); + $this->assertArrayHasKey('memory', $data); + $this->assertIsNumeric($data['duration']); + $this->assertIsNumeric($data['memory']); + $this->assertGreaterThanOrEqual(0, $data['duration']); + $this->assertGreaterThan(0, $data['memory']); + } + + public function testItHandlesJsonResponse() + { + $response = $this->get('test/json'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('response', $data); + $this->assertIsArray($data['response']); + $this->assertArrayHasKey('message', $data['response']); + $this->assertEquals('Hello', $data['response']['message']); + } + + public function testItHidesResponseParameters() + { + $response = $this->get('test/json'); + + $collector = new HttpCollector( + $this->app['request'], + $response->baseResponse, + null, + [], + [], + ['secret'], // Hidden response parameters + [] + ); + + $data = $collector->collect(); + + $this->assertEquals('********', $data['response']['secret']); + } + + public function testItHandlesRedirectResponse() + { + $response = $this->get('test/redirect'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('response', $data); + $this->assertStringContainsString('Redirected to', $data['response']); + } + + public function testItHandlesViewResponse() + { + $response = $this->get('test/view'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('response', $data); + $this->assertIsArray($data['response']); + $this->assertArrayHasKey('view', $data['response']); + $this->assertArrayHasKey('data', $data['response']); + } + + public function testItHandlesPlainTextResponse() + { + $response = $this->get('test/plain-text'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('response', $data); + $this->assertEquals('Plain text response', $data['response']); + } + + public function testItHandlesHtmlResponse() + { + $response = $this->get('web/html'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('response', $data); + $this->assertEquals('HTML Response', $data['response']); + } + + public function testItPurgesLargeResponses() + { + $response = $this->get('test/large-response'); + + // Set a small size limit (1KB) + $collector = new HttpCollector( + $this->app['request'], + $response->baseResponse, + null, + [], + [], + [], + [], + 1 // 1KB size limit + ); + + $data = $collector->collect(); + + $this->assertArrayHasKey('response', $data); + $this->assertStringContainsString('Purged By Debugbar', $data['response']); + } + + public function testItIgnoresConfiguredStatusCodes() + { + $response = $this->get('web/html'); // Returns 200 + + $collector = new HttpCollector( + $this->app['request'], + $response->baseResponse, + null, + [], + [], + [], + [200] // Ignore 200 status codes + ); + + $data = $collector->collect(); + + $this->assertEmpty($data); + } + + public function testItCollectsPostRequests() + { + $response = $this->post('test/with-input', ['test' => 'data']); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertEquals('POST', $data['method']); + $this->assertEquals(200, $data['response_status']); + } + + public function testItFormatsUriCorrectly() + { + $response = $this->get('/web/html'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('uri', $data); + $this->assertStringContainsString('web/html', $data['uri']); + } + + public function testItCollectsTitle() + { + $response = $this->get('web/plain'); + $collector = $this->createCollectorFromResponse($response); + + $data = $collector->collect(); + + $this->assertArrayHasKey('title', $data); + $this->assertArrayHasKey('description', $data); + $this->assertStringContainsString('HTTP Status Code', $data['title']); + $this->assertStringContainsString('200', $data['title']); + } + + /** + * Helper method to create a collector from the current request/response + */ + protected function createCollector(): HttpCollector + { + $request = Request::create('/test', 'GET'); + $response = new Response('Test'); + + return new HttpCollector($request, $response); + } + + /** + * Helper method to create a collector from a test response + */ + protected function createCollectorFromResponse($testResponse): HttpCollector + { + return new HttpCollector( + $this->app['request'], + $testResponse->baseResponse + ); + } +}