From e7845129332d6debab8cc9612ec6fdcb22a3c13e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 30 May 2024 12:54:44 +0200 Subject: [PATCH 01/10] Make it possible to pass functions to change output streaming --- src/main/php/web/Response.class.php | 22 ++++++++++++--- .../php/web/unittest/ResponseTest.class.php | 27 ++++++++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/php/web/Response.class.php b/src/main/php/web/Response.class.php index e9842bdd..dfbc59fe 100755 --- a/src/main/php/web/Response.class.php +++ b/src/main/php/web/Response.class.php @@ -12,6 +12,7 @@ */ class Response { private $output; + private $streaming= null; private $flushed= false; private $status= 200; private $message= 'OK'; @@ -152,9 +153,22 @@ public function end() { } /** - * Returns a stream to write on + * Changes the implementation used inside `stream()` to determine the output. * - * @param int $size If omitted, uses chunked transfer encoding + * @param function(self, ?int): web.io.Output $func + * @return self + */ + public function streaming($func) { + $this->streaming= $func; + return $this; + } + + /** + * Returns a stream to write on. By default, uses chunked transfer encoding if + * a length is passed, and sets the `Content-Length` header and writes directly + * to the raw underlying output otherwise. + * + * @param int $size * @return io.streams.OutputStream * @throws lang.IllegalStateException */ @@ -163,7 +177,9 @@ public function stream($size= null) { throw new IllegalStateException('Response already flushed'); } - if (null === $size) { + if ($this->streaming) { + $output= ($this->streaming)($this, $size); + } else if (null === $size) { $output= $this->output->stream(); } else { $this->headers['Content-Length']= [$size]; diff --git a/src/test/php/web/unittest/ResponseTest.class.php b/src/test/php/web/unittest/ResponseTest.class.php index 13b9b4d3..b6168aa4 100755 --- a/src/test/php/web/unittest/ResponseTest.class.php +++ b/src/test/php/web/unittest/ResponseTest.class.php @@ -5,7 +5,7 @@ use lang\{IllegalArgumentException, IllegalStateException}; use test\{Assert, Expect, Test, Values}; use util\URI; -use web\io\{Buffered, TestOutput}; +use web\io\{Output, Buffered, TestOutput}; use web\{Cookie, Response}; class ResponseTest { @@ -315,4 +315,29 @@ public function flush_twice() { $res->flush(); $res->flush(); } + + #[Test] + public function streaming() { + $res= new Response(new TestOutput()); + $res->streaming(function($res, $size) use(&$buffer) { + return new class($buffer) extends Output { + private $buffer; + + public function __construct(&$buffer) { $this->buffer= &$buffer; } + + public function begin($status, $message, $headers) { } + + public function write($chunk) { $this->buffer.= strrev($chunk); } + + public function flush() { } + + public function finish() { } + }; + }); + + $buffer= ''; + $res->send('Test'); + + Assert::equals('tseT', $buffer); + } } \ No newline at end of file From 2e2846e14872af55bab3e8aec4ba52efd56601d9 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 30 May 2024 13:04:45 +0200 Subject: [PATCH 02/10] QA: Shorten streaming test --- src/test/php/web/unittest/ResponseTest.class.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/php/web/unittest/ResponseTest.class.php b/src/test/php/web/unittest/ResponseTest.class.php index b6168aa4..d3bbf176 100755 --- a/src/test/php/web/unittest/ResponseTest.class.php +++ b/src/test/php/web/unittest/ResponseTest.class.php @@ -318,8 +318,7 @@ public function flush_twice() { #[Test] public function streaming() { - $res= new Response(new TestOutput()); - $res->streaming(function($res, $size) use(&$buffer) { + $res= (new Response(new TestOutput()))->streaming(function($res, $size) use(&$buffer) { return new class($buffer) extends Output { private $buffer; From 065f228b786a7e6ae436f360781a5cb64b7049b5 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 30 May 2024 13:12:47 +0200 Subject: [PATCH 03/10] Include streaming tests --- .../php/web/unittest/ResponseTest.class.php | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/test/php/web/unittest/ResponseTest.class.php b/src/test/php/web/unittest/ResponseTest.class.php index d3bbf176..2b4bbd33 100755 --- a/src/test/php/web/unittest/ResponseTest.class.php +++ b/src/test/php/web/unittest/ResponseTest.class.php @@ -10,6 +10,28 @@ class ResponseTest { + /** + * Output implementation reversing the written bytes into the given buffer + * + * @param string $buffer + * @return web.io.Output + */ + private function reverse(&$buffer) { + return new class($buffer) extends Output { + private $buffer; + + public function __construct(&$buffer) { $this->buffer= &$buffer; } + + public function begin($status, $message, $headers) { } + + public function write($chunk) { $this->buffer.= strrev($chunk); } + + public function flush() { } + + public function finish() { } + }; + } + /** * Assertion helper * @@ -317,26 +339,29 @@ public function flush_twice() { } #[Test] - public function streaming() { + public function streaming_with_send() { $res= (new Response(new TestOutput()))->streaming(function($res, $size) use(&$buffer) { - return new class($buffer) extends Output { - private $buffer; - - public function __construct(&$buffer) { $this->buffer= &$buffer; } - - public function begin($status, $message, $headers) { } + return $this->reverse($buffer); + }); - public function write($chunk) { $this->buffer.= strrev($chunk); } + $buffer= ''; + $res->send('Test'); - public function flush() { } + Assert::equals('tseT', $buffer); + } - public function finish() { } - }; + #[Test] + public function streaming_with_stream() { + $res= (new Response(new TestOutput()))->streaming(function($res, $size) use(&$buffer) { + return $this->reverse($buffer); }); $buffer= ''; - $res->send('Test'); + $stream= $res->stream(); + $stream->write('Test'); + $stream->write('OK'); + $stream->close(); - Assert::equals('tseT', $buffer); + Assert::equals('tseTKO', $buffer); } } \ No newline at end of file From 878674ffc9223428d5f48810e755968aabe12a57 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 9 Jun 2024 10:32:50 +0200 Subject: [PATCH 04/10] Also use streaming function when Response::flush() is called --- src/main/php/web/Response.class.php | 46 +++++++++++++---------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/main/php/web/Response.class.php b/src/main/php/web/Response.class.php index dfbc59fe..e1dab0fc 100755 --- a/src/main/php/web/Response.class.php +++ b/src/main/php/web/Response.class.php @@ -110,13 +110,30 @@ public function headers() { return $r; } - /** @param web.io.Output $output */ + /** + * Begins output by sending the status line and headers + * + * @param web.io.Output $output + * @return web.io.Output + */ private function begin($output) { $output->begin($this->status, $this->message, $this->cookies - ? array_merge($this->headers, ['Set-Cookie' => array_map(function($c) { return $c->header(); }, $this->cookies)]) + ? $this->headers + ['Set-Cookie' => array_map(function($c) { return $c->header(); }, $this->cookies)] : $this->headers ); $this->flushed= true; + return $output; + } + + /** + * Changes the implementation used inside `stream()` to determine the output. + * + * @param function(self, ?int): web.io.Output $func + * @return self + */ + public function streaming($func) { + $this->streaming= $func; + return $this; } /** @@ -130,7 +147,7 @@ public function flush() { throw new IllegalStateException('Response already flushed'); } - $this->begin($this->output); + $this->begin($this->streaming ? ($this->streaming)($this, 0) : $this->output); } /** @@ -152,17 +169,6 @@ public function end() { $this->output->close(); } - /** - * Changes the implementation used inside `stream()` to determine the output. - * - * @param function(self, ?int): web.io.Output $func - * @return self - */ - public function streaming($func) { - $this->streaming= $func; - return $this; - } - /** * Returns a stream to write on. By default, uses chunked transfer encoding if * a length is passed, and sets the `Content-Length` header and writes directly @@ -177,17 +183,7 @@ public function stream($size= null) { throw new IllegalStateException('Response already flushed'); } - if ($this->streaming) { - $output= ($this->streaming)($this, $size); - } else if (null === $size) { - $output= $this->output->stream(); - } else { - $this->headers['Content-Length']= [$size]; - $output= $this->output; - } - - $this->begin($output); - return $output; + return $this->begin($this->streaming ? ($this->streaming)($this, $size) : $this->output->stream($size)); } /** From ca06077e0ecd12fa42db0834dacaaa0d1b614142 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 9 Jun 2024 10:33:44 +0200 Subject: [PATCH 05/10] QA: CS/WS consistency --- src/main/php/web/io/WriteChunks.class.php | 6 +++--- src/test/php/web/unittest/io/WriteChunksTest.class.php | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/php/web/io/WriteChunks.class.php b/src/main/php/web/io/WriteChunks.class.php index f7a43fe9..b5d5128f 100755 --- a/src/main/php/web/io/WriteChunks.class.php +++ b/src/main/php/web/io/WriteChunks.class.php @@ -4,15 +4,15 @@ * Writes Chunked transfer encoding * * @see https://tools.ietf.org/html/rfc7230#section-4.1 - * @test xp://web.unittest.io.WriteChunksTest + * @test web.unittest.io.WriteChunksTest */ class WriteChunks extends Output { - const BUFFER_SIZE = 4096; + const BUFFER_SIZE= 4096; private $target; private $buffer= ''; - /** @param web.io.Output $target */ + /** @param parent $target */ public function __construct($target) { $this->target= $target; } diff --git a/src/test/php/web/unittest/io/WriteChunksTest.class.php b/src/test/php/web/unittest/io/WriteChunksTest.class.php index fe1bd327..51f5475f 100755 --- a/src/test/php/web/unittest/io/WriteChunksTest.class.php +++ b/src/test/php/web/unittest/io/WriteChunksTest.class.php @@ -1,7 +1,6 @@ Date: Sun, 9 Jun 2024 10:42:01 +0200 Subject: [PATCH 06/10] Refactor Output::stream() to accept length and return one of the following: . WriteLength, when length !== null . WriteChunks, for HTTP/1.1 . Buffered, otherwise This way, we do not need to set the Content-Length header inside Response and can prevent code duplication in various places, including streaming functions, at the cost of a tiny overhead when writing data with a known content length. This overhead consists of additional method calls and instantiating and later garbage-collecting an additional object, all of which is unmeasurable compared to the network I/O --- src/main/php/web/io/Buffered.class.php | 6 +- src/main/php/web/io/Output.class.php | 5 +- src/main/php/web/io/TestOutput.class.php | 13 +++- src/main/php/web/io/WriteLength.class.php | 54 ++++++++++++++ src/main/php/xp/web/SAPI.class.php | 12 ++-- src/main/php/xp/web/dev/Buffer.class.php | 41 ----------- .../php/xp/web/dev/CaptureOutput.class.php | 71 +++++++++++++++++++ src/main/php/xp/web/dev/Console.class.php | 40 ++++++----- src/main/php/xp/web/srv/Output.class.php | 15 ++-- .../web/unittest/io/WriteLengthTest.class.php | 67 +++++++++++++++++ 10 files changed, 246 insertions(+), 78 deletions(-) create mode 100755 src/main/php/web/io/WriteLength.class.php delete mode 100755 src/main/php/xp/web/dev/Buffer.class.php create mode 100755 src/main/php/xp/web/dev/CaptureOutput.class.php create mode 100755 src/test/php/web/unittest/io/WriteLengthTest.class.php diff --git a/src/main/php/web/io/Buffered.class.php b/src/main/php/web/io/Buffered.class.php index eadabd24..caa8dce3 100755 --- a/src/main/php/web/io/Buffered.class.php +++ b/src/main/php/web/io/Buffered.class.php @@ -1,14 +1,12 @@ target= $target; } diff --git a/src/main/php/web/io/Output.class.php b/src/main/php/web/io/Output.class.php index 85e8bc1f..2d1e8da1 100755 --- a/src/main/php/web/io/Output.class.php +++ b/src/main/php/web/io/Output.class.php @@ -36,9 +36,10 @@ public abstract function write($bytes); * Returns an output used when the content-length is not known at the * time of starting the output. * + * @param ?int $length * @return self */ - public function stream() { return $this; } + public function stream($length= null) { return $this; } /** @return void */ public function finish() { } @@ -47,7 +48,7 @@ public function finish() { } public function flush() { } /** @return void */ - public function close() { + public final function close() { if ($this->closed) return; $this->finish(); $this->closed= true; diff --git a/src/main/php/web/io/TestOutput.class.php b/src/main/php/web/io/TestOutput.class.php index 9cfc32dd..23dabe6e 100755 --- a/src/main/php/web/io/TestOutput.class.php +++ b/src/main/php/web/io/TestOutput.class.php @@ -5,7 +5,7 @@ /** * Input for testing purposes * - * @test xp://web.unittest.io.TestOutputTest + * @test web.unittest.io.TestOutputTest */ class TestOutput extends Output { private $stream; @@ -53,8 +53,15 @@ public function begin($status, $message, $headers) { $this->bytes.= "\r\n"; } - /** @return web.io.Output */ - public function stream() { return $this->stream->newInstance($this); } + /** + * Returns writer with length if known, using the configured stream otherwise + * + * @param ?int $length + * @return web.io.Output + */ + public function stream($length= null) { + return null === $length ? $this->stream->newInstance($this) : new WriteLength($this, $length); + } /** * Writes the bytes (in this case, to the internal buffer which can be diff --git a/src/main/php/web/io/WriteLength.class.php b/src/main/php/web/io/WriteLength.class.php new file mode 100755 index 00000000..e571a1d4 --- /dev/null +++ b/src/main/php/web/io/WriteLength.class.php @@ -0,0 +1,54 @@ +target= $target; + $this->length= $length; + } + + /** + * Begins output + * + * @param int $status + * @param string $message + * @param [:string] $headers + * @return void + */ + public function begin($status, $message, $headers) { + $headers['Content-Length']= [$this->length]; + $this->target->begin($status, $message, $headers); + } + + /** + * Writes a chunk of data + * + * @param string $chunk + * @return void + */ + public function write($chunk) { + $this->target->write($chunk); + } + + /** @return void */ + public function flush() { + $this->target->flush(); + } + + /** @return void */ + public function finish() { + $this->target->finish(); + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/SAPI.class.php b/src/main/php/xp/web/SAPI.class.php index 19746e7d..abf7da7a 100755 --- a/src/main/php/xp/web/SAPI.class.php +++ b/src/main/php/xp/web/SAPI.class.php @@ -1,6 +1,6 @@ status= $status; - $this->message= $message; - $this->headers= $headers; - } - - public function write($bytes) { - $this->bytes.= $bytes; - } - - /** - * Drain this buffered output to a given output instance, closing it - * once finished. - * - * @param web.Response $res - * @return void - */ - public function drain($res) { - $res->answer($this->status, $this->message); - foreach ($this->headers as $name => $value) { - $res->header($name, $value); - } - - if ('' !== $this->bytes) { - $out= $res->stream($this->headers['Content-Length'][0] ?? null); - try { - $out->write($this->bytes); - } finally { - $out->close(); - } - } - } -} \ No newline at end of file diff --git a/src/main/php/xp/web/dev/CaptureOutput.class.php b/src/main/php/xp/web/dev/CaptureOutput.class.php new file mode 100755 index 00000000..001a9b58 --- /dev/null +++ b/src/main/php/xp/web/dev/CaptureOutput.class.php @@ -0,0 +1,71 @@ +length= $length; + return $this; + } + + /** + * Begins output + * + * @param int $status + * @param string $message + * @param [:string] $headers + * @return void + */ + public function begin($status, $message, $headers) { + $this->status= $status; + $this->message= $message; + $this->headers= $headers; + } + + /** + * Writes a chunk of data + * + * @param string $chunk + * @return void + */ + public function write($bytes) { + $this->bytes.= $bytes; + } + + /** + * Ensure response is flushed + * + * @param web.Response $response + * @return void + */ + public function end($response) { + if (-1 === $this->length) $response->flush(); + } + + /** + * Drain this buffered output to a given output instance, closing it + * once finished. + * + * @param web.Response $response + * @return void + */ + public function drain($response) { + $out= $response->output()->stream($this->length); + try { + $out->begin($this->status, $this->message, $this->headers); + $out->write($this->bytes); + } finally { + $out->close(); + } + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/dev/Console.class.php b/src/main/php/xp/web/dev/Console.class.php index 1d9beeb4..0b0088a9 100755 --- a/src/main/php/xp/web/dev/Console.class.php +++ b/src/main/php/xp/web/dev/Console.class.php @@ -1,6 +1,6 @@ proceed($req, $buffer); + yield from $invocation->proceed($req, $res->streaming(function($res, $length) use($capture) { + return $capture->length($length); + })); } finally { - $buffer->end(); + $capture->end($res); $debug= ob_get_clean(); + if (0 === strlen($debug)) return $capture->drain($res); } - $res->trace= $buffer->trace; - $out= $buffer->output(); - if (0 === strlen($debug)) { - $out->drain($res); - } else { - $res->status(200, 'Debug'); - $res->send(sprintf( - typeof($this)->getClassLoader()->getResource($this->template), - htmlspecialchars($debug), - $out->status, - htmlspecialchars($out->message), - $this->rows($out->headers), - htmlspecialchars($out->bytes) - )); + $console= sprintf( + typeof($this)->getClassLoader()->getResource($this->template), + htmlspecialchars($debug), + $capture->status, + htmlspecialchars($capture->message), + $this->rows($capture->headers), + htmlspecialchars($capture->bytes) + ); + $target= $res->output()->stream(strlen($console)); + try { + $target->begin(200, 'Debug', ['Content-Type' => ['text/html; charset=utf-8']]); + $target->write($console); + } finally { + $target->close(); } } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Output.class.php b/src/main/php/xp/web/srv/Output.class.php index 24287393..616b8ce9 100755 --- a/src/main/php/xp/web/srv/Output.class.php +++ b/src/main/php/xp/web/srv/Output.class.php @@ -1,7 +1,7 @@ version < '1.1' ? new Buffered($this) : new WriteChunks($this); + public function stream($length= null) { + if (null !== $length) { + return new WriteLength($this, $length); + } else if ($this->version >= '1.1') { + return new WriteChunks($this); + } else { + return new Buffered($this); + } } /** diff --git a/src/test/php/web/unittest/io/WriteLengthTest.class.php b/src/test/php/web/unittest/io/WriteLengthTest.class.php new file mode 100755 index 00000000..57876a69 --- /dev/null +++ b/src/test/php/web/unittest/io/WriteLengthTest.class.php @@ -0,0 +1,67 @@ +begin(204, 'No Content', []); + + Assert::equals('Content-Length: 0', $out->headers()); + } + + #[Test] + public function sets_content_length() { + $out= new TestOutput(); + + $w= new WriteLength($out, 4); + $w->begin(200, 'OK', []); + $w->write('Test'); + + Assert::equals('Content-Length: 4', $out->headers()); + } + + #[Test] + public function overwrites_content_length() { + $out= new TestOutput(); + + $w= new WriteLength($out, 4); + $w->begin(200, 'OK', ['Content-Length' => [6100]]); + $w->write('Test'); + + Assert::equals('Content-Length: 4', $out->headers()); + } + + #[Test] + public function write_one_chunk() { + $out= new TestOutput(); + + $w= new WriteLength($out, 4); + $w->begin(200, 'OK', []); + $w->write('Test'); + + Assert::equals('Test', $out->body()); + } + + #[Test] + public function write_two_small_chunks() { + $out= new TestOutput(); + + $w= new WriteLength($out, 8); + $w->begin(200, 'OK', []); + $w->write('Unit'); + $w->write('Test'); + + Assert::equals('UnitTest', $out->body()); + } +} \ No newline at end of file From 2041559f05b19c680f555b2c6e2dbced7ac9b550 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 9 Jun 2024 10:46:42 +0200 Subject: [PATCH 07/10] Adjust stream() documentation, use $length for parameter name --- src/main/php/web/Response.class.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/php/web/Response.class.php b/src/main/php/web/Response.class.php index e1dab0fc..8be33b50 100755 --- a/src/main/php/web/Response.class.php +++ b/src/main/php/web/Response.class.php @@ -170,20 +170,20 @@ public function end() { } /** - * Returns a stream to write on. By default, uses chunked transfer encoding if - * a length is passed, and sets the `Content-Length` header and writes directly - * to the raw underlying output otherwise. + * Returns a stream to write on: When given a length, sets the `Content-Length` + * header to this value and writes the subsequently given bytes unmodified to + * the output. Otheriwse, chunked transfer encoding is used. * - * @param int $size + * @param ?int $length * @return io.streams.OutputStream * @throws lang.IllegalStateException */ - public function stream($size= null) { + public function stream($length= null) { if ($this->flushed) { throw new IllegalStateException('Response already flushed'); } - return $this->begin($this->streaming ? ($this->streaming)($this, $size) : $this->output->stream($size)); + return $this->begin($this->streaming ? ($this->streaming)($this, $length) : $this->output->stream($length)); } /** From b1347cd0b85e1304e6ea93799ea8fe32ee9442fb Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 9 Jun 2024 10:48:02 +0200 Subject: [PATCH 08/10] Type-hint implementation parameter for streaming() --- src/main/php/web/Response.class.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/php/web/Response.class.php b/src/main/php/web/Response.class.php index 8be33b50..1a6a18f3 100755 --- a/src/main/php/web/Response.class.php +++ b/src/main/php/web/Response.class.php @@ -128,11 +128,11 @@ private function begin($output) { /** * Changes the implementation used inside `stream()` to determine the output. * - * @param function(self, ?int): web.io.Output $func + * @param function(self, ?int): web.io.Output $implementation * @return self */ - public function streaming($func) { - $this->streaming= $func; + public function streaming(callable $implementation) { + $this->streaming= $implementation; return $this; } From 4df76c4ff1eae5568a34d55504094ebe61727b36 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 9 Jun 2024 11:17:10 +0200 Subject: [PATCH 09/10] Render exceptions in developer console --- src/main/php/xp/web/dev/Console.class.php | 16 ++++++--- .../web/unittest/server/ConsoleTest.class.php | 35 ++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/main/php/xp/web/dev/Console.class.php b/src/main/php/xp/web/dev/Console.class.php index 0b0088a9..6763cc63 100755 --- a/src/main/php/xp/web/dev/Console.class.php +++ b/src/main/php/xp/web/dev/Console.class.php @@ -1,6 +1,8 @@ proceed($req, $res->streaming(function($res, $length) use($capture) { return $capture->length($length); })); - } finally { - $capture->end($res); $debug= ob_get_clean(); if (0 === strlen($debug)) return $capture->drain($res); + } catch (Any $e) { + $res->answer($e instanceof Error ? $e->status() : 500); + $debug= ob_get_clean()."\n".Throwable::wrap($e)->toString(); + } finally { + $capture->end($res); } $console= sprintf( @@ -67,7 +72,10 @@ public function filter($req, $res, $invocation) { ); $target= $res->output()->stream(strlen($console)); try { - $target->begin(200, 'Debug', ['Content-Type' => ['text/html; charset=utf-8']]); + $target->begin(200, 'Debug', [ + 'Content-Type' => ['text/html; charset='.\xp::ENCODING], + 'Cache-Control' => ['no-cache, no-store'], + ]); $target->write($console); } finally { $target->close(); diff --git a/src/test/php/web/unittest/server/ConsoleTest.class.php b/src/test/php/web/unittest/server/ConsoleTest.class.php index f216b9ee..f85c4999 100755 --- a/src/test/php/web/unittest/server/ConsoleTest.class.php +++ b/src/test/php/web/unittest/server/ConsoleTest.class.php @@ -1,8 +1,9 @@ output()->bytes() ); } + + #[Test] + public function uncaught_exceptions() { + $res= $this->handle(function($req, $res) { + throw new IllegalArgumentException('Test'); + }); + + Assert::matches( + '/HTTP\/1.1 500 Internal Server Error<\/span>/', + $res->output()->bytes() + ); + Assert::matches( + '/Exception lang.IllegalArgumentException \(Test\)/', + $res->output()->bytes() + ); + } + + #[Test] + public function uncaught_errors() { + $res= $this->handle(function($req, $res) { + throw new Error(404); + }); + + Assert::matches( + '/HTTP\/1.1 404 Not Found<\/span>/', + $res->output()->bytes() + ); + Assert::matches( + '/Error web.Error\(#404: Not Found\)/', + $res->output()->bytes() + ); + } } \ No newline at end of file From ca7d4fd65a78f7d427eaa67409e48a37ba9c1328 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 9 Jun 2024 12:16:34 +0200 Subject: [PATCH 10/10] Change from `sprintf()` for templating to a Mustache MVP --- src/main/php/xp/web/dev/Console.class.php | 54 ++++++++++++++--------- src/main/php/xp/web/dev/console.html | 14 +++--- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/main/php/xp/web/dev/Console.class.php b/src/main/php/xp/web/dev/Console.class.php index 6763cc63..5ecc879c 100755 --- a/src/main/php/xp/web/dev/Console.class.php +++ b/src/main/php/xp/web/dev/Console.class.php @@ -1,6 +1,6 @@ $value) { - $r.= ' - '.htmlspecialchars($name).' - '.htmlspecialchars(implode(', ', $value)).' - '; - } - return $r; + private function transform($template, $context) { + return preg_replace_callback( + '/\{\{([^ }]+) ?([^}]+)?\}\}/', + function($m) use($context) { + $value= $context[$m[1]] ?? ''; + return $value instanceof Closure ? $value($context[$m[2]] ?? '') : htmlspecialchars($value); + }, + $template + ); } /** @@ -53,23 +54,36 @@ public function filter($req, $res, $invocation) { yield from $invocation->proceed($req, $res->streaming(function($res, $length) use($capture) { return $capture->length($length); })); + + $kind= 'Debug'; $debug= ob_get_clean(); if (0 === strlen($debug)) return $capture->drain($res); } catch (Any $e) { + $kind= 'Error'; $res->answer($e instanceof Error ? $e->status() : 500); $debug= ob_get_clean()."\n".Throwable::wrap($e)->toString(); } finally { $capture->end($res); } - $console= sprintf( - typeof($this)->getClassLoader()->getResource($this->template), - htmlspecialchars($debug), - $capture->status, - htmlspecialchars($capture->message), - $this->rows($capture->headers), - htmlspecialchars($capture->bytes) - ); + $console= $this->transform(typeof($this)->getClassLoader()->getResource($this->template), [ + 'kind' => $kind, + 'debug' => $debug, + 'status' => $capture->status, + 'message' => $capture->message, + 'headers' => $capture->headers, + 'contents' => $capture->bytes, + '#rows' => function($headers) { + $r= ''; + foreach ($headers as $name => $value) { + $r.= ' + '.htmlspecialchars($name).' + '.htmlspecialchars(implode(', ', $value)).' + '; + } + return $r; + } + ]); $target= $res->output()->stream(strlen($console)); try { $target->begin(200, 'Debug', [ diff --git a/src/main/php/xp/web/dev/console.html b/src/main/php/xp/web/dev/console.html index 7ff8e308..e62d68bd 100755 --- a/src/main/php/xp/web/dev/console.html +++ b/src/main/php/xp/web/dev/console.html @@ -1,12 +1,12 @@ - Debug + {{kind}}