feat: grpc 기능 deep 구현
This commit is contained in:
@@ -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
|
||||
+447
@@ -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<>();
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+120
@@ -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
|
||||
+66
@@ -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;
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
+324
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user