/src/generated/java.
"""
let generatedEntrypoint(moduleDir: Directory!, name: String!): Directory! {
+ processed(moduleDir, name).directory("/module/target/generated-sources/annotations")
+ }
+
+ """
+ The manifest-v2 entrypoint the processor emits alongside the Java one, as Dang.
+
+ It is written as a resource rather than a source file, so it lands beside the
+ compiled classes instead of in the generated-sources tree that becomes the
+ module's committed Java.
+ """
+ let generatedDangEntrypoint(moduleDir: Directory!, name: String!): File! {
+ processed(moduleDir, name).file("/module/target/classes/dagger/entrypoint/main.dang")
+ }
+
+ """
+ Run the annotation processor over a module and return the container, so both
+ of its outputs come from one build.
+ """
+ let processed(moduleDir: Directory!, name: String!): Container! {
mvn
.withoutEntrypoint
.withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2"))
@@ -205,7 +224,6 @@ type Mod {
.withWorkdir("/module")
.withEnvVariable("_DAGGER_JAVA_SDK_MODULE_NAME", name)
.withExec(["mvn", "compile", "-Ddagger.proc=full", "-Ddagger.sdk=prebuilt", "-Ddagger.sdk.version=" + name, "--no-transfer-progress"])
- .directory("/module/target/generated-sources/annotations")
}
"""
@@ -258,6 +276,89 @@ type Mod {
after.changes(wsWithDeps)
}
+ """
+ Stage the manifest-v2 entrypoint and manifest for this module, on top of the
+ regular generated output.
+
+ Prototype only — nothing loads either file yet. See
+ hack/designs/2026-09-02-manifest-v2-prototype.md.
+
+ The manifest is written as dagger-module.v2.toml rather than replacing
+ dagger-module.toml: this SDK reads a module's schema by asking the engine to
+ load it, and engine v1.0.0-beta.11 loads manifest v1 only. Replacing the
+ manifest would make the module unloadable, so this function could never run on
+ it twice.
+ """
+ let generateV2Module(ws: Workspace!): Changeset! {
+ let wsWithDeps = ws.withChanges(
+ ws.moduleSource(workspaceRef(rootPath)).generateLocalDependencies(ws),
+ )
+
+ let modSource = wsWithDeps.moduleSource(workspaceRef(rootPath))
+ let name = modSource.moduleName
+ let introspectionJSON = modSource.introspectionSchemaJSON
+ let vendored = vendoredSdk(introspectionJSON, name)
+
+ let baseDir = moduleDir(wsWithDeps, rootPath)
+ .withoutDirectory("sdk")
+ .withDirectory("sdk", vendored)
+ .withoutDirectory("src/generated")
+
+ let staged = wsWithDeps
+ .withNewDirectory(workspaceRef(joinPath(rootPath, "sdk")), vendored)
+ .withNewDirectory(
+ workspaceRef(joinPath(rootPath, "src/generated/java")),
+ generatedEntrypoint(baseDir, name),
+ )
+ .withNewFile(
+ workspaceRef(joinPath(rootPath, "src/generated/dang/entrypoint/main.dang")),
+ generatedDangEntrypoint(baseDir, name).contents,
+ )
+ .withNewFile(
+ workspaceRef(joinPath(rootPath, "dagger-module.v2.toml")),
+ # Exactly the three top-level keys a v2 manifest may carry. The engine
+ # rejects any other, engineVersion included.
+ "manifestVersion = 2\n"
+ + "name = \""
+ + tomlString(modSource.moduleOriginalName)
+ + "\"\n"
+ + "\n"
+ + "[entrypoint]\n"
+ + "kind = \"dang\"\n"
+ + "source = \"./src/generated/dang/entrypoint\"\n",
+ )
+
+ let after = if (vendorSdkJar) {
+ staged.withNewDirectory(
+ workspaceRef(joinPath(rootPath, "sdk/repo")),
+ vendoredSdkJar(introspectionJSON, name),
+ )
+ } else {
+ staged
+ }
+
+ after.changes(wsWithDeps)
+ }
+
+ """
+ Escape a value for a TOML basic string.
+
+ TOML gives backslash, quote, backspace, form feed, newline, carriage return and
+ tab their own escapes and forbids the remaining control characters outright.
+ Covering all seven means a module name cannot produce a manifest the engine
+ reads differently from the one intended.
+ """
+ let tomlString(value: String!): String! {
+ value
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\u0008", "\\b")
+ .replace("\u000c", "\\f")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ }
+
"""
The module's committed source directory, workspace-rooted.
diff --git a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java
index 83fd5fe..1fb2611 100644
--- a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java
+++ b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java
@@ -17,6 +17,7 @@
import io.dagger.client.ID;
import io.dagger.client.JSON;
import io.dagger.client.JsonConverter;
+import io.dagger.client.ModuleDispatcher;
import io.dagger.client.TypeDef;
import io.dagger.client.exception.DaggerExecException;
import io.dagger.client.exception.DaggerQueryException;
@@ -40,6 +41,7 @@
import io.dagger.module.info.ParameterInfo;
import io.dagger.module.info.TypeInfo;
import java.io.IOException;
+import java.io.Writer;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.HashMap;
@@ -68,6 +70,8 @@
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
+import javax.tools.FileObject;
+import javax.tools.StandardLocation;
@SupportedAnnotationTypes({
"io.dagger.module.annotation.Module",
@@ -84,6 +88,8 @@
@AutoService(Processor.class)
public class DaggerModuleAnnotationProcessor extends AbstractProcessor {
+ static final String DANG_ENTRYPOINT_PATH = "dagger/entrypoint/main.dang";
+
private Elements elementUtils;
@Override
@@ -446,9 +452,10 @@ static JavaFile generate(ModuleInfo moduleInfo) {
rm.addCode(";\n") // end of module instantiation
.addStatement("return module.id()");
+ // The manifest-v2 entrypoint calls this directly, with no ambient FunctionCall.
var im =
- MethodSpec.methodBuilder("invoke")
- .addModifiers(Modifier.PRIVATE)
+ MethodSpec.methodBuilder("daggerDispatch")
+ .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.returns(JSON.class)
.addException(Exception.class)
.addParameter(JSON.class, "parentJson")
@@ -519,6 +526,14 @@ static JavaFile generate(ModuleInfo moduleInfo) {
.addException(Exception.class)
.returns(void.class)
.addParameter(String[].class, "args")
+ .beginControlFlow(
+ "if (args.length > 0 && $S.equals(args[0]))", "engine-call")
+ .addStatement(
+ "$T.exit($T.engineCallMain($T::daggerDispatch))",
+ System.class,
+ ModuleDispatcher.class,
+ ClassName.get("io.dagger.gen.entrypoint", "Entrypoint"))
+ .endControlFlow()
.beginControlFlow(
"try ($T telemetry = new $T())", Telemetry.class, Telemetry.class)
.addStatement(
@@ -558,7 +573,7 @@ static JavaFile generate(ModuleInfo moduleInfo) {
.addStatement("result = $T.toJSON(modID)", JsonConverter.class)
.nextControlFlow("else")
.addStatement(
- "result = invoke(parentJson, parentName, fnName, inputArgs)")
+ "result = daggerDispatch(parentJson, parentName, fnName, inputArgs)")
.endControlFlow()
.addStatement("fnCall.returnValue(result)")
.addStatement("return null")
@@ -783,6 +798,7 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
JavaFile f = generate(moduleInfo);
f.writeTo(processingEnv.getFiler());
+ writeDangEntrypoint(moduleInfo);
} catch (IOException e) {
throw new RuntimeException(e);
}
@@ -790,6 +806,22 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
return true;
}
+ /**
+ * Emit the manifest-v2 entrypoint beside the compiled classes.
+ *
+ * CLASS_OUTPUT rather than SOURCE_OUTPUT: the generation step vendors the whole
+ * SOURCE_OUTPUT tree as the module's Java sources, and this is not Java.
+ */
+ private void writeDangEntrypoint(ModuleInfo moduleInfo) throws IOException {
+ FileObject file =
+ processingEnv
+ .getFiler()
+ .createResource(StandardLocation.CLASS_OUTPUT, "", DANG_ENTRYPOINT_PATH);
+ try (Writer writer = file.openWriter()) {
+ writer.write(DangEntrypointRenderer.render(moduleInfo));
+ }
+ }
+
private static Boolean isNotBlank(String str) {
return str != null && !str.isBlank();
}
diff --git a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java
index 95ae000..7b0bf66 100644
--- a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java
+++ b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java
@@ -18,6 +18,12 @@ public static void setKnownEnums(Set enums) {
abstract CodeBlock toDaggerTypeDef();
+ /**
+ * The same type definition as {@link #toDaggerTypeDef()}, rendered as Dang source for a
+ * manifest-v2 module entrypoint. The two must describe the same schema.
+ */
+ abstract String toDangTypeDef();
+
abstract CodeBlock toJavaType();
CodeBlock toClass() {
@@ -129,6 +135,11 @@ CodeBlock toDaggerTypeDef() {
return CodeBlock.of("$T.dag().typeDef().withEnum($S)", Dagger.class, simpleName);
}
+ @Override
+ String toDangTypeDef() {
+ return "typeDef.withEnum(" + Dang.quote(simpleName) + ")";
+ }
+
@Override
CodeBlock toJavaType() {
return CodeBlock.of("$T", ClassName.bestGuess(qualifiedName));
@@ -144,27 +155,37 @@ public Kind(String simpleName, boolean isOptional) {
this.isOptional = isOptional;
}
- @Override
- CodeBlock toDaggerTypeDef() {
+ private String kindName() {
String name =
switch (simpleName) {
case "byte", "short", "int", "long", "char" -> "integer";
case "float", "double" -> "float";
default -> simpleName;
};
+ return "%s_KIND".formatted(name.toUpperCase());
+ }
+
+ @Override
+ CodeBlock toDaggerTypeDef() {
CodeBlock.Builder cb =
CodeBlock.builder()
.add(
"$T.dag().typeDef().withKind($T.$L)",
Dagger.class,
TypeDefKind.class,
- "%s_KIND".formatted(name.toUpperCase()));
+ kindName());
if (isOptional) {
cb.add(".withOptional(true)");
}
return cb.build();
}
+ @Override
+ String toDangTypeDef() {
+ String def = "typeDef.withKind(TypeDefKind." + kindName() + ")";
+ return isOptional ? def + ".withOptional(true)" : def;
+ }
+
@Override
CodeBlock toJavaType() {
return switch (simpleName) {
@@ -205,6 +226,11 @@ CodeBlock toDaggerTypeDef() {
return CodeBlock.of("$T.dag().typeDef().withScalar($S)", Dagger.class, simpleName);
}
+ @Override
+ String toDangTypeDef() {
+ return "typeDef.withScalar(" + Dang.quote(simpleName) + ")";
+ }
+
@Override
CodeBlock toJavaType() {
return CodeBlock.of("$T", ClassName.bestGuess(qualifiedName));
@@ -223,6 +249,11 @@ CodeBlock toDaggerTypeDef() {
return CodeBlock.builder().add(inner.toDaggerTypeDef()).add(".withOptional(true)").build();
}
+ @Override
+ String toDangTypeDef() {
+ return inner.toDangTypeDef() + ".withOptional(true)";
+ }
+
@Override
CodeBlock toJavaType() {
return CodeBlock.of("$T<$L>", java.util.Optional.class, inner.toJavaType());
@@ -248,6 +279,11 @@ CodeBlock toDaggerTypeDef() {
return CodeBlock.of("$T.dag().typeDef().withObject($S)", Dagger.class, simpleName);
}
+ @Override
+ String toDangTypeDef() {
+ return "typeDef.withObject(" + Dang.quote(simpleName) + ")";
+ }
+
@Override
CodeBlock toJavaType() {
return CodeBlock.of("$T", ClassName.bestGuess(qualifiedName));
@@ -271,6 +307,11 @@ CodeBlock toDaggerTypeDef() {
return cb.build();
}
+ @Override
+ String toDangTypeDef() {
+ return "typeDef.withListOf(" + of(innerName).toDangTypeDef() + ")";
+ }
+
@Override
CodeBlock toJavaType() {
return CodeBlock.of(
@@ -307,6 +348,11 @@ CodeBlock toDaggerTypeDef() {
return cb.build();
}
+ @Override
+ String toDangTypeDef() {
+ return "typeDef.withListOf(" + of(innerName).toDangTypeDef() + ")";
+ }
+
@Override
CodeBlock toJavaType() {
return CodeBlock.of("$L[]", of(innerName).toJavaType());
diff --git a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/Dang.java b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/Dang.java
new file mode 100644
index 0000000..5c52ed1
--- /dev/null
+++ b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/Dang.java
@@ -0,0 +1,41 @@
+package io.dagger.annotation.processor;
+
+/** Dang source fragments shared by the type and entrypoint renderers. */
+final class Dang {
+
+ private Dang() {}
+
+ /**
+ * Quote a value as a Dang string literal.
+ *
+ * Nothing parses the generated Dang before an engine loads it, so an unescaped quote,
+ * backslash or newline turns into a syntax error a long way from here. Javadoc descriptions are
+ * routinely multi-line.
+ */
+ 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);
+ switch (c) {
+ case '"' -> out.append("\\\"");
+ case '\\' -> out.append("\\\\");
+ case '\n' -> out.append("\\n");
+ case '\r' -> out.append("\\r");
+ case '\t' -> out.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ out.append("\\u%04x".formatted((int) c));
+ } else {
+ out.append(c);
+ }
+ }
+ }
+ }
+ return out.append('"').toString();
+ }
+
+ static String indent(String value, int spaces) {
+ String pad = " ".repeat(spaces);
+ return pad + value.replace("\n", "\n" + pad);
+ }
+}
diff --git a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DangEntrypointRenderer.java b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DangEntrypointRenderer.java
new file mode 100644
index 0000000..b68a36a
--- /dev/null
+++ b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DangEntrypointRenderer.java
@@ -0,0 +1,220 @@
+package io.dagger.annotation.processor;
+
+import io.dagger.module.info.EnumInfo;
+import io.dagger.module.info.FieldInfo;
+import io.dagger.module.info.FunctionInfo;
+import io.dagger.module.info.ModuleInfo;
+import io.dagger.module.info.ObjectInfo;
+import io.dagger.module.info.ParameterInfo;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The manifest-v2 backend: the same {@link ModuleInfo} the JavaPoet backend turns into a runtime
+ * {@code register()} call, rendered instead as a Dang {@code ModuleEntrypoint}.
+ *
+ *
The two backends must describe the same schema. Read {@code types()} here against
+ * {@code register()} in {@link DaggerModuleAnnotationProcessor} when changing either.
+ */
+final class DangEntrypointRenderer {
+
+ private static final String MAVEN_IMAGE =
+ "maven:3.9.9-eclipse-temurin-21-alpine@sha256:4cbb8bf76c46b97e028998f2486ed014759a8e932480431039bdb93dffe6813e";
+ private static final String JRE_IMAGE = "eclipse-temurin:21-jre-alpine";
+
+ private static final String PICK_JAR =
+ "set -e; jar=$(ls -1 target/*.jar 2>/dev/null | grep -v '/original-' | head -n1);"
+ + " test -n \"$jar\" || { echo \"no packaged jar found in $(pwd)/target\" >&2; exit 1; };"
+ + " cp \"$jar\" /tmp/module.jar";
+
+ private DangEntrypointRenderer() {}
+
+ static String render(ModuleInfo moduleInfo) {
+ // DaggerType.of classifies a named type as an enum only when this static says so.
+ DaggerType.setKnownEnums(moduleInfo.enumInfos().keySet());
+
+ List defs = new ArrayList<>();
+ for (ObjectInfo object : moduleInfo.objects()) {
+ defs.add(renderObject(object));
+ }
+ for (EnumInfo enumInfo : moduleInfo.enumInfos().values()) {
+ defs.add(renderEnum(enumInfo));
+ }
+
+ StringBuilder b = new StringBuilder();
+ b.append("# This file has been generated by dagger-java-sdk. DO NOT EDIT.\n\n");
+ b.append("type Entrypoint implements ModuleEntrypoint {\n");
+ b.append(jarField());
+ b.append("\n pub types(workspace: Workspace!): [TypeDef!]! {\n");
+ b.append(" [\n");
+ for (int i = 0; i < defs.size(); i++) {
+ b.append(Dang.indent(defs.get(i), 6));
+ b.append(i == defs.size() - 1 ? "\n" : ",\n");
+ }
+ b.append(" ]\n");
+ b.append(" }\n\n");
+ b.append(callField());
+ b.append("}\n");
+ return b.toString();
+ }
+
+ private static String jarField() {
+ return String.join(
+ "\n",
+ " let jar(workspace: Workspace!): File! {",
+ " container",
+ " .from(" + Dang.quote(MAVEN_IMAGE) + ")",
+ " .withoutEntrypoint",
+ " .withMountedCache(",
+ " path: \"/root/.m2\",",
+ " cache: cacheVolume(\"sdk-java-maven-m2\"),",
+ " sharing: CacheSharingMode.LOCKED,",
+ " )",
+ " .withDirectory(\"/src\", workspace.directory(\"/\"), exclude: [\"**/target/**\"])",
+ " .withWorkdir(\"/src/\" + workspace.cwd)",
+ " .withExec([\"mvn\", \"package\", \"-DskipTests\", \"--threads\", \"1C\", \"--no-transfer-progress\"])",
+ " # The shade plugin leaves the pre-shaded artifact beside the shaded one.",
+ " .withExec([\"sh\", \"-c\", " + Dang.quote(PICK_JAR) + "])",
+ " .file(\"/tmp/module.jar\")",
+ " }",
+ "");
+ }
+
+ private static String callField() {
+ return String.join(
+ "\n",
+ " pub call(",
+ " workspace: Workspace!,",
+ " receiverType: String!,",
+ " receiverValue: JSON,",
+ " fnName: String!,",
+ " fnArgs: JSON!,",
+ " ): JSON! {",
+ " let request = JSON.encode({{",
+ " receiverType: receiverType,",
+ " receiverValue: receiverValue,",
+ " fnName: fnName,",
+ " fnArgs: fnArgs,",
+ " }})",
+ " let result = container",
+ " .from(" + Dang.quote(JRE_IMAGE) + ")",
+ " .withoutEntrypoint",
+ " .withFile(\"/opt/module/module.jar\", jar(workspace))",
+ " .withWorkdir(\"/opt/module\")",
+ " .withExec(",
+ " [\"java\", \"-jar\", \"/opt/module/module.jar\", \"engine-call\"],",
+ " stdin: request,",
+ " experimentalPrivilegedNesting: true,",
+ " )",
+ " .stdout",
+ " (result :: JSON!)",
+ " }",
+ "");
+ }
+
+ private static String renderObject(ObjectInfo object) {
+ StringBuilder b = new StringBuilder("typeDef.withObject(").append(Dang.quote(object.name()));
+ appendDescription(b, object.description());
+ b.append(")");
+
+ for (FunctionInfo function : object.functions()) {
+ b.append("\n .withFunction(\n")
+ .append(Dang.indent(renderFunction(object, function), 4))
+ .append("\n )");
+ }
+ for (FieldInfo field : object.fields()) {
+ b.append("\n .withField(")
+ .append(Dang.quote(field.name()))
+ .append(", ")
+ .append(DaggerType.of(field.type()).toDangTypeDef());
+ appendDescription(b, field.description());
+ b.append(")");
+ }
+ if (object.constructor().isPresent()) {
+ b.append("\n .withConstructor(\n")
+ .append(Dang.indent(renderFunction(object, object.constructor().get()), 4))
+ .append("\n )");
+ }
+ return b.toString();
+ }
+
+ private static String renderEnum(EnumInfo enumInfo) {
+ StringBuilder b = new StringBuilder("typeDef.withEnum(").append(Dang.quote(enumInfo.name()));
+ appendDescription(b, enumInfo.description());
+ b.append(")");
+ for (var value : enumInfo.values()) {
+ b.append("\n .withEnumValue(").append(Dang.quote(value.value()));
+ appendDescription(b, value.description());
+ b.append(")");
+ }
+ return b.toString();
+ }
+
+ private static String renderFunction(ObjectInfo object, FunctionInfo function) {
+ boolean isConstructor = "".equals(function.name());
+ String returnType =
+ isConstructor
+ ? DaggerType.of(object.qualifiedName()).toDangTypeDef()
+ : DaggerType.of(function.returnType()).toDangTypeDef();
+
+ StringBuilder b =
+ new StringBuilder("function(")
+ .append(Dang.quote(isConstructor ? "" : function.name()))
+ .append(", ")
+ .append(returnType)
+ .append(")");
+
+ if (isNotBlank(function.description())) {
+ b.append("\n .withDescription(").append(Dang.quote(function.description())).append(")");
+ }
+ if (function.isCheck()) {
+ b.append("\n .withCheck");
+ }
+ if (function.isGenerate()) {
+ b.append("\n .withGenerator");
+ }
+ if (function.isUp()) {
+ b.append("\n .withUp");
+ }
+ for (ParameterInfo parameter : function.parameters()) {
+ b.append("\n .withArg(").append(Dang.quote(parameter.name())).append(", ");
+ b.append(DaggerType.of(parameter.type()).toDangTypeDef());
+ // Argument optionality is recorded on ParameterInfo, not on the type: the processor strips
+ // Optional<...> before DaggerType ever sees it.
+ if (parameter.optional()) {
+ b.append(".withOptional(true)");
+ }
+ if (isNotBlank(parameter.description())) {
+ b.append(", description: ").append(Dang.quote(parameter.description()));
+ }
+ if (parameter.defaultValue().isPresent()) {
+ b.append(", defaultValue: JSON.decode(")
+ .append(Dang.quote(parameter.defaultValue().get()))
+ .append(")");
+ }
+ if (parameter.defaultPath().isPresent()) {
+ b.append(", defaultPath: ").append(Dang.quote(parameter.defaultPath().get()));
+ }
+ if (parameter.ignore().isPresent()) {
+ b.append(", ignore: [");
+ String[] ignore = parameter.ignore().get();
+ for (int i = 0; i < ignore.length; i++) {
+ b.append(i == 0 ? "" : ", ").append(Dang.quote(ignore[i]));
+ }
+ b.append("]");
+ }
+ b.append(")");
+ }
+ return b.toString();
+ }
+
+ private static void appendDescription(StringBuilder b, String description) {
+ if (isNotBlank(description)) {
+ b.append(", description: ").append(Dang.quote(description));
+ }
+ }
+
+ private static boolean isNotBlank(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeDangTest.java b/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeDangTest.java
new file mode 100644
index 0000000..d5a95d3
--- /dev/null
+++ b/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeDangTest.java
@@ -0,0 +1,89 @@
+package io.dagger.annotation.processor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.dagger.module.info.TypeInfo;
+import java.util.Set;
+import javax.lang.model.type.TypeKind;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The Dang backend must describe the same schema as the JavaPoet backend. Every case goes through
+ * {@link DaggerType#of} rather than a subclass constructor, so a shape the factory never produces
+ * cannot pass here.
+ */
+class DaggerTypeDangTest {
+
+ @AfterEach
+ void resetKnownEnums() {
+ DaggerType.setKnownEnums(Set.of());
+ }
+
+ @Test
+ void primitiveKindsCollapseTheWayTheJavaBackendCollapsesThem() {
+ assertThat(DaggerType.of("int").toDangTypeDef())
+ .isEqualTo("typeDef.withKind(TypeDefKind.INTEGER_KIND)");
+ assertThat(DaggerType.of("long").toDangTypeDef())
+ .isEqualTo("typeDef.withKind(TypeDefKind.INTEGER_KIND)");
+ assertThat(DaggerType.of("double").toDangTypeDef())
+ .isEqualTo("typeDef.withKind(TypeDefKind.FLOAT_KIND)");
+ assertThat(DaggerType.of("boolean").toDangTypeDef())
+ .isEqualTo("typeDef.withKind(TypeDefKind.BOOLEAN_KIND)");
+ assertThat(declared("java.lang.String").toDangTypeDef())
+ .isEqualTo("typeDef.withKind(TypeDefKind.STRING_KIND)");
+ }
+
+ @Test
+ void voidIsAnOptionalVoidKind() {
+ assertThat(DaggerType.of("void").toDangTypeDef())
+ .isEqualTo("typeDef.withKind(TypeDefKind.VOID_KIND).withOptional(true)");
+ }
+
+ @Test
+ void objectsScalarsAndEnumsRenderByName() {
+ assertThat(declared("io.dagger.client.Container").toDangTypeDef())
+ .isEqualTo("typeDef.withObject(\"Container\")");
+ assertThat(declared("io.dagger.client.JSON").toDangTypeDef())
+ .isEqualTo("typeDef.withScalar(\"JSON\")");
+
+ DaggerType.setKnownEnums(Set.of("io.dagger.modules.demo.Severity"));
+ assertThat(declared("io.dagger.modules.demo.Severity").toDangTypeDef())
+ .isEqualTo("typeDef.withEnum(\"Severity\")");
+ }
+
+ /** Without the enum context the same name is classified as an object, not an enum. */
+ @Test
+ void enumClassificationDependsOnTheKnownEnumsContext() {
+ assertThat(declared("io.dagger.modules.demo.Severity").toDangTypeDef())
+ .isEqualTo("typeDef.withObject(\"Severity\")");
+ }
+
+ @Test
+ void listsAndArraysBothRenderAsListOf() {
+ assertThat(declared("java.util.List").toDangTypeDef())
+ .isEqualTo("typeDef.withListOf(typeDef.withObject(\"File\"))");
+ assertThat(DaggerType.of("io.dagger.client.File[]").toDangTypeDef())
+ .isEqualTo("typeDef.withListOf(typeDef.withObject(\"File\"))");
+ }
+
+ @Test
+ void optionalDecoratesItsInnerTypeInTheSameOrderTheJavaBackendUses() {
+ DaggerType type = declared("java.util.Optional>");
+
+ assertThat(type.toDangTypeDef())
+ .isEqualTo(
+ "typeDef.withListOf(typeDef.withKind(TypeDefKind.STRING_KIND)).withOptional(true)");
+ assertThat(type.toDaggerTypeDef().toString()).endsWith(".withOptional(true)");
+ }
+
+ @Test
+ void stringLiteralsAreEscaped() {
+ assertThat(Dang.quote("a \"b\" \\ c\nd")).isEqualTo("\"a \\\"b\\\" \\\\ c\\nd\"");
+ assertThat(Dang.quote("x\fy")).isEqualTo("\"x\\u000cy\"");
+ }
+
+ private static DaggerType declared(String typeName) {
+ return DaggerType.of(new TypeInfo(typeName, TypeKind.DECLARED.name()));
+ }
+}
diff --git a/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DangEntrypointRendererTest.java b/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DangEntrypointRendererTest.java
new file mode 100644
index 0000000..edae66f
--- /dev/null
+++ b/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DangEntrypointRendererTest.java
@@ -0,0 +1,254 @@
+package io.dagger.annotation.processor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.dagger.module.info.EnumInfo;
+import io.dagger.module.info.EnumValueInfo;
+import io.dagger.module.info.FieldInfo;
+import io.dagger.module.info.FunctionInfo;
+import io.dagger.module.info.ModuleInfo;
+import io.dagger.module.info.ObjectInfo;
+import io.dagger.module.info.ParameterInfo;
+import io.dagger.module.info.TypeInfo;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import javax.lang.model.type.TypeKind;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class DangEntrypointRendererTest {
+
+ private static final String ENUM_QNAME = "io.dagger.modules.demo.Severity";
+
+ @AfterEach
+ void resetKnownEnums() {
+ DaggerType.setKnownEnums(Set.of());
+ }
+
+ @Test
+ void theEntrypointImplementsTheThreeModuleEntrypointFields() {
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang)
+ .contains("type Entrypoint implements ModuleEntrypoint {")
+ .contains("pub types(workspace: Workspace!): [TypeDef!]! {")
+ .contains("pub call(")
+ .contains("): JSON! {");
+ }
+
+ /**
+ * The interface has two fields. An earlier informal draft had a third, {@code main}, naming the
+ * module's root object; the engine instead finds the object that declares a constructor.
+ */
+ @Test
+ void theInterfaceHasNoMainField() {
+ assertThat(DangEntrypointRenderer.render(module())).doesNotContain("main(");
+ }
+
+ /**
+ * The same schema {@code register()} builds at run time, as Dang. Pinned whole rather than by
+ * fragments: nothing parses this output before an engine loads it, so the layout is the
+ * artifact.
+ */
+ @Test
+ void typesDescribesEveryObjectAndEnum() {
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang)
+ .contains(
+ String.join(
+ "\n",
+ " pub types(workspace: Workspace!): [TypeDef!]! {",
+ " [",
+ " typeDef.withObject(\"Demo\", description: \"A demo module\")",
+ " .withFunction(",
+ " function(\"greet\", typeDef.withKind(TypeDefKind.STRING_KIND))",
+ " .withDescription(\"Say hello\")",
+ " .withArg(\"name\", typeDef.withKind(TypeDefKind.STRING_KIND), description: \"who to greet\")",
+ " .withArg(\"loud\", typeDef.withKind(TypeDefKind.BOOLEAN_KIND).withOptional(true))",
+ " .withArg(\"level\", typeDef.withEnum(\"Severity\"))",
+ " )",
+ " .withField(\"source\", typeDef.withObject(\"Directory\"), description: \"the source tree\")",
+ " .withConstructor(",
+ " function(\"\", typeDef.withObject(\"Demo\"))",
+ " .withArg(\"prefix\", typeDef.withKind(TypeDefKind.STRING_KIND), defaultValue: JSON.decode(\"\\\"Hi\\\"\"))",
+ " ),",
+ " typeDef.withEnum(\"Severity\")",
+ " .withEnumValue(\"HIGH\", description: \"very bad\")",
+ " .withEnumValue(\"LOW\")",
+ " ]",
+ " }"));
+ }
+
+ /**
+ * Argument optionality is carried on ParameterInfo, not on the type, so a Dang backend that only
+ * asks DaggerType would silently make every optional argument required.
+ */
+ @Test
+ void anOptionalArgumentIsRegisteredAsOptional() {
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang)
+ .contains(".withArg(\"loud\", typeDef.withKind(TypeDefKind.BOOLEAN_KIND).withOptional(true))");
+ }
+
+ @Test
+ void anEnumTypedArgumentNeedsTheEnumContextTheRendererSetsItself() {
+ DaggerType.setKnownEnums(Set.of());
+
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang).contains(".withArg(\"level\", typeDef.withEnum(\"Severity\"))");
+ }
+
+ @Test
+ void callRunsTheBuiltJarOverStandardInputWithNestingEnabled() {
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang)
+ .contains(
+ String.join(
+ "\n",
+ " pub call(",
+ " workspace: Workspace!,",
+ " receiverType: String!,",
+ " receiverValue: JSON,",
+ " fnName: String!,",
+ " fnArgs: JSON!,",
+ " ): JSON! {"))
+ .contains("let request = JSON.encode({{")
+ .contains("receiverType: receiverType,")
+ .contains("fnArgs: fnArgs,")
+ .contains("[\"java\", \"-jar\", \"/opt/module/module.jar\", \"engine-call\"],")
+ .contains("stdin: request,")
+ .contains("experimentalPrivilegedNesting: true,")
+ // stdout already holds JSON text, so the result is cast rather than decoded.
+ .contains("(result :: JSON!)");
+ }
+
+ /** The shade plugin leaves an unshaded backup in target/; picking it would run the wrong jar. */
+ @Test
+ void theBuildPicksTheShadedJarAndNotTheBackup() {
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang)
+ .contains("\"mvn\", \"package\", \"-DskipTests\"")
+ .contains("grep -v '/original-'")
+ .contains(".file(\"/tmp/module.jar\")");
+ }
+
+ @Test
+ void descriptionsAreEscapedSoTheOutputStaysParseable() {
+ ObjectInfo object =
+ new ObjectInfo(
+ "Demo",
+ "io.dagger.modules.demo.Demo",
+ "a \"quoted\" \\ description\nover two lines",
+ new FieldInfo[0],
+ new FunctionInfo[0],
+ Optional.empty());
+
+ String dang = DangEntrypointRenderer.render(new ModuleInfo(null, new ObjectInfo[] {object}, Map.of()));
+
+ assertThat(dang)
+ .contains(
+ "typeDef.withObject(\"Demo\", description:"
+ + " \"a \\\"quoted\\\" \\\\ description\\nover two lines\")");
+ }
+
+ /**
+ * The engine finds a module's entry object by looking for the type that declares a constructor,
+ * and accepts one across the whole module. Java satisfies that without trying: its analyzer only
+ * records a constructor on the object whose name matches the module's.
+ */
+ @Test
+ void exactlyOneTypeDeclaresAConstructor() {
+ String dang = DangEntrypointRenderer.render(module());
+
+ assertThat(dang.split("\\.withConstructor\\(", -1)).hasSize(2);
+ }
+
+ private static ModuleInfo module() {
+ FunctionInfo greet =
+ new FunctionInfo(
+ "greet",
+ "greet",
+ "Say hello",
+ new TypeInfo("java.lang.String", TypeKind.DECLARED.name()),
+ new ParameterInfo[] {
+ new ParameterInfo(
+ "name",
+ "who to greet",
+ new TypeInfo("java.lang.String", TypeKind.DECLARED.name()),
+ false,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()),
+ new ParameterInfo(
+ "loud",
+ "",
+ new TypeInfo("boolean", TypeKind.BOOLEAN.name()),
+ true,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()),
+ new ParameterInfo(
+ "level",
+ "",
+ new TypeInfo(ENUM_QNAME, TypeKind.DECLARED.name()),
+ false,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()),
+ },
+ false,
+ false,
+ false);
+
+ FunctionInfo constructor =
+ new FunctionInfo(
+ "",
+ "",
+ "",
+ new TypeInfo("io.dagger.modules.demo.Demo", TypeKind.DECLARED.name()),
+ new ParameterInfo[] {
+ new ParameterInfo(
+ "prefix",
+ "",
+ new TypeInfo("java.lang.String", TypeKind.DECLARED.name()),
+ false,
+ Optional.of("\"Hi\""),
+ Optional.empty(),
+ Optional.empty()),
+ },
+ false,
+ false,
+ false);
+
+ ObjectInfo demo =
+ new ObjectInfo(
+ "Demo",
+ "io.dagger.modules.demo.Demo",
+ "A demo module",
+ new FieldInfo[] {
+ new FieldInfo(
+ "source",
+ "the source tree",
+ new TypeInfo("io.dagger.client.Directory", TypeKind.DECLARED.name()))
+ },
+ new FunctionInfo[] {greet},
+ Optional.of(constructor));
+
+ EnumInfo severity =
+ new EnumInfo(
+ "Severity",
+ "",
+ new EnumValueInfo[] {
+ new EnumValueInfo("HIGH", "very bad"), new EnumValueInfo("LOW", "")
+ });
+
+ return new ModuleInfo(
+ "A demo module", new ObjectInfo[] {demo}, Map.of(ENUM_QNAME, severity));
+ }
+}
diff --git a/sdk/dagger-java-sdk/src/main/java/io/dagger/client/ModuleDispatcher.java b/sdk/dagger-java-sdk/src/main/java/io/dagger/client/ModuleDispatcher.java
new file mode 100644
index 0000000..25d06de
--- /dev/null
+++ b/sdk/dagger-java-sdk/src/main/java/io/dagger/client/ModuleDispatcher.java
@@ -0,0 +1,199 @@
+package io.dagger.client;
+
+import io.dagger.client.exception.DaggerExecException;
+import jakarta.json.Json;
+import jakarta.json.JsonException;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonObjectBuilder;
+import jakarta.json.JsonString;
+import jakarta.json.JsonValue;
+import jakarta.json.stream.JsonGenerator;
+import jakarta.json.stream.JsonParser;
+import jakarta.json.stream.JsonParsingException;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * The manifest-v2 call protocol: one JSON request on standard input, one JSON result on standard
+ * output.
+ *
+ * This lives in {@code io.dagger.client} rather than {@code io.dagger.module} because it has to
+ * write a {@link JSON} scalar's raw value, and {@code Scalar.convert()} is package-private here.
+ * Serialising the scalar from another package would double-encode the result.
+ */
+public final class ModuleDispatcher {
+
+ private ModuleDispatcher() {}
+
+ /** The generated entrypoint's static dispatcher, as seen by this protocol. */
+ @FunctionalInterface
+ public interface Dispatch {
+ JSON call(JSON parentJson, String parentName, String fnName, Map inputArgs)
+ throws Exception;
+ }
+
+ /**
+ * Run one call and return a process exit code, reporting failures on standard error.
+ *
+ * {@code call(...): JSON!} in the manifest-v2 interface has no error field, so a failure can
+ * only be a non-zero exit. The envelope written here is not part of that contract and nothing
+ * consumes it; it exists to show what an SDK would need a real error channel to carry.
+ */
+ public static int engineCallMain(Dispatch dispatch) {
+ try {
+ engineCall(System.in, System.out, dispatch);
+ return 0;
+ } catch (Exception e) {
+ System.err.println(errorEnvelope(e));
+ return 2;
+ }
+ }
+
+ public static void engineCall(InputStream in, PrintStream out, Dispatch dispatch)
+ throws Exception {
+ JsonObject request;
+ try (JsonParser parser = Json.createParser(in)) {
+ if (!parser.hasNext() || parser.next() != JsonParser.Event.START_OBJECT) {
+ throw new IllegalArgumentException("call request is not a JSON object");
+ }
+ request = parser.getObject();
+ boolean trailing;
+ try {
+ trailing = parser.hasNext();
+ } catch (JsonParsingException e) {
+ // Parsson reports input after the request object by throwing rather than returning true.
+ trailing = true;
+ }
+ if (trailing) {
+ throw new IllegalArgumentException("call request has trailing data");
+ }
+ }
+
+ String parentName = required(request, "receiverType");
+ String fnName = required(request, "fnName");
+
+ // receiverValue and fnArgs cross ModuleEntrypoint.call as JSON scalars, so the entrypoint's
+ // JSON.encode writes them into the request as strings holding JSON text. Decode once.
+ String receiverValue = nestedJson(request, "receiverValue", "{}");
+ JSON parentJson = JSON.from(parseValue(receiverValue, "receiverValue"));
+
+ Map inputArgs = new HashMap<>();
+ JsonObject args = parseObject(nestedJson(request, "fnArgs", null), "fnArgs");
+ for (Map.Entry arg : args.entrySet()) {
+ inputArgs.put(arg.getKey(), JSON.from(arg.getValue().toString()));
+ }
+
+ // Module code shares this process's standard output, and the caller decodes that stream as a
+ // single JSON value. Keep the module's own printing away from it.
+ PrintStream saved = System.out;
+ System.setOut(System.err);
+ try {
+ JSON value = dispatch.call(parentJson, parentName, fnName, inputArgs);
+ out.println(value == null ? "null" : value.convert());
+ out.flush();
+ } finally {
+ System.setOut(saved);
+ }
+ }
+
+ private static String required(JsonObject request, String name) {
+ JsonValue value = request.get(name);
+ if (value == null || value.getValueType() != JsonValue.ValueType.STRING) {
+ throw new IllegalArgumentException("call request is missing " + name);
+ }
+ return ((JsonString) value).getString();
+ }
+
+ /**
+ * Read a field carrying JSON text as a JSON string. A null fallback makes the field required.
+ */
+ private static String nestedJson(JsonObject request, String name, String fallback) {
+ JsonValue value = request.get(name);
+ if (value == null || value.getValueType() == JsonValue.ValueType.NULL) {
+ if (fallback == null) {
+ throw new IllegalArgumentException("call request is missing " + name);
+ }
+ return fallback;
+ }
+ if (value.getValueType() != JsonValue.ValueType.STRING) {
+ throw new IllegalArgumentException("call request field " + name + " is not JSON text");
+ }
+ return ((JsonString) value).getString();
+ }
+
+ private static JsonObject parseObject(String json, String name) {
+ JsonValue value = parse(json, name);
+ if (value.getValueType() != JsonValue.ValueType.OBJECT) {
+ throw new IllegalArgumentException("call request field " + name + " is not a JSON object");
+ }
+ return value.asJsonObject();
+ }
+
+ /** Validate JSON text and hand back the original, so encodings survive untouched. */
+ private static String parseValue(String json, String name) {
+ parse(json, name);
+ return json;
+ }
+
+ private static JsonValue parse(String json, String name) {
+ try (JsonParser parser = Json.createParser(new StringReader(json))) {
+ if (!parser.hasNext()) {
+ throw new IllegalArgumentException("call request field " + name + " is empty");
+ }
+ parser.next();
+ JsonValue value = parser.getValue();
+ if (parser.hasNext()) {
+ throw new IllegalArgumentException("call request field " + name + " has trailing data");
+ }
+ return value;
+ } catch (JsonException e) {
+ throw new IllegalArgumentException("call request field " + name + " is not valid JSON", e);
+ }
+ }
+
+ private static String errorEnvelope(Exception e) {
+ Throwable cause = e instanceof InvocationTargetException ite ? ite.getTargetException() : e;
+
+ var builder = Json.createObjectBuilder();
+ builder.add("message", String.valueOf(cause.getMessage()));
+ builder.add("type", cause.getClass().getName());
+ if (cause instanceof DaggerExecException exec) {
+ // The exec accessors read GraphQL error extensions and throw when the engine did not send
+ // them. This is the failure path; it must not fail again.
+ put(builder, "stdout", () -> Json.createValue(String.valueOf(exec.getStdOut())));
+ put(builder, "stderr", () -> Json.createValue(String.valueOf(exec.getStdErr())));
+ put(builder, "cmd", () -> strings(exec.getCmd()));
+ put(builder, "exitCode", () -> Json.createValue(exec.getExitCode()));
+ put(builder, "path", () -> strings(exec.getPath()));
+ }
+
+ StringWriter out = new StringWriter();
+ try (JsonGenerator generator = Json.createGenerator(out)) {
+ generator.write(builder.build());
+ }
+ return out.toString();
+ }
+
+ private static void put(JsonObjectBuilder builder, String name, Supplier value) {
+ try {
+ builder.add(name, value.get());
+ } catch (RuntimeException ignored) {
+ // the engine did not send this extension
+ }
+ }
+
+ private static JsonValue strings(List values) {
+ var array = Json.createArrayBuilder();
+ if (values != null) {
+ values.forEach(array::add);
+ }
+ return array.build();
+ }
+}
diff --git a/sdk/dagger-java-sdk/src/test/java/io/dagger/client/ModuleDispatcherTest.java b/sdk/dagger-java-sdk/src/test/java/io/dagger/client/ModuleDispatcherTest.java
new file mode 100644
index 0000000..f5068b0
--- /dev/null
+++ b/sdk/dagger-java-sdk/src/test/java/io/dagger/client/ModuleDispatcherTest.java
@@ -0,0 +1,276 @@
+package io.dagger.client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.dagger.client.exception.DaggerExecException;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.lang.reflect.InvocationTargetException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class ModuleDispatcherTest {
+
+ @Test
+ void argumentValuesReachTheDispatcherVerbatim() throws Exception {
+ var seen = new Object() {
+ Map args;
+ String parentName;
+ String fnName;
+ JSON parentJson;
+ };
+
+ String out =
+ dispatch(
+ """
+ {"receiverType":"Hello","receiverValue":"{\\"prefix\\":\\"Hi\\"}",
+ "fnName":"greet","fnArgs":"{\\"name\\":\\"World\\",\\"times\\":3}"}
+ """,
+ (parentJson, parentName, fnName, inputArgs) -> {
+ seen.parentJson = parentJson;
+ seen.parentName = parentName;
+ seen.fnName = fnName;
+ seen.args = inputArgs;
+ return JSON.from("\"Hi, World\"");
+ });
+
+ assertThat(seen.parentName).isEqualTo("Hello");
+ assertThat(seen.fnName).isEqualTo("greet");
+ assertThat(seen.parentJson.convert()).isEqualTo("{\"prefix\":\"Hi\"}");
+ assertThat(seen.args.get("name").convert()).isEqualTo("\"World\"");
+ assertThat(seen.args.get("times").convert()).isEqualTo("3");
+ assertThat(out).isEqualTo("\"Hi, World\"\n");
+ }
+
+ /**
+ * A constructor call names its owning type and an empty function name, and carries no receiver
+ * state. The generated dispatcher expects an empty object, not null.
+ */
+ @Test
+ void aConstructorCallCarriesItsTypeAndAnEmptyReceiver() throws Exception {
+ var seen = new String[3];
+
+ dispatch(
+ // The engine marshals a constructor's absent receiver as the JSON text "null",
+ // which reaches the request as a string, not as a JSON null.
+ "{\"receiverType\":\"Hello\",\"receiverValue\":\"null\",\"fnName\":\"\",\"fnArgs\":\"{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> {
+ seen[0] = parentName;
+ seen[1] = fnName;
+ seen[2] = parentJson.convert();
+ return JSON.from("null");
+ });
+
+ assertThat(seen[0]).isEqualTo("Hello");
+ assertThat(seen[1]).isEmpty();
+ assertThat(seen[2]).isEqualTo("null");
+ }
+
+ @Test
+ void aRequestWithNoReceiverTypeIsRejected() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"fnName\":\"greet\",\"fnArgs\":\"{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("receiverType");
+ }
+
+ @Test
+ void anythingAfterTheRequestObjectIsRejected() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"greet\",\"fnArgs\":\"{}\"}{}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("trailing data");
+ }
+
+ @Test
+ void aRequestWithNoFunctionNameIsRejected() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnArgs\":\"{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("fnName");
+ }
+
+ @Test
+ void anAbsentReceiverValueBecomesAnEmptyObject() throws Exception {
+ var seen = new String[1];
+
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"\",\"fnArgs\":\"{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> {
+ seen[0] = parentJson.convert();
+ return JSON.from("null");
+ });
+
+ assertThat(seen[0]).isEqualTo("{}");
+ }
+
+ @Test
+ void trailingDataInsideTheArgumentsTextIsRejected() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"greet\",\"fnArgs\":\"{}{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("fnArgs");
+ }
+
+ @Test
+ void malformedReceiverTextIsRejectedEvenWhenTheCallWouldIgnoreIt() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"receiverValue\":\"{\",\"fnName\":\"\",\"fnArgs\":\"{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("receiverValue");
+ }
+
+ /** fnArgs is JSON text, so an inline object rather than a string is a malformed request. */
+ @Test
+ void argumentsSentAsAnInlineObjectRatherThanJsonTextAreRejected() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"greet\",\"fnArgs\":{\"name\":\"World\"}}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("fnArgs");
+ }
+
+ @Test
+ void aRequestWithNoArgumentsObjectIsRejected() {
+ assertThatThrownBy(
+ () ->
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"greet\"}",
+ (parentJson, parentName, fnName, inputArgs) -> JSON.from("null")))
+ .hasMessageContaining("fnArgs");
+ }
+
+ @Test
+ void moduleCodePrintingToStandardOutputDoesNotCorruptTheResult() throws Exception {
+ String out =
+ dispatch(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"greet\",\"fnArgs\":\"{}\"}",
+ (parentJson, parentName, fnName, inputArgs) -> {
+ System.out.println("building something");
+ return JSON.from("\"done\"");
+ });
+
+ assertThat(out).isEqualTo("\"done\"\n");
+ }
+
+ @Test
+ void aPlainFailureReportsItsMessageAndTypeOnStandardError() {
+ var report = failWith(new IllegalStateException("no such thing"));
+
+ assertThat(report.exitCode).isEqualTo(2);
+ assertThat(report.stderr)
+ .contains("\"message\":\"no such thing\"")
+ .contains("\"type\":\"java.lang.IllegalStateException\"");
+ }
+
+ @Test
+ void aReflectionWrapperReportsItsTargetsMessage() {
+ // Error is io.dagger.client.Error in this package; the wrapper carries the JDK one.
+ var report =
+ failWith(new InvocationTargetException(new java.lang.Error("unknown function nope")));
+
+ assertThat(report.stderr)
+ .contains("\"message\":\"unknown function nope\"")
+ .contains("\"type\":\"java.lang.Error\"");
+ }
+
+ @Test
+ void anExecFailureReportsWhatRanAndWhatItPrinted() {
+ var report = failWith(new StubExecException());
+
+ assertThat(report.stderr)
+ .contains("\"stdout\":\"out\"")
+ .contains("\"stderr\":\"err\"")
+ .contains("\"cmd\":[\"sh\",\"-c\",\"false\"]")
+ .contains("\"exitCode\":1")
+ .contains("\"path\":[\"container\",\"stdout\"]");
+ }
+
+ /**
+ * The exec accessors read GraphQL error extensions the engine sends. An exception carrying none
+ * must still produce an envelope rather than failing inside the failure path.
+ */
+ @Test
+ void anExecFailureWithNoEngineExtensionsStillReports() {
+ var report = failWith(new DaggerExecException());
+
+ assertThat(report.exitCode).isEqualTo(2);
+ assertThat(report.stderr).contains("\"type\":\"io.dagger.client.exception.DaggerExecException\"");
+ }
+
+ private static String dispatch(String request, ModuleDispatcher.Dispatch dispatch)
+ throws Exception {
+ var out = new ByteArrayOutputStream();
+ ModuleDispatcher.engineCall(
+ new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)),
+ new PrintStream(out, true, StandardCharsets.UTF_8),
+ dispatch);
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ private record Report(int exitCode, String stderr) {}
+
+ private static Report failWith(Exception failure) {
+ var captured = new ByteArrayOutputStream();
+ PrintStream savedErr = System.err;
+ var savedIn = System.in;
+ System.setErr(new PrintStream(captured, true, StandardCharsets.UTF_8));
+ System.setIn(
+ new ByteArrayInputStream(
+ "{\"receiverType\":\"Hello\",\"fnName\":\"greet\",\"fnArgs\":\"{}\"}"
+ .getBytes(StandardCharsets.UTF_8)));
+ try {
+ int code =
+ ModuleDispatcher.engineCallMain(
+ (parentJson, parentName, fnName, inputArgs) -> {
+ throw failure;
+ });
+ return new Report(code, captured.toString(StandardCharsets.UTF_8));
+ } finally {
+ System.setErr(savedErr);
+ System.setIn(savedIn);
+ }
+ }
+
+ private static final class StubExecException extends DaggerExecException {
+ @Override
+ public String getStdOut() {
+ return "out";
+ }
+
+ @Override
+ public String getStdErr() {
+ return "err";
+ }
+
+ @Override
+ public List getCmd() {
+ return List.of("sh", "-c", "false");
+ }
+
+ @Override
+ public Integer getExitCode() {
+ return 1;
+ }
+
+ @Override
+ public List getPath() {
+ return List.of("container", "stdout");
+ }
+ }
+}