Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
with:
persist-credentials: false

- name: Verify release promotion policy
run: python scripts/test-release-promotion-policy.py

- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- "!v*.*.*-RC*"

permissions:
actions: read
contents: read

concurrency:
Expand Down Expand Up @@ -43,6 +44,11 @@ jobs:
test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = \
"$(git rev-parse "origin/main^{commit}")"

- name: Verify release-candidate promotion
env:
GITHUB_TOKEN: ${{ github.token }}
run: python scripts/verify-release-promotion.py

- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
Expand All @@ -57,7 +63,7 @@ jobs:
python scripts/verify-compatibility-matrix.py
./gradlew clean verifyAll --stacktrace

- name: Publish clean release-candidate artifacts
- name: Publish clean release artifacts
run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven

- name: Verify Gradle Java consumer
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* Added ten-minute Spring and gRPC boundary examples.
* Added a reproducible release-candidate adopter gate against two pinned external codebases.
* Added a dedicated release-candidate workflow that publishes to Maven Central before running the external adopter gate.
* Added a final-release promotion gate that requires a successful same-line RC and allows only version substitution between the accepted RC and final release.

### Changed

Expand All @@ -28,7 +29,7 @@
* Kept stable Codes identity in the Spring `code` extension for every mapped failure.
* Reworked the Spring orders reference to consume `codes-spring` instead of duplicating adapter behavior.
* Enforced lossless Codes identity compatibility with `google.rpc.ErrorInfo.domain` and `ErrorInfo.reason`.
* Preserved exposed structured issues through `google.rpc.BadRequest` without normalizing coded issue identity.
* Restricted exposed `google.rpc.BadRequest` issues to `INVALID_ARGUMENT` and `OUT_OF_RANGE`, requiring request-field paths and rejecting incompatible representations.
* Reworked the gRPC orders reference to consume `codes-grpc-java` instead of constructing rich error details manually.
* Moved publication consumer verification to freshly emptied Maven repositories so compatibility checks cannot be satisfied by stale local artifacts.
* Documented cases where Codes should not be introduced.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io.github.aalsanie.codes.Outcome;
import io.github.aalsanie.codes.OutcomeCode;
import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper;
import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode;
import java.util.Objects;
import java.util.regex.Pattern;

Expand All @@ -21,9 +22,14 @@
* the Google RPC contract. A mapped outcome whose name cannot be represented as an
* {@code ErrorInfo.reason} is rejected.
*
* <p>When a coded {@link Issue} is exposed through {@link BadRequest.FieldViolation#getReason() reason},
* its namespace must match the enclosing outcome namespace. This keeps the field-level reason
* scoped by the same {@code ErrorInfo.domain} and avoids silently changing issue identity.
* <p>Structured issues are exposed through {@link BadRequest} only for gRPC
* {@code INVALID_ARGUMENT} and {@code OUT_OF_RANGE}. Every exposed issue must have a path that the
* application intends as a request-field path. Pathless issues cannot be represented honestly as
* {@link BadRequest.FieldViolation} and are rejected instead of being emitted with an empty field.
*
* <p>When a coded {@link Issue} is exposed through {@link BadRequest.FieldViolation#getReason()
* reason}, its namespace must match the enclosing outcome namespace. This keeps the field-level
* reason scoped by the same {@code ErrorInfo.domain} and avoids silently changing issue identity.
*/
public final class GoogleRpcOutcomeMapper {
private static final int MAX_GOOGLE_REASON_LENGTH = 63;
Expand Down Expand Up @@ -55,17 +61,17 @@ public MappingResult<com.google.rpc.Status> map(Outcome outcome) {
}

return mapper.map(outcome).fold(
status -> MappingResult.mapped(createStatus(outcome, status.getValue())),
status -> MappingResult.mapped(createStatus(outcome, status)),
MappingResult::unmapped
);
}

private com.google.rpc.Status createStatus(Outcome outcome, int statusCode) {
private com.google.rpc.Status createStatus(Outcome outcome, GrpcStatusCode statusCode) {
OutcomeCode outcomeCode = outcome.getCode();
requireGoogleReason(outcomeCode, "outcome code", "google.rpc.ErrorInfo.reason");

com.google.rpc.Status.Builder status = com.google.rpc.Status.newBuilder()
.setCode(statusCode)
.setCode(statusCode.getValue())
.setMessage(exposure.exposeMessage() ? outcome.getMessage() : outcomeCode.getValue())
.addDetails(Any.pack(
ErrorInfo.newBuilder()
Expand All @@ -83,24 +89,32 @@ private com.google.rpc.Status createStatus(Outcome outcome, int statusCode) {
}

if (exposure.exposeIssues() && !outcome.getIssues().isEmpty()) {
status.addDetails(Any.pack(toBadRequest(outcome)));
status.addDetails(Any.pack(toBadRequest(outcome, statusCode)));
}

return status.build();
}

private static BadRequest toBadRequest(Outcome outcome) {
private static BadRequest toBadRequest(Outcome outcome, GrpcStatusCode statusCode) {
requireBadRequestStatus(outcome, statusCode);

OutcomeCode outcomeCode = outcome.getCode();
BadRequest.Builder request = BadRequest.newBuilder();

for (Issue issue : outcome.getIssues()) {
String path = issue.getPath();
if (path == null) {
throw new IllegalArgumentException(
"issue on outcome '" + outcomeCode.getValue()
+ "' cannot be represented as google.rpc.BadRequest.FieldViolation"
+ "; exposed gRPC issues must have a request-field path"
);
}

BadRequest.FieldViolation.Builder violation = BadRequest.FieldViolation.newBuilder()
.setField(path)
.setDescription(issue.getMessage());

if (issue.getPath() != null) {
violation.setField(issue.getPath());
}

OutcomeCode issueCode = issue.getCode();
if (issueCode != null) {
requireFieldViolationIdentity(outcomeCode, issueCode);
Expand All @@ -113,6 +127,20 @@ private static BadRequest toBadRequest(Outcome outcome) {
return request.build();
}

private static void requireBadRequestStatus(Outcome outcome, GrpcStatusCode statusCode) {
if (statusCode == GrpcStatusCode.INVALID_ARGUMENT
|| statusCode == GrpcStatusCode.OUT_OF_RANGE) {
return;
}

throw new IllegalArgumentException(
"issues on outcome '" + outcome.getCode().getValue()
+ "' cannot be represented as google.rpc.BadRequest when mapped to gRPC "
+ statusCode
+ "; BadRequest issue exposure requires INVALID_ARGUMENT or OUT_OF_RANGE"
);
}

private static void requireFieldViolationIdentity(
OutcomeCode outcomeCode,
OutcomeCode issueCode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,33 @@ class GoogleRpcOutcomeMapperTest {
"The payment was declined."
);

private static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom(
APP_NAMESPACE,
"CHECKOUT_INVALID",
OutcomeState.FAILED,
"Checkout request is invalid."
);

private static final OutcomeDefinition RANGE_INVALID = OutcomeDefinition.custom(
APP_NAMESPACE,
"RANGE_INVALID",
OutcomeState.FAILED,
"Requested range is invalid."
);

private static final OutcomeCode PAYMENT_METHOD_INVALID = OutcomeCode.of(
APP_NAMESPACE,
"PAYMENT_METHOD_INVALID"
);

private static final GrpcOutcomeMapper APP_MAPPER = GrpcOutcomeMapper.standard()
.withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION);
.withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION)
.withMapping(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT)
.withMapping(RANGE_INVALID, GrpcStatusCode.OUT_OF_RANGE);

@Test
void safeDefaultsExposeOnlyMachineIdentity() throws Exception {
Outcome outcome = testOutcome();
Outcome outcome = paymentDeclinedOutcome();
GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.safeDefaults());

com.google.rpc.Status status = mapper.map(outcome).orNull();
Expand All @@ -55,19 +71,20 @@ void safeDefaultsExposeOnlyMachineIdentity() throws Exception {
}

@Test
void publicErrorsExposeMessageAndStructuredIssuesButNotOccurrenceDetail() throws Exception {
Outcome outcome = testOutcome();
void publicErrorsExposeRequestIssuesButNotOccurrenceDetail() throws Exception {
Outcome outcome = validationOutcome();
GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.publicErrors());

com.google.rpc.Status status = mapper.map(outcome).orNull();

assertNotNull(status);
assertEquals(3, status.getCode());
assertEquals(outcome.getMessage(), status.getMessage());
assertEquals(2, status.getDetailsCount());

ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class);
assertEquals(APP_NAMESPACE, info.getDomain());
assertEquals("PAYMENT_DECLINED", info.getReason());
assertEquals("CHECKOUT_INVALID", info.getReason());

BadRequest request = status.getDetails(1).unpack(BadRequest.class);
assertEquals(2, request.getFieldViolationsCount());
Expand All @@ -85,7 +102,7 @@ void publicErrorsExposeMessageAndStructuredIssuesButNotOccurrenceDetail() throws

@Test
void explicitDetailExposureAddsDebugInfo() throws Exception {
Outcome outcome = testOutcome();
Outcome outcome = validationOutcome();
GoogleRpcOutcomeMapper mapper = mapper(new GrpcOutcomeExposure(true, true, true));

com.google.rpc.Status status = mapper.map(outcome).orNull();
Expand All @@ -97,6 +114,52 @@ void explicitDetailExposureAddsDebugInfo() throws Exception {
assertTrue(status.getDetails(2).is(BadRequest.class));
}

@Test
void outOfRangeCanExposeBadRequest() throws Exception {
Outcome outcome = Outcome.of(
RANGE_INVALID,
null,
List.of(Issue.at("offset", "Offset is outside the available range."))
);

com.google.rpc.Status status = mapper(GrpcOutcomeExposure.publicErrors())
.map(outcome)
.orNull();

assertNotNull(status);
assertEquals(11, status.getCode());
BadRequest request = status.getDetails(1).unpack(BadRequest.class);
assertEquals("offset", request.getFieldViolations(0).getField());
}

@Test
void rejectsBadRequestIssuesForFailedPrecondition() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> mapper(GrpcOutcomeExposure.publicErrors()).map(paymentDeclinedOutcome())
);

assertTrue(exception.getMessage().contains("FAILED_PRECONDITION"));
assertTrue(exception.getMessage().contains("INVALID_ARGUMENT or OUT_OF_RANGE"));
}

@Test
void rejectsPathlessIssueAsBadRequestFieldViolation() {
Outcome outcome = Outcome.of(
CHECKOUT_INVALID,
null,
List.of(Issue.of("The request is invalid."))
);

IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> mapper(GrpcOutcomeExposure.publicErrors()).map(outcome)
);

assertTrue(exception.getMessage().contains("request-field path"));
assertTrue(exception.getMessage().contains(CHECKOUT_INVALID.getCode().getValue()));
}

@Test
void rejectsOutcomeNamesLongerThanErrorInfoReasonAllows() {
OutcomeDefinition incompatible = OutcomeDefinition.custom(
Expand Down Expand Up @@ -147,7 +210,7 @@ void rejectsCodedIssueFromDifferentDomainInsteadOfChangingItsIdentity() {
"PAYMENT_METHOD_INVALID"
);
Outcome outcome = Outcome.of(
PAYMENT_DECLINED,
CHECKOUT_INVALID,
null,
List.of(
Issue.at(
Expand Down Expand Up @@ -191,9 +254,9 @@ void exposurePoliciesAreExplicit() {
assertTrue(GrpcOutcomeExposure.publicErrors().exposeIssues());
}

private static Outcome testOutcome() {
private static Outcome validationOutcome() {
return Outcome.of(
PAYMENT_DECLINED,
CHECKOUT_INVALID,
"gateway_token=secret-123",
List.of(
Issue.at(
Expand All @@ -206,6 +269,14 @@ private static Outcome testOutcome() {
);
}

private static Outcome paymentDeclinedOutcome() {
return Outcome.of(
PAYMENT_DECLINED,
"gateway_token=secret-123",
List.of(Issue.of("Payment cannot be completed."))
);
}

private static GoogleRpcOutcomeMapper mapper(GrpcOutcomeExposure exposure) {
return new GoogleRpcOutcomeMapper(APP_MAPPER, exposure);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
Expand All @@ -32,11 +31,11 @@ class GrpcStatusRuntimeExceptionRoundTripTest {
private static final String PROTECTED_DETAIL = "gateway_token=secret-123";
private static final String PROTECTED_ISSUE_MESSAGE = "Payment method is invalid.";

private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom(
private static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom(
APP_NAMESPACE,
"PAYMENT_DECLINED",
"CHECKOUT_INVALID",
OutcomeState.FAILED,
"The payment was declined."
"Checkout request is invalid."
);

private static final OutcomeCode PAYMENT_METHOD_INVALID = OutcomeCode.of(
Expand All @@ -45,7 +44,7 @@ class GrpcStatusRuntimeExceptionRoundTripTest {
);

private static final GrpcOutcomeMapper APP_MAPPER = GrpcOutcomeMapper.standard()
.withMapping(PAYMENT_DECLINED, GrpcStatusCode.FAILED_PRECONDITION);
.withMapping(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT);

@Test
void decodedStatusRuntimeExceptionsMatchGoldenContract() throws Exception {
Expand Down Expand Up @@ -99,7 +98,7 @@ private static com.google.rpc.Status roundTrip(

assertNotNull(exception);
assertEquals(
io.grpc.Status.Code.FAILED_PRECONDITION,
io.grpc.Status.Code.INVALID_ARGUMENT,
exception.getStatus().getCode()
);

Expand Down Expand Up @@ -226,7 +225,7 @@ private static String orAbsent(String value) {

private static Outcome testOutcome() {
return Outcome.of(
PAYMENT_DECLINED,
CHECKOUT_INVALID,
PROTECTED_DETAIL,
List.of(
Issue.at(
Expand Down
4 changes: 3 additions & 1 deletion compatibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ Core semantic snapshots do not freeze human-readable messages, occurrence detail

A change to that fixture is therefore a reviewed Spring wire-contract change.

`grpc-google-rpc-status.snapshot` is the gRPC adapter wire fixture. It is captured after a `google.rpc.Status` is encoded into a `StatusRuntimeException` and decoded from its trailers again. It verifies exact application identity in `ErrorInfo.domain` and `ErrorInfo.reason`, safe message behavior, explicit detail exposure through `DebugInfo`, and structured issues through `BadRequest`.
`grpc-google-rpc-status.snapshot` is the gRPC adapter wire fixture. It is captured after a `google.rpc.Status` is encoded into a `StatusRuntimeException` and decoded from its trailers again. It verifies exact application identity in `ErrorInfo.domain` and `ErrorInfo.reason`, safe message behavior, explicit detail exposure through `DebugInfo`, and request-field issues through `BadRequest` for a status whose standard detail is `BadRequest`.

When gRPC issue exposure is enabled, the adapter emits `BadRequest` only for `INVALID_ARGUMENT` and `OUT_OF_RANGE`. Every exposed issue must have a request-field path. Incompatible statuses and pathless issues are rejected instead of being encoded with misleading wire semantics.

A change to that fixture is therefore a reviewed gRPC wire-contract change.

Expand Down
Loading
Loading