Skip to content

Commit bd2a7c6

Browse files
committed
wip
1 parent 06be133 commit bd2a7c6

File tree

2 files changed

+125
-22
lines changed

2 files changed

+125
-22
lines changed

json-java21-jtd/src/main/java/json/java21/jtd/Jtd.java

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ public class Jtd {
1515

1616
private static final Logger LOG = Logger.getLogger(Jtd.class.getName());
1717

18+
/// Top-level definitions map for ref resolution
19+
private final Map<String, JsonValue> definitionValues = new java.util.HashMap<>();
20+
private final Map<String, JtdSchema> compiledDefinitions = new java.util.HashMap<>();
21+
1822
/// Stack frame for iterative validation with path and offset tracking
1923
record Frame(JtdSchema schema, JsonValue instance, String ptr, Crumbs crumbs) {}
2024

@@ -62,6 +66,13 @@ public Result validate(JsonValue schema, JsonValue instance) {
6266
LOG.fine(() -> "JTD validation - schema: " + schema + ", instance: " + instance);
6367

6468
try {
69+
// Clear previous definitions and extract top-level definitions
70+
definitionValues.clear();
71+
compiledDefinitions.clear();
72+
if (schema instanceof JsonObject obj) {
73+
extractTopLevelDefinitions(obj);
74+
}
75+
6576
JtdSchema jtdSchema = compileSchema(schema);
6677
Result result = validateWithStack(jtdSchema, instance);
6778

@@ -71,7 +82,9 @@ public Result validate(JsonValue schema, JsonValue instance) {
7182
return result;
7283
} catch (Exception e) {
7384
LOG.warning(() -> "JTD validation failed: " + e.getMessage());
74-
return Result.failure("Schema parsing failed: " + e.getMessage());
85+
String error = enrichedError("Schema parsing failed: " + e.getMessage(),
86+
new Frame(null, schema, "#", Crumbs.root()), schema);
87+
return Result.failure(error);
7588
}
7689
}
7790

@@ -317,20 +330,38 @@ JtdSchema compileRefSchema(JsonObject obj) {
317330
}
318331
String refName = str.value();
319332

320-
// Resolve reference against definitions
321-
JsonValue definitionsValue = members.get("definitions");
322-
if (!(definitionsValue instanceof JsonObject definitions)) {
323-
throw new IllegalArgumentException("ref requires definitions object at root level");
333+
// Look for definitions in the stored top-level definitions
334+
JsonValue definitionValue = definitionValues.get(refName);
335+
if (definitionValue == null) {
336+
// Fallback: check if definitions exist in current object (for backward compatibility)
337+
JsonValue definitionsValue = members.get("definitions");
338+
if (definitionsValue instanceof JsonObject definitions) {
339+
definitionValue = definitions.members().get(refName);
340+
}
341+
342+
if (definitionValue == null) {
343+
throw new IllegalArgumentException("ref '" + refName + "' not found in definitions");
344+
}
324345
}
325346

326-
JsonValue definitionValue = definitions.members().get(refName);
327-
if (definitionValue == null) {
328-
throw new IllegalArgumentException("ref '" + refName + "' not found in definitions");
347+
// Check for circular references and compile the referenced schema
348+
if (compiledDefinitions.containsKey(refName)) {
349+
// Already compiled, return cached version
350+
return compiledDefinitions.get(refName);
329351
}
330352

331-
// Compile the referenced schema
332-
JtdSchema resolvedSchema = compileSchema(definitionValue);
333-
return new JtdSchema.RefSchema(refName, resolvedSchema);
353+
// Mark as being compiled to handle circular references
354+
compiledDefinitions.put(refName, null); // placeholder
355+
356+
try {
357+
JtdSchema resolvedSchema = compileSchema(definitionValue);
358+
compiledDefinitions.put(refName, resolvedSchema);
359+
return new JtdSchema.RefSchema(refName, resolvedSchema);
360+
} catch (Exception e) {
361+
// Remove placeholder on error
362+
compiledDefinitions.remove(refName);
363+
throw e;
364+
}
334365
}
335366

336367
JtdSchema compileTypeSchema(JsonObject obj) {
@@ -374,7 +405,6 @@ JtdSchema compileElementsSchema(JsonObject obj) {
374405
JtdSchema compilePropertiesSchema(JsonObject obj) {
375406
Map<String, JtdSchema> properties = Map.of();
376407
Map<String, JtdSchema> optionalProperties = Map.of();
377-
boolean additionalProperties = true;
378408

379409
Map<String, JsonValue> members = obj.members();
380410

@@ -396,13 +426,17 @@ JtdSchema compilePropertiesSchema(JsonObject obj) {
396426
optionalProperties = parsePropertySchemas(optPropsObj);
397427
}
398428

399-
// Check additionalProperties
429+
// RFC 8927: additionalProperties defaults to false when properties or optionalProperties are defined
430+
boolean additionalProperties = false;
400431
if (members.containsKey("additionalProperties")) {
401432
JsonValue addPropsValue = members.get("additionalProperties");
402433
if (!(addPropsValue instanceof JsonBoolean bool)) {
403434
throw new IllegalArgumentException("additionalProperties must be a boolean");
404435
}
405436
additionalProperties = bool.value();
437+
} else if (properties.isEmpty() && optionalProperties.isEmpty()) {
438+
// Empty schema with no properties defined allows additional properties by default
439+
additionalProperties = true;
406440
}
407441

408442
return new JtdSchema.PropertiesSchema(properties, optionalProperties, additionalProperties);
@@ -437,6 +471,18 @@ JtdSchema compileDiscriminatorSchema(JsonObject obj) {
437471
return new JtdSchema.DiscriminatorSchema(discStr.value(), mapping);
438472
}
439473

474+
/// Extracts and stores top-level definitions for ref resolution
475+
void extractTopLevelDefinitions(JsonObject schema) {
476+
JsonValue definitionsValue = schema.members().get("definitions");
477+
if (definitionsValue instanceof JsonObject definitions) {
478+
for (String name : definitions.members().keySet()) {
479+
JsonValue definitionValue = definitions.members().get(name);
480+
definitionValues.put(name, definitionValue);
481+
LOG.fine(() -> "Extracted definition: " + name);
482+
}
483+
}
484+
}
485+
440486
private Map<String, JtdSchema> parsePropertySchemas(JsonObject propsObj) {
441487
Map<String, JtdSchema> schemas = new java.util.HashMap<>();
442488
for (String key : propsObj.members().keySet()) {

json-java21-jtd/src/main/java/json/java21/jtd/JtdSchema.java

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,24 +144,81 @@ Jtd.Result validateString(JsonValue instance, boolean verboseErrors) {
144144

145145
Jtd.Result validateTimestamp(JsonValue instance, boolean verboseErrors) {
146146
if (instance instanceof JsonString str) {
147-
try {
148-
// Parse using RFC 3339 format (ISO_OFFSET_DATE_TIME)
149-
OffsetDateTime.parse(str.value(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
147+
String timestamp = str.value();
148+
149+
// Use static functional validation for RFC 3339 with leap second support
150+
if (isValidRfc3339Timestamp(timestamp)) {
150151
return Jtd.Result.success();
151-
} catch (Exception e) {
152-
// Invalid RFC 3339 timestamp format
153-
String error = verboseErrors
154-
? Jtd.Error.EXPECTED_TIMESTAMP.message(instance, instance.getClass().getSimpleName())
155-
: Jtd.Error.EXPECTED_TIMESTAMP.message(instance.getClass().getSimpleName());
156-
return Jtd.Result.failure(error);
157152
}
153+
154+
// Invalid RFC 3339 timestamp format
155+
String error = verboseErrors
156+
? Jtd.Error.EXPECTED_TIMESTAMP.message(instance, instance.getClass().getSimpleName())
157+
: Jtd.Error.EXPECTED_TIMESTAMP.message(instance.getClass().getSimpleName());
158+
return Jtd.Result.failure(error);
158159
}
159160
String error = verboseErrors
160161
? Jtd.Error.EXPECTED_TIMESTAMP.message(instance, instance.getClass().getSimpleName())
161162
: Jtd.Error.EXPECTED_TIMESTAMP.message(instance.getClass().getSimpleName());
162163
return Jtd.Result.failure(error);
163164
}
164165

166+
/// Package-protected static validation for RFC 3339 timestamp format with leap second support
167+
/// RFC 3339 grammar: date-time = full-date "T" full-time
168+
/// Supports leap seconds (seconds = 60 when minutes = 59)
169+
static boolean isValidRfc3339Timestamp(String timestamp) {
170+
// RFC 3339 regex pattern with leap second support
171+
String rfc3339Pattern = "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$";
172+
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(rfc3339Pattern);
173+
java.util.regex.Matcher matcher = pattern.matcher(timestamp);
174+
175+
if (!matcher.matches()) {
176+
return false;
177+
}
178+
179+
try {
180+
int year = Integer.parseInt(matcher.group(1));
181+
int month = Integer.parseInt(matcher.group(2));
182+
int day = Integer.parseInt(matcher.group(3));
183+
int hour = Integer.parseInt(matcher.group(4));
184+
int minute = Integer.parseInt(matcher.group(5));
185+
int second = Integer.parseInt(matcher.group(6));
186+
187+
// Validate basic date/time components
188+
if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) {
189+
return false;
190+
}
191+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
192+
return false;
193+
}
194+
195+
// Handle leap seconds: seconds = 60 is valid only if minutes = 59
196+
if (second == 60) {
197+
if (minute != 59) {
198+
return false;
199+
}
200+
// For leap seconds, we accept the format but don't validate the specific date
201+
// This matches RFC 8927 behavior - format validation only
202+
return true;
203+
}
204+
205+
if (second < 0 || second > 59) {
206+
return false;
207+
}
208+
209+
// For normal timestamps, delegate to OffsetDateTime.parse for full validation
210+
try {
211+
OffsetDateTime.parse(timestamp, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
212+
return true;
213+
} catch (Exception e) {
214+
return false;
215+
}
216+
217+
} catch (NumberFormatException e) {
218+
return false;
219+
}
220+
}
221+
165222
Jtd.Result validateInteger(JsonValue instance, String type, boolean verboseErrors) {
166223
if (instance instanceof JsonNumber num) {
167224
Number value = num.toNumber();

0 commit comments

Comments
 (0)