feat: grpc 기능 deep 구현

This commit is contained in:
DongHyeonka
2026-08-31 07:28:39 +09:00
parent a24ece9cf7
commit 21234e38cd
400 changed files with 40217 additions and 4 deletions
+12
View File
@@ -0,0 +1,12 @@
apply plugin: 'java-library'
// Schema source of truth: the `.proto` files plus the rule engine that judges them.
//
// No protobuf plugin and no protoc invocation here — see
// docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md D6. The validator reads
// `.proto` text and enforces the Stable style manifest (proto3 + explicit optional, package
// versioning, reserved history, enum zero suffix, WKT allowlist), which is the invariant the plan
// owns; running protoc is a separate, gated decision.
dependencies {
api project(':grpc:grpc-core-api')
}
@@ -0,0 +1,84 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=compileClasspath,runtimeClasspath
@@ -0,0 +1,447 @@
package dev.caskeleton.grpc.contract;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Judges committed {@code .proto} sources against {@link GrpcProtoStyleManifest} and the schema's
* own removal history.
*
* <p>A line scanner, not a Protobuf parser, and that is a deliberate limit rather than a shortcut.
* Everything this checks is a property of the source text a reviewer reads — the syntax line, the
* package, the two Java options, the zero enum value, the {@code reserved} declarations, the
* imports, the {@code map} fields, explicit presence. Semantics that need a compiled descriptor
* belong to {@code grpc-codegen}'s descriptor artifact, which is where a real {@code protoc} run
* would be consumed.
*
* <p>The removal history is an input rather than something inferred, because it cannot be inferred:
* a field that is simply gone from the current source is indistinguishable from one that never
* existed. Recording removals and checking them against {@code reserved} is the only way the "do
* not reuse a field number" rule survives the commit that deletes the field.
*/
public final class GrpcProtoContractValidator {
/** Rule ids, so a build log and a review checklist use the same names. */
public static final String RULE_PROTO3_SYNTAX = "PROTO3_SYNTAX";
/** The package must be {@code organization.domain.vMAJOR}. */
public static final String RULE_PACKAGE_VERSIONED = "PACKAGE_VERSIONED";
/** {@code option java_multiple_files = true} must be present. */
public static final String RULE_JAVA_MULTIPLE_FILES = "JAVA_MULTIPLE_FILES";
/** The generated Java package must not collide with a hand-written one. */
public static final String RULE_JAVA_PACKAGE_SEPARATE = "JAVA_PACKAGE_SEPARATE";
/** An enum's zero value must end in {@code _UNSPECIFIED}. */
public static final String RULE_ENUM_ZERO_UNSPECIFIED = "ENUM_ZERO_UNSPECIFIED";
/** A removed field number or name must be {@code reserved}. */
public static final String RULE_RESERVED_HISTORY = "RESERVED_HISTORY";
/** A {@code google.protobuf} import must be on the allowlist. */
public static final String RULE_WELL_KNOWN_TYPE_ALLOWLIST = "WELL_KNOWN_TYPE_ALLOWLIST";
/** A {@code map} field must be on the allowlist. */
public static final String RULE_MAP_ALLOWLIST = "MAP_ALLOWLIST";
/** A field the manifest marks presence-required must be declared {@code optional}. */
public static final String RULE_EXPLICIT_PRESENCE = "EXPLICIT_PRESENCE";
private static final Pattern SYNTAX = Pattern.compile("^\\s*syntax\\s*=\\s*\"([^\"]+)\"\\s*;");
private static final Pattern PACKAGE = Pattern.compile("^\\s*package\\s+([A-Za-z0-9_.]+)\\s*;");
private static final Pattern IMPORT =
Pattern.compile("^\\s*import\\s+(?:public\\s+|weak\\s+)?\"([^\"]+)\"\\s*;");
private static final Pattern OPTION =
Pattern.compile("^\\s*option\\s+([A-Za-z0-9_]+)\\s*=\\s*(.+?)\\s*;");
private static final Pattern SCOPE_OPEN =
Pattern.compile("^\\s*(message|enum|service|oneof)\\s+([A-Za-z0-9_]+)\\s*\\{");
private static final Pattern ENUM_VALUE =
Pattern.compile("^\\s*([A-Za-z0-9_]+)\\s*=\\s*(-?\\d+)\\s*(?:\\[[^\\]]*\\])?\\s*;");
private static final Pattern MAP_FIELD =
Pattern.compile("^\\s*map\\s*<[^>]+>\\s+([A-Za-z0-9_]+)\\s*=\\s*(\\d+)");
private static final Pattern FIELD =
Pattern.compile(
"^\\s*(optional\\s+|repeated\\s+)?([A-Za-z0-9_.]+)\\s+([A-Za-z0-9_]+)\\s*=\\s*(\\d+)");
private static final Pattern RESERVED_NUMBERS =
Pattern.compile("^\\s*reserved\\s+([^\";]*\\d[^\";]*);");
private static final Pattern RESERVED_NAMES =
Pattern.compile("^\\s*reserved\\s+(\"[^;]*\")\\s*;");
private static final Pattern QUOTED_NAME = Pattern.compile("\"([A-Za-z0-9_]+)\"");
private static final Pattern NUMBER = Pattern.compile("\\d+");
private static final Pattern LINE_COMMENT = Pattern.compile("//.*$");
private final GrpcProtoStyleManifest manifest;
/** Binds a validator to the manifest it judges against. */
public GrpcProtoContractValidator(GrpcProtoStyleManifest manifest) {
if (manifest == null) {
throw new IllegalArgumentException("a validator needs a style manifest");
}
this.manifest = manifest;
}
/**
* What was removed from a schema, and therefore what must stay reserved.
*
* <p>Keys are message names as written in the file, nested ones qualified with a dot — {@code
* PreconditionFailure.Violation}.
*/
public record SchemaHistory(
Map<String, Set<Integer>> removedFieldNumbers, Map<String, Set<String>> removedFieldNames) {
/** Copies both maps. */
public SchemaHistory {
if (removedFieldNumbers == null || removedFieldNames == null) {
throw new IllegalArgumentException("a schema history needs both removal maps");
}
removedFieldNumbers = copyOf(removedFieldNumbers);
removedFieldNames = copyOf(removedFieldNames);
}
private static <T> Map<String, Set<T>> copyOf(Map<String, Set<T>> source) {
Map<String, Set<T>> copy = new LinkedHashMap<>();
source.forEach((key, value) -> copy.put(key, Set.copyOf(value)));
return Map.copyOf(copy);
}
/** A schema nothing has been removed from yet. */
public static SchemaHistory empty() {
return new SchemaHistory(Map.of(), Map.of());
}
}
/** Validates one file against the manifest, with no removal history to check. */
public List<GrpcProtoRuleViolation> validate(String fileName, String source) {
return validate(fileName, source, SchemaHistory.empty());
}
/**
* Validates one file against the manifest and its removal history.
*
* @return every violation found, in source order; empty when the file is compliant
*/
public List<GrpcProtoRuleViolation> validate(
String fileName, String source, SchemaHistory history) {
if (fileName == null || fileName.isBlank()) {
throw new IllegalArgumentException("a proto file needs a name");
}
if (source == null) {
throw new IllegalArgumentException("a proto file needs source text");
}
if (history == null) {
throw new IllegalArgumentException("a schema history must be present; use empty()");
}
List<GrpcProtoRuleViolation> violations = new ArrayList<>();
Scan scan = scan(fileName, source, violations);
checkFileHeader(fileName, scan, violations);
checkRemovalHistory(fileName, scan, history, violations);
return List.copyOf(violations);
}
private void checkFileHeader(
String fileName, Scan scan, List<GrpcProtoRuleViolation> violations) {
if (!"proto3".equals(scan.syntax)) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_PROTO3_SYNTAX,
fileName,
"Stable schema source must declare syntax = \"proto3\"; found "
+ (scan.syntax == null ? "no syntax declaration" : "'" + scan.syntax + "'")));
}
String expectedPrefix = manifest.organization() + ".";
if (scan.protoPackage == null) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_PACKAGE_VERSIONED, fileName, "no package declaration"));
} else if (!scan.protoPackage.startsWith(expectedPrefix)
|| !scan.protoPackage.matches(".*\\.v[1-9]\\d*$")) {
violations.add(
new GrpcProtoRuleViolation(
RULE_PACKAGE_VERSIONED,
fileName,
scan.packageLine,
"package must be '"
+ manifest.organization()
+ ".<domain>.v<major>'; got '"
+ scan.protoPackage
+ "'"));
}
if (!"true".equals(scan.options.get("java_multiple_files"))) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_JAVA_MULTIPLE_FILES,
fileName,
"option java_multiple_files = true is required; one outer class per file hides every "
+ "generated type behind a name the schema never mentions"));
}
String javaPackage = unquote(scan.options.get("java_package"));
if (javaPackage == null || javaPackage.isBlank()) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_JAVA_PACKAGE_SEPARATE, fileName, "option java_package is required"));
} else {
for (String handWritten : manifest.handWrittenJavaPackages()) {
if (javaPackage.equals(handWritten) || javaPackage.startsWith(handWritten + ".")) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_JAVA_PACKAGE_SEPARATE,
fileName,
"generated java_package '"
+ javaPackage
+ "' is inside hand-written package '"
+ handWritten
+ "'; generated and hand-written types must not share a package"));
}
}
}
}
private static void checkRemovalHistory(
String fileName, Scan scan, SchemaHistory history, List<GrpcProtoRuleViolation> violations) {
history
.removedFieldNumbers()
.forEach(
(message, numbers) -> {
Set<Integer> reserved = scan.reservedNumbers.getOrDefault(message, Set.of());
Set<Integer> missing = new LinkedHashSet<>(numbers);
missing.removeAll(reserved);
if (!missing.isEmpty()) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_RESERVED_HISTORY,
fileName,
"message '"
+ message
+ "' removed field numbers "
+ missing.stream().sorted().toList()
+ " without reserving them; the next field added would reuse a number "
+ "an old client still writes"));
}
});
history
.removedFieldNames()
.forEach(
(message, names) -> {
Set<String> reserved = scan.reservedNames.getOrDefault(message, Set.of());
Set<String> missing = new LinkedHashSet<>(names);
missing.removeAll(reserved);
if (!missing.isEmpty()) {
violations.add(
GrpcProtoRuleViolation.ofFile(
RULE_RESERVED_HISTORY,
fileName,
"message '"
+ message
+ "' removed field names "
+ missing.stream().sorted().toList()
+ " without reserving them; reusing the name changes the JSON contract"));
}
});
}
private Scan scan(String fileName, String source, List<GrpcProtoRuleViolation> violations) {
Scan scan = new Scan();
Deque<String> scopeKinds = new ArrayDeque<>();
Deque<String> scopeNames = new ArrayDeque<>();
int lineNumber = 0;
for (String rawLine : source.lines().toList()) {
lineNumber++;
String line = LINE_COMMENT.matcher(rawLine).replaceAll("");
if (line.isBlank()) {
continue;
}
Matcher syntax = SYNTAX.matcher(line);
if (syntax.find()) {
scan.syntax = syntax.group(1);
continue;
}
Matcher packageMatcher = PACKAGE.matcher(line);
if (packageMatcher.find()) {
scan.protoPackage = packageMatcher.group(1);
scan.packageLine = lineNumber;
continue;
}
Matcher importMatcher = IMPORT.matcher(line);
if (importMatcher.find()) {
String importPath = importMatcher.group(1);
if (!manifest.wellKnownTypeAllowed(importPath)) {
violations.add(
new GrpcProtoRuleViolation(
RULE_WELL_KNOWN_TYPE_ALLOWLIST,
fileName,
lineNumber,
"import '"
+ importPath
+ "' is not on the well-known type allowlist; Any and Struct erase the "
+ "schema they are supposed to describe, so each use is granted by name"));
}
continue;
}
Matcher option = OPTION.matcher(line);
if (option.find() && scopeKinds.isEmpty()) {
scan.options.put(option.group(1), option.group(2));
continue;
}
Matcher scopeOpen = SCOPE_OPEN.matcher(line);
if (scopeOpen.find()) {
scopeKinds.push(scopeOpen.group(1));
scopeNames.push(qualify(scopeNames, scopeOpen.group(2)));
continue;
}
if (line.strip().startsWith("}")) {
if (!scopeKinds.isEmpty()) {
scopeKinds.pop();
scopeNames.pop();
}
continue;
}
if (scopeKinds.isEmpty()) {
continue;
}
String scopeKind = scopeKinds.peek();
String scopeName = scopeNames.peek();
if ("enum".equals(scopeKind)) {
scanEnumValue(fileName, line, lineNumber, scopeName, violations);
} else if ("message".equals(scopeKind) || "oneof".equals(scopeKind)) {
scanMessageMember(fileName, line, lineNumber, scopeName, scan, violations);
}
}
return scan;
}
private static void scanEnumValue(
String fileName,
String line,
int lineNumber,
String enumName,
List<GrpcProtoRuleViolation> violations) {
Matcher value = ENUM_VALUE.matcher(line);
if (!value.find()) {
return;
}
if (!"0".equals(value.group(2))) {
return;
}
String name = value.group(1);
if (!name.endsWith("_UNSPECIFIED")) {
violations.add(
new GrpcProtoRuleViolation(
RULE_ENUM_ZERO_UNSPECIFIED,
fileName,
lineNumber,
"enum '"
+ enumName
+ "' zero value is '"
+ name
+ "'; proto3 gives an unset field the zero value, so a meaningful name there is "
+ "indistinguishable from a field nobody set"));
}
}
private void scanMessageMember(
String fileName,
String line,
int lineNumber,
String messageName,
Scan scan,
List<GrpcProtoRuleViolation> violations) {
Matcher reservedNames = RESERVED_NAMES.matcher(line);
if (reservedNames.find()) {
Matcher quoted = QUOTED_NAME.matcher(reservedNames.group(1));
while (quoted.find()) {
scan.reservedNames
.computeIfAbsent(messageName, key -> new LinkedHashSet<>())
.add(quoted.group(1));
}
return;
}
Matcher reservedNumbers = RESERVED_NUMBERS.matcher(line);
if (reservedNumbers.find()) {
Matcher number = NUMBER.matcher(reservedNumbers.group(1));
while (number.find()) {
scan.reservedNumbers
.computeIfAbsent(messageName, key -> new LinkedHashSet<>())
.add(Integer.valueOf(number.group()));
}
return;
}
Matcher mapField = MAP_FIELD.matcher(line);
if (mapField.find()) {
String qualified = messageName + "." + mapField.group(1);
if (!manifest.mapAllowed(qualified)) {
violations.add(
new GrpcProtoRuleViolation(
RULE_MAP_ALLOWLIST,
fileName,
lineNumber,
"map field '"
+ qualified
+ "' is not on the allowlist; a map is an unversioned key space that no "
+ "breaking-change check can reason about"));
}
return;
}
Matcher field = FIELD.matcher(line);
if (!field.find()) {
return;
}
String modifier = field.group(1) == null ? "" : field.group(1).strip();
String fieldName = field.group(3);
if (manifest.presenceRequired(messageName, fieldName) && !"optional".equals(modifier)) {
violations.add(
new GrpcProtoRuleViolation(
RULE_EXPLICIT_PRESENCE,
fileName,
lineNumber,
"field '"
+ messageName
+ "."
+ fieldName
+ "' needs presence and must be declared optional; without it, absent and "
+ "default-valued are the same message on the wire"));
}
}
private static String qualify(Deque<String> scopeNames, String name) {
String parent = scopeNames.peek();
return parent == null ? name : parent + "." + name;
}
private static String unquote(String value) {
if (value == null) {
return null;
}
String stripped = value.strip();
if (stripped.length() >= 2 && stripped.startsWith("\"") && stripped.endsWith("\"")) {
return stripped.substring(1, stripped.length() - 1);
}
return stripped;
}
/** Everything one pass over a file collected. */
private static final class Scan {
private String syntax;
private String protoPackage;
private int packageLine;
private final Map<String, String> options = new LinkedHashMap<>();
private final Map<String, Set<Integer>> reservedNumbers = new LinkedHashMap<>();
private final Map<String, Set<String>> reservedNames = new LinkedHashMap<>();
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.grpc.contract;
/**
* One schema rule broken, at one place.
*
* <p>Returned rather than thrown, and carrying a line number, because a schema review is a list. A
* validator that throws on the first violation turns "this file breaks four rules" into four
* separate runs, and the author fixes them one at a time without ever seeing the shape of the
* problem.
*/
public record GrpcProtoRuleViolation(String rule, String file, int line, String detail) {
/** Requires a named rule, a file and a one-based line. */
public GrpcProtoRuleViolation {
if (rule == null || rule.isBlank()) {
throw new IllegalArgumentException("a violation names the rule it broke");
}
if (file == null || file.isBlank()) {
throw new IllegalArgumentException("a violation names the file it is in");
}
if (detail == null || detail.isBlank()) {
throw new IllegalArgumentException("a violation explains itself");
}
if (line < 0) {
throw new IllegalArgumentException("line must not be negative");
}
}
/** A violation about a whole file rather than a line in it. */
public static GrpcProtoRuleViolation ofFile(String rule, String file, String detail) {
return new GrpcProtoRuleViolation(rule, file, 0, detail);
}
/** {@code file:line rule — detail}, the form a build log is read in. */
public String describe() {
return file + (line > 0 ? ":" + line : "") + " " + rule + "" + detail;
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.grpc.contract;
import java.util.Map;
import java.util.Set;
/**
* The schema rules this repository has decided on, as data rather than as prose in a review
* checklist.
*
* <p>Three of the five fields are allowlists, and that shape is the decision: {@code Any}, {@code
* Struct} and {@code map} are not banned, they are things you have to ask for by name. A ban gets
* worked around; an allowlist entry gets read by the next person to open the manifest and carries
* the field it was granted for.
*/
public record GrpcProtoStyleManifest(
String organization,
Set<String> handWrittenJavaPackages,
Set<String> wellKnownTypeAllowlist,
Set<String> mapFieldAllowlist,
Map<String, Set<String>> presenceRequiredFields) {
/** The well-known types every schema may use without asking. */
private static final Set<String> ALWAYS_ALLOWED_WELL_KNOWN_TYPES =
Set.of(
"google/protobuf/timestamp.proto",
"google/protobuf/duration.proto",
"google/protobuf/field_mask.proto",
"google/protobuf/empty.proto",
"google/protobuf/wrappers.proto");
/** Copies every collection so a manifest cannot widen after it has been reviewed. */
public GrpcProtoStyleManifest {
if (organization == null || organization.isBlank()) {
throw new IllegalArgumentException("a style manifest names the owning organization");
}
if (handWrittenJavaPackages == null
|| wellKnownTypeAllowlist == null
|| mapFieldAllowlist == null
|| presenceRequiredFields == null) {
throw new IllegalArgumentException("every manifest allowlist must be present");
}
handWrittenJavaPackages = Set.copyOf(handWrittenJavaPackages);
wellKnownTypeAllowlist = Set.copyOf(wellKnownTypeAllowlist);
mapFieldAllowlist = Set.copyOf(mapFieldAllowlist);
presenceRequiredFields =
presenceRequiredFields.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
Map.Entry::getKey, entry -> Set.copyOf(entry.getValue())));
}
/**
* This repository's manifest.
*
* <p>The hand-written package is {@code dev.caskeleton.grpc}: generated code must land somewhere
* else, because a generated class and a hand-written one in the same package are
* indistinguishable to a reader and the generated one gets edited exactly once before somebody
* regenerates.
*/
public static GrpcProtoStyleManifest caSkeleton() {
return new GrpcProtoStyleManifest(
"hyeonworks",
Set.of("dev.caskeleton"),
ALWAYS_ALLOWED_WELL_KNOWN_TYPES,
Set.of(),
Map.of());
}
/** Whether {@code importPath} may be imported. */
public boolean wellKnownTypeAllowed(String importPath) {
return !importPath.startsWith("google/protobuf/")
|| wellKnownTypeAllowlist.contains(importPath);
}
/** Whether a {@code map} field is allowed at {@code message.field}. */
public boolean mapAllowed(String qualifiedField) {
return mapFieldAllowlist.contains(qualifiedField);
}
/** Whether {@code field} of {@code message} must be declared {@code optional}. */
public boolean presenceRequired(String message, String field) {
return presenceRequiredFields.getOrDefault(message, Set.of()).contains(field);
}
/** A copy of this manifest that also allows the given well-known type imports. */
public GrpcProtoStyleManifest allowingWellKnownTypes(Set<String> additionalImports) {
java.util.Set<String> widened = new java.util.LinkedHashSet<>(wellKnownTypeAllowlist);
widened.addAll(additionalImports);
return new GrpcProtoStyleManifest(
organization, handWrittenJavaPackages, widened, mapFieldAllowlist, presenceRequiredFields);
}
/** A copy of this manifest that also allows the given {@code message.field} map fields. */
public GrpcProtoStyleManifest allowingMapFields(Set<String> qualifiedFields) {
java.util.Set<String> widened = new java.util.LinkedHashSet<>(mapFieldAllowlist);
widened.addAll(qualifiedFields);
return new GrpcProtoStyleManifest(
organization,
handWrittenJavaPackages,
wellKnownTypeAllowlist,
widened,
presenceRequiredFields);
}
/** A copy of this manifest that also requires explicit presence on the given fields. */
public GrpcProtoStyleManifest requiringPresence(String message, Set<String> fields) {
java.util.Map<String, Set<String>> widened =
new java.util.LinkedHashMap<>(presenceRequiredFields);
widened.merge(
message,
Set.copyOf(fields),
(existing, added) -> {
java.util.Set<String> merged = new java.util.LinkedHashSet<>(existing);
merged.addAll(added);
return merged;
});
return new GrpcProtoStyleManifest(
organization, handWrittenJavaPackages, wellKnownTypeAllowlist, mapFieldAllowlist, widened);
}
}
@@ -0,0 +1,23 @@
# What a Buf-driven generation would emit, recorded even though this build does not run it.
#
# No protoc runs here (ADR-GRPC-002), so this file is a specification rather than a step. It exists
# because the three decisions it encodes are the ones GrpcCodegenManifest enforces from Java, and a
# reader who wants to know what "the single codegen owner produces" means should find one answer
# rather than two:
#
# - generated Java lands under build/, never in a source tree;
# - the generated package is disjoint from the hand-written dev.caskeleton root;
# - plugin versions come from the managed platform, so no version literal appears below.
version: v2
managed:
enabled: true
override:
- file_option: java_multiple_files
value: true
- file_option: java_package_suffix
value: generated
plugins:
- remote: buf.build/protocolbuffers/java
out: build/generated/source/proto/main/java
- remote: buf.build/grpc/java
out: build/generated/source/proto/main/grpc
@@ -0,0 +1,11 @@
# Buf dependency lock.
#
# Empty by design: the Stable schema imports nothing outside itself. The well-known types this
# platform allows (timestamp, duration, field_mask, empty, wrappers) ship with protoc rather than
# coming from a Buf module, and GrpcProtoStyleManifest refuses any other google.protobuf import
# without an explicit allowlist entry.
#
# The file is committed rather than omitted so that adding a first dependency is a visible diff in a
# file that already exists, instead of a new file nobody reviews.
version: v2
deps: []
@@ -0,0 +1,18 @@
# Buf module configuration for the Stable schema.
#
# The rules named here are also implemented in GrpcProtoContractValidator, which is what actually
# fails this repository's build: the Buf CLI is not part of this toolchain, and a gate that silently
# passes when a binary is missing is worse than one that computes the same judgement from the
# committed schema. This file stays accurate so that running `buf lint` / `buf breaking` in an
# environment that has the CLI reaches the same verdict.
version: v2
modules:
- path: .
lint:
use:
- STANDARD
breaking:
# FILE, not WIRE or WIRE_JSON. Wire compatibility alone would let a field rename or a Java package
# move through as "compatible" while every generated consumer stops compiling.
use:
- FILE
@@ -0,0 +1,66 @@
syntax = "proto3";
package hyeonworks.grpc.common.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.grpc.common.v1.generated";
option java_outer_classname = "ErrorProto";
// The platform's allowlisted rich error details.
//
// Deliberately this repository's own messages rather than google.rpc.*: the Stable contract is that
// a client branches on a code, a reason and a typed detail — never on a message string — and owning
// the detail types is what keeps that surface reviewable. The shapes mirror google.rpc so a future
// move onto the common protos is a rename rather than a redesign.
// Why a request was refused before it reached a use case.
message FieldViolation {
// Fully qualified field path, e.g. "document.title".
string field = 1;
// Stable machine-readable reason, e.g. "LENGTH_EXCEEDED".
string reason = 2;
// Human-readable description. Never echoes the rejected value.
optional string description = 3;
}
// The transport validator's verdict.
message BadRequest {
repeated FieldViolation field_violations = 1;
}
// Whether and when the caller may try again. The only channel for a retry hint.
message RetryInfo {
// Seconds to wait before the next attempt. Zero means "do not retry".
int64 retry_delay_seconds = 1;
// Attempts already made, 1-based.
int32 attempt = 2;
}
// A stable classification a client may branch on.
message ErrorInfo {
// Stable reason constant, e.g. "IDEMPOTENCY_KEY_REQUIRED".
string reason = 1;
// The service that produced the failure.
string domain = 2;
// Opaque execution id an operator can correlate. Never a stack trace or a driver message.
optional string execution_id = 3;
}
// The state that made the operation impossible.
message PreconditionFailure {
message Violation {
string type = 1;
string subject = 2;
optional string description = 3;
}
repeated Violation violations = 1;
}
// What the caller may conclude about a state-changing call.
enum CompletionOutcome {
COMPLETION_OUTCOME_UNSPECIFIED = 0;
COMPLETION_OUTCOME_COMPLETED = 1;
COMPLETION_OUTCOME_REJECTED = 2;
COMPLETION_OUTCOME_COMPLETION_UNKNOWN = 3;
COMPLETION_OUTCOME_PARTIAL_STREAM = 4;
}
@@ -0,0 +1,57 @@
syntax = "proto3";
package hyeonworks.grpc.common.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.grpc.common.v1.generated";
option java_outer_classname = "StreamProto";
// The Stable server-streaming envelope.
//
// Every Stable server stream carries these fields around its payload, because resume, gap detection
// and drain all need a position and a generation, and a stream that ships payloads alone can offer
// none of them.
// Why a stream ended.
enum StreamTerminationReason {
STREAM_TERMINATION_REASON_UNSPECIFIED = 0;
STREAM_TERMINATION_REASON_COMPLETED = 1;
STREAM_TERMINATION_REASON_CLIENT_CANCELLED = 2;
STREAM_TERMINATION_REASON_IDLE_TIMEOUT = 3;
STREAM_TERMINATION_REASON_MAX_DURATION = 4;
STREAM_TERMINATION_REASON_SERVER_DRAIN = 5;
STREAM_TERMINATION_REASON_SLOW_CONSUMER = 6;
STREAM_TERMINATION_REASON_CREDENTIAL_EXPIRED = 7;
STREAM_TERMINATION_REASON_FULL_RESYNC_REQUIRED = 8;
}
// What a stream message is.
enum StreamMessageKind {
STREAM_MESSAGE_KIND_UNSPECIFIED = 0;
STREAM_MESSAGE_KIND_SNAPSHOT = 1;
STREAM_MESSAGE_KIND_SNAPSHOT_COMPLETE = 2;
STREAM_MESSAGE_KIND_LIVE = 3;
STREAM_MESSAGE_KIND_HEARTBEAT = 4;
STREAM_MESSAGE_KIND_TERMINATION = 5;
}
// The envelope every Stable server-stream message carries.
message StreamEnvelope {
// Opaque stream identity. Never used as a metric label.
string stream_id = 1;
// Monotonic within one stream generation. Restarts only on a new generation.
uint64 sequence = 2;
// The snapshot this stream is consistent with. Distinct from the resume cursor.
optional string snapshot_version = 3;
// Opaque, signed continuation handle. Absent when the stream is not resumable.
optional string resume_token = 4;
StreamMessageKind kind = 5;
// Set only on a terminal message.
optional StreamTerminationReason termination_reason = 6;
}
// A liveness signal. Explicitly not an application acknowledgement and not an ordering guarantee.
message StreamHeartbeat {
StreamEnvelope envelope = 1;
int64 server_time_unix_millis = 2;
}
@@ -0,0 +1,324 @@
package dev.caskeleton.grpc.contract;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcProtoContractValidatorTest {
private final GrpcProtoContractValidator validator =
new GrpcProtoContractValidator(GrpcProtoStyleManifest.caSkeleton());
private static String committedSchema(String resourcePath) {
try (InputStream stream =
GrpcProtoContractValidatorTest.class.getClassLoader().getResourceAsStream(resourcePath)) {
if (stream == null) {
throw new IllegalStateException("missing committed schema resource: " + resourcePath);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
@DisplayName("the committed Stable schema passes every style rule")
void committedSchemaIsCompliant() {
List<String> files =
List.of(
"proto/hyeonworks/grpc/common/v1/error.proto",
"proto/hyeonworks/grpc/common/v1/stream.proto");
for (String file : files) {
List<GrpcProtoRuleViolation> violations = validator.validate(file, committedSchema(file));
assertThat(violations)
.describedAs(
"violations in %s: %s",
file, violations.stream().map(GrpcProtoRuleViolation::describe).toList())
.isEmpty();
}
}
@Test
@DisplayName("the committed Buf configuration states the same rules the validator enforces")
void theBufConfigurationAgreesWithTheValidator() {
String bufYaml = committedSchema("proto/buf.yaml");
String bufGen = committedSchema("proto/buf.gen.yaml");
String bufLock = committedSchema("proto/buf.lock");
// FILE, not WIRE: the category the Stable gate is fixed at.
assertThat(bufYaml).contains("use:").contains("- FILE").contains("- STANDARD");
// Generation lands under build/ and into a package the hand-written root does not occupy.
assertThat(bufGen)
.contains("build/generated/source/proto/main/java")
.contains("build/generated/source/proto/main/grpc")
.contains("java_package_suffix")
// Never into a source tree: generated code that lands in src/ gets committed and then
// edited. The prose above the plugins block names dev.caskeleton as the package to stay out
// of, so the check is on the emitted paths rather than on the word.
.doesNotContain("out: src/");
// No version literal: the managed platform owns plugin versions.
assertThat(bufGen).doesNotContain("version: v1");
assertThat(bufLock).contains("deps: []");
}
@Test
@DisplayName("proto2 source is refused")
void proto2SourceIsRefused() {
String source =
"""
syntax = "proto2";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
""";
assertThat(validator.validate("legacy.proto", source))
.extracting(GrpcProtoRuleViolation::rule)
.contains(GrpcProtoContractValidator.RULE_PROTO3_SYNTAX);
}
@Test
@DisplayName("an unversioned package is refused")
void unversionedPackageIsRefused() {
String source =
"""
syntax = "proto3";
package hyeonworks.document;
option java_multiple_files = true;
option java_package = "hyeonworks.document.generated";
""";
assertThat(validator.validate("document.proto", source))
.extracting(GrpcProtoRuleViolation::rule)
.contains(GrpcProtoContractValidator.RULE_PACKAGE_VERSIONED);
}
@Test
@DisplayName("a generated java_package inside a hand-written package is refused")
void generatedPackageMayNotCollideWithHandWrittenCode() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "dev.caskeleton.grpc.document";
""";
assertThat(validator.validate("document.proto", source))
.extracting(GrpcProtoRuleViolation::rule)
.contains(GrpcProtoContractValidator.RULE_JAVA_PACKAGE_SEPARATE);
}
@Test
@DisplayName("a missing java_multiple_files option is refused")
void javaMultipleFilesIsRequired() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_package = "hyeonworks.document.v1.generated";
""";
assertThat(validator.validate("document.proto", source))
.extracting(GrpcProtoRuleViolation::rule)
.contains(GrpcProtoContractValidator.RULE_JAVA_MULTIPLE_FILES);
}
@Test
@DisplayName("an enum zero value without the _UNSPECIFIED suffix is refused, with its line")
void enumZeroValueNeedsTheUnspecifiedSuffix() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
enum DocumentState {
DRAFT = 0;
PUBLISHED = 1;
}
""";
List<GrpcProtoRuleViolation> violations = validator.validate("document.proto", source);
assertThat(violations)
.singleElement()
.satisfies(
violation -> {
assertThat(violation.rule())
.isEqualTo(GrpcProtoContractValidator.RULE_ENUM_ZERO_UNSPECIFIED);
assertThat(violation.line()).isEqualTo(7);
assertThat(violation.detail()).contains("DocumentState").contains("DRAFT");
});
}
@Test
@DisplayName("a removed field number that is not reserved fails, and reserving it passes")
void removedFieldNumbersMustStayReserved() {
String withoutReserved =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
message Document {
string id = 1;
string title = 4;
}
""";
GrpcProtoContractValidator.SchemaHistory history =
new GrpcProtoContractValidator.SchemaHistory(
Map.of("Document", Set.of(2, 3)), Map.of("Document", Set.of("legacy_body")));
assertThat(validator.validate("document.proto", withoutReserved, history))
.extracting(GrpcProtoRuleViolation::rule)
.containsOnly(GrpcProtoContractValidator.RULE_RESERVED_HISTORY);
String withReserved =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
message Document {
reserved 2, 3;
reserved "legacy_body";
string id = 1;
string title = 4;
}
""";
assertThat(validator.validate("document.proto", withReserved, history)).isEmpty();
}
@Test
@DisplayName("Any and Struct need an explicit allowlist entry; Timestamp does not")
void anyAndStructAreAllowlistedByName() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
import "google/protobuf/timestamp.proto";
import "google/protobuf/any.proto";
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
""";
assertThat(validator.validate("document.proto", source))
.singleElement()
.satisfies(
violation -> {
assertThat(violation.rule())
.isEqualTo(GrpcProtoContractValidator.RULE_WELL_KNOWN_TYPE_ALLOWLIST);
assertThat(violation.detail()).contains("google/protobuf/any.proto");
});
GrpcProtoContractValidator widened =
new GrpcProtoContractValidator(
GrpcProtoStyleManifest.caSkeleton()
.allowingWellKnownTypes(Set.of("google/protobuf/any.proto")));
assertThat(widened.validate("document.proto", source)).isEmpty();
}
@Test
@DisplayName("a map field needs an allowlist entry naming the message and field")
void mapFieldsAreAllowlistedByName() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
message Document {
map<string, string> labels = 1;
}
""";
assertThat(validator.validate("document.proto", source))
.extracting(GrpcProtoRuleViolation::rule)
.containsOnly(GrpcProtoContractValidator.RULE_MAP_ALLOWLIST);
GrpcProtoContractValidator widened =
new GrpcProtoContractValidator(
GrpcProtoStyleManifest.caSkeleton().allowingMapFields(Set.of("Document.labels")));
assertThat(widened.validate("document.proto", source)).isEmpty();
}
@Test
@DisplayName("a presence-required field must be declared optional")
void presenceRequiredFieldsMustBeOptional() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
message Document {
string id = 1;
string title = 2;
}
""";
GrpcProtoContractValidator strict =
new GrpcProtoContractValidator(
GrpcProtoStyleManifest.caSkeleton().requiringPresence("Document", Set.of("title")));
assertThat(strict.validate("document.proto", source))
.singleElement()
.satisfies(
violation -> {
assertThat(violation.rule())
.isEqualTo(GrpcProtoContractValidator.RULE_EXPLICIT_PRESENCE);
assertThat(violation.detail()).contains("Document.title");
});
String withOptional = source.replace(" string title = 2;", " optional string title = 2;");
assertThat(strict.validate("document.proto", withOptional)).isEmpty();
}
@Test
@DisplayName("nested messages are qualified, so a rule names the type it means")
void nestedMessagesAreQualified() {
String source =
"""
syntax = "proto3";
package hyeonworks.document.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.document.v1.generated";
message Outer {
message Inner {
map<string, string> labels = 1;
}
}
""";
assertThat(validator.validate("document.proto", source))
.singleElement()
.satisfies(violation -> assertThat(violation.detail()).contains("Outer.Inner.labels"));
}
@Test
@DisplayName("a violation renders as file:line rule — detail")
void violationRendersForABuildLog() {
GrpcProtoRuleViolation violation =
new GrpcProtoRuleViolation("RULE_X", "document.proto", 12, "something is wrong");
assertThat(violation.describe()).isEqualTo("document.proto:12 RULE_X — something is wrong");
assertThat(GrpcProtoRuleViolation.ofFile("RULE_Y", "document.proto", "whole file").describe())
.isEqualTo("document.proto RULE_Y — whole file");
}
}