Skip to content

Commit d357396

Browse files
committed
Add Json and JsonStr types with validation, parsing methods, and tests
- Introduced `Json` class for strict validation of JSON strings with methods for object and array conversion. - Added `JsonStr` alias for ease of usage and improved code readability. - Implemented comprehensive unit tests to ensure correct behavior, including exception handling for invalid JSON. - Refactored `README.md` for better formatting and structure. - Updated `typeUsageTest.php` with demonstrations of `Json` and `JsonStr` usage.
1 parent 3f8c949 commit d357396

File tree

6 files changed

+195
-4
lines changed

6 files changed

+195
-4
lines changed

README.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ composer require georgii-web/php-typed-values
1616

1717
## Usage
1818

19-
2019
#### 1. Use existing typed values with validation built in:
2120

2221
```php
@@ -40,7 +39,7 @@ readonly class Id extends PositiveInt {}
4039
Id::fromInt(123);
4140
```
4241

43-
#### 3. Create a composite value object from other typed values (nullable values example):
42+
#### 3. Create a composite value object:
4443

4544
```php
4645
final class Profile
@@ -77,7 +76,6 @@ $profile = Profile::fromScalars(...[157, 'Tom', null]);
7776
$profile->getHeight(); // "172.5 \ Undefined" type class
7877
```
7978

80-
8179
## Key Features
8280

8381
- **Static analysis** – Designed for tools like Psalm and PHPStan with precise type annotations.
@@ -92,7 +90,6 @@ $profile->getHeight(); // "172.5 \ Undefined" type class
9290
See [docs/USAGE.md](docs/USAGE.md) for usage examples.
9391
See [docs/DEVELOP.md](docs/DEVELOP.md) for development details.
9492

95-
9693
## License
9794

9895
MIT
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\Exception;
6+
7+
class JsonTypeException extends TypeException
8+
{
9+
}

src/String/Alias/JsonStr.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\String\Alias;
6+
7+
use PhpTypedValues\String\Json;
8+
9+
/**
10+
* @psalm-immutable
11+
*/
12+
readonly class JsonStr extends Json
13+
{
14+
}

src/String/Json.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\String;
6+
7+
use const JSON_THROW_ON_ERROR;
8+
9+
use JsonException;
10+
use PhpTypedValues\Abstract\String\StrType;
11+
use PhpTypedValues\Exception\JsonTypeException;
12+
13+
use function json_decode;
14+
use function sprintf;
15+
16+
/**
17+
* Represents a valid JSON text.
18+
*
19+
* Example '{"a":1}'
20+
*
21+
* @psalm-immutable
22+
*/
23+
readonly class Json extends StrType
24+
{
25+
protected string $value;
26+
27+
/**
28+
* @throws JsonTypeException
29+
*/
30+
public function __construct(string $value)
31+
{
32+
self::assertJsonString($value);
33+
34+
$this->value = $value;
35+
}
36+
37+
/**
38+
* @throws JsonTypeException
39+
*/
40+
public static function fromString(string $value): static
41+
{
42+
return new static($value);
43+
}
44+
45+
public function value(): string
46+
{
47+
return $this->value;
48+
}
49+
50+
/**
51+
* @throws JsonException
52+
*/
53+
public function toObject(): object
54+
{
55+
// Use named arguments and omit depth literal to avoid integer-mutation issues
56+
return json_decode(json: $this->value, associative: false, flags: JSON_THROW_ON_ERROR);
57+
}
58+
59+
/**
60+
* @throws JsonException
61+
*/
62+
public function toArray(): array
63+
{
64+
// Use named arguments and omit depth literal to avoid integer-mutation issues
65+
return json_decode(json: $this->value, associative: true, flags: JSON_THROW_ON_ERROR);
66+
}
67+
68+
/**
69+
* @throws JsonTypeException
70+
*/
71+
protected static function assertJsonString(string $value): void
72+
{
73+
try {
74+
/**
75+
* Only validate; ignore the decoded result. Exceptions signal invalid JSON.
76+
*
77+
* @psalm-suppress UnusedFunctionCall
78+
*/
79+
json_decode(json: $value, flags: JSON_THROW_ON_ERROR);
80+
} catch (JsonException $e) {
81+
throw new JsonTypeException(sprintf('String "%s" has no valid JSON value', $value), 0, $e);
82+
}
83+
}
84+
}

src/typeUsageTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@
3333
use PhpTypedValues\Integer\IntegerStandard;
3434
use PhpTypedValues\Integer\IntegerWeekDay;
3535
use PhpTypedValues\Integer\MariaDb\IntTiny;
36+
use PhpTypedValues\String\Alias\JsonStr;
3637
use PhpTypedValues\String\Alias\NonEmptyStr;
3738
use PhpTypedValues\String\Alias\Str;
3839
use PhpTypedValues\String\Alias\StrType;
40+
use PhpTypedValues\String\Json;
3941
use PhpTypedValues\String\StringNonEmpty;
4042
use PhpTypedValues\String\StringStandard;
4143
use PhpTypedValues\Undefined\Alias\NotExist;
@@ -119,6 +121,10 @@
119121
$tsVo = TimestampMilliseconds::fromString('1735787045123');
120122
echo TimestampMilliseconds::fromDateTime($tsVo->value())->toString() . \PHP_EOL;
121123

124+
// JSON
125+
echo json_encode(JsonStr::fromString('{"a": 1, "b": "hi"}')->toArray(), \JSON_THROW_ON_ERROR);
126+
echo json_encode(Json::fromString('{"a": 1, "b": "hi"}')->toObject(), \JSON_THROW_ON_ERROR);
127+
122128
// Undefined
123129
try {
124130
UndefinedStandard::create()->toString();

tests/Unit/String/JsonTest.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpTypedValues\Exception\JsonTypeException;
6+
use PhpTypedValues\String\Json;
7+
8+
it('constructs valid JSON via constructor', function (): void {
9+
$json = new Json('{"a":1,"b":[true,null,3.14]}');
10+
expect($json->value())->toBe('{"a":1,"b":[true,null,3.14]}')
11+
->and($json->toString())->toBe('{"a":1,"b":[true,null,3.14]}');
12+
});
13+
14+
it('creates from string factory with valid JSON', function (): void {
15+
$json = Json::fromString('"hello"');
16+
expect($json->value())->toBe('"hello"');
17+
});
18+
19+
it('accepts top-level number/boolean/null JSON', function (): void {
20+
$n = Json::fromString('123');
21+
$t = Json::fromString('true');
22+
$nul = Json::fromString('null');
23+
expect($n->toString())->toBe('123')
24+
->and($t->value())->toBe('true')
25+
->and($nul->toString())->toBe('null');
26+
});
27+
28+
it('throws on empty string', function (): void {
29+
expect(fn() => new Json(''))
30+
->toThrow(JsonTypeException::class, 'String "" has no valid JSON value');
31+
});
32+
33+
it('throws on invalid JSON string via constructor', function (): void {
34+
expect(fn() => new Json('{a:1}'))
35+
->toThrow(JsonTypeException::class, 'String "{a:1}" has no valid JSON value');
36+
});
37+
38+
it('throws on invalid JSON string via fromString', function (): void {
39+
expect(fn() => Json::fromString('{"a": }'))
40+
->toThrow(JsonTypeException::class, 'String "{"a": }" has no valid JSON value');
41+
});
42+
43+
it('decodes to stdClass via toObject', function (): void {
44+
$json = Json::fromString('{"name":"Alice","age":30,"flags":[true,false]}');
45+
$obj = $json->toObject();
46+
47+
expect($obj)->toBeObject()
48+
->and($obj->name)->toBe('Alice')
49+
->and($obj->age)->toBe(30)
50+
->and($obj->flags)->toBeArray()
51+
->and($obj->flags)->toEqual([true, false]);
52+
});
53+
54+
it('decodes to associative array via toArray', function (): void {
55+
$json = Json::fromString('{"a":1,"b":{"c":[1,2,3]},"d":null}');
56+
$arr = $json->toArray();
57+
58+
expect($arr)->toBeArray()
59+
->and($arr['a'])->toBe(1)
60+
->and($arr['b'])->toBeArray()
61+
->and($arr['b']['c'])->toEqual([1, 2, 3])
62+
->and($arr['d'])->toBeNull();
63+
});
64+
65+
it('exception code is 0 for invalid JSON via constructor', function (): void {
66+
try {
67+
new Json('{a:1}');
68+
expect()->fail('Expected exception not thrown');
69+
} catch (JsonTypeException $e) {
70+
expect($e->getCode())->toBe(0);
71+
}
72+
});
73+
74+
it('exception code is 0 for invalid JSON via fromString', function (): void {
75+
try {
76+
Json::fromString('{"a": }');
77+
expect()->fail('Expected exception not thrown');
78+
} catch (JsonTypeException $e) {
79+
expect($e->getCode())->toBe(0);
80+
}
81+
});

0 commit comments

Comments
 (0)