Skip to content

Commit 7b71cc5

Browse files
Issue #136 Add jtd-esm-codegen module and CLI generator
Co-authored-by: Simon Massey <simbo1905@users.noreply.github.com>
1 parent 3faec69 commit 7b71cc5

File tree

10 files changed

+939
-0
lines changed

10 files changed

+939
-0
lines changed

jtd-esm-codegen/pom.xml

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<parent>
8+
<groupId>io.github.simbo1905.json</groupId>
9+
<artifactId>parent</artifactId>
10+
<version>0.1.9</version>
11+
</parent>
12+
13+
<artifactId>jtd-esm-codegen</artifactId>
14+
<packaging>jar</packaging>
15+
16+
<name>JTD to ES2020 Validator Code Generator (Experimental)</name>
17+
<url>https://simbo1905.github.io/java.util.json.Java21/</url>
18+
<scm>
19+
<connection>scm:git:https://github.com/simbo1905/java.util.json.Java21.git</connection>
20+
<developerConnection>scm:git:git@github.com:simbo1905/java.util.json.Java21.git</developerConnection>
21+
<url>https://github.com/simbo1905/java.util.json.Java21</url>
22+
<tag>HEAD</tag>
23+
</scm>
24+
<description>Experimental CLI that generates vanilla ES2020 ESM validators from a deliberately-limited JTD (RFC 8927) subset for browser payload validation.</description>
25+
26+
<properties>
27+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
28+
<maven.compiler.release>21</maven.compiler.release>
29+
<maven-shade-plugin.version>3.6.0</maven-shade-plugin.version>
30+
</properties>
31+
32+
<dependencies>
33+
<dependency>
34+
<groupId>io.github.simbo1905.json</groupId>
35+
<artifactId>java.util.json</artifactId>
36+
<version>${project.version}</version>
37+
</dependency>
38+
39+
<!-- Test dependencies -->
40+
<dependency>
41+
<groupId>org.junit.jupiter</groupId>
42+
<artifactId>junit-jupiter-api</artifactId>
43+
<scope>test</scope>
44+
</dependency>
45+
<dependency>
46+
<groupId>org.junit.jupiter</groupId>
47+
<artifactId>junit-jupiter-engine</artifactId>
48+
<scope>test</scope>
49+
</dependency>
50+
<dependency>
51+
<groupId>org.junit.jupiter</groupId>
52+
<artifactId>junit-jupiter-params</artifactId>
53+
<scope>test</scope>
54+
</dependency>
55+
<dependency>
56+
<groupId>org.assertj</groupId>
57+
<artifactId>assertj-core</artifactId>
58+
<scope>test</scope>
59+
</dependency>
60+
</dependencies>
61+
62+
<build>
63+
<finalName>jtd-esm-codegen</finalName>
64+
<plugins>
65+
<!-- Treat all warnings as errors, enable all lint warnings -->
66+
<plugin>
67+
<groupId>org.apache.maven.plugins</groupId>
68+
<artifactId>maven-compiler-plugin</artifactId>
69+
<configuration>
70+
<release>21</release>
71+
<compilerArgs>
72+
<arg>-Xlint:all</arg>
73+
<arg>-Werror</arg>
74+
<arg>-Xdiags:verbose</arg>
75+
</compilerArgs>
76+
</configuration>
77+
</plugin>
78+
79+
<plugin>
80+
<groupId>org.apache.maven.plugins</groupId>
81+
<artifactId>maven-surefire-plugin</artifactId>
82+
<configuration>
83+
<argLine>-ea</argLine>
84+
</configuration>
85+
</plugin>
86+
87+
<!-- Build an uber JAR suitable for `java -jar` and GraalVM native-image -->
88+
<plugin>
89+
<groupId>org.apache.maven.plugins</groupId>
90+
<artifactId>maven-shade-plugin</artifactId>
91+
<version>${maven-shade-plugin.version}</version>
92+
<executions>
93+
<execution>
94+
<phase>package</phase>
95+
<goals>
96+
<goal>shade</goal>
97+
</goals>
98+
<configuration>
99+
<createDependencyReducedPom>false</createDependencyReducedPom>
100+
<transformers>
101+
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
102+
<mainClass>io.github.simbo1905.json.jtd.codegen.JtdToEsmCli</mainClass>
103+
</transformer>
104+
</transformers>
105+
</configuration>
106+
</execution>
107+
</executions>
108+
</plugin>
109+
</plugins>
110+
</build>
111+
</project>
112+
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package io.github.simbo1905.json.jtd.codegen;
2+
3+
import java.time.Instant;
4+
import java.util.ArrayList;
5+
import java.util.Comparator;
6+
import java.util.LinkedHashMap;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.Objects;
10+
import java.util.TreeMap;
11+
12+
import static io.github.simbo1905.json.jtd.codegen.JtdNode.*;
13+
14+
/// Renders an ES2020 JavaScript module exporting `validate(instance)`.
15+
///
16+
/// The generated validator:
17+
/// - Treats the root as a JSON object (non-array, non-null)
18+
/// - Checks required `properties` presence and validates leaf `type`/`enum`
19+
/// - Checks optional `optionalProperties` only when present
20+
/// - Ignores additional properties (RFC 8927 "properties" form allows them)
21+
final class EsmRenderer {
22+
private EsmRenderer() {}
23+
24+
static String render(SchemaNode schema, String sha256Hex, String sha256Prefix8) {
25+
Objects.requireNonNull(schema, "schema must not be null");
26+
Objects.requireNonNull(sha256Hex, "sha256Hex must not be null");
27+
Objects.requireNonNull(sha256Prefix8, "sha256Prefix8 must not be null");
28+
29+
final var sb = new StringBuilder(8 * 1024);
30+
31+
sb.append("// ").append(schema.id()).append("-").append(sha256Prefix8).append(".js\n");
32+
sb.append("// Generated from JTD schema: ").append(schema.id()).append("\n");
33+
sb.append("// SHA-256: ").append(sha256Prefix8).append("...").append("\n");
34+
sb.append("// WARNING: Experimental - flat schemas only\n");
35+
sb.append("// Generated at: ").append(Instant.now()).append("\n\n");
36+
37+
sb.append("const SCHEMA_ID = ").append(jsString(schema.id())).append(";\n\n");
38+
39+
final var enumConsts = enumConstants(schema);
40+
for (var e : enumConsts.entrySet()) {
41+
sb.append("const ").append(e.getKey()).append(" = ").append(jsStringArray(e.getValue())).append(";\n");
42+
}
43+
if (!enumConsts.isEmpty()) {
44+
sb.append("\n");
45+
}
46+
47+
sb.append("function isString(v) { return typeof v === \"string\"; }\n");
48+
sb.append("function isBoolean(v) { return typeof v === \"boolean\"; }\n");
49+
sb.append("function isTimestamp(v) { return typeof v === \"string\" && !Number.isNaN(Date.parse(v)); }\n");
50+
sb.append("function isNumber(v) { return typeof v === \"number\" && Number.isFinite(v); }\n");
51+
sb.append("function isInt(v) { return Number.isInteger(v); }\n");
52+
sb.append("function isIntRange(v, min, max) { return isInt(v) && v >= min && v <= max; }\n\n");
53+
54+
sb.append("export function validate(instance) {\n");
55+
sb.append(" const errors = [];\n\n");
56+
57+
sb.append(" if (instance === null || typeof instance !== \"object\" || Array.isArray(instance)) {\n");
58+
sb.append(" errors.push({ instancePath: \"\", schemaPath: \"\" });\n");
59+
sb.append(" return errors;\n");
60+
sb.append(" }\n\n");
61+
62+
final var required = new TreeMap<>(schema.properties());
63+
for (var p : required.values()) {
64+
renderRequiredProperty(sb, p, enumConsts, "/properties");
65+
sb.append("\n");
66+
}
67+
68+
final var optional = new TreeMap<>(schema.optionalProperties());
69+
for (var p : optional.values()) {
70+
renderOptionalProperty(sb, p, enumConsts, "/optionalProperties");
71+
sb.append("\n");
72+
}
73+
74+
sb.append(" return errors;\n");
75+
sb.append("}\n\n");
76+
sb.append("export { SCHEMA_ID };\n");
77+
78+
return sb.toString();
79+
}
80+
81+
private static Map<String, List<String>> enumConstants(SchemaNode schema) {
82+
final var out = new LinkedHashMap<String, List<String>>();
83+
final var allProps = new ArrayList<PropertyNode>();
84+
allProps.addAll(schema.properties().values());
85+
allProps.addAll(schema.optionalProperties().values());
86+
allProps.sort(Comparator.comparing(PropertyNode::name));
87+
88+
int i = 0;
89+
for (var p : allProps) {
90+
if (p.type() instanceof EnumNode en) {
91+
final String base = "ENUM_" + toConstName(p.name());
92+
String name = base;
93+
while (out.containsKey(name)) {
94+
i++;
95+
name = base + "_" + i;
96+
}
97+
out.put(name, en.values());
98+
}
99+
}
100+
return out;
101+
}
102+
103+
private static void renderRequiredProperty(StringBuilder sb, PropertyNode p, Map<String, List<String>> enumConsts, String schemaPrefix) {
104+
final String prop = p.name();
105+
final String schemaPathProp = schemaPrefix + "/" + pointerEscape(prop);
106+
107+
sb.append(" // Required: ").append(prop).append("\n");
108+
sb.append(" if (!(\"").append(jsStringRaw(prop)).append("\" in instance)) {\n");
109+
sb.append(" errors.push({ instancePath: \"\", schemaPath: \"").append(schemaPathProp).append("\" });\n");
110+
sb.append(" } else {\n");
111+
112+
renderLeafCheck(sb, "instance[" + jsString(prop) + "]", "/" + pointerEscape(prop), schemaPathProp, p.type(), enumConsts);
113+
114+
sb.append(" }\n");
115+
}
116+
117+
private static void renderOptionalProperty(StringBuilder sb, PropertyNode p, Map<String, List<String>> enumConsts, String schemaPrefix) {
118+
final String prop = p.name();
119+
final String schemaPathProp = schemaPrefix + "/" + pointerEscape(prop);
120+
121+
sb.append(" // Optional: ").append(prop).append("\n");
122+
sb.append(" if (\"").append(jsStringRaw(prop)).append("\" in instance) {\n");
123+
124+
renderLeafCheck(sb, "instance[" + jsString(prop) + "]", "/" + pointerEscape(prop), schemaPathProp, p.type(), enumConsts);
125+
126+
sb.append(" }\n");
127+
}
128+
129+
private static void renderLeafCheck(
130+
StringBuilder sb,
131+
String valueExpr,
132+
String instancePath,
133+
String schemaPathProp,
134+
JtdNode node,
135+
Map<String, List<String>> enumConsts
136+
) {
137+
switch (node) {
138+
case EmptyNode ignored -> {
139+
// Empty schema accepts any value.
140+
}
141+
case TypeNode tn -> {
142+
final String type = tn.type();
143+
final String check = typeCheckExpr(type, valueExpr);
144+
sb.append(" if (!(").append(check).append(")) {\n");
145+
sb.append(" errors.push({ instancePath: \"").append(instancePath).append("\", schemaPath: \"")
146+
.append(schemaPathProp).append("/type\" });\n");
147+
sb.append(" }\n");
148+
}
149+
case EnumNode en -> {
150+
final String constName = findEnumConst(enumConsts, en.values());
151+
sb.append(" if (!").append(constName).append(".includes(").append(valueExpr).append(")) {\n");
152+
sb.append(" errors.push({ instancePath: \"").append(instancePath).append("\", schemaPath: \"")
153+
.append(schemaPathProp).append("/enum\" });\n");
154+
sb.append(" }\n");
155+
}
156+
default -> throw new IllegalStateException("Unexpected node in leaf position: " + node);
157+
}
158+
}
159+
160+
private static String findEnumConst(Map<String, List<String>> enumConsts, List<String> values) {
161+
for (var e : enumConsts.entrySet()) {
162+
if (e.getValue().equals(values)) {
163+
return e.getKey();
164+
}
165+
}
166+
throw new IllegalStateException("Enum constants map missing values: " + values);
167+
}
168+
169+
private static String typeCheckExpr(String type, String valueExpr) {
170+
return switch (type) {
171+
case "string" -> "isString(" + valueExpr + ")";
172+
case "boolean" -> "isBoolean(" + valueExpr + ")";
173+
case "timestamp" -> "isTimestamp(" + valueExpr + ")";
174+
case "float32", "float64" -> "isNumber(" + valueExpr + ")";
175+
case "int8" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -128, 127)";
176+
case "uint8" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 255)";
177+
case "int16" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -32768, 32767)";
178+
case "uint16" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 65535)";
179+
case "int32" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -2147483648, 2147483647)";
180+
case "uint32" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 4294967295)";
181+
default -> throw new IllegalArgumentException("Unsupported type: " + type);
182+
};
183+
}
184+
185+
private static String toConstName(String propName) {
186+
final var sb = new StringBuilder(propName.length() + 8);
187+
for (int i = 0; i < propName.length(); i++) {
188+
final char c = propName.charAt(i);
189+
if (c >= 'a' && c <= 'z') {
190+
sb.append((char) (c - 32));
191+
} else if (c >= 'A' && c <= 'Z') {
192+
sb.append(c);
193+
} else if (c >= '0' && c <= '9') {
194+
sb.append(c);
195+
} else {
196+
sb.append('_');
197+
}
198+
}
199+
if (sb.isEmpty()) {
200+
return "PROP";
201+
}
202+
if (sb.charAt(0) >= '0' && sb.charAt(0) <= '9') {
203+
sb.insert(0, "P_");
204+
}
205+
return sb.toString();
206+
}
207+
208+
/// Escape a JSON Pointer path segment.
209+
private static String pointerEscape(String s) {
210+
return s.replace("~", "~0").replace("/", "~1");
211+
}
212+
213+
private static String jsString(String s) {
214+
return "\"" + jsStringRaw(s) + "\"";
215+
}
216+
217+
private static String jsStringRaw(String s) {
218+
final var sb = new StringBuilder(s.length() + 8);
219+
for (int i = 0; i < s.length(); i++) {
220+
final char c = s.charAt(i);
221+
switch (c) {
222+
case '\\' -> sb.append("\\\\");
223+
case '"' -> sb.append("\\\"");
224+
case '\n' -> sb.append("\\n");
225+
case '\r' -> sb.append("\\r");
226+
case '\t' -> sb.append("\\t");
227+
default -> sb.append(c);
228+
}
229+
}
230+
return sb.toString();
231+
}
232+
233+
private static String jsStringArray(List<String> values) {
234+
final var sb = new StringBuilder(values.size() * 16);
235+
sb.append("[");
236+
for (int i = 0; i < values.size(); i++) {
237+
if (i > 0) sb.append(", ");
238+
sb.append(jsString(values.get(i)));
239+
}
240+
sb.append("]");
241+
return sb.toString();
242+
}
243+
}
244+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.github.simbo1905.json.jtd.codegen;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
/// Minimal AST for the experimental "flat JTD → ESM validator" code generator.
7+
///
8+
/// Supported JTD subset:
9+
/// - root: `properties`, `optionalProperties`, `metadata.id`
10+
/// - leaf: `{}` (empty form), `{ "type": ... }`, `{ "enum": [...] }`
11+
sealed interface JtdNode permits JtdNode.SchemaNode, JtdNode.PropertyNode, JtdNode.TypeNode, JtdNode.EnumNode, JtdNode.EmptyNode {
12+
13+
record SchemaNode(
14+
String id, // metadata.id
15+
Map<String, PropertyNode> properties,
16+
Map<String, PropertyNode> optionalProperties
17+
) implements JtdNode {}
18+
19+
record PropertyNode(String name, JtdNode type) implements JtdNode {}
20+
21+
/// JTD primitive type keyword as a string, e.g. "string", "int32", "timestamp".
22+
record TypeNode(String type) implements JtdNode {}
23+
24+
/// Enum values (strings only in RFC 8927).
25+
record EnumNode(List<String> values) implements JtdNode {}
26+
27+
/// Empty form `{}`: accepts any JSON value.
28+
record EmptyNode() implements JtdNode {}
29+
}
30+

0 commit comments

Comments
 (0)