diff --git a/flight/Engine.php b/flight/Engine.php
index 00bcbe1..c3e769e 100644
--- a/flight/Engine.php
+++ b/flight/Engine.php
@@ -14,6 +14,7 @@ use flight\net\Request;
use flight\net\Response;
use flight\net\Router;
use flight\template\View;
+use flight\util\Json;
use Throwable;
use flight\net\Route;
use Psr\Container\ContainerInterface;
@@ -522,11 +523,11 @@ class Engine
// not doing much here, just setting the requestHandled flag to true
$this->requestHandled = true;
- // Allow filters to run
- // This prevents multiple after events from being registered
- $this->after('start', function () use ($self) {
- $self->stop();
- });
+ // Allow filters to run
+ // This prevents multiple after events from being registered
+ $this->after('start', function () use ($self) {
+ $self->stop();
+ });
} else {
// deregister the request and response objects and re-register them with new instances
$this->unregister('request');
@@ -665,7 +666,7 @@ class Engine
500 Internal Server Error
%s (%s)
%s
- HTML,
+ HTML, // phpcs:ignore
$e->getMessage(),
$e->getCode(),
$e->getTraceAsString()
@@ -906,9 +907,7 @@ class Engine
?string $charset = 'utf-8',
int $option = 0
): void {
- // add some default flags
- $option |= JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR;
- $json = $encode ? json_encode($data, $option) : $data;
+ $json = $encode ? Json::encode($data, $option) : $data;
$this->response()
->status($code)
@@ -966,7 +965,7 @@ class Engine
string $charset = 'utf-8',
int $option = 0
): void {
- $json = $encode ? json_encode($data, $option) : $data;
+ $json = $encode ? Json::encode($data, $option) : $data;
$callback = $this->request()->query[$param];
$this->response()
diff --git a/flight/commands/AiGenerateInstructionsCommand.php b/flight/commands/AiGenerateInstructionsCommand.php
index 096d32b..90dd7cb 100644
--- a/flight/commands/AiGenerateInstructionsCommand.php
+++ b/flight/commands/AiGenerateInstructionsCommand.php
@@ -85,7 +85,7 @@ class AiGenerateInstructionsCommand extends Command
$detailsText
Current instructions:
$context
- EOT;
+ EOT; // phpcs:ignore
// Read LLM creds
$creds = json_decode(file_get_contents($runwayCredsFile), true);
diff --git a/flight/util/Json.php b/flight/util/Json.php
new file mode 100644
index 0000000..2d5e8b4
--- /dev/null
+++ b/flight/util/Json.php
@@ -0,0 +1,114 @@
+getMessage(), $e->getCode(), $e);
+ }
+ }
+
+ /**
+ * Decodes JSON string to PHP data.
+ *
+ * @param string $json JSON string to decode
+ * @param bool $associative Whether to return associative arrays instead of objects
+ * @param int $depth Maximum decoding depth
+ * @param int $options JSON decoding options (bitmask)
+ *
+ * @return mixed Decoded data
+ * @throws Exception If decoding fails
+ */
+ public static function decode(string $json, bool $associative = false, int $depth = 512, int $options = self::DEFAULT_DECODE_OPTIONS)
+ {
+ $options = $options | self::DEFAULT_DECODE_OPTIONS; // Ensure default options are applied
+ try {
+ return json_decode($json, $associative, $depth, $options);
+ } catch (JsonException $e) {
+ throw new JsonException('JSON decoding failed: ' . $e->getMessage(), $e->getCode(), $e);
+ }
+ }
+
+ /**
+ * Checks if a string is valid JSON.
+ *
+ * @param string $json String to validate
+ *
+ * @return bool True if valid JSON, false otherwise
+ */
+ public static function isValid(string $json): bool
+ {
+ try {
+ json_decode($json, false, 512, JSON_THROW_ON_ERROR);
+ return true;
+ } catch (JsonException $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Gets the last JSON error message.
+ *
+ * @return string Error message or empty string if no error
+ */
+ public static function getLastError(): string
+ {
+ $error = json_last_error();
+ if ($error === JSON_ERROR_NONE) {
+ return '';
+ }
+ return json_last_error_msg();
+ }
+
+ /**
+ * Pretty prints JSON data.
+ *
+ * @param mixed $data Data to encode
+ * @param int $additionalOptions Additional options to merge with pretty print
+ *
+ * @return string Pretty formatted JSON string
+ * @throws Exception If encoding fails
+ */
+ public static function prettyPrint($data, int $additionalOptions = 0): string
+ {
+ $options = self::DEFAULT_ENCODE_OPTIONS | JSON_PRETTY_PRINT | $additionalOptions;
+ return self::encode($data, $options);
+ }
+}
diff --git a/tests/EngineTest.php b/tests/EngineTest.php
index 07b8cfc..1b553ae 100644
--- a/tests/EngineTest.php
+++ b/tests/EngineTest.php
@@ -387,8 +387,9 @@ class EngineTest extends TestCase
public function testJsonWithDuplicateDefaultFlags()
{
$engine = new Engine();
+ $flags = JSON_HEX_TAG | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
// utf8 emoji
- $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => '😀'], 201, true, '', JSON_HEX_TAG | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ $engine->json(['key1' => 'value1', 'key2' => 'value2', 'utf8_emoji' => '😀'], 201, true, '', $flags);
$this->assertEquals('application/json', $engine->response()->headers()['Content-Type']);
$this->assertEquals(201, $engine->response()->status());
$this->assertEquals('{"key1":"value1","key2":"value2","utf8_emoji":"😀"}', $engine->response()->getBody());
diff --git a/tests/FlightTest.php b/tests/FlightTest.php
index 82a620b..5b0b516 100644
--- a/tests/FlightTest.php
+++ b/tests/FlightTest.php
@@ -399,7 +399,7 @@ class FlightTest extends TestCase
Hi
- html;
+ html; // phpcs:ignore
$html = str_replace(["\n", "\r"], '', $html);
diff --git a/tests/JsonTest.php b/tests/JsonTest.php
new file mode 100644
index 0000000..af833fc
--- /dev/null
+++ b/tests/JsonTest.php
@@ -0,0 +1,221 @@
+ 'error']);
+ }
+
+ // Test basic encoding
+ public function testEncode(): void
+ {
+ $data = ['name' => 'John', 'age' => 30];
+ $result = Json::encode($data);
+ $this->assertIsString($result);
+ $this->assertJson($result);
+ }
+
+ // Test encoding with custom options
+ public function testEncodeWithOptions(): void
+ {
+ $data = ['url' => 'https://example.com/path'];
+ $result = Json::encode($data, JSON_UNESCAPED_SLASHES);
+ $this->assertStringContainsString('https://example.com/path', $result);
+ }
+
+ // Test encoding with invalid data
+ public function testEncodeInvalidData(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('JSON encoding failed');
+
+ // Create a resource that cannot be encoded
+ $resource = fopen('php://memory', 'r');
+ Json::encode($resource);
+ fclose($resource);
+ }
+
+ // Test basic decoding
+ public function testDecode(): void
+ {
+ $json = '{"name":"John","age":30}';
+ $result = Json::decode($json);
+ $this->assertIsObject($result);
+ $this->assertEquals('John', $result->name);
+ $this->assertEquals(30, $result->age);
+ }
+
+ // Test decoding to associative array
+ public function testDecodeAssociative(): void
+ {
+ $json = '{"name":"John","age":30}';
+ $result = Json::decode($json, true);
+ $this->assertIsArray($result);
+ $this->assertEquals('John', $result['name']);
+ $this->assertEquals(30, $result['age']);
+ }
+
+ // Test decoding with custom depth
+ public function testDecodeWithDepth(): void
+ {
+ $json = '{"level1":{"level2":{"level3":"value"}}}';
+ $result = Json::decode($json, true, 512);
+ $this->assertEquals('value', $result['level1']['level2']['level3']);
+ }
+
+ // Test decoding invalid JSON
+ public function testDecodeInvalidJson(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('JSON decoding failed');
+
+ Json::decode('{"invalid": json}');
+ }
+
+ // Test JSON validation with valid JSON
+ public function testIsValidWithValidJson(): void
+ {
+ $validJson = '{"name":"John","age":30}';
+ $this->assertTrue(Json::isValid($validJson));
+ }
+
+ // Test JSON validation with invalid JSON
+ public function testIsValidWithInvalidJson(): void
+ {
+ $invalidJson = '{"invalid": json}';
+ $this->assertFalse(Json::isValid($invalidJson));
+ }
+
+ // Test JSON validation with empty string
+ public function testIsValidWithEmptyString(): void
+ {
+ $this->assertFalse(Json::isValid(''));
+ }
+
+ // Test pretty print functionality
+ public function testPrettyPrint(): void
+ {
+ $data = ['name' => 'John', 'age' => 30];
+ $result = Json::prettyPrint($data);
+ $this->assertStringContainsString("\n", $result);
+ $this->assertStringContainsString(' ', $result); // Should contain indentation
+ }
+
+ // Test pretty print with additional options
+ public function testPrettyPrintWithAdditionalOptions(): void
+ {
+ $data = ['html' => ''];
+ $result = Json::prettyPrint($data, JSON_HEX_TAG);
+ $this->assertStringContainsString('\u003C', $result); // Should escape < character
+ }
+
+ // Test getLastError when no error
+ public function testGetLastErrorNoError(): void
+ {
+ // Perform a valid JSON operation first
+ Json::encode(['valid' => 'data']);
+ $this->assertEquals('', Json::getLastError());
+ }
+
+ // Test getLastError when there is an error
+ public function testGetLastErrorWithError(): void
+ {
+ // Trigger a JSON error by using json_decode directly with invalid JSON
+ // This bypasses our Json class exception handling to test getLastError()
+ json_decode('{"invalid": json}');
+
+ $errorMessage = Json::getLastError();
+ $this->assertNotEmpty($errorMessage);
+ $this->assertIsString($errorMessage);
+ }
+
+ // Test encoding arrays
+ public function testEncodeArray(): void
+ {
+ $data = [1, 2, 3, 'four'];
+ $result = Json::encode($data);
+ $this->assertEquals('[1,2,3,"four"]', $result);
+ }
+
+ // Test encoding null
+ public function testEncodeNull(): void
+ {
+ $result = Json::encode(null);
+ $this->assertEquals('null', $result);
+ }
+
+ // Test encoding boolean values
+ public function testEncodeBoolean(): void
+ {
+ $this->assertEquals('true', Json::encode(true));
+ $this->assertEquals('false', Json::encode(false));
+ }
+
+ // Test encoding strings
+ public function testEncodeString(): void
+ {
+ $result = Json::encode('Hello World');
+ $this->assertEquals('"Hello World"', $result);
+ }
+
+ // Test encoding numbers
+ public function testEncodeNumbers(): void
+ {
+ $this->assertEquals('42', Json::encode(42));
+ $this->assertEquals('3.14', Json::encode(3.14));
+ }
+
+ // Test decoding arrays
+ public function testDecodeArray(): void
+ {
+ $json = '[1,2,3,"four"]';
+ $result = Json::decode($json, true);
+ $this->assertEquals([1, 2, 3, 'four'], $result);
+ }
+
+ // Test decoding nested objects
+ public function testDecodeNestedObjects(): void
+ {
+ $json = '{"user":{"name":"John","profile":{"age":30}}}';
+ $result = Json::decode($json, true);
+ $this->assertEquals('John', $result['user']['name']);
+ $this->assertEquals(30, $result['user']['profile']['age']);
+ }
+
+ // Test default encoding options are applied
+ public function testDefaultEncodingOptions(): void
+ {
+ $data = ['url' => 'https://example.com/path'];
+ $result = Json::encode($data);
+ // Should not escape slashes due to JSON_UNESCAPED_SLASHES
+ $this->assertStringContainsString('https://example.com/path', $result);
+ }
+
+ // Test round trip encoding/decoding
+ public function testRoundTrip(): void
+ {
+ $original = [
+ 'string' => 'test',
+ 'number' => 42,
+ 'boolean' => true,
+ 'null' => null,
+ 'array' => [1, 2, 3],
+ 'object' => ['nested' => 'value']
+ ];
+
+ $encoded = Json::encode($original);
+ $decoded = Json::decode($encoded, true);
+
+ $this->assertEquals($original, $decoded);
+ }
+}
diff --git a/tests/ViewTest.php b/tests/ViewTest.php
index 736d0b4..ddbf32b 100644
--- a/tests/ViewTest.php
+++ b/tests/ViewTest.php
@@ -180,7 +180,7 @@ class ViewTest extends TestCase
Hi
- html;
+ html; // phpcs:ignore
// if windows replace \n with \r\n
$html = str_replace(["\n", "\r"], '', $html);
@@ -202,7 +202,7 @@ class ViewTest extends TestCase
$html = <<<'html'
qux
bar
- html;
+ html; // phpcs:ignore
$html = str_replace(["\n", "\r"], '', $html);
@@ -217,12 +217,12 @@ class ViewTest extends TestCase
$html1 = <<<'html'
Hi
- html;
+ html; // phpcs:ignore
$html2 = <<<'html'
- html;
+ html; // phpcs:ignore
$html1 = str_replace(["\n", "\r"], '', $html1);
$html2 = str_replace(["\n", "\r"], '', $html2);
diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php
index b4f2291..7bcc123 100644
--- a/tests/commands/RouteCommandTest.php
+++ b/tests/commands/RouteCommandTest.php
@@ -115,7 +115,7 @@ PHP;
| /put | PUT | | No | - |
| /patch | PATCH | | No | Bad Middleware |
+---------+-----------+-------+----------+----------------+
- output;
+ output; // phpcs:ignore
$this->assertStringContainsString(
$expected,
@@ -138,7 +138,7 @@ PHP;
+---------+---------+-------+----------+------------+
| /post | POST | | No | Closure |
+---------+---------+-------+----------+------------+
- output;
+ output; // phpcs:ignore
$this->assertStringContainsString(
$expected,
diff --git a/tests/server/index.php b/tests/server/index.php
index a4cd289..bca95f7 100644
--- a/tests/server/index.php
+++ b/tests/server/index.php
@@ -190,7 +190,7 @@ Flight::map('error', function (Throwable $e) {
500 Internal Server Error
%s (%s)
%s
- HTML,
+ HTML, // phpcs:ignore
$e->getMessage(),
$e->getCode(),
str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString())