Skip to content

Commit 7b9ea0e

Browse files
committed
feat: Improving on format keyword
1 parent edcdde2 commit 7b9ea0e

File tree

4 files changed

+90
-14
lines changed

4 files changed

+90
-14
lines changed

src/JsonSchema/ConstraintError.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class ConstraintError extends Enum
2828
public const FORMAT_EMAIL = 'emailFormat';
2929
public const FORMAT_HOSTNAME = 'styleHostName';
3030
public const FORMAT_IP = 'ipFormat';
31+
public const FORMAT_JSON_POINTER = 'jsonPointerFormat';
3132
public const FORMAT_PHONE = 'phoneFormat';
3233
public const FORMAT_REGEX= 'regexFormat';
3334
public const FORMAT_STYLE = 'styleFormat';
@@ -84,6 +85,7 @@ public function getMessage()
8485
self::FORMAT_EMAIL => 'Invalid email',
8586
self::FORMAT_HOSTNAME => 'Invalid hostname',
8687
self::FORMAT_IP => 'Invalid IP address',
88+
self::FORMAT_JSON_POINTER => 'Invalid JSON pointer',
8789
self::FORMAT_PHONE => 'Invalid phone number',
8890
self::FORMAT_REGEX=> 'Invalid regex format %s',
8991
self::FORMAT_STYLE => 'Invalid style',

src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n
4444
}
4545
break;
4646
case 'date-time':
47-
if (Rfc3339::createFromString($value) === null) {
47+
if (!$this->validateRfc3339DateTime($value)) {
4848
$this->addError(ConstraintError::FORMAT_DATE_TIME(), $path, ['dateTime' => $value, 'format' => $schema->format]);
4949
}
5050
break;
@@ -108,6 +108,12 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n
108108
$this->addError(ConstraintError::FORMAT_HOSTNAME(), $path, ['format' => $schema->format]);
109109
}
110110
break;
111+
case 'json-pointer':
112+
if (!$this->validateJsonPointer($value)) {
113+
$this->addError(ConstraintError::FORMAT_JSON_POINTER(), $path, ['format' => $schema->format]);
114+
}
115+
break;
116+
break;
111117
default:
112118
break;
113119
}
@@ -167,4 +173,36 @@ private function validateHostname(string $host): bool
167173

168174
return preg_match($hostnameRegex, $host) !== false;
169175
}
176+
177+
private function validateJsonPointer(string $value): bool
178+
{
179+
// Must be empty or start with a forward slash
180+
if ($value !== '' && $value[0] !== '/') {
181+
return false;
182+
}
183+
184+
// Split into reference tokens and check for invalid escape sequences
185+
$tokens = explode('/', $value);
186+
array_shift($tokens); // remove leading empty part due to leading slash
187+
188+
foreach ($tokens as $token) {
189+
// "~" must only be followed by "0" or "1"
190+
if (preg_match('/~(?![01])/', $token)) {
191+
return false;
192+
}
193+
}
194+
195+
return true;
196+
}
197+
198+
private function validateRfc3339DateTime(string $value): bool
199+
{
200+
$dateTime = Rfc3339::createFromString($value);
201+
if (is_null($dateTime)) {
202+
return false;
203+
}
204+
205+
// Compare value and date result to be equal
206+
return true;
207+
}
170208
}

src/JsonSchema/Rfc3339.php

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,51 @@
66

77
class Rfc3339
88
{
9-
private const REGEX = '/^(\d{4}-\d{2}-\d{2}[T ]{1}\d{2}:\d{2}:\d{2})(\.\d+)?(Z|([+-]\d{2}):?(\d{2}))$/';
9+
private const REGEX = '/^(\d{4}-\d{2}-\d{2}[T ](0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):((?:[0-5][0-9]|60)))(\.\d+)?(Z|([+-](0[0-9]|1[0-9]|2[0-3])):([0-5][0-9]))$/';
1010

1111
/**
1212
* Try creating a DateTime instance
1313
*
14-
* @param string $string
14+
* @param string $input
1515
*
1616
* @return \DateTime|null
1717
*/
18-
public static function createFromString($string)
18+
public static function createFromString($input): ?\DateTime
1919
{
20-
if (!preg_match(self::REGEX, strtoupper($string), $matches)) {
20+
if (!preg_match(self::REGEX, strtoupper($input), $matches)) {
2121
return null;
2222
}
2323

24+
$input = strtoupper($input); // Cleanup for lowercase t and z
25+
$inputHasTSeparator = strpos($input, 'T');
26+
2427
$dateAndTime = $matches[1];
25-
$microseconds = $matches[2] ?: '.000000';
26-
$timeZone = 'Z' !== $matches[3] ? $matches[4] . ':' . $matches[5] : '+00:00';
27-
$dateFormat = strpos($dateAndTime, 'T') === false ? 'Y-m-d H:i:s.uP' : 'Y-m-d\TH:i:s.uP';
28-
$dateTime = \DateTime::createFromFormat($dateFormat, $dateAndTime . $microseconds . $timeZone, new \DateTimeZone('UTC'));
28+
$microseconds = $matches[5] ?: '.000000';
29+
$timeZone = 'Z' !== $matches[6] ? $matches[6] : '+00:00';
30+
$dateFormat = $inputHasTSeparator === false ? 'Y-m-d H:i:s.uP' : 'Y-m-d\TH:i:s.uP';
31+
$dateTime = \DateTimeImmutable::createFromFormat($dateFormat, $dateAndTime . $microseconds . $timeZone, new \DateTimeZone('UTC'));
32+
33+
if ($dateTime === false) {
34+
return null;
35+
}
36+
37+
$utcDateTime = $dateTime->setTimezone(new \DateTimeZone('+00:00'));
38+
$oneSecond = new \DateInterval('PT1S');
39+
40+
// handle leap seconds
41+
if ($matches[4] === '60' && $utcDateTime->sub($oneSecond)->format('H:i:s') === '23:59:59') {
42+
$dateTime = $dateTime->sub($oneSecond);
43+
$matches[1] = str_replace(':60', ':59', $matches[1]);
44+
}
45+
46+
// Ensure we still have the same year, month, day, hour, minutes and seconds to ensure no rollover took place.
47+
if ($dateTime->format($inputHasTSeparator ? 'Y-m-d\TH:i:s' : 'Y-m-d H:i:s') !== $matches[1]) {
48+
return null;
49+
}
50+
51+
$mutable = \DateTime::createFromFormat('U.u', $dateTime->format('U.u'));
52+
$mutable->setTimezone($dateTime->getTimezone());
2953

30-
return $dateTime ?: null;
54+
return $mutable;
3155
}
3256
}

src/JsonSchema/Tool/Validator/UriValidator.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ public static function isValid(string $uri): bool
1919
(\#(.*))? # Optional fragment
2020
$/ix';
2121

22-
// RFC 3986: Non-Hierarchical URIs (mailto, data, urn)
22+
// RFC 3986: Non-Hierarchical URIs (mailto, data, urn, news)
2323
$nonHierarchicalPattern = '/^
24-
(mailto|data|urn): # Only allow known non-hierarchical schemes
25-
(.+) # Must contain at least one character after scheme
24+
(mailto|data|urn|news|tel): # Only allow known non-hierarchical schemes
25+
(.+) # Must contain at least one character after scheme
2626
$/ix';
2727

28+
// Validation for newsgroup name (alphanumeric + dots, no empty segments)
29+
$newsGroupPattern = '/^[a-z0-9]+(\.[a-z0-9]+)*$/i';
30+
$telPattern = '/^\+?[0-9.\-() ]+$/'; // Allows +, digits, separators
31+
2832
// RFC 5322-compliant email validation for `mailto:` URIs
2933
$emailPattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/';
3034

@@ -40,7 +44,7 @@ public static function isValid(string $uri): bool
4044
return false;
4145
}
4246

43-
// Validate path (reject illegal characters: < > { } | \ ^ `)
47+
// Validate the path (reject illegal characters: < > { } | \ ^ `)
4448
if (!empty($matches[6]) && preg_match('/[<>{}|\\\^`]/', $matches[6])) {
4549
return false;
4650
}
@@ -57,6 +61,14 @@ public static function isValid(string $uri): bool
5761
return preg_match($emailPattern, $matches[2]) === 1;
5862
}
5963

64+
if ($scheme === 'news') {
65+
return preg_match($newsGroupPattern, $matches[2]) === 1;
66+
}
67+
68+
if ($scheme === 'tel') {
69+
return preg_match($telPattern, $matches[2]) === 1;
70+
}
71+
6072
return true; // Valid non-hierarchical URI
6173
}
6274

0 commit comments

Comments
 (0)