diff --git a/CHANGELOG.md b/CHANGELOG.md index 95887875..d3193f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- JSON Schema `pattern` violation reasons are now identical in every target: + `must match pattern "^[a-z]+$", got "AB1"`, with the authored pattern (not + Java's and Python's `$`→`\z` / `\Z` end-anchor rewrite) and the offending + value each quoted as a JSON string. Java previously printed both bare and Go + quoted with `%q`. A `propertyNames` pattern failure reads + `invalid property name "Bad": must match pattern "^[a-z]+$"` everywhere + (Java dropped the prefix and TypeScript appended `, got …`), and every + `invalid property name` prefix now JSON-quotes the key. Generated Java + `Violation` gains a public `quote(String)` helper. - Operation-free WIT interfaces now honor `@nexus.namespace`, and .NET names their namespace after the interface (`Nexgen.`) instead of falling back to `Nexgen.Generated`. diff --git a/advanced/samples/go/json_schema/api/chat/definitions.go b/advanced/samples/go/json_schema/api/chat/definitions.go index d317d3f3..67fab20e 100644 --- a/advanced/samples/go/json_schema/api/chat/definitions.go +++ b/advanced/samples/go/json_schema/api/chat/definitions.go @@ -45,6 +45,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/advanced/samples/go/json_schema/api/kb/definitions.go b/advanced/samples/go/json_schema/api/kb/definitions.go index efcc2f49..b9f61d5b 100644 --- a/advanced/samples/go/json_schema/api/kb/definitions.go +++ b/advanced/samples/go/json_schema/api/kb/definitions.go @@ -45,6 +45,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/advanced/samples/go/json_schema/api/showcase/definitions.go b/advanced/samples/go/json_schema/api/showcase/definitions.go index 02b73cd5..a53faecd 100644 --- a/advanced/samples/go/json_schema/api/showcase/definitions.go +++ b/advanced/samples/go/json_schema/api/showcase/definitions.go @@ -49,6 +49,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/advanced/samples/go/json_schema/api/showcase/showcase.go b/advanced/samples/go/json_schema/api/showcase/showcase.go index 60153b78..6b8e34c3 100644 --- a/advanced/samples/go/json_schema/api/showcase/showcase.go +++ b/advanced/samples/go/json_schema/api/showcase/showcase.go @@ -585,7 +585,7 @@ func (ShowcaseMeasurementsString) isShowcaseMeasurements() {} func (v ShowcaseMeasurementsString) Validate() error { var errs []Violation if !_nexgenJsonSchemaPattern5e5b612d7a5d2b24.MatchString(string(v)) { - errs = append(errs, Violation{"", fmt.Sprintf("must match pattern %q, got %q", "^[a-z]+$", string(v))}) + errs = append(errs, Violation{"", "must match pattern \"^[a-z]+$\", got " + quoteValue(string(v))}) } if len(errs) > 0 { return newPayloadValidationError(errs) @@ -1260,7 +1260,7 @@ func (m Attributes) Validate() error { } for k := range m.AdditionalProperties { if n := utf8.RuneCountInString(k); n > 8 { - errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %q: must have length <= 8, got %d", k, n)}) + errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %s: must have length <= 8, got %d", quoteValue(k), n)}) } } if len(errs) > 0 { @@ -1292,7 +1292,7 @@ func (m *Attributes) UnmarshalJSON(data []byte) error { } for k := range raw { if n := utf8.RuneCountInString(k); n > 8 { - errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %q: must have length <= 8, got %d", k, n)}) + errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %s: must have length <= 8, got %d", quoteValue(k), n)}) } } if len(errs) > 0 { @@ -2679,12 +2679,12 @@ func (m Showcase) Validate() error { } if m.Sku != nil { if !_nexgenJsonSchemaPattern5e5b412d5a5d7b322c347d24.MatchString(*m.Sku) { - errs = append(errs, Violation{"sku", fmt.Sprintf("must match pattern %q, got %q", "^[A-Z]{2,4}$", *m.Sku)}) + errs = append(errs, Violation{"sku", "must match pattern \"^[A-Z]{2,4}$\", got " + quoteValue(*m.Sku)}) } } if m.Phrase != nil { if !_nexgenJsonSchemaPattern5e5b5e5c745c6e5c7830425c665c72205d2b5b5c745c6e5c7830425c665c72205d5b5e5c745c6e5c7830425c665c72205d2b24.MatchString(*m.Phrase) { - errs = append(errs, Violation{"phrase", fmt.Sprintf("must match pattern %q, got %q", "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+$", *m.Phrase)}) + errs = append(errs, Violation{"phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + quoteValue(*m.Phrase)}) } } if m.RequestID != nil { @@ -3023,7 +3023,7 @@ func (m Showcase) Validate() error { } if m.Wildcard != nil { if !_nexgenJsonSchemaPattern615b5e5c6e5d62.MatchString(*m.Wildcard) { - errs = append(errs, Violation{"wildcard", fmt.Sprintf("must match pattern %q, got %q", "a[^\\n]b", *m.Wildcard)}) + errs = append(errs, Violation{"wildcard", "must match pattern \"a[^\\\\n]b\", got " + quoteValue(*m.Wildcard)}) } } if len(errs) > 0 { @@ -3138,13 +3138,13 @@ func (m *Showcase) UnmarshalJSON(data []byte) error { if v, ok := parseStringField(get("sku"), "sku", false, false, &errs); ok { m.Sku = &v if !_nexgenJsonSchemaPattern5e5b412d5a5d7b322c347d24.MatchString(v) { - errs = append(errs, Violation{"sku", fmt.Sprintf("must match pattern %q, got %q", "^[A-Z]{2,4}$", v)}) + errs = append(errs, Violation{"sku", "must match pattern \"^[A-Z]{2,4}$\", got " + quoteValue(v)}) } } if v, ok := parseStringField(get("phrase"), "phrase", false, false, &errs); ok { m.Phrase = &v if !_nexgenJsonSchemaPattern5e5b5e5c745c6e5c7830425c665c72205d2b5b5c745c6e5c7830425c665c72205d5b5e5c745c6e5c7830425c665c72205d2b24.MatchString(v) { - errs = append(errs, Violation{"phrase", fmt.Sprintf("must match pattern %q, got %q", "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+$", v)}) + errs = append(errs, Violation{"phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + quoteValue(v)}) } } if v, ok := parseStringField(get("requestId"), "requestId", false, false, &errs); ok { @@ -3976,7 +3976,7 @@ func (m *Showcase) UnmarshalJSON(data []byte) error { if v, ok := parseStringField(get("wildcard"), "wildcard", false, false, &errs); ok { m.Wildcard = &v if !_nexgenJsonSchemaPattern615b5e5c6e5d62.MatchString(v) { - errs = append(errs, Violation{"wildcard", fmt.Sprintf("must match pattern %q, got %q", "a[^\\n]b", v)}) + errs = append(errs, Violation{"wildcard", "must match pattern \"a[^\\\\n]b\", got " + quoteValue(v)}) } } if v, ok := parseStringField(get("quoted"), "quoted", false, false, &errs); ok { @@ -5130,7 +5130,7 @@ func (m Tokens) Validate() error { errs = append(errs, Violation{path, fmt.Sprintf("must have length <= 8, got %d", n)}) } if !_nexgenJsonSchemaPattern5e5b612d7a5d2b24.MatchString(v) { - errs = append(errs, Violation{path, fmt.Sprintf("must match pattern %q, got %q", "^[a-z]+$", v)}) + errs = append(errs, Violation{path, "must match pattern \"^[a-z]+$\", got " + quoteValue(v)}) } } if len(errs) > 0 { @@ -5158,7 +5158,7 @@ func (m *Tokens) UnmarshalJSON(data []byte) error { errs = append(errs, Violation{path, fmt.Sprintf("must have length <= 8, got %d", n)}) } if !_nexgenJsonSchemaPattern5e5b612d7a5d2b24.MatchString(value) { - errs = append(errs, Violation{path, fmt.Sprintf("must match pattern %q, got %q", "^[a-z]+$", value)}) + errs = append(errs, Violation{path, "must match pattern \"^[a-z]+$\", got " + quoteValue(value)}) } m.AdditionalProperties[k] = value } diff --git a/advanced/samples/go/json_schema/api/temporal/definitions.go b/advanced/samples/go/json_schema/api/temporal/definitions.go index 3ac0871a..de5ebe2f 100644 --- a/advanced/samples/go/json_schema/api/temporal/definitions.go +++ b/advanced/samples/go/json_schema/api/temporal/definitions.go @@ -48,6 +48,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/advanced/samples/java/src/main/java/json_schema/api/chat/Violation.java b/advanced/samples/java/src/main/java/json_schema/api/chat/Violation.java index b843ea65..7706db4b 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/chat/Violation.java +++ b/advanced/samples/java/src/main/java/json_schema/api/chat/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/advanced/samples/java/src/main/java/json_schema/api/kb/Violation.java b/advanced/samples/java/src/main/java/json_schema/api/kb/Violation.java index c77143db..bbf9bad2 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/kb/Violation.java +++ b/advanced/samples/java/src/main/java/json_schema/api/kb/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/advanced/samples/java/src/main/java/json_schema/api/showcase/Attributes.java b/advanced/samples/java/src/main/java/json_schema/api/showcase/Attributes.java index 14c91fc8..caa01302 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/showcase/Attributes.java +++ b/advanced/samples/java/src/main/java/json_schema/api/showcase/Attributes.java @@ -78,7 +78,7 @@ public void serialize(Attributes value, JsonGenerator gen, SerializerProvider se for (String pnKey : value.additionalProperties.keySet()) { int pnLength = pnKey.codePointCount(0, pnKey.length()); if (pnLength > 8) { - violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name \"" + pnKey + "\": must have length <= 8, got " + pnLength)); + violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name " + Violation.quote(pnKey) + ": must have length <= 8, got " + pnLength)); } } for (Map.Entry entry : value.additionalProperties.entrySet()) { @@ -138,7 +138,7 @@ public Attributes deserialize(JsonParser parser, DeserializationContext context) String pnKey = propertyNameKeys.next(); int pnLength = pnKey.codePointCount(0, pnKey.length()); if (pnLength > 8) { - violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name \"" + pnKey + "\": must have length <= 8, got " + pnLength)); + violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name " + Violation.quote(pnKey) + ": must have length <= 8, got " + pnLength)); } } if (!violations.isEmpty()) { diff --git a/advanced/samples/java/src/main/java/json_schema/api/showcase/Showcase.java b/advanced/samples/java/src/main/java/json_schema/api/showcase/Showcase.java index 10698b9d..ae6105c7 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/showcase/Showcase.java +++ b/advanced/samples/java/src/main/java/json_schema/api/showcase/Showcase.java @@ -638,7 +638,7 @@ public String getValue() { void validate(String path, List violations) { if (!VALUE_PATTERN.matcher(value).find()) { - violations.add(new Violation(path, "must match pattern " + "^[a-z]+\\z" + ", got " + value)); + violations.add(new Violation(path, "must match pattern \"^[a-z]+$\", got " + Violation.quote(value))); } } @@ -2303,12 +2303,12 @@ public void serialize(Showcase value, JsonGenerator gen, SerializerProvider seri } if (value.sku != null) { if (!SKU_PATTERN.matcher(value.sku).find()) { - violations.add(new Violation("sku", "must match pattern " + "^[A-Z]{2,4}\\z" + ", got " + value.sku)); + violations.add(new Violation("sku", "must match pattern \"^[A-Z]{2,4}$\", got " + Violation.quote(value.sku))); } } if (value.phrase != null) { if (!PHRASE_PATTERN.matcher(value.phrase).find()) { - violations.add(new Violation("phrase", "must match pattern " + "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z" + ", got " + value.phrase)); + violations.add(new Violation("phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + Violation.quote(value.phrase))); } } if (value.requestId != null) { @@ -2682,7 +2682,7 @@ public void serialize(Showcase value, JsonGenerator gen, SerializerProvider seri } if (value.wildcard != null) { if (!WILDCARD_PATTERN.matcher(value.wildcard).find()) { - violations.add(new Violation("wildcard", "must match pattern " + "a[^\\n]b" + ", got " + value.wildcard)); + violations.add(new Violation("wildcard", "must match pattern \"a[^\\\\n]b\", got " + Violation.quote(value.wildcard))); } } gen.writeStartObject(); @@ -3878,7 +3878,7 @@ public Showcase deserialize(JsonParser parser, DeserializationContext context) t } else { sku = field.textValue(); if (!SKU_PATTERN.matcher(sku).find()) { - violations.add(new Violation("sku", "must match pattern " + "^[A-Z]{2,4}\\z" + ", got " + sku)); + violations.add(new Violation("sku", "must match pattern \"^[A-Z]{2,4}$\", got " + Violation.quote(sku))); } } } @@ -3895,7 +3895,7 @@ public Showcase deserialize(JsonParser parser, DeserializationContext context) t } else { phrase = field.textValue(); if (!PHRASE_PATTERN.matcher(phrase).find()) { - violations.add(new Violation("phrase", "must match pattern " + "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z" + ", got " + phrase)); + violations.add(new Violation("phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + Violation.quote(phrase))); } } } @@ -5304,7 +5304,7 @@ public Showcase deserialize(JsonParser parser, DeserializationContext context) t } else { wildcard = field.textValue(); if (!WILDCARD_PATTERN.matcher(wildcard).find()) { - violations.add(new Violation("wildcard", "must match pattern " + "a[^\\n]b" + ", got " + wildcard)); + violations.add(new Violation("wildcard", "must match pattern \"a[^\\\\n]b\", got " + Violation.quote(wildcard))); } } } diff --git a/advanced/samples/java/src/main/java/json_schema/api/showcase/Tokens.java b/advanced/samples/java/src/main/java/json_schema/api/showcase/Tokens.java index c57a6a0c..d5fc18a2 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/showcase/Tokens.java +++ b/advanced/samples/java/src/main/java/json_schema/api/showcase/Tokens.java @@ -83,7 +83,7 @@ public void serialize(Tokens value, JsonGenerator gen, SerializerProvider serial violations.add(new Violation(Violation.memberPath(entry.getKey()), "must have length <= 8, got " + length)); } if (!VALUE_PATTERN.matcher(entry.getValue()).find()) { - violations.add(new Violation(Violation.memberPath(entry.getKey()), "must match pattern " + "^[a-z]+\\z" + ", got " + entry.getValue())); + violations.add(new Violation(Violation.memberPath(entry.getKey()), "must match pattern \"^[a-z]+$\", got " + Violation.quote(entry.getValue()))); } } } @@ -132,7 +132,7 @@ public Tokens deserialize(JsonParser parser, DeserializationContext context) thr violations.add(new Violation(path, "must have length <= 8, got " + length)); } if (!VALUE_PATTERN.matcher(value).find()) { - violations.add(new Violation(path, "must match pattern " + "^[a-z]+\\z" + ", got " + value)); + violations.add(new Violation(path, "must match pattern \"^[a-z]+$\", got " + Violation.quote(value))); } additionalProperties.put(key, value); } diff --git a/advanced/samples/java/src/main/java/json_schema/api/showcase/Violation.java b/advanced/samples/java/src/main/java/json_schema/api/showcase/Violation.java index c011e2cb..f5d574d4 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/showcase/Violation.java +++ b/advanced/samples/java/src/main/java/json_schema/api/showcase/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/advanced/samples/java/src/main/java/json_schema/api/temporal/Violation.java b/advanced/samples/java/src/main/java/json_schema/api/temporal/Violation.java index 02adec2f..4cb77b48 100644 --- a/advanced/samples/java/src/main/java/json_schema/api/temporal/Violation.java +++ b/advanced/samples/java/src/main/java/json_schema/api/temporal/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index 5b7f75f4..579df494 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -1951,7 +1951,8 @@ def from_transfer_type( violations.append( Violation( path="sku", - reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_value_raw)}", + reason='must match pattern "^[A-Z]{2,4}$", got ' + + _quote(sku_value_raw), ) ) @@ -1973,7 +1974,8 @@ def from_transfer_type( violations.append( Violation( path="phrase", - reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_value_raw)}", + reason='must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$", got ' + + _quote(phrase_value_raw), ) ) @@ -3636,7 +3638,8 @@ def from_transfer_type( violations.append( Violation( path="wildcard", - reason=f"must match pattern {_PATTERN_F7DE686CF7F23810.pattern}, got {_quote(wildcard_value_raw)}", + reason='must match pattern "a[^\\\\n]b", got ' + + _quote(wildcard_value_raw), ) ) @@ -4031,7 +4034,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="sku", - reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_value)}", + reason='must match pattern "^[A-Z]{2,4}$", got ' + + _quote(sku_value), ) ) out["sku"] = sku_value @@ -4044,7 +4048,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="phrase", - reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_value)}", + reason='must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$", got ' + + _quote(phrase_value), ) ) out["phrase"] = phrase_value @@ -4526,7 +4531,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="measurements", - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(measurements_value)}", + reason='must match pattern "^[a-z]+$", got ' + + _quote(measurements_value), ) ) if not ( @@ -5283,7 +5289,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="wildcard", - reason=f"must match pattern {_PATTERN_F7DE686CF7F23810.pattern}, got {_quote(wildcard_value)}", + reason='must match pattern "a[^\\\\n]b", got ' + + _quote(wildcard_value), ) ) out["wildcard"] = wildcard_value @@ -7148,7 +7155,8 @@ def from_transfer_type( violations.append( Violation( path=path, - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(member_raw)}", + reason='must match pattern "^[a-z]+$", got ' + + _quote(member_raw), ) ) additional_properties[key] = member @@ -7194,7 +7202,8 @@ def to_transfer_type(self, value: "Tokens") -> typing.Any: violations.append( Violation( path=path, - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(entry)}", + reason='must match pattern "^[a-z]+$", got ' + + _quote(entry), ) ) out[key] = entry @@ -7913,7 +7922,7 @@ def _showcase_measurements_from_transfer_type( violations.append( Violation( path=path, - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value)}", + reason='must match pattern "^[a-z]+$", got ' + _quote(value), ) ) return value diff --git a/advanced/samples/typescript/json_schema/api/showcase/models.ts b/advanced/samples/typescript/json_schema/api/showcase/models.ts index 5c1c17af..5b350b65 100644 --- a/advanced/samples/typescript/json_schema/api/showcase/models.ts +++ b/advanced/samples/typescript/json_schema/api/showcase/models.ts @@ -978,7 +978,7 @@ export const attributesTransferTypeConverter = if (__nexgenDefinitions.codePointLength(key, 8) > 8) { violations.push({ path: __nexgenDefinitions.memberPath(key), - reason: `invalid property name "${key}": must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, + reason: `invalid property name ${JSON.stringify(key)}: must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, }); } } @@ -1040,7 +1040,7 @@ export const attributesTransferTypeConverter = if (__nexgenDefinitions.codePointLength(key, 8) > 8) { violations.push({ path: __nexgenDefinitions.memberPath(key), - reason: `invalid property name "${key}": must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, + reason: `invalid property name ${JSON.stringify(key)}: must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, }); } } @@ -2629,7 +2629,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_821EF753B4B37A85.test(raw["sku"])) { violations.push({ path: "sku", - reason: `must match pattern ^[A-Z]{2,4}\$, got ${JSON.stringify(raw["sku"])}`, + reason: `must match pattern "^[A-Z]{2,4}\$", got ${JSON.stringify(raw["sku"])}`, }); } } @@ -2649,7 +2649,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_AF8AB992526D6283.test(raw["phrase"])) { violations.push({ path: "phrase", - reason: `must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\$, got ${JSON.stringify(raw["phrase"])}`, + reason: `must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+\$", got ${JSON.stringify(raw["phrase"])}`, }); } } @@ -3414,7 +3414,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(measurements as string)) { violations.push({ path: "measurements", - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(measurements as string)}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(measurements as string)}`, }); } } @@ -4406,7 +4406,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_F7DE686CF7F23810.test(raw["wildcard"])) { violations.push({ path: "wildcard", - reason: `must match pattern a[^\\n]b, got ${JSON.stringify(raw["wildcard"])}`, + reason: `must match pattern "a[^\\\\n]b", got ${JSON.stringify(raw["wildcard"])}`, }); } } @@ -4944,7 +4944,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_821EF753B4B37A85.test(value.sku)) { violations.push({ path: "sku", - reason: `must match pattern ^[A-Z]{2,4}\$, got ${JSON.stringify(value.sku)}`, + reason: `must match pattern "^[A-Z]{2,4}\$", got ${JSON.stringify(value.sku)}`, }); } } @@ -4961,7 +4961,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_AF8AB992526D6283.test(value.phrase)) { violations.push({ path: "phrase", - reason: `must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\$, got ${JSON.stringify(value.phrase)}`, + reason: `must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+\$", got ${JSON.stringify(value.phrase)}`, }); } } @@ -5597,7 +5597,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(value.measurements as string)) { violations.push({ path: "measurements", - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(value.measurements as string)}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(value.measurements as string)}`, }); } } @@ -6499,7 +6499,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_F7DE686CF7F23810.test(value.wildcard)) { violations.push({ path: "wildcard", - reason: `must match pattern a[^\\n]b, got ${JSON.stringify(value.wildcard)}`, + reason: `must match pattern "a[^\\\\n]b", got ${JSON.stringify(value.wildcard)}`, }); } } @@ -7715,7 +7715,7 @@ export const tokensTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(raw[key])) { violations.push({ path: path, - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(raw[key])}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(raw[key])}`, }); } } @@ -7765,7 +7765,7 @@ export const tokensTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(entry)) { violations.push({ path: path, - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(entry)}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(entry)}`, }); } } diff --git a/samples/go/chat/definitions.go b/samples/go/chat/definitions.go index d317d3f3..67fab20e 100644 --- a/samples/go/chat/definitions.go +++ b/samples/go/chat/definitions.go @@ -45,6 +45,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/samples/go/kb/definitions.go b/samples/go/kb/definitions.go index efcc2f49..b9f61d5b 100644 --- a/samples/go/kb/definitions.go +++ b/samples/go/kb/definitions.go @@ -45,6 +45,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/samples/go/showcase/definitions.go b/samples/go/showcase/definitions.go index 02b73cd5..a53faecd 100644 --- a/samples/go/showcase/definitions.go +++ b/samples/go/showcase/definitions.go @@ -49,6 +49,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/samples/go/showcase/showcase.go b/samples/go/showcase/showcase.go index 05ba00b8..48236ce7 100644 --- a/samples/go/showcase/showcase.go +++ b/samples/go/showcase/showcase.go @@ -559,7 +559,7 @@ func (ShowcaseMeasurementsString) isShowcaseMeasurements() {} func (v ShowcaseMeasurementsString) Validate() error { var errs []Violation if !_nexgenJsonSchemaPattern5e5b612d7a5d2b24.MatchString(string(v)) { - errs = append(errs, Violation{"", fmt.Sprintf("must match pattern %q, got %q", "^[a-z]+$", string(v))}) + errs = append(errs, Violation{"", "must match pattern \"^[a-z]+$\", got " + quoteValue(string(v))}) } if len(errs) > 0 { return newPayloadValidationError(errs) @@ -1234,7 +1234,7 @@ func (m Attributes) Validate() error { } for k := range m.AdditionalProperties { if n := utf8.RuneCountInString(k); n > 8 { - errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %q: must have length <= 8, got %d", k, n)}) + errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %s: must have length <= 8, got %d", quoteValue(k), n)}) } } if len(errs) > 0 { @@ -1266,7 +1266,7 @@ func (m *Attributes) UnmarshalJSON(data []byte) error { } for k := range raw { if n := utf8.RuneCountInString(k); n > 8 { - errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %q: must have length <= 8, got %d", k, n)}) + errs = append(errs, Violation{memberPath(k), fmt.Sprintf("invalid property name %s: must have length <= 8, got %d", quoteValue(k), n)}) } } if len(errs) > 0 { @@ -2653,12 +2653,12 @@ func (m Showcase) Validate() error { } if m.Sku != nil { if !_nexgenJsonSchemaPattern5e5b412d5a5d7b322c347d24.MatchString(*m.Sku) { - errs = append(errs, Violation{"sku", fmt.Sprintf("must match pattern %q, got %q", "^[A-Z]{2,4}$", *m.Sku)}) + errs = append(errs, Violation{"sku", "must match pattern \"^[A-Z]{2,4}$\", got " + quoteValue(*m.Sku)}) } } if m.Phrase != nil { if !_nexgenJsonSchemaPattern5e5b5e5c745c6e5c7830425c665c72205d2b5b5c745c6e5c7830425c665c72205d5b5e5c745c6e5c7830425c665c72205d2b24.MatchString(*m.Phrase) { - errs = append(errs, Violation{"phrase", fmt.Sprintf("must match pattern %q, got %q", "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+$", *m.Phrase)}) + errs = append(errs, Violation{"phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + quoteValue(*m.Phrase)}) } } if m.RequestID != nil { @@ -2997,7 +2997,7 @@ func (m Showcase) Validate() error { } if m.Wildcard != nil { if !_nexgenJsonSchemaPattern615b5e5c6e5d62.MatchString(*m.Wildcard) { - errs = append(errs, Violation{"wildcard", fmt.Sprintf("must match pattern %q, got %q", "a[^\\n]b", *m.Wildcard)}) + errs = append(errs, Violation{"wildcard", "must match pattern \"a[^\\\\n]b\", got " + quoteValue(*m.Wildcard)}) } } if len(errs) > 0 { @@ -3112,13 +3112,13 @@ func (m *Showcase) UnmarshalJSON(data []byte) error { if v, ok := parseStringField(get("sku"), "sku", false, false, &errs); ok { m.Sku = &v if !_nexgenJsonSchemaPattern5e5b412d5a5d7b322c347d24.MatchString(v) { - errs = append(errs, Violation{"sku", fmt.Sprintf("must match pattern %q, got %q", "^[A-Z]{2,4}$", v)}) + errs = append(errs, Violation{"sku", "must match pattern \"^[A-Z]{2,4}$\", got " + quoteValue(v)}) } } if v, ok := parseStringField(get("phrase"), "phrase", false, false, &errs); ok { m.Phrase = &v if !_nexgenJsonSchemaPattern5e5b5e5c745c6e5c7830425c665c72205d2b5b5c745c6e5c7830425c665c72205d5b5e5c745c6e5c7830425c665c72205d2b24.MatchString(v) { - errs = append(errs, Violation{"phrase", fmt.Sprintf("must match pattern %q, got %q", "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+$", v)}) + errs = append(errs, Violation{"phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + quoteValue(v)}) } } if v, ok := parseStringField(get("requestId"), "requestId", false, false, &errs); ok { @@ -3950,7 +3950,7 @@ func (m *Showcase) UnmarshalJSON(data []byte) error { if v, ok := parseStringField(get("wildcard"), "wildcard", false, false, &errs); ok { m.Wildcard = &v if !_nexgenJsonSchemaPattern615b5e5c6e5d62.MatchString(v) { - errs = append(errs, Violation{"wildcard", fmt.Sprintf("must match pattern %q, got %q", "a[^\\n]b", v)}) + errs = append(errs, Violation{"wildcard", "must match pattern \"a[^\\\\n]b\", got " + quoteValue(v)}) } } if v, ok := parseStringField(get("quoted"), "quoted", false, false, &errs); ok { @@ -5104,7 +5104,7 @@ func (m Tokens) Validate() error { errs = append(errs, Violation{path, fmt.Sprintf("must have length <= 8, got %d", n)}) } if !_nexgenJsonSchemaPattern5e5b612d7a5d2b24.MatchString(v) { - errs = append(errs, Violation{path, fmt.Sprintf("must match pattern %q, got %q", "^[a-z]+$", v)}) + errs = append(errs, Violation{path, "must match pattern \"^[a-z]+$\", got " + quoteValue(v)}) } } if len(errs) > 0 { @@ -5132,7 +5132,7 @@ func (m *Tokens) UnmarshalJSON(data []byte) error { errs = append(errs, Violation{path, fmt.Sprintf("must have length <= 8, got %d", n)}) } if !_nexgenJsonSchemaPattern5e5b612d7a5d2b24.MatchString(value) { - errs = append(errs, Violation{path, fmt.Sprintf("must match pattern %q, got %q", "^[a-z]+$", value)}) + errs = append(errs, Violation{path, "must match pattern \"^[a-z]+$\", got " + quoteValue(value)}) } m.AdditionalProperties[k] = value } diff --git a/samples/go/temporal/definitions.go b/samples/go/temporal/definitions.go index 3ac0871a..de5ebe2f 100644 --- a/samples/go/temporal/definitions.go +++ b/samples/go/temporal/definitions.go @@ -48,6 +48,40 @@ func memberPath(key string) string { return `["` + escaped + `"]` } +func quoteValue(s string) string { + const hex = "0123456789abcdef" + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if r < 0x20 { + b.WriteString(`\u00`) + b.WriteByte(hex[r>>4]) + b.WriteByte(hex[r&0xf]) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + func newPayloadValidationError(violations []Violation) error { // TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release. return temporal.NewNonRetryableApplicationError("Payload validation failed", "PayloadValidationError", nil, violations) diff --git a/samples/go/tests/json_schema_showcase_test.go b/samples/go/tests/json_schema_showcase_test.go index b6baa660..d8ccaf03 100644 --- a/samples/go/tests/json_schema_showcase_test.go +++ b/samples/go/tests/json_schema_showcase_test.go @@ -219,6 +219,9 @@ func TestJSONSchemaShowcasePattern(t *testing.T) { require.Error(t, err) require.Contains(t, validationText(err), "must match pattern") require.Contains(t, validationText(err), `got "ab"`) + // The spec's reason form, byte-identical in every target: the authored + // pattern and the offending value, each quoted as a JSON string. + require.Contains(t, validationText(err), `must match pattern "^[A-Z]{2,4}$", got "ab"`) // Too-long sku (5 letters, above {2,4}). err = decodeValidation(jsonPayload([]byte(base+`,"sku":"ABCDE"}`)), &out) diff --git a/samples/java/src/main/java/json_schema/definitions/chat/Violation.java b/samples/java/src/main/java/json_schema/definitions/chat/Violation.java index 9f246c94..da9655e4 100644 --- a/samples/java/src/main/java/json_schema/definitions/chat/Violation.java +++ b/samples/java/src/main/java/json_schema/definitions/chat/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/samples/java/src/main/java/json_schema/definitions/kb/Violation.java b/samples/java/src/main/java/json_schema/definitions/kb/Violation.java index 87caf7d8..717005e2 100644 --- a/samples/java/src/main/java/json_schema/definitions/kb/Violation.java +++ b/samples/java/src/main/java/json_schema/definitions/kb/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/samples/java/src/main/java/json_schema/definitions/showcase/Attributes.java b/samples/java/src/main/java/json_schema/definitions/showcase/Attributes.java index 5a942377..9381c0a7 100644 --- a/samples/java/src/main/java/json_schema/definitions/showcase/Attributes.java +++ b/samples/java/src/main/java/json_schema/definitions/showcase/Attributes.java @@ -78,7 +78,7 @@ public void serialize(Attributes value, JsonGenerator gen, SerializerProvider se for (String pnKey : value.additionalProperties.keySet()) { int pnLength = pnKey.codePointCount(0, pnKey.length()); if (pnLength > 8) { - violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name \"" + pnKey + "\": must have length <= 8, got " + pnLength)); + violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name " + Violation.quote(pnKey) + ": must have length <= 8, got " + pnLength)); } } for (Map.Entry entry : value.additionalProperties.entrySet()) { @@ -138,7 +138,7 @@ public Attributes deserialize(JsonParser parser, DeserializationContext context) String pnKey = propertyNameKeys.next(); int pnLength = pnKey.codePointCount(0, pnKey.length()); if (pnLength > 8) { - violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name \"" + pnKey + "\": must have length <= 8, got " + pnLength)); + violations.add(new Violation(Violation.memberPath(pnKey), "invalid property name " + Violation.quote(pnKey) + ": must have length <= 8, got " + pnLength)); } } if (!violations.isEmpty()) { diff --git a/samples/java/src/main/java/json_schema/definitions/showcase/Showcase.java b/samples/java/src/main/java/json_schema/definitions/showcase/Showcase.java index e2588b9c..435c449c 100644 --- a/samples/java/src/main/java/json_schema/definitions/showcase/Showcase.java +++ b/samples/java/src/main/java/json_schema/definitions/showcase/Showcase.java @@ -638,7 +638,7 @@ public String getValue() { void validate(String path, List violations) { if (!VALUE_PATTERN.matcher(value).find()) { - violations.add(new Violation(path, "must match pattern " + "^[a-z]+\\z" + ", got " + value)); + violations.add(new Violation(path, "must match pattern \"^[a-z]+$\", got " + Violation.quote(value))); } } @@ -2303,12 +2303,12 @@ public void serialize(Showcase value, JsonGenerator gen, SerializerProvider seri } if (value.sku != null) { if (!SKU_PATTERN.matcher(value.sku).find()) { - violations.add(new Violation("sku", "must match pattern " + "^[A-Z]{2,4}\\z" + ", got " + value.sku)); + violations.add(new Violation("sku", "must match pattern \"^[A-Z]{2,4}$\", got " + Violation.quote(value.sku))); } } if (value.phrase != null) { if (!PHRASE_PATTERN.matcher(value.phrase).find()) { - violations.add(new Violation("phrase", "must match pattern " + "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z" + ", got " + value.phrase)); + violations.add(new Violation("phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + Violation.quote(value.phrase))); } } if (value.requestId != null) { @@ -2682,7 +2682,7 @@ public void serialize(Showcase value, JsonGenerator gen, SerializerProvider seri } if (value.wildcard != null) { if (!WILDCARD_PATTERN.matcher(value.wildcard).find()) { - violations.add(new Violation("wildcard", "must match pattern " + "a[^\\n]b" + ", got " + value.wildcard)); + violations.add(new Violation("wildcard", "must match pattern \"a[^\\\\n]b\", got " + Violation.quote(value.wildcard))); } } gen.writeStartObject(); @@ -3878,7 +3878,7 @@ public Showcase deserialize(JsonParser parser, DeserializationContext context) t } else { sku = field.textValue(); if (!SKU_PATTERN.matcher(sku).find()) { - violations.add(new Violation("sku", "must match pattern " + "^[A-Z]{2,4}\\z" + ", got " + sku)); + violations.add(new Violation("sku", "must match pattern \"^[A-Z]{2,4}$\", got " + Violation.quote(sku))); } } } @@ -3895,7 +3895,7 @@ public Showcase deserialize(JsonParser parser, DeserializationContext context) t } else { phrase = field.textValue(); if (!PHRASE_PATTERN.matcher(phrase).find()) { - violations.add(new Violation("phrase", "must match pattern " + "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z" + ", got " + phrase)); + violations.add(new Violation("phrase", "must match pattern \"^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$\", got " + Violation.quote(phrase))); } } } @@ -5304,7 +5304,7 @@ public Showcase deserialize(JsonParser parser, DeserializationContext context) t } else { wildcard = field.textValue(); if (!WILDCARD_PATTERN.matcher(wildcard).find()) { - violations.add(new Violation("wildcard", "must match pattern " + "a[^\\n]b" + ", got " + wildcard)); + violations.add(new Violation("wildcard", "must match pattern \"a[^\\\\n]b\", got " + Violation.quote(wildcard))); } } } diff --git a/samples/java/src/main/java/json_schema/definitions/showcase/Tokens.java b/samples/java/src/main/java/json_schema/definitions/showcase/Tokens.java index f695e692..3ab9682c 100644 --- a/samples/java/src/main/java/json_schema/definitions/showcase/Tokens.java +++ b/samples/java/src/main/java/json_schema/definitions/showcase/Tokens.java @@ -83,7 +83,7 @@ public void serialize(Tokens value, JsonGenerator gen, SerializerProvider serial violations.add(new Violation(Violation.memberPath(entry.getKey()), "must have length <= 8, got " + length)); } if (!VALUE_PATTERN.matcher(entry.getValue()).find()) { - violations.add(new Violation(Violation.memberPath(entry.getKey()), "must match pattern " + "^[a-z]+\\z" + ", got " + entry.getValue())); + violations.add(new Violation(Violation.memberPath(entry.getKey()), "must match pattern \"^[a-z]+$\", got " + Violation.quote(entry.getValue()))); } } } @@ -132,7 +132,7 @@ public Tokens deserialize(JsonParser parser, DeserializationContext context) thr violations.add(new Violation(path, "must have length <= 8, got " + length)); } if (!VALUE_PATTERN.matcher(value).find()) { - violations.add(new Violation(path, "must match pattern " + "^[a-z]+\\z" + ", got " + value)); + violations.add(new Violation(path, "must match pattern \"^[a-z]+$\", got " + Violation.quote(value))); } additionalProperties.put(key, value); } diff --git a/samples/java/src/main/java/json_schema/definitions/showcase/Violation.java b/samples/java/src/main/java/json_schema/definitions/showcase/Violation.java index 35143098..28d8eafa 100644 --- a/samples/java/src/main/java/json_schema/definitions/showcase/Violation.java +++ b/samples/java/src/main/java/json_schema/definitions/showcase/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/samples/java/src/main/java/json_schema/definitions/temporal/Violation.java b/samples/java/src/main/java/json_schema/definitions/temporal/Violation.java index 05ab7eea..423518f7 100644 --- a/samples/java/src/main/java/json_schema/definitions/temporal/Violation.java +++ b/samples/java/src/main/java/json_schema/definitions/temporal/Violation.java @@ -28,6 +28,34 @@ public static String memberPath(String key) { return "[\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\"]"; } + /** Quotes {@code value} as a JSON string literal for a violation reason. */ + public static String quote(String value) { + StringBuilder out = new StringBuilder(value.length() + 2).append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + out.append("\\\""); + } else if (c == '\\') { + out.append("\\\\"); + } else if (c == '\b') { + out.append("\\b"); + } else if (c == '\f') { + out.append("\\f"); + } else if (c == '\n') { + out.append("\\n"); + } else if (c == '\r') { + out.append("\\r"); + } else if (c == '\t') { + out.append("\\t"); + } else if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + public Violation withPathPrefix(String prefix) { if (path == null || path.isEmpty()) { return new Violation(prefix, reason); diff --git a/samples/java/src/test/java/jsonschema/JsonSchemaShowcaseRoundTripTest.java b/samples/java/src/test/java/jsonschema/JsonSchemaShowcaseRoundTripTest.java index 2281943e..89a89ddd 100644 --- a/samples/java/src/test/java/jsonschema/JsonSchemaShowcaseRoundTripTest.java +++ b/samples/java/src/test/java/jsonschema/JsonSchemaShowcaseRoundTripTest.java @@ -354,6 +354,11 @@ void patternConstraintsRoundTripAndReject() throws IOException { Showcase.class, Showcase.class)); assertTrue(messageChain(badSkuCase).contains("must match pattern"), messageChain(badSkuCase)); + // The reason quotes the authored pattern (`$`), not the `\z` rewrite + // compiled for matching — the same text Go/TS/Python print. + assertTrue( + messageChain(badSkuCase).contains("must match pattern \"^[A-Z]{2,4}$\", got \"ab\""), + messageChain(badSkuCase)); RuntimeException longSku = assertThrows(RuntimeException.class, () -> CONVERTER.fromPayload( diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index 5b7f75f4..579df494 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -1951,7 +1951,8 @@ def from_transfer_type( violations.append( Violation( path="sku", - reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_value_raw)}", + reason='must match pattern "^[A-Z]{2,4}$", got ' + + _quote(sku_value_raw), ) ) @@ -1973,7 +1974,8 @@ def from_transfer_type( violations.append( Violation( path="phrase", - reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_value_raw)}", + reason='must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$", got ' + + _quote(phrase_value_raw), ) ) @@ -3636,7 +3638,8 @@ def from_transfer_type( violations.append( Violation( path="wildcard", - reason=f"must match pattern {_PATTERN_F7DE686CF7F23810.pattern}, got {_quote(wildcard_value_raw)}", + reason='must match pattern "a[^\\\\n]b", got ' + + _quote(wildcard_value_raw), ) ) @@ -4031,7 +4034,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="sku", - reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_value)}", + reason='must match pattern "^[A-Z]{2,4}$", got ' + + _quote(sku_value), ) ) out["sku"] = sku_value @@ -4044,7 +4048,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="phrase", - reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_value)}", + reason='must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+$", got ' + + _quote(phrase_value), ) ) out["phrase"] = phrase_value @@ -4526,7 +4531,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="measurements", - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(measurements_value)}", + reason='must match pattern "^[a-z]+$", got ' + + _quote(measurements_value), ) ) if not ( @@ -5283,7 +5289,8 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation( path="wildcard", - reason=f"must match pattern {_PATTERN_F7DE686CF7F23810.pattern}, got {_quote(wildcard_value)}", + reason='must match pattern "a[^\\\\n]b", got ' + + _quote(wildcard_value), ) ) out["wildcard"] = wildcard_value @@ -7148,7 +7155,8 @@ def from_transfer_type( violations.append( Violation( path=path, - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(member_raw)}", + reason='must match pattern "^[a-z]+$", got ' + + _quote(member_raw), ) ) additional_properties[key] = member @@ -7194,7 +7202,8 @@ def to_transfer_type(self, value: "Tokens") -> typing.Any: violations.append( Violation( path=path, - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(entry)}", + reason='must match pattern "^[a-z]+$", got ' + + _quote(entry), ) ) out[key] = entry @@ -7913,7 +7922,7 @@ def _showcase_measurements_from_transfer_type( violations.append( Violation( path=path, - reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value)}", + reason='must match pattern "^[a-z]+$", got ' + _quote(value), ) ) return value diff --git a/samples/python/tests/test_showcase.py b/samples/python/tests/test_showcase.py index a5ffdb3d..6f073fc5 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -494,6 +494,12 @@ def test_pattern_constraints_roundtrip_and_reject() -> None: assert [path for path, _ in violations] == [member] assert violations[0][1].startswith("must match pattern ") + # The reason quotes the authored pattern (`$`), not the `\Z` rewrite + # compiled for matching — the same text Go/TS/Java print. + assert parse_violations({**BASE, "sku": "ab"}) == [ + ("sku", 'must match pattern "^[A-Z]{2,4}$", got "ab"') + ] + ok = parse({**BASE, "sku": "XY", "phrase": "hello world"}) assert ok.sku == "XY" assert ok.phrase == "hello world" diff --git a/samples/typescript/showcase/models.ts b/samples/typescript/showcase/models.ts index 22ace88a..15b77be4 100644 --- a/samples/typescript/showcase/models.ts +++ b/samples/typescript/showcase/models.ts @@ -967,7 +967,7 @@ export const attributesTransferTypeConverter = if (__nexgenDefinitions.codePointLength(key, 8) > 8) { violations.push({ path: __nexgenDefinitions.memberPath(key), - reason: `invalid property name "${key}": must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, + reason: `invalid property name ${JSON.stringify(key)}: must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, }); } } @@ -1029,7 +1029,7 @@ export const attributesTransferTypeConverter = if (__nexgenDefinitions.codePointLength(key, 8) > 8) { violations.push({ path: __nexgenDefinitions.memberPath(key), - reason: `invalid property name "${key}": must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, + reason: `invalid property name ${JSON.stringify(key)}: must have length <= 8, got ${__nexgenDefinitions.codePointLength(key)}`, }); } } @@ -2618,7 +2618,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_821EF753B4B37A85.test(raw["sku"])) { violations.push({ path: "sku", - reason: `must match pattern ^[A-Z]{2,4}\$, got ${JSON.stringify(raw["sku"])}`, + reason: `must match pattern "^[A-Z]{2,4}\$", got ${JSON.stringify(raw["sku"])}`, }); } } @@ -2638,7 +2638,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_AF8AB992526D6283.test(raw["phrase"])) { violations.push({ path: "phrase", - reason: `must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\$, got ${JSON.stringify(raw["phrase"])}`, + reason: `must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+\$", got ${JSON.stringify(raw["phrase"])}`, }); } } @@ -3403,7 +3403,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(measurements as string)) { violations.push({ path: "measurements", - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(measurements as string)}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(measurements as string)}`, }); } } @@ -4395,7 +4395,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_F7DE686CF7F23810.test(raw["wildcard"])) { violations.push({ path: "wildcard", - reason: `must match pattern a[^\\n]b, got ${JSON.stringify(raw["wildcard"])}`, + reason: `must match pattern "a[^\\\\n]b", got ${JSON.stringify(raw["wildcard"])}`, }); } } @@ -4933,7 +4933,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_821EF753B4B37A85.test(value.sku)) { violations.push({ path: "sku", - reason: `must match pattern ^[A-Z]{2,4}\$, got ${JSON.stringify(value.sku)}`, + reason: `must match pattern "^[A-Z]{2,4}\$", got ${JSON.stringify(value.sku)}`, }); } } @@ -4950,7 +4950,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_AF8AB992526D6283.test(value.phrase)) { violations.push({ path: "phrase", - reason: `must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\$, got ${JSON.stringify(value.phrase)}`, + reason: `must match pattern "^[^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+[\\\\t\\\\n\\\\x0B\\\\f\\\\r ][^\\\\t\\\\n\\\\x0B\\\\f\\\\r ]+\$", got ${JSON.stringify(value.phrase)}`, }); } } @@ -5586,7 +5586,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(value.measurements as string)) { violations.push({ path: "measurements", - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(value.measurements as string)}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(value.measurements as string)}`, }); } } @@ -6488,7 +6488,7 @@ export const showcaseTransferTypeConverter = if (!PATTERN_F7DE686CF7F23810.test(value.wildcard)) { violations.push({ path: "wildcard", - reason: `must match pattern a[^\\n]b, got ${JSON.stringify(value.wildcard)}`, + reason: `must match pattern "a[^\\\\n]b", got ${JSON.stringify(value.wildcard)}`, }); } } @@ -7704,7 +7704,7 @@ export const tokensTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(raw[key])) { violations.push({ path: path, - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(raw[key])}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(raw[key])}`, }); } } @@ -7754,7 +7754,7 @@ export const tokensTransferTypeConverter = if (!PATTERN_C182F89FDB221836.test(entry)) { violations.push({ path: path, - reason: `must match pattern ^[a-z]+\$, got ${JSON.stringify(entry)}`, + reason: `must match pattern "^[a-z]+\$", got ${JSON.stringify(entry)}`, }); } } diff --git a/samples/typescript/tests/json-schema-showcase.test.ts b/samples/typescript/tests/json-schema-showcase.test.ts index 7b8502ec..eb816f71 100644 --- a/samples/typescript/tests/json-schema-showcase.test.ts +++ b/samples/typescript/tests/json-schema-showcase.test.ts @@ -479,6 +479,11 @@ describe("json-schema showcase generated definitions", () => { expect(() => showcaseTransferTypeConverter.fromTransferType({ ...base, sku: "ab" }), ).toThrow(/must match pattern/); + // The spec's reason form, byte-identical in every target: the authored + // pattern and the offending value, each quoted as a JSON string. + expect(() => + showcaseTransferTypeConverter.fromTransferType({ ...base, sku: "ab" }), + ).toThrow('must match pattern "^[A-Z]{2,4}$", got "ab"'); expect(() => showcaseTransferTypeConverter.fromTransferType({ ...base, sku: "ABCDE" }), ).toThrow(/must match pattern/); diff --git a/specs/json-schema/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index 95a01a25..b38f1cad 100644 --- a/specs/json-schema/PRINCIPLES.md +++ b/specs/json-schema/PRINCIPLES.md @@ -35,7 +35,7 @@ renumber them. ### Validation & error behavior 10. **Validation is enforced, not advisory.** Constraints (`minLength`, `pattern`, `minimum`, …), `const`, and discriminator strings are checked at the (de)serializer boundary in **both** directions (P12) — schemas are not just documentation. Violations aggregate per P11. -11. **Aggregate validation errors as a Temporal payload-validation failure.** Surface every violation in one shot as a non-retryable Temporal application failure whose message is `Payload validation failed`, whose type is `PayloadValidationError`, and whose first detail is the language's list of `Violation { path, reason }`. The SDK class is named `ApplicationFailure` in TypeScript and Java and `ApplicationError` in Python and Go. The `reason` *text* is not held byte-identical across targets; the cross-language contract is the accepted-and-rejected value set (P1) plus this structure. Temporal's Nexus integration recognizes the reserved failure type and reports the payload rejection; generated service bindings do not wrap it in a separate `HandlerError`. +11. **Aggregate validation errors as a Temporal payload-validation failure.** Surface every violation in one shot as a non-retryable Temporal application failure whose message is `Payload validation failed`, whose type is `PayloadValidationError`, and whose first detail is the language's list of `Violation { path, reason }`. The SDK class is named `ApplicationFailure` in TypeScript and Java and `ApplicationError` in Python and Go. The `reason` *text* is not held byte-identical across targets unless a feature spec defines an exact form (as [[pattern]] does for `must match pattern …`); the cross-language contract is the accepted-and-rejected value set (P1) plus this structure. Temporal's Nexus integration recognizes the reserved failure type and reports the payload rejection; generated service bindings do not wrap it in a separate `HandlerError`. 1. **The aggregate is direction-symmetric.** The violation *set* a value produces on serialize is the set its wire form produces on parse (P12) — a set, not a sequence: nothing in P11 constrains the order of the `Violation` list. A direction that stops early does not satisfy this. Java serializes each nested child into a `TokenBuffer`, merges any child `PayloadValidationError`, keeps validating siblings, and copies buffered tokens to the real generator only after the complete aggregate is empty; no bytes escape before validation succeeds. 2. **`Violation.path` has a defined grammar, and it is escaped.** `path` locates the offending value inside the payload and is compared target-to-target — the conformance driver's cross-target signature is the sorted path list — so it is part of the contract, not a diagnostic nicety. The grammar: * The root is the empty string. diff --git a/specs/json-schema/features/pattern.md b/specs/json-schema/features/pattern.md index 6c5871cc..66a0b8a4 100644 --- a/specs/json-schema/features/pattern.md +++ b/specs/json-schema/features/pattern.md @@ -282,14 +282,15 @@ init) and reuses it; the flags/method are the P1-pinned choices above, and `` denotes the **gate-normalized** form — `\s`/`\S` already expanded to the explicit ASCII class and `$` rewritten per target (`\Z`/`\z`), inline flags already rejected — so no runtime row has to cope -with them. +with them. `` in a reason is the authored text, quoted per +**Informative `reason` strings** below. | Language | Strategy | |---|---| -| Go | Package-level `var patRe = regexp.MustCompile()` (compiled once at init; the load-time gate already proved it compiles). The shared `Validate` checks `if !patRe.MatchString(v) { push(Violation{Path, Reason: fmt.Sprintf("must match pattern %q, got %q", , v)}) }` — `MatchString` is unanchored; RE2 is ASCII-class + rune-`.`. Collected into one `PayloadValidationError` application failure. | -| TypeScript | Module-level ``const PAT_RE = //u;`` (or `new RegExp(, "u")` when the literal can't be spelled). **The `u` flag is mandatory** (code-point `.`; verified). ``if (!PAT_RE.test(v)) push(Violation{path, reason: `must match pattern ${PAT_RE}, got ${JSON.stringify(v)}`})``. `test` is unanchored and — with no `g` flag — stateless. Throw one `PayloadValidationError` application failure. | -| Python | A module-level `_PATTERN_ = re.compile(, re.ASCII)` (with the `$`→`\Z` normalization applied), keyed by the pattern text so identical patterns share one compiled instance per module. Both directions of the model's `_TransferTypeConverter` inline the check — `if _PATTERN_.search(value) is None: violations.append(Violation(path=…, reason=f"must match pattern , got {_quote(value)}"))` — collected into the single `PayloadValidationError` application failure (**PRINCIPLES Python §2/§3**). The comparison is emitted inline rather than behind a runtime helper, the same way TypeScript emits it. **`re.search` (unanchored — never `re.match`, which anchors the start, or `fullmatch`), `re.ASCII` (ASCII `\d\w\s`).** | -| Java | Static `private static final Pattern PAT_RE = Pattern.compile();` (**default flags** — ASCII `\d\w\s`, code-point `.`; with the `$`→`\z` normalization applied). The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `String` and checks `if (!PAT_RE.matcher(v).find())`, pushing a `Violation{path, "must match pattern " + + ", got " + v}` into the single `PayloadValidationError` application failure. **`Matcher.find` (unanchored), never `matches()`** (which anchors the whole input — verified footgun). Not bean-validation `@Pattern`. | +| Go | Package-level `var patRe = regexp.MustCompile()` (compiled once at init; the load-time gate already proved it compiles). The shared `Validate` checks `if !patRe.MatchString(v) { push(Violation{Path, Reason: "must match pattern , got " + quoteValue(v)}) }` (`quoteValue` is the runtime's JSON-string quoter — not `%q`, whose escapes differ from JSON's) — `MatchString` is unanchored; RE2 is ASCII-class + rune-`.`. Collected into one `PayloadValidationError` application failure. | +| TypeScript | Module-level ``const PAT_RE = //u;`` (or `new RegExp(, "u")` when the literal can't be spelled). **The `u` flag is mandatory** (code-point `.`; verified). ``if (!PAT_RE.test(v)) push(Violation{path, reason: `must match pattern , got ${JSON.stringify(v)}`})``. `test` is unanchored and — with no `g` flag — stateless. Throw one `PayloadValidationError` application failure. | +| Python | A module-level `_PATTERN_ = re.compile(, re.ASCII)` (with the `$`→`\Z` normalization applied), keyed by the pattern text so identical patterns share one compiled instance per module. Both directions of the model's `_TransferTypeConverter` inline the check — `if _PATTERN_.search(value) is None: violations.append(Violation(path=…, reason="must match pattern , got " + _quote(value)))` (the authored pattern in a plain string literal, never the `\Z`-rewritten `_PATTERN_.pattern`) — collected into the single `PayloadValidationError` application failure (**PRINCIPLES Python §2/§3**). The comparison is emitted inline rather than behind a runtime helper, the same way TypeScript emits it. **`re.search` (unanchored — never `re.match`, which anchors the start, or `fullmatch`), `re.ASCII` (ASCII `\d\w\s`).** | +| Java | Static `private static final Pattern PAT_RE = Pattern.compile();` (**default flags** — ASCII `\d\w\s`, code-point `.`; with the `$`→`\z` normalization applied). The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `String` and checks `if (!PAT_RE.matcher(v).find())`, pushing a `Violation{path, "must match pattern , got " + Violation.quote(v)}` (the authored pattern, never the `\z`-rewritten one) into the single `PayloadValidationError` application failure. **`Matcher.find` (unanchored), never `matches()`** (which anchors the whole input — verified footgun). Not bean-validation `@Pattern`. | The compiled identifiers are also part of **P15**. Go derives `Pattern` (and the parallel `Format` / `ContentEncoding` @@ -302,8 +303,33 @@ than producing a duplicate package variable or class field. **Informative `reason` strings.** The `Violation` `reason` names the **pattern and the offending value** (`must match pattern "^[a-z]+$", got -"AB1"`), per the [[maximum]] convention. The pattern is an emitted -compile-time constant; the value is interpolated at runtime. +"AB1"`), per the [[maximum]] convention. Unlike most reasons (P11 leaves +wording target-idiomatic), this form is exact and **byte-identical in every +target**: + + must match pattern , got + +- `` is the **authored (loader-normalized) text** — the + per-target `$`→`\z`/`\Z` rewrite (and any other host-engine respelling) is + used only for matching, never shown in the reason. It is a compile-time + constant: the generator quotes it once and every target embeds the same + bytes. +- Both quotings are a **JSON string literal**: wrap in `"`; escape `"` and + `\` with a backslash; `\b` `\f` `\n` `\r` `\t` by name; any other code point + below U+0020 as `\u00xx` (lower-case hex); every other code point verbatim. + That is `JSON.stringify` (TypeScript), `json.dumps(…, ensure_ascii=False)` + (Python `_quote`), and the runtime quoters Go `quoteValue` and Java + `Violation.quote`. Go's `%q` is **not** used: it escapes non-printable and + non-ASCII code points differently. So the pattern `^"[a-z]\.$` with the + value `x"` reports `must match pattern "^\"[a-z]\\.$", got "x\""` everywhere. +- On a [[propertyNames]] key the reason is the shared + `invalid property name : ` prefix followed by + `must match pattern ` with **no** `, got` suffix — the + prefix already names the key (`invalid property name "Bad": must match + pattern "^[a-z]+$"`). + +`tests/json_schema_violation_reasons.rs` drives the same wire values through +all four runtimes and requires these exact strings. **The emitted pattern is a string literal in the target's own escape grammar.** The pattern text can contain any code point the schema author wrote, diff --git a/specs/json-schema/features/propertyNames.md b/specs/json-schema/features/propertyNames.md index 01fe3a1c..2a1f410c 100644 --- a/specs/json-schema/features/propertyNames.md +++ b/specs/json-schema/features/propertyNames.md @@ -80,15 +80,19 @@ restated here. | Language | Strategy | |---|---| -| Go | Iterate the wire keys and run the key predicate (compiled `regexp` for [[pattern]], length checks); a failure → a `Violation` at the key's path with `Reason: fmt.Sprintf("invalid property name %q: %s", key, why)` (`why` is the underlying assertion's reason, e.g. `must match ^[a-z]+$`), collected into one `PayloadValidationError` application failure. | -| TypeScript | the same predicate over the wire object's own keys; a failure → push a `Violation` at the key's path with ``reason: `invalid property name "${k}": ${why}` ``, throw one `PayloadValidationError` application failure. | -| Python | both directions of the `_TransferTypeConverter` (**PRINCIPLES Python §3**) loop the map's keys and apply the same key check; a failure appends a `Violation` at the key's path with ``reason=f'invalid property name "{key}": {why}'`` per bad key into the single `PayloadValidationError` application failure. | -| Java | in the per-POJO collecting deserializer (PRINCIPLES Java §5), iterate the parsed tree's keys, apply the same key check, and push a `Violation` at the key's path with `"invalid property name \"" + key + "\": " + why` per bad key into the single `PayloadValidationError` application failure. | - -Every row states the same predicate and the same `invalid property name "": ` -prefix on both paths; per **P12.2** that identity is the requirement, and whether -a target reaches it through one exported validator or an inlined check is an -emission choice. +| Go | Iterate the wire keys and run the key predicate (compiled `regexp` for [[pattern]], length checks); a failure → a `Violation` at the key's path with `Reason: "invalid property name " + quoteValue(key) + ": " + why` (`why` is the underlying assertion's reason, e.g. `must match pattern "^[a-z]+$"`), collected into one `PayloadValidationError` application failure. | +| TypeScript | the same predicate over the wire object's own keys; a failure → push a `Violation` at the key's path with ``reason: `invalid property name ${JSON.stringify(k)}: ${why}` ``, throw one `PayloadValidationError` application failure. | +| Python | both directions of the `_TransferTypeConverter` (**PRINCIPLES Python §3**) loop the map's keys and apply the same key check; a failure appends a `Violation` at the key's path with ``reason=f'invalid property name {_quote(key)}: {why}'`` per bad key into the single `PayloadValidationError` application failure. | +| Java | in the per-POJO collecting deserializer (PRINCIPLES Java §5), iterate the parsed tree's keys, apply the same key check, and push a `Violation` at the key's path with `"invalid property name " + Violation.quote(key) + ": " + why` per bad key into the single `PayloadValidationError` application failure. | + +Every row states the same predicate and the same `invalid property name : ` +prefix on both paths, with `` quoted as a JSON string literal (the +[[pattern]] quoting rule, so a key containing `"` or `\` reads identically in +every target). A [[pattern]] key failure's `why` is `must match pattern +` with no `, got` suffix, since the prefix already names the +key — `invalid property name "Bad": must match pattern "^[a-z]+$"`. Per +**P12.2** that identity is the requirement, and whether a target reaches it +through one exported validator or an inlined check is an emission choice. Reuses whatever the string-assertion specs ([[pattern]], [[minLength]], [[maxLength]], [[enum]], [[format]]) emit — `propertyNames` is just those diff --git a/src/generator/json_schema/go.rs b/src/generator/json_schema/go.rs index b4fb3c8c..c1cff21d 100644 --- a/src/generator/json_schema/go.rs +++ b/src/generator/json_schema/go.rs @@ -538,11 +538,12 @@ fn render_go_pattern_check_to( output.push_str(errs_value); output.push_str(", Violation{"); output.push_str(path); - output.push_str(", fmt.Sprintf("); - output.push_str(&go_string_literal("must match pattern %q, got %q")); - output.push_str(", "); - output.push_str(&go_string_literal(pattern)); output.push_str(", "); + output.push_str(&go_string_literal(&format!( + "{}, got ", + crate::json_schema::pattern::violation_reason(pattern) + ))); + output.push_str(" + quoteValue("); output.push_str(value_expr); output.push_str(")})\n"); output.push_str(indent); @@ -1038,8 +1039,8 @@ fn render_go_property_name_checks( )); output.push_str(&inner); output.push_str(&format!( - "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, k, n)}})\n", - go_string_literal(&format!("invalid property name %q: {reason}")) + "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, quoteValue(k), n)}})\n", + go_string_literal(&format!("invalid property name %s: {reason}")) )); output.push_str(&inner); output.push_str("}\n"); @@ -1064,9 +1065,10 @@ fn render_go_property_name_checks( )); output.push_str(&inner); output.push_str(&format!( - "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, k)}})\n", + "\terrs = append(errs, Violation{{memberPath(k), \"invalid property name \" + quoteValue(k) + {}}})\n", go_string_literal(&format!( - "invalid property name %q: must match pattern {pattern}" + ": {}", + crate::json_schema::pattern::violation_reason(pattern) )) )); output.push_str(&inner); @@ -1084,8 +1086,8 @@ fn render_go_property_name_checks( output.push_str(&format!("if !({alternatives}) {{\n")); output.push_str(&inner); output.push_str(&format!( - "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, k)}})\n", - go_string_literal("invalid property name %q: must equal an allowed value") + "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, quoteValue(k))}})\n", + go_string_literal("invalid property name %s: must equal an allowed value") )); output.push_str(&inner); output.push_str("}\n"); @@ -1104,9 +1106,9 @@ fn render_go_property_name_checks( output.push_str(&format!("if {condition} {{\n")); output.push_str(&inner); output.push_str(&format!( - "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, k)}})\n", + "\terrs = append(errs, Violation{{memberPath(k), fmt.Sprintf({}, quoteValue(k))}})\n", go_string_literal(&format!( - "invalid property name %q: must be a valid {}", + "invalid property name %s: must be a valid {}", check.name )) )); @@ -1736,6 +1738,23 @@ fn render_validator_core(output: &mut String) { "\tescaped := strings.ReplaceAll(strings.ReplaceAll(key, `\\`, `\\\\`), `\"`, `\\\"`)\n", ); output.push_str("\treturn `[\"` + escaped + `\"]`\n}\n\n"); + // quoteValue renders a string the way every target quotes an offending + // value or key in a violation reason: a JSON string literal (Python + // `json.dumps(ensure_ascii=False)`, TypeScript `JSON.stringify`, Java + // `Violation.quote`), not Go's `%q`, whose escapes differ. + output.push_str("func quoteValue(s string) string {\n"); + output.push_str("\tconst hex = \"0123456789abcdef\"\n"); + output.push_str("\tvar b strings.Builder\n\tb.WriteByte('\"')\n"); + output.push_str("\tfor _, r := range s {\n\t\tswitch r {\n"); + output.push_str("\t\tcase '\"':\n\t\t\tb.WriteString(`\\\"`)\n"); + output.push_str("\t\tcase '\\\\':\n\t\t\tb.WriteString(`\\\\`)\n"); + output.push_str("\t\tcase '\\b':\n\t\t\tb.WriteString(`\\b`)\n"); + output.push_str("\t\tcase '\\f':\n\t\t\tb.WriteString(`\\f`)\n"); + output.push_str("\t\tcase '\\n':\n\t\t\tb.WriteString(`\\n`)\n"); + output.push_str("\t\tcase '\\r':\n\t\t\tb.WriteString(`\\r`)\n"); + output.push_str("\t\tcase '\\t':\n\t\t\tb.WriteString(`\\t`)\n"); + output.push_str("\t\tdefault:\n\t\t\tif r < 0x20 {\n\t\t\t\tb.WriteString(`\\u00`)\n\t\t\t\tb.WriteByte(hex[r>>4])\n\t\t\t\tb.WriteByte(hex[r&0xf])\n\t\t\t} else {\n\t\t\t\tb.WriteRune(r)\n\t\t\t}\n\t\t}\n\t}\n"); + output.push_str("\tb.WriteByte('\"')\n\treturn b.String()\n}\n\n"); output.push_str("func newPayloadValidationError(violations []Violation) error {\n"); output.push_str("\t// TODO: Use temporal.NewPayloadValidationError once it is available in an SDK release.\n"); output.push_str("\treturn temporal.NewNonRetryableApplicationError(\"Payload validation failed\", \"PayloadValidationError\", nil, violations)\n"); diff --git a/src/generator/json_schema/java.rs b/src/generator/json_schema/java.rs index e98e285b..35faf3c4 100644 --- a/src/generator/json_schema/java.rs +++ b/src/generator/json_schema/java.rs @@ -132,8 +132,12 @@ struct StringLengthConstraints { max_length: Option, /// The loader-normalized `pattern` with the per-target `$`→`\z` rewrite /// already applied (Java's strict end-of-input anchor). See - /// `specs/json-schema/features/pattern.md`. + /// `specs/json-schema/features/pattern.md`. Compiled for matching only. pattern: Option, + /// The loader-normalized `pattern` *before* the per-target rewrite — the + /// text the violation reason quotes, so Java prints the same pattern as + /// every other target rather than its host-regex rewrite. + authored_pattern: Option, /// A pinned `format` check (regex + optional length guard) on the same node. format: Option, } @@ -147,6 +151,7 @@ impl StringLengthConstraints { .pattern .as_deref() .map(|pattern| crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z")), + authored_pattern: schema.pattern.clone(), format: schema .format .as_deref() @@ -603,11 +608,15 @@ fn render_java_string_checks( // `pattern`: unanchored `Matcher.find()` (never `matches()`, which anchors // the whole input), default flags (ASCII `\d\w\s`, code-point `.`). The // compiled `Pattern` is a static field on the class (compiled once). - if let Some(pattern) = &constraints.pattern { + // The reason quotes the authored pattern, not the `\z`-rewritten one. + if let Some(authored) = &constraints.authored_pattern { output.push_str(&format!( - "{indent}if (!{field_pattern}.matcher({value_expr}).find()) {{\n{indent} violations.add(new Violation({json}, \"must match pattern \" + {pattern_literal} + \", got \" + {value_expr}));\n{indent}}}\n", + "{indent}if (!{field_pattern}.matcher({value_expr}).find()) {{\n{indent} violations.add(new Violation({json}, {reason_literal} + Violation.quote({value_expr})));\n{indent}}}\n", field_pattern = java_pattern_field_name(field_java_name), - pattern_literal = java_string_literal(pattern), + reason_literal = java_string_literal(&format!( + "{}, got ", + crate::json_schema::pattern::violation_reason(authored) + )), )); } // `format`: the length guard (if any) short-circuits **before** the pinned @@ -658,11 +667,14 @@ fn render_java_inline_string_checks( )); } } - if let Some(pattern) = constraints.pattern { + if let (Some(pattern), Some(authored)) = (&constraints.pattern, &constraints.authored_pattern) { output.push_str(&format!( - "{indent}if (!java.util.regex.Pattern.compile({}).matcher({value_expr}).find()) {{\n{indent} violations.add(new Violation({path_expr}, \"must match pattern \" + {} + \", got \" + {value_expr}));\n{indent}}}\n", - java_string_literal(&pattern), - java_string_literal(&pattern), + "{indent}if (!java.util.regex.Pattern.compile({}).matcher({value_expr}).find()) {{\n{indent} violations.add(new Violation({path_expr}, {} + Violation.quote({value_expr})));\n{indent}}}\n", + java_string_literal(pattern), + java_string_literal(&format!( + "{}, got ", + crate::json_schema::pattern::violation_reason(authored) + )), )); } if let Some(format) = constraints.format { @@ -810,19 +822,22 @@ fn render_java_property_name_checks( if let Some(min) = constraints.min_length { let min_literal = java_count_literal(min); output.push_str(&format!( - "{indent} if (pnLength < {min_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \\\"\" + pnKey + \"\\\": must have length >= {min}, got \" + pnLength));\n{indent} }}\n" + "{indent} if (pnLength < {min_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \" + Violation.quote(pnKey) + \": must have length >= {min}, got \" + pnLength));\n{indent} }}\n" )); } if let Some(max) = constraints.max_length { let max_literal = java_count_literal(max); output.push_str(&format!( - "{indent} if (pnLength > {max_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \\\"\" + pnKey + \"\\\": must have length <= {max}, got \" + pnLength));\n{indent} }}\n" + "{indent} if (pnLength > {max_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \" + Violation.quote(pnKey) + \": must have length <= {max}, got \" + pnLength));\n{indent} }}\n" )); } } + render_java_property_name_pattern_check(output, &constraints, &format!("{indent} ")); let mut non_length = constraints.clone(); non_length.min_length = None; non_length.max_length = None; + non_length.pattern = None; + non_length.authored_pattern = None; render_java_string_checks( output, "pnKey", @@ -3904,19 +3919,22 @@ fn render_java_serialize_property_name_checks( if let Some(min) = constraints.min_length { let min_literal = java_count_literal(min); output.push_str(&format!( - "{indent} if (pnLength < {min_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \\\"\" + pnKey + \"\\\": must have length >= {min}, got \" + pnLength));\n{indent} }}\n" + "{indent} if (pnLength < {min_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \" + Violation.quote(pnKey) + \": must have length >= {min}, got \" + pnLength));\n{indent} }}\n" )); } if let Some(max) = constraints.max_length { let max_literal = java_count_literal(max); output.push_str(&format!( - "{indent} if (pnLength > {max_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \\\"\" + pnKey + \"\\\": must have length <= {max}, got \" + pnLength));\n{indent} }}\n" + "{indent} if (pnLength > {max_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \" + Violation.quote(pnKey) + \": must have length <= {max}, got \" + pnLength));\n{indent} }}\n" )); } } + render_java_property_name_pattern_check(output, &constraints, &format!("{indent} ")); let mut non_length = constraints.clone(); non_length.min_length = None; non_length.max_length = None; + non_length.pattern = None; + non_length.authored_pattern = None; render_java_string_checks( output, "pnKey", @@ -3935,6 +3953,27 @@ fn render_java_serialize_property_name_checks( output.push_str(&format!("{indent}}}\n")); } +/// Emits the `propertyNames` `pattern` check over `pnKey`. Unlike a value +/// position, the reason leads with the `invalid property name "": ` +/// prefix every `propertyNames` reason carries and has no `, got` suffix (the +/// prefix already names the key). See `specs/json-schema/features/propertyNames.md`. +fn render_java_property_name_pattern_check( + output: &mut String, + constraints: &StringLengthConstraints, + indent: &str, +) { + if let Some(authored) = &constraints.authored_pattern { + output.push_str(&format!( + "{indent}if (!{field_pattern}.matcher(pnKey).find()) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \" + Violation.quote(pnKey) + {reason_literal}));\n{indent}}}\n", + field_pattern = java_pattern_field_name(PROPERTY_NAME_POSITION), + reason_literal = java_string_literal(&format!( + ": {}", + crate::json_schema::pattern::violation_reason(authored) + )), + )); + } +} + fn render_java_closed_string_checks( output: &mut String, value_expr: &str, @@ -6248,6 +6287,37 @@ pub(in crate::generator) fn render_violation_file(package: &str) -> String { output.push_str(" public static String memberPath(String key) {\n"); output.push_str(" if (key.matches(\"[A-Za-z_][A-Za-z0-9_]*\")) {\n return key;\n }\n"); output.push_str(" return \"[\\\"\" + key.replace(\"\\\\\", \"\\\\\\\\\").replace(\"\\\"\", \"\\\\\\\"\") + \"\\\"]\";\n }\n\n"); + // quote renders a string the way every target quotes an offending value or + // key in a reason: a JSON string literal (Go `quoteValue`, Python + // `json.dumps(ensure_ascii=False)`, TypeScript `JSON.stringify`). + output.push_str( + " /** Quotes {@code value} as a JSON string literal for a violation reason. */\n", + ); + output.push_str(" public static String quote(String value) {\n"); + output.push_str( + " StringBuilder out = new StringBuilder(value.length() + 2).append('\"');\n", + ); + output.push_str(" for (int i = 0; i < value.length(); i++) {\n"); + output.push_str(" char c = value.charAt(i);\n"); + output.push_str(" if (c == '\"') {\n out.append(\"\\\\\\\"\");\n"); + output.push_str( + " } else if (c == '\\\\') {\n out.append(\"\\\\\\\\\");\n", + ); + output + .push_str(" } else if (c == '\\b') {\n out.append(\"\\\\b\");\n"); + output + .push_str(" } else if (c == '\\f') {\n out.append(\"\\\\f\");\n"); + output + .push_str(" } else if (c == '\\n') {\n out.append(\"\\\\n\");\n"); + output + .push_str(" } else if (c == '\\r') {\n out.append(\"\\\\r\");\n"); + output + .push_str(" } else if (c == '\\t') {\n out.append(\"\\\\t\");\n"); + output.push_str(" } else if (c < 0x20) {\n out.append(String.format(\"\\\\u%04x\", (int) c));\n"); + output.push_str(" } else {\n out.append(c);\n }\n"); + output.push_str(" }\n"); + output.push_str(" return out.append('\"').toString();\n"); + output.push_str(" }\n\n"); output.push_str(" public Violation withPathPrefix(String prefix) {\n"); output.push_str(" if (path == null || path.isEmpty()) {\n return new Violation(prefix, reason);\n }\n"); output.push_str(" return new Violation(path.startsWith(\"[\") ? prefix + path : prefix + \".\" + path, reason);\n }\n\n"); diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index 739cf9d1..c8f38733 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -1854,10 +1854,12 @@ fn render_py_string_checks( } } -/// Emits the `pattern` predicate. The message reads the pattern text back off -/// the compiled object (`.pattern`) rather than embedding it in the f-string, -/// which sidesteps escaping a regex inside a Python string literal entirely. -/// `re.search` is unanchored — never `match` (anchors the start) or `fullmatch`. +/// Emits the `pattern` predicate. The check runs the compiled (`\Z`-rewritten) +/// regex, but the message quotes the *authored* pattern — the same text every +/// other target prints — as a plain string literal concatenated with the +/// offending value (never interpolated into an f-string, so no brace or quote +/// in the regex needs f-string escaping). `re.search` is unanchored — never +/// `match` (anchors the start) or `fullmatch`. fn render_py_pattern_check( output: &mut String, value_expr: &str, @@ -1872,7 +1874,13 @@ fn render_py_pattern_check( indent, &format!("{const_name}.search({value_expr}) is None"), path_expr, - &format!("f\"must match pattern {{{const_name}.pattern}}, got {{_quote({value_expr})}}\""), + &format!( + "{} + _quote({value_expr})", + python_string_literal(&format!( + "{}, got ", + crate::json_schema::pattern::violation_reason(pattern) + )) + ), ); } @@ -2070,7 +2078,8 @@ fn render_py_property_name_checks( &format!("{const_name}.search(key) is None"), "_member_path(key)", &format!( - "f'invalid property name {{_quote(key)}}: must match pattern {{{const_name}.pattern}}'" + "f'invalid property name {{_quote(key)}}: ' + {}", + python_string_literal(&crate::json_schema::pattern::violation_reason(pattern)) ), ); } diff --git a/src/generator/json_schema/typescript.rs b/src/generator/json_schema/typescript.rs index 101b81f9..cdff5a43 100644 --- a/src/generator/json_schema/typescript.rs +++ b/src/generator/json_schema/typescript.rs @@ -678,12 +678,12 @@ fn render_ts_pattern_check( indent: &str, ) { let const_name = ts_pattern_const_name(pattern); - let escaped = ts_template_escape(pattern); + let escaped = ts_template_escape(&crate::json_schema::pattern::violation_reason(pattern)); output.push_str(indent); output.push_str(&format!("if (!{const_name}.test({value_expr})) {{\n")); output.push_str(indent); output.push_str(&format!( - " violations.push({{ path: {path_expr}, reason: `must match pattern {escaped}, got ${{JSON.stringify({value_expr})}}` }});\n" + " violations.push({{ path: {path_expr}, reason: `{escaped}, got ${{JSON.stringify({value_expr})}}` }});\n" )); output.push_str(indent); output.push_str("}\n"); @@ -854,7 +854,7 @@ fn render_ts_property_name_checks( output.push_str(&format!("if ({condition}) {{\n")); output.push_str(&inner); output.push_str(&format!( - " violations.push({{ path: {DEFINITIONS_NAMESPACE}.memberPath(key), reason: `invalid property name \"${{key}}\": {reason}` }});\n" + " violations.push({{ path: {DEFINITIONS_NAMESPACE}.memberPath(key), reason: `invalid property name ${{JSON.stringify(key)}}: {reason}` }});\n" )); output.push_str(&inner); output.push_str("}\n"); @@ -896,12 +896,12 @@ fn render_ts_property_name_checks( drop(emit); if let Some(pattern) = &matcher.pattern { let const_name = ts_pattern_const_name(pattern); - let escaped = ts_template_escape(pattern); + let escaped = ts_template_escape(&crate::json_schema::pattern::violation_reason(pattern)); output.push_str(&inner); output.push_str(&format!("if (!{const_name}.test(key)) {{\n")); output.push_str(&inner); output.push_str(&format!( - " violations.push({{ path: {DEFINITIONS_NAMESPACE}.memberPath(key), reason: `invalid property name \"${{key}}\": must match pattern {escaped}, got ${{JSON.stringify(key)}}` }});\n" + " violations.push({{ path: {DEFINITIONS_NAMESPACE}.memberPath(key), reason: `invalid property name ${{JSON.stringify(key)}}: {escaped}` }});\n" )); output.push_str(&inner); output.push_str("}\n"); @@ -921,7 +921,7 @@ fn render_ts_property_name_checks( output.push_str(&format!("!{const_name}.test(key)) {{\n")); output.push_str(&inner); output.push_str(&format!( - " violations.push({{ path: {DEFINITIONS_NAMESPACE}.memberPath(key), reason: `invalid property name \"${{key}}\": must be a valid {}, got ${{JSON.stringify(key)}}` }});\n", + " violations.push({{ path: {DEFINITIONS_NAMESPACE}.memberPath(key), reason: `invalid property name ${{JSON.stringify(key)}}: must be a valid {}, got ${{JSON.stringify(key)}}` }});\n", check.name )); output.push_str(&inner); diff --git a/src/json_schema/pattern.rs b/src/json_schema/pattern.rs index c4a491f5..a9221729 100644 --- a/src/json_schema/pattern.rs +++ b/src/json_schema/pattern.rs @@ -132,6 +132,18 @@ pub fn gate_and_normalize(pattern: &str) -> Result { Ok(apply_edits(pattern, edits)) } +/// The target-neutral `must match pattern ""` lead of a `pattern` +/// violation reason. `pattern` is the loader-normalized text (never a +/// per-target `$` rewrite), quoted as a JSON string — the same form every +/// target quotes the offending value in — so every target prints the same +/// bytes for the same schema. Backends embed it as a literal in their own +/// string grammar and append `, got ` for value positions. +/// See `specs/json-schema/features/pattern.md` ("Informative reason strings"). +pub fn violation_reason(pattern: &str) -> String { + let quoted = serde_json::to_string(pattern).expect("a string always serializes"); + format!("must match pattern {quoted}") +} + /// Rewrite every unescaped `$` end-anchor assertion to `replacement` (Python /// `\Z`, Java `\z`), leaving `^`, escaped `\$`, and literals untouched. Applied /// by the Python/Java backends at emit time; Go/JS keep `$` (already diff --git a/tests/generate_java.rs b/tests/generate_java.rs index b2c54647..bace5898 100644 --- a/tests/generate_java.rs +++ b/tests/generate_java.rs @@ -461,6 +461,10 @@ fn java_json_validates_non_object_union_branch_constraints() { assert!(declaring.contains("void validate(String path, List violations) {")); assert!(declaring.contains("must have length >= 3, got ")); assert!(declaring.contains("if (!VALUE_PATTERN.matcher(value).find()) {")); + // The reason quotes the authored pattern, not the `\z`-rewritten one the + // static compiles — the same text every other target prints. + assert!(declaring.contains("\"must match pattern \\\"^[a-z]+$\\\", got \" + Violation.quote(")); + assert!(!declaring.contains("must match pattern \\\"^[a-z]+\\\\z")); assert!(declaring.contains("must be >= 1, got ")); assert!(declaring.contains("must have at least 1 items, got ")); assert!(declaring.contains("duplicate items: element at index ")); diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 8a7dac25..a531a09e 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -323,7 +323,7 @@ reported = violations(lambda: converter(PatternNames).from_transfer_type( )) assert reported == [ ("A", 'invalid property name "A": must have length >= 2, got 1'), - ("A", r'invalid property name "A": must match pattern ^[a-z]+\Z'), + ("A", 'invalid property name "A": must match pattern "^[a-z]+$"'), ("toolongkey", 'invalid property name "toolongkey": must have length <= 8, got 10'), ], reported assert violations(lambda: converter(EnumNames).to_transfer_type( @@ -337,10 +337,10 @@ assert violations(lambda: converter(FormatNames).from_transfer_type( # canonical string produced from a native value. assert violations(lambda: converter(Contract).from_transfer_type( {"known": "ok", "day": "2025-08-21"}, Contract -)) == [("day", 'must match pattern ^2026-, got "2025-08-21"')] +)) == [("day", 'must match pattern "^2026-", got "2025-08-21"')] changed = Contract(known="ok", day=datetime.date(2025, 8, 21)) assert violations(lambda: converter(Contract).to_transfer_type(changed)) == [ - ("day", 'must match pattern ^2026-, got "2025-08-21"') + ("day", 'must match pattern "^2026-", got "2025-08-21"') ] wrong_closed = Contract( @@ -353,7 +353,7 @@ reported = violations(lambda: converter(Contract).to_transfer_type(wrong_closed) assert reported == [ ("fixedDay", 'must equal "2026-08-21"'), ("dayChoice", 'must be one of ["2026-08-21", "2026-08-22"], got "2026-08-23"'), - ("fixedBlob", r'must match pattern ^YQ==\Z, got "Yg=="'), + ("fixedBlob", 'must match pattern "^YQ==$", got "Yg=="'), ("fixedBlob", 'must equal "YQ=="'), ], reported "#; @@ -523,7 +523,7 @@ assert converter.to_transfer_type(model) == valid, converter.to_transfer_type(mo expected = [ ("violations", "must have length >= 2, got 1"), ("math", "must be a finite number, got inf"), - ("re", 'must match pattern ^[a-z]+\\Z, got "ABC"'), + ("re", 'must match pattern "^[a-z]+$", got "ABC"'), ] # The critical case: a property named `violations` rebound the violation @@ -1868,6 +1868,12 @@ fn python_json_validates_non_object_union_branch_constraints() { rendered.contains("_PATTERN_F242E3A159C2422C = re.compile(\"^[a-z]+\\\\Z\", re.ASCII)") ); assert!(rendered.contains("if _PATTERN_F242E3A159C2422C.search(value) is None:")); + // The reason quotes the authored pattern, not the `\Z`-rewritten source the + // compiled const carries — the same text every other target prints. + assert!( + rendered.contains("reason=\"must match pattern \\\"^[a-z]+$\\\", got \" + _quote(value)") + ); + assert!(!rendered.contains("must match pattern {_PATTERN_")); assert!(rendered.contains("_check_unique_items(")); // The array branch's element schema is part of the serialize contract too: // its unconstrained `number` still contributes the uniform finiteness guard diff --git a/tests/json_schema_violation_reasons.rs b/tests/json_schema_violation_reasons.rs new file mode 100644 index 00000000..92b4137b --- /dev/null +++ b/tests/json_schema_violation_reasons.rs @@ -0,0 +1,149 @@ +//! `pattern` violation reasons, compared byte-for-byte across the four runtimes. +//! +//! The conformance manifest compares violation *paths* only (P11 leaves reason +//! wording target-idiomatic in general), but `pattern.md` and +//! `propertyNames.md` define one exact `pattern` reason form. This drives the +//! same wire values through generated Go, Java, Python and TypeScript and +//! requires every target to report exactly the specified `(path, reason)` set — +//! including a pattern and values that contain `"`, `\` and control characters, +//! where ad-hoc quoting (Go `%q`, plain wrapping, a bare value) would diverge. + +mod toolchain; + +use std::collections::BTreeMap; +use std::fs; + +use serde_json::json; + +use toolchain::{PlanCase, Probe, ProbeKind, TARGETS, Violation, Workspace}; + +const MODEL: &str = "PatternReasons"; +const DIR: &str = "pattern_reasons"; + +fn violation(path: &str, reason: &str) -> Violation { + Violation { + path: path.to_string(), + reason: reason.to_string(), + } +} + +#[test] +fn pattern_violation_reasons_are_identical_in_every_runtime() { + let workspace = Workspace::new("pattern-reasons"); + let schemas = workspace.root().join("schemas"); + fs::create_dir_all(&schemas).expect("create schema directory"); + let schema = schemas.join(format!("{MODEL}.json")); + let document = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "code": { "type": "string", "pattern": "^[a-z]+$" }, + // A pattern containing `"` and `\`: the reason quotes it as a JSON + // string, so both are backslash-escaped identically everywhere. + "quoted": { "type": "string", "pattern": "^\"[a-z]\\.\"$" }, + "names": { + "type": "object", + "additionalProperties": { "type": "string" }, + "propertyNames": { "type": "string", "pattern": "^[a-z]+$" } + } + } + }); + fs::write( + &schema, + serde_json::to_vec_pretty(&document).expect("render schema"), + ) + .expect("write schema"); + for target in TARGETS { + workspace + .generate(target, &schema, DIR) + .unwrap_or_else(|error| panic!("{target}: generate: {error}")); + } + + let parse_wire = serde_json::to_string(&json!({ + "code": "AB1", + "quoted": "x\"\\\t\u{1}", + "names": { "ok": "x", "Bad \"Key\"": "y" } + })) + .expect("render wire"); + let plan = vec![PlanCase { + id: "pattern-reasons".to_string(), + dir: DIR.to_string(), + model: MODEL.to_string(), + java_model: MODEL.to_string(), + probes: vec![ + Probe { + id: "parse".to_string(), + kind: ProbeKind::Parse, + wire: parse_wire, + mutations: Vec::new(), + }, + Probe { + id: "serialize".to_string(), + kind: ProbeKind::Serialize, + wire: r#"{"code":"ab","quoted":"\"a.\"","names":{"ok":"x"}}"#.to_string(), + mutations: vec![json!({ + "path": "names", + "put_map_entry": { "key": "BAD", "value": "x" } + })], + }, + ], + }]; + + let expected: BTreeMap<&str, Vec> = BTreeMap::from([ + ( + "parse", + vec![ + violation("code", r#"must match pattern "^[a-z]+$", got "AB1""#), + violation( + r#"names["Bad \"Key\""]"#, + r#"invalid property name "Bad \"Key\"": must match pattern "^[a-z]+$""#, + ), + violation( + "quoted", + r#"must match pattern "^\"[a-z]\\.\"$", got "x\"\\\t\u0001""#, + ), + ], + ), + ( + "serialize", + vec![violation( + "names.BAD", + r#"invalid property name "BAD": must match pattern "^[a-z]+$""#, + )], + ), + ]); + + let mut findings = Vec::new(); + for target in TARGETS { + let verdicts = match toolchain::run_target(&workspace, target, &plan) { + Ok(verdicts) => verdicts, + Err(error) => { + findings.push(format!("{target}: {}", toolchain::brief(&error))); + continue; + } + }; + for (probe, want) in &expected { + let Some(verdict) = verdicts + .get("pattern-reasons") + .and_then(|probes| probes.get(*probe)) + else { + findings.push(format!("{target} {probe}: no verdict")); + continue; + }; + let mut got = verdict.violations.clone(); + got.sort(); + if &got != want { + findings.push(format!( + "{target} {probe} ({}):\n got {got:?}\n want {want:?}", + verdict.outcome + )); + } + } + } + assert!( + findings.is_empty(), + "pattern violation reasons diverge from the specified form:\n {}", + findings.join("\n ") + ); +}