From 3d0599eb67ea4ab1b37933d87cab99d57715b8f5 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Sun, 20 Jul 2025 09:41:48 -0600 Subject: [PATCH] added JSON util to use elsewhere --- flight/Engine.php | 19 +- .../AiGenerateInstructionsCommand.php | 2 +- flight/util/Json.php | 114 +++++++++ tests/EngineTest.php | 3 +- tests/FlightTest.php | 2 +- tests/JsonTest.php | 221 ++++++++++++++++++ tests/ViewTest.php | 8 +- tests/commands/RouteCommandTest.php | 4 +- tests/server/index.php | 2 +- 9 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 flight/util/Json.php create mode 100644 tests/JsonTest.php 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())