From 915ffcfd4aa09ffa48baaf8ff19cb97f2d05a169 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:21:19 +0300 Subject: [PATCH 01/50] Add RFC 9457 problem type model --- .../aalsanie/codes/DefaultProblemType.java | 65 +++++++++++++++++++ .../io/github/aalsanie/codes/ProblemType.java | 16 +++++ .../aalsanie/codes/ProblemTypeTest.java | 53 +++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/main/java/io/github/aalsanie/codes/DefaultProblemType.java create mode 100644 src/main/java/io/github/aalsanie/codes/ProblemType.java create mode 100644 src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java diff --git a/src/main/java/io/github/aalsanie/codes/DefaultProblemType.java b/src/main/java/io/github/aalsanie/codes/DefaultProblemType.java new file mode 100644 index 0000000..a9e7e04 --- /dev/null +++ b/src/main/java/io/github/aalsanie/codes/DefaultProblemType.java @@ -0,0 +1,65 @@ +package io.github.aalsanie.codes; + +import java.net.URI; +import java.util.Objects; + +final class DefaultProblemType implements ProblemType { + private final URI type; + private final int status; + private final String title; + + private DefaultProblemType(URI type, int status, String title) { + this.type = type; + this.status = status; + this.title = title; + } + + static ProblemType create(URI type, int status, String title) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(title, "title"); + if (type.toString().isEmpty()) { + throw new IllegalArgumentException("type must not be empty"); + } + if (status < 100 || status > 599) { + throw new IllegalArgumentException("status must be between 100 and 599"); + } + if (title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + return new DefaultProblemType(type, status, title); + } + + @Override + public URI getType() { + return type; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getTitle() { + return title; + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof DefaultProblemType that + && status == that.status + && type.equals(that.type) + && title.equals(that.title)); + } + + @Override + public int hashCode() { + return Objects.hash(type, status, title); + } + + @Override + public String toString() { + return "ProblemType(type=" + type + ", status=" + status + ", title=" + title + ")"; + } +} diff --git a/src/main/java/io/github/aalsanie/codes/ProblemType.java b/src/main/java/io/github/aalsanie/codes/ProblemType.java new file mode 100644 index 0000000..f7bc9fa --- /dev/null +++ b/src/main/java/io/github/aalsanie/codes/ProblemType.java @@ -0,0 +1,16 @@ +package io.github.aalsanie.codes; + +import java.net.URI; + +public interface ProblemType { + + URI getType(); + + int getStatus(); + + String getTitle(); + + static ProblemType of(URI type, int status, String title) { + return DefaultProblemType.create(type, status, title); + } +} diff --git a/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java new file mode 100644 index 0000000..0813e8e --- /dev/null +++ b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java @@ -0,0 +1,53 @@ +package io.github.aalsanie.codes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import org.junit.jupiter.api.Test; + +class ProblemTypeTest { + + @Test + void createsReusableProblemType() { + ProblemType problem = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + + assertEquals(URI.create("https://api.example.com/problems/order-not-found"), problem.getType()); + assertEquals(404, problem.getStatus()); + assertEquals("Order not found", problem.getTitle()); + } + + @Test + void supportsNonEmptyUriReferences() { + assertEquals(URI.create("about:blank"), ProblemType.of(URI.create("about:blank"), 500, "Error").getType()); + assertEquals(URI.create("order-not-found"), ProblemType.of(URI.create("order-not-found"), 404, "Missing").getType()); + } + + @Test + void validatesDefinition() { + assertThrows(NullPointerException.class, () -> ProblemType.of(null, 404, "Missing")); + assertThrows(NullPointerException.class, () -> ProblemType.of(URI.create("urn:test"), 404, null)); + assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create(""), 404, "Missing")); + assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create("urn:test"), 99, "Missing")); + assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create("urn:test"), 600, "Missing")); + assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create("urn:test"), 400, " ")); + } + + @Test + void factoryValuesHaveValueSemantics() { + ProblemType first = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); + ProblemType second = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); + ProblemType different = ProblemType.of(URI.create("urn:problem:missing"), 409, "Missing"); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + assertNotEquals(first, different); + assertTrue(first.toString().contains("urn:problem:missing")); + } +} From 00a2a9a2f969d419dcadf674d797244d7bb952d3 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:22:39 +0300 Subject: [PATCH 02/50] Simplify core to problem types --- api/codes.api | 228 +----------------- build.gradle.kts | 14 +- gradle.properties | 8 +- settings.gradle.kts | 3 - .../io/github/aalsanie/codes/Constraints.java | 113 --------- .../java/io/github/aalsanie/codes/Issue.java | 90 ------- .../github/aalsanie/codes/MappingResult.java | 107 -------- .../io/github/aalsanie/codes/Outcome.java | 102 -------- .../io/github/aalsanie/codes/OutcomeCode.java | 81 ------- .../aalsanie/codes/OutcomeDefinition.java | 110 --------- .../aalsanie/codes/OutcomeException.java | 30 --- .../aalsanie/codes/OutcomeExceptions.java | 14 -- .../github/aalsanie/codes/OutcomeMapper.java | 11 - .../aalsanie/codes/OutcomeRegistry.java | 80 ------ .../github/aalsanie/codes/OutcomeState.java | 7 - .../aalsanie/codes/StandardOutcomes.java | 48 ---- .../aalsanie/codes/ValidationResult.java | 130 ---------- .../protocol/grpc/GrpcOutcomeMapper.java | 88 ------- .../codes/protocol/grpc/GrpcStatusCode.java | 50 ---- .../codes/protocol/grpc/package-info.java | 4 - .../protocol/http/HttpOutcomeMapper.java | 83 ------- .../codes/protocol/http/HttpStatusCode.java | 101 -------- .../codes/protocol/http/package-info.java | 4 - .../io/github/aalsanie/codes/ApiSnapshot.java | 171 +------------ .../aalsanie/codes/IssueOutcomeTest.java | 105 -------- .../aalsanie/codes/JavaInteropTest.java | 88 ------- .../aalsanie/codes/MappingResultTest.java | 38 --- .../codes/NullabilityContractTest.java | 62 ----- .../aalsanie/codes/OutcomeCodeTest.java | 66 ----- .../aalsanie/codes/OutcomeDefinitionTest.java | 56 ----- .../codes/RegistryMappingExceptionTest.java | 59 ----- .../codes/SemanticCompatibilityTest.java | 88 ------- .../aalsanie/codes/StandardOutcomesTest.java | 24 -- .../aalsanie/codes/ValidationResultTest.java | 69 ------ .../protocol/grpc/GrpcOutcomeMapperTest.java | 50 ---- .../protocol/http/HttpOutcomeMapperTest.java | 90 ------- 36 files changed, 15 insertions(+), 2457 deletions(-) delete mode 100644 src/main/java/io/github/aalsanie/codes/Constraints.java delete mode 100644 src/main/java/io/github/aalsanie/codes/Issue.java delete mode 100644 src/main/java/io/github/aalsanie/codes/MappingResult.java delete mode 100644 src/main/java/io/github/aalsanie/codes/Outcome.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeCode.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeException.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeMapper.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java delete mode 100644 src/main/java/io/github/aalsanie/codes/OutcomeState.java delete mode 100644 src/main/java/io/github/aalsanie/codes/StandardOutcomes.java delete mode 100644 src/main/java/io/github/aalsanie/codes/ValidationResult.java delete mode 100644 src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java delete mode 100644 src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java delete mode 100644 src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java delete mode 100644 src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java delete mode 100644 src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java delete mode 100644 src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java delete mode 100644 src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/JavaInteropTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/MappingResultTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/ValidationResultTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java delete mode 100644 src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java diff --git a/api/codes.api b/api/codes.api index 33bfce1..7ed2cfb 100644 --- a/api/codes.api +++ b/api/codes.api @@ -1,223 +1,5 @@ -public final class io.github.aalsanie.codes.Issue - method public boolean equals(java.lang.Object) - method public int hashCode() - method public io.github.aalsanie.codes.OutcomeCode getCode() - method public java.lang.String getMessage() - method public java.lang.String getPath() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.Issue at(java.lang.String, io.github.aalsanie.codes.OutcomeCode, java.lang.String) - method public static io.github.aalsanie.codes.Issue at(java.lang.String, java.lang.String) - method public static io.github.aalsanie.codes.Issue coded(io.github.aalsanie.codes.OutcomeCode, java.lang.String) - method public static io.github.aalsanie.codes.Issue of(java.lang.String) - -public abstract class io.github.aalsanie.codes.MappingResult permits io.github.aalsanie.codes.MappingResult$Mapped, io.github.aalsanie.codes.MappingResult$Unmapped - method public abstract T orNull() - method public abstract boolean isMapped() - method public final R fold(java.util.function.Function, java.util.function.Supplier) - method public final boolean isUnmapped() - method public static io.github.aalsanie.codes.MappingResult mapped(T) - method public static io.github.aalsanie.codes.MappingResult unmapped() - -public static final class io.github.aalsanie.codes.MappingResult$Mapped extends io.github.aalsanie.codes.MappingResult - method public T getValue() - method public T orNull() - method public boolean equals(java.lang.Object) - method public boolean isMapped() - method public int hashCode() - method public java.lang.String toString() - -public static final class io.github.aalsanie.codes.MappingResult$Unmapped extends io.github.aalsanie.codes.MappingResult - method public T orNull() - method public boolean equals(java.lang.Object) - method public boolean isMapped() - method public int hashCode() - method public java.lang.String toString() - -public final class io.github.aalsanie.codes.Outcome - method public boolean isFailed() - method public boolean isPending() - method public boolean isSuccessful() - method public boolean isTerminal() - method public io.github.aalsanie.codes.OutcomeCode getCode() - method public io.github.aalsanie.codes.OutcomeDefinition getDefinition() - method public io.github.aalsanie.codes.OutcomeState getState() - method public java.lang.String getDefaultMessage() - method public java.lang.String getDetail() - method public java.lang.String getMessage() - method public java.lang.String toString() - method public java.util.List getIssues() - method public static io.github.aalsanie.codes.Outcome of(io.github.aalsanie.codes.OutcomeDefinition) - method public static io.github.aalsanie.codes.Outcome of(io.github.aalsanie.codes.OutcomeDefinition, java.lang.String) - method public static io.github.aalsanie.codes.Outcome of(io.github.aalsanie.codes.OutcomeDefinition, java.lang.String, java.util.List) - -public final class io.github.aalsanie.codes.OutcomeCode implements java.lang.Comparable - method public boolean equals(java.lang.Object) - method public int compareTo(io.github.aalsanie.codes.OutcomeCode) - method public int hashCode() - method public java.lang.String getName() - method public java.lang.String getNamespace() - method public java.lang.String getValue() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.OutcomeCode of(java.lang.String, java.lang.String) - method public static io.github.aalsanie.codes.OutcomeCode parse(java.lang.String) - method public static io.github.aalsanie.codes.OutcomeCode parseOrNull(java.lang.String) - -public final class io.github.aalsanie.codes.OutcomeDefinition - method public io.github.aalsanie.codes.OutcomeCode getCode() - method public io.github.aalsanie.codes.OutcomeState getState() - method public java.lang.String getDefaultMessage() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.OutcomeDefinition custom(io.github.aalsanie.codes.OutcomeCode, io.github.aalsanie.codes.OutcomeState, java.lang.String) - method public static io.github.aalsanie.codes.OutcomeDefinition custom(java.lang.String, java.lang.String, io.github.aalsanie.codes.OutcomeState, java.lang.String) - -public final class io.github.aalsanie.codes.OutcomeException extends java.lang.RuntimeException - ctor public (io.github.aalsanie.codes.Outcome) - ctor public (io.github.aalsanie.codes.Outcome, java.lang.Throwable) - method public io.github.aalsanie.codes.Outcome getOutcome() - -public final class io.github.aalsanie.codes.OutcomeExceptions - method public static io.github.aalsanie.codes.OutcomeException toException(io.github.aalsanie.codes.Outcome) - method public static io.github.aalsanie.codes.OutcomeException toException(io.github.aalsanie.codes.Outcome, java.lang.Throwable) - -public abstract interface io.github.aalsanie.codes.OutcomeMapper - method public abstract io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - -public final class io.github.aalsanie.codes.OutcomeRegistry - method public boolean contains(io.github.aalsanie.codes.OutcomeCode) - method public int getSize() - method public io.github.aalsanie.codes.OutcomeDefinition find(io.github.aalsanie.codes.OutcomeCode) - method public io.github.aalsanie.codes.OutcomeDefinition find(java.lang.String) - method public io.github.aalsanie.codes.OutcomeDefinition require(io.github.aalsanie.codes.OutcomeCode) - method public io.github.aalsanie.codes.OutcomeRegistry with(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.OutcomeRegistry withAll(java.lang.Iterable) - method public java.util.List definitions() - method public static io.github.aalsanie.codes.OutcomeRegistry empty() - method public static io.github.aalsanie.codes.OutcomeRegistry of(io.github.aalsanie.codes.OutcomeDefinition[]) - method public static io.github.aalsanie.codes.OutcomeRegistry standard() - -public final enum io.github.aalsanie.codes.OutcomeState - field public static final io.github.aalsanie.codes.OutcomeState FAILED - field public static final io.github.aalsanie.codes.OutcomeState PENDING - field public static final io.github.aalsanie.codes.OutcomeState SUCCEEDED - method public static io.github.aalsanie.codes.OutcomeState valueOf(java.lang.String) - method public static io.github.aalsanie.codes.OutcomeState[] values() - -public final class io.github.aalsanie.codes.StandardOutcomes - field public static final io.github.aalsanie.codes.OutcomeDefinition ABORTED - field public static final io.github.aalsanie.codes.OutcomeDefinition ALREADY_EXISTS - field public static final io.github.aalsanie.codes.OutcomeDefinition CANCELLED - field public static final io.github.aalsanie.codes.OutcomeDefinition DATA_LOSS - field public static final io.github.aalsanie.codes.OutcomeDefinition DEADLINE_EXCEEDED - field public static final io.github.aalsanie.codes.OutcomeDefinition FAILED_PRECONDITION - field public static final io.github.aalsanie.codes.OutcomeDefinition INTERNAL - field public static final io.github.aalsanie.codes.OutcomeDefinition INVALID_ARGUMENT - field public static final io.github.aalsanie.codes.OutcomeDefinition NOT_FOUND - field public static final io.github.aalsanie.codes.OutcomeDefinition OK - field public static final io.github.aalsanie.codes.OutcomeDefinition OUT_OF_RANGE - field public static final io.github.aalsanie.codes.OutcomeDefinition PERMISSION_DENIED - field public static final io.github.aalsanie.codes.OutcomeDefinition RATE_LIMITED - field public static final io.github.aalsanie.codes.OutcomeDefinition RESOURCE_EXHAUSTED - field public static final io.github.aalsanie.codes.OutcomeDefinition UNAUTHENTICATED - field public static final io.github.aalsanie.codes.OutcomeDefinition UNAVAILABLE - field public static final io.github.aalsanie.codes.OutcomeDefinition UNIMPLEMENTED - field public static final java.lang.String NAMESPACE - field public static final java.util.List all - -public abstract class io.github.aalsanie.codes.ValidationResult permits io.github.aalsanie.codes.ValidationResult$Invalid, io.github.aalsanie.codes.ValidationResult$Valid - method public abstract java.util.List issues() - method public final boolean isInvalid() - method public final boolean isValid() - method public final io.github.aalsanie.codes.Outcome toOutcome(io.github.aalsanie.codes.OutcomeDefinition) - method public final io.github.aalsanie.codes.Outcome toOutcome(io.github.aalsanie.codes.OutcomeDefinition, java.lang.String) - method public static io.github.aalsanie.codes.ValidationResult combine(io.github.aalsanie.codes.ValidationResult[]) - method public static io.github.aalsanie.codes.ValidationResult combine(java.util.List) - method public static io.github.aalsanie.codes.ValidationResult invalid(io.github.aalsanie.codes.Issue) - method public static io.github.aalsanie.codes.ValidationResult invalid(java.util.List) - method public static io.github.aalsanie.codes.ValidationResult valid() - -public static final class io.github.aalsanie.codes.ValidationResult$Invalid extends io.github.aalsanie.codes.ValidationResult - method public boolean equals(java.lang.Object) - method public int hashCode() - method public java.lang.String toString() - method public java.util.List getIssues() - method public java.util.List issues() - -public static final class io.github.aalsanie.codes.ValidationResult$Valid extends io.github.aalsanie.codes.ValidationResult - method public boolean equals(java.lang.Object) - method public int hashCode() - method public java.lang.String toString() - method public java.util.List issues() - -public final class io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper implements io.github.aalsanie.codes.OutcomeMapper - method public boolean contains(io.github.aalsanie.codes.OutcomeCode) - method public int getSize() - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper withMapping(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode) - method public io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper withOverride(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode) - method public java.util.Map mappings() - method public static io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper empty() - method public static io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper standard() - -public final enum io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode ABORTED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode ALREADY_EXISTS - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode CANCELLED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode DATA_LOSS - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode DEADLINE_EXCEEDED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode FAILED_PRECONDITION - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode INTERNAL - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode INVALID_ARGUMENT - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode NOT_FOUND - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode OK - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode OUT_OF_RANGE - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode PERMISSION_DENIED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode RESOURCE_EXHAUSTED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNAUTHENTICATED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNAVAILABLE - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNIMPLEMENTED - field public static final io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode UNKNOWN - method public int getValue() - method public static io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode fromValue(int) - method public static io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode valueOf(java.lang.String) - method public static io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode[] values() - -public final class io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper implements io.github.aalsanie.codes.OutcomeMapper - method public boolean contains(io.github.aalsanie.codes.OutcomeCode) - method public int getSize() - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper withMapping(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.http.HttpStatusCode) - method public io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper withOverride(io.github.aalsanie.codes.OutcomeDefinition, io.github.aalsanie.codes.protocol.http.HttpStatusCode) - method public java.util.Map mappings() - method public static io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper empty() - method public static io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper standard() - -public final class io.github.aalsanie.codes.protocol.http.HttpStatusCode implements java.lang.Comparable - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode ACCEPTED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode BAD_REQUEST - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode CONFLICT - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode CREATED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode FORBIDDEN - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode GATEWAY_TIMEOUT - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode INTERNAL_SERVER_ERROR - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode NOT_FOUND - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode NOT_IMPLEMENTED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode NO_CONTENT - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode OK - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode PAYLOAD_TOO_LARGE - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode PRECONDITION_FAILED - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode SERVICE_UNAVAILABLE - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode TOO_MANY_REQUESTS - field public static final io.github.aalsanie.codes.protocol.http.HttpStatusCode UNAUTHORIZED - method public boolean equals(java.lang.Object) - method public boolean isClientError() - method public boolean isInformational() - method public boolean isRedirection() - method public boolean isServerError() - method public boolean isSuccessful() - method public int compareTo(io.github.aalsanie.codes.protocol.http.HttpStatusCode) - method public int getFamily() - method public int getValue() - method public int hashCode() - method public java.lang.String toString() - method public static io.github.aalsanie.codes.protocol.http.HttpStatusCode of(int) \ No newline at end of file +public abstract interface io.github.aalsanie.codes.ProblemType + method public abstract int getStatus() + method public abstract java.lang.String getTitle() + method public abstract java.net.URI getType() + method public static io.github.aalsanie.codes.ProblemType of(java.net.URI, int, java.lang.String) diff --git a/build.gradle.kts b/build.gradle.kts index 18085b5..76e8135 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,10 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +sourceSets.test { + java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) +} + tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) @@ -70,11 +74,8 @@ tasks.register("verifyCoreRuntimeDependencies") { doLast { val runtimeFiles = runtimeClasspath.get().files - check(runtimeFiles.isEmpty()) { - "Codes core must remain dependency-free at runtime: ${ - runtimeFiles.joinToString { it.name } - }" + "Codes core must remain dependency-free at runtime: ${runtimeFiles.joinToString { it.name }}" } } } @@ -95,7 +96,6 @@ tasks.register("verifyPublishedPomHasNoDependencies") { .parse(pomFile.get().asFile) val dependencies = document.getElementsByTagName("dependency") - check(dependencies.length == 0) { "Codes core Maven POM must not declare dependencies; found ${dependencies.length}." } @@ -121,13 +121,11 @@ tasks.jar { tasks.register("verifyAll") { group = "verification" - description = "Verifies the core, incubating adapters, and reference applications." + description = "Verifies Codes, the Spring bridge, and the reference application." dependsOn( tasks.check, ":codes-spring:check", - ":codes-grpc-java:check", ":reference-spring-orders:check", - ":reference-grpc-orders:check", ) } diff --git a/gradle.properties b/gradle.properties index 4fe73f5..d0ba0fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ GROUP=io.github.aalsanie POM_ARTIFACT_ID=codes -VERSION_NAME=0.4.0-RC1 +VERSION_NAME=0.4.0 POM_NAME=Codes -POM_DESCRIPTION=Stable application outcomes, structured issues, and explicit protocol mappings for Java and Kotlin. +POM_DESCRIPTION=Dependency-free Java model for reusable RFC 9457 problem type definitions. POM_INCEPTION_YEAR=2026 POM_URL=https://github.com/aalsanie/codes POM_LICENSE_NAME=The Apache License, Version 2.0 @@ -17,10 +17,6 @@ POM_DEVELOPER_URL=https://github.com/aalsanie junitVersion=6.1.3 springFrameworkVersion=7.0.9 springBootVersion=4.1.1 -jackson3Version=3.1.5 -grpcVersion=1.83.1 -protoGoogleCommonProtosVersion=2.75.0 -protobufVersion=4.33.6 org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.parallel=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 1230986..ab21e4f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,8 +15,5 @@ dependencyResolutionManagement { rootProject.name = "codes" include("codes-spring") -include("codes-grpc-java") include("reference-spring-orders") project(":reference-spring-orders").projectDir = file("reference/spring-orders") -include("reference-grpc-orders") -project(":reference-grpc-orders").projectDir = file("reference/grpc-orders") diff --git a/src/main/java/io/github/aalsanie/codes/Constraints.java b/src/main/java/io/github/aalsanie/codes/Constraints.java deleted file mode 100644 index d5e2ba5..0000000 --- a/src/main/java/io/github/aalsanie/codes/Constraints.java +++ /dev/null @@ -1,113 +0,0 @@ -package io.github.aalsanie.codes; - -final class Constraints { - private static final int MAX_NAMESPACE_LENGTH = 128; - private static final int MAX_NAME_LENGTH = 64; - private static final int MAX_MESSAGE_LENGTH = 1024; - private static final int MAX_DETAIL_LENGTH = 4096; - private static final int MAX_PATH_LENGTH = 256; - - private Constraints() { - } - - static void requireNamespace(String namespace) { - if (namespace == null) { - throw new NullPointerException("namespace"); - } - if (namespace.isEmpty() || namespace.length() > MAX_NAMESPACE_LENGTH) { - throw new IllegalArgumentException( - "namespace length must be between 1 and " + MAX_NAMESPACE_LENGTH - ); - } - - String[] segments = namespace.split("\\.", -1); - for (String segment : segments) { - if (segment.isEmpty()) { - throw new IllegalArgumentException("namespace must not contain empty segments"); - } - if (segment.length() > 63) { - throw new IllegalArgumentException("namespace segment length must not exceed 63"); - } - if (!isLowerAsciiLetter(segment.charAt(0))) { - throw new IllegalArgumentException( - "namespace segments must start with a lowercase ASCII letter" - ); - } - char last = segment.charAt(segment.length() - 1); - if (!(isLowerAsciiLetter(last) || isAsciiDigit(last))) { - throw new IllegalArgumentException( - "namespace segments must end with a lowercase ASCII letter or digit" - ); - } - for (int i = 0; i < segment.length(); i++) { - char c = segment.charAt(i); - if (!(isLowerAsciiLetter(c) || isAsciiDigit(c) || c == '-')) { - throw new IllegalArgumentException( - "namespace segments may contain only lowercase ASCII letters, digits, and hyphens" - ); - } - } - } - } - - static void requireName(String name) { - if (name == null) { - throw new NullPointerException("name"); - } - if (name.isEmpty() || name.length() > MAX_NAME_LENGTH) { - throw new IllegalArgumentException( - "name length must be between 1 and " + MAX_NAME_LENGTH - ); - } - if (!isUpperAsciiLetter(name.charAt(0))) { - throw new IllegalArgumentException("name must start with an uppercase ASCII letter"); - } - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (!(isUpperAsciiLetter(c) || isAsciiDigit(c) || c == '_')) { - throw new IllegalArgumentException( - "name may contain only uppercase ASCII letters, digits, and underscores" - ); - } - } - } - - static void requireMessage(String message) { - requireText(message, "message", MAX_MESSAGE_LENGTH); - } - - static void requireDetail(String detail) { - requireText(detail, "detail", MAX_DETAIL_LENGTH); - } - - static void requirePath(String path) { - requireText(path, "path", MAX_PATH_LENGTH); - } - - private static void requireText(String value, String label, int maxLength) { - if (value == null) { - throw new NullPointerException(label); - } - if (value.isBlank()) { - throw new IllegalArgumentException(label + " must not be blank"); - } - if (value.length() > maxLength) { - throw new IllegalArgumentException(label + " length must not exceed " + maxLength); - } - if (value.indexOf('\0') >= 0) { - throw new IllegalArgumentException(label + " must not contain NUL"); - } - } - - private static boolean isLowerAsciiLetter(char c) { - return c >= 'a' && c <= 'z'; - } - - private static boolean isUpperAsciiLetter(char c) { - return c >= 'A' && c <= 'Z'; - } - - private static boolean isAsciiDigit(char c) { - return c >= '0' && c <= '9'; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/Issue.java b/src/main/java/io/github/aalsanie/codes/Issue.java deleted file mode 100644 index 9ccb187..0000000 --- a/src/main/java/io/github/aalsanie/codes/Issue.java +++ /dev/null @@ -1,90 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.Objects; -import org.jspecify.annotations.Nullable; - -public final class Issue { - private final @Nullable OutcomeCode code; - private final @Nullable String path; - private final String message; - - private Issue(@Nullable OutcomeCode code, @Nullable String path, String message) { - this.code = code; - this.path = path; - this.message = message; - } - - public static Issue of(String message) { - return create(null, null, message); - } - - public static Issue coded(OutcomeCode code, String message) { - return create(Objects.requireNonNull(code, "code"), null, message); - } - - public static Issue at(String path, String message) { - return create(null, Objects.requireNonNull(path, "path"), message); - } - - public static Issue at(String path, OutcomeCode code, String message) { - return create( - Objects.requireNonNull(code, "code"), - Objects.requireNonNull(path, "path"), - message - ); - } - - public @Nullable OutcomeCode getCode() { - return code; - } - - public @Nullable String getPath() { - return path; - } - - public String getMessage() { - return message; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof Issue issue)) { - return false; - } - return Objects.equals(code, issue.code) - && Objects.equals(path, issue.path) - && message.equals(issue.message); - } - - @Override - public int hashCode() { - return Objects.hash(code, path, message); - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - if (code != null) { - builder.append(code).append(' '); - } - if (path != null) { - builder.append(path).append(": "); - } - return builder.append(message).toString(); - } - - private static Issue create( - @Nullable OutcomeCode code, - @Nullable String path, - String message - ) { - if (path != null) { - Constraints.requirePath(path); - } - Constraints.requireMessage(message); - return new Issue(code, path, message); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/MappingResult.java b/src/main/java/io/github/aalsanie/codes/MappingResult.java deleted file mode 100644 index c17525a..0000000 --- a/src/main/java/io/github/aalsanie/codes/MappingResult.java +++ /dev/null @@ -1,107 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.Objects; -import java.util.function.Function; -import java.util.function.Supplier; - -public abstract sealed class MappingResult permits MappingResult.Mapped, MappingResult.Unmapped { - private MappingResult() { - } - - public static MappingResult mapped(T value) { - return new Mapped<>(Objects.requireNonNull(value, "value")); - } - - @SuppressWarnings("unchecked") - public static MappingResult unmapped() { - return (MappingResult) Unmapped.INSTANCE; - } - - public abstract boolean isMapped(); - - public final boolean isUnmapped() { - return !isMapped(); - } - - public abstract @Nullable T orNull(); - - public final R fold(Function onMapped, Supplier onUnmapped) { - Objects.requireNonNull(onMapped, "onMapped"); - Objects.requireNonNull(onUnmapped, "onUnmapped"); - if (this instanceof Mapped mapped) { - return onMapped.apply(mapped.value); - } - return onUnmapped.get(); - } - - public static final class Mapped extends MappingResult { - private final T value; - - private Mapped(T value) { - this.value = value; - } - - public T getValue() { - return value; - } - - @Override - public boolean isMapped() { - return true; - } - - @Override - public @Nullable T orNull() { - return value; - } - - @Override - public boolean equals(Object other) { - return this == other || (other instanceof Mapped mapped && value.equals(mapped.value)); - } - - @Override - public int hashCode() { - return value.hashCode(); - } - - @Override - public String toString() { - return "Mapped(value=" + value + ")"; - } - } - - public static final class Unmapped extends MappingResult { - private static final Unmapped INSTANCE = new Unmapped<>(); - - private Unmapped() { - } - - @Override - public boolean isMapped() { - return false; - } - - @Override - public @Nullable T orNull() { - return null; - } - - @Override - public boolean equals(Object other) { - return this == other; - } - - @Override - public int hashCode() { - return 0; - } - - @Override - public String toString() { - return "Unmapped"; - } - } -} diff --git a/src/main/java/io/github/aalsanie/codes/Outcome.java b/src/main/java/io/github/aalsanie/codes/Outcome.java deleted file mode 100644 index fc44040..0000000 --- a/src/main/java/io/github/aalsanie/codes/Outcome.java +++ /dev/null @@ -1,102 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.List; -import java.util.Objects; - -public final class Outcome { - private final OutcomeDefinition definition; - private final @Nullable String detail; - private final List issues; - - private Outcome( - OutcomeDefinition definition, - @Nullable String detail, - List issues - ) { - this.definition = definition; - this.detail = detail; - this.issues = issues; - } - - public static Outcome of(OutcomeDefinition definition) { - return of(definition, null, List.of()); - } - - public static Outcome of( - OutcomeDefinition definition, - @Nullable String detail - ) { - return of(definition, detail, List.of()); - } - - public static Outcome of( - OutcomeDefinition definition, - @Nullable String detail, - List issues - ) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(issues, "issues"); - if (detail != null) { - Constraints.requireDetail(detail); - } - if (definition.getState() != OutcomeState.FAILED && !issues.isEmpty()) { - throw new IllegalArgumentException("issues are only allowed on failed outcomes"); - } - return new Outcome(definition, detail, List.copyOf(issues)); - } - - public OutcomeDefinition getDefinition() { - return definition; - } - - public OutcomeCode getCode() { - return definition.getCode(); - } - - public OutcomeState getState() { - return definition.getState(); - } - - public String getDefaultMessage() { - return definition.getDefaultMessage(); - } - - public String getMessage() { - return getDefaultMessage(); - } - - public @Nullable String getDetail() { - return detail; - } - - public List getIssues() { - return issues; - } - - public boolean isSuccessful() { - return getState() == OutcomeState.SUCCEEDED; - } - - public boolean isPending() { - return getState() == OutcomeState.PENDING; - } - - public boolean isFailed() { - return getState() == OutcomeState.FAILED; - } - - public boolean isTerminal() { - return getState() != OutcomeState.PENDING; - } - - @Override - public String toString() { - return "Outcome(code=" + getCode() - + ", state=" + getState() - + ", message=" + getMessage() - + ", issues=" + issues.size() - + ")"; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeCode.java b/src/main/java/io/github/aalsanie/codes/OutcomeCode.java deleted file mode 100644 index 613fec8..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeCode.java +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -public final class OutcomeCode implements Comparable { - private static final char DELIMITER = ':'; - - private final String namespace; - private final String name; - private final String value; - - private OutcomeCode(String namespace, String name) { - this.namespace = namespace; - this.name = name; - this.value = namespace + DELIMITER + name; - } - - public static OutcomeCode of(String namespace, String name) { - Constraints.requireNamespace(namespace); - Constraints.requireName(name); - return new OutcomeCode(namespace, name); - } - - public static OutcomeCode parse(String value) { - Objects.requireNonNull(value, "value"); - int delimiterIndex = value.indexOf(DELIMITER); - if (delimiterIndex <= 0 || delimiterIndex != value.lastIndexOf(DELIMITER)) { - throw new IllegalArgumentException("outcome code must use the format namespace:NAME"); - } - return of(value.substring(0, delimiterIndex), value.substring(delimiterIndex + 1)); - } - - public static @Nullable OutcomeCode parseOrNull(String value) { - Objects.requireNonNull(value, "value"); - try { - return parse(value); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - public String getNamespace() { - return namespace; - } - - public String getName() { - return name; - } - - public String getValue() { - return value; - } - - @Override - public int compareTo(OutcomeCode other) { - return value.compareTo(Objects.requireNonNull(other, "other").value); - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof OutcomeCode that)) { - return false; - } - return namespace.equals(that.namespace) && name.equals(that.name); - } - - @Override - public int hashCode() { - return 31 * namespace.hashCode() + name.hashCode(); - } - - @Override - public String toString() { - return value; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java b/src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java deleted file mode 100644 index f9e0c7f..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeDefinition.java +++ /dev/null @@ -1,110 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class OutcomeDefinition { - static final String ROOT_NAMESPACE = "io.github.aalsanie.codes"; - static final String STANDARD_NAMESPACE = "io.github.aalsanie.codes.standard"; - - private static final Map STANDARD_DEFINITIONS = createStandardDefinitions(); - - private final OutcomeCode code; - private final OutcomeState state; - private final String defaultMessage; - - private OutcomeDefinition(OutcomeCode code, OutcomeState state, String defaultMessage) { - this.code = code; - this.state = state; - this.defaultMessage = defaultMessage; - } - - public static OutcomeDefinition custom( - String namespace, - String name, - OutcomeState state, - String defaultMessage - ) { - return custom(OutcomeCode.of(namespace, name), state, defaultMessage); - } - - public static OutcomeDefinition custom( - OutcomeCode code, - OutcomeState state, - String defaultMessage - ) { - Objects.requireNonNull(code, "code"); - Objects.requireNonNull(state, "state"); - if (isReservedNamespace(code.getNamespace())) { - throw new IllegalArgumentException("namespace '" + code.getNamespace() + "' is reserved"); - } - Constraints.requireMessage(defaultMessage); - return new OutcomeDefinition(code, state, defaultMessage); - } - - static OutcomeDefinition standard(String name) { - OutcomeDefinition definition = STANDARD_DEFINITIONS.get(name); - if (definition == null) { - throw new IllegalStateException("unknown standard outcome: " + name); - } - return definition; - } - - public OutcomeCode getCode() { - return code; - } - - public OutcomeState getState() { - return state; - } - - public String getDefaultMessage() { - return defaultMessage; - } - - @Override - public String toString() { - return "OutcomeDefinition(code=" + code + ", state=" + state + ", defaultMessage=" + defaultMessage + ")"; - } - - private static Map createStandardDefinitions() { - LinkedHashMap definitions = new LinkedHashMap<>(); - add(definitions, "OK", OutcomeState.SUCCEEDED, "The operation completed successfully."); - add(definitions, "INVALID_ARGUMENT", OutcomeState.FAILED, "An argument supplied to the operation is invalid."); - add(definitions, "UNAUTHENTICATED", OutcomeState.FAILED, "Authentication is required or invalid."); - add(definitions, "PERMISSION_DENIED", OutcomeState.FAILED, "The authenticated caller does not have permission."); - add(definitions, "NOT_FOUND", OutcomeState.FAILED, "The requested resource was not found."); - add(definitions, "ALREADY_EXISTS", OutcomeState.FAILED, "The resource already exists."); - add(definitions, "FAILED_PRECONDITION", OutcomeState.FAILED, "A required precondition was not satisfied."); - add(definitions, "OUT_OF_RANGE", OutcomeState.FAILED, "A value is outside the allowed range."); - add(definitions, "RATE_LIMITED", OutcomeState.FAILED, "The caller exceeded an allowed operation rate."); - add(definitions, "CANCELLED", OutcomeState.FAILED, "The operation was cancelled before completion."); - add(definitions, "DEADLINE_EXCEEDED", OutcomeState.FAILED, "The operation exceeded its deadline."); - add(definitions, "ABORTED", OutcomeState.FAILED, "The operation was aborted before completion."); - add(definitions, "UNIMPLEMENTED", OutcomeState.FAILED, "The requested operation is not implemented."); - add(definitions, "UNAVAILABLE", OutcomeState.FAILED, "The service is temporarily unavailable."); - add(definitions, "INTERNAL", OutcomeState.FAILED, "The service encountered an internal error."); - add(definitions, "DATA_LOSS", OutcomeState.FAILED, "Unrecoverable data loss or corruption was detected."); - add(definitions, "RESOURCE_EXHAUSTED", OutcomeState.FAILED, "A required resource limit was exhausted."); - return Collections.unmodifiableMap(definitions); - } - - private static void add( - Map definitions, - String name, - OutcomeState state, - String defaultMessage - ) { - Constraints.requireMessage(defaultMessage); - definitions.put( - name, - new OutcomeDefinition(OutcomeCode.of(STANDARD_NAMESPACE, name), state, defaultMessage) - ); - } - - private static boolean isReservedNamespace(String namespace) { - return namespace.equals(ROOT_NAMESPACE) || namespace.startsWith(ROOT_NAMESPACE + "."); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeException.java b/src/main/java/io/github/aalsanie/codes/OutcomeException.java deleted file mode 100644 index 0e29c91..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeException.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.Objects; - -public final class OutcomeException extends RuntimeException { - private final Outcome outcome; - - public OutcomeException(Outcome outcome) { - this(outcome, null); - } - - public OutcomeException(Outcome outcome, @Nullable Throwable cause) { - super(requireFailed(outcome).getMessage(), cause); - this.outcome = outcome; - } - - public Outcome getOutcome() { - return outcome; - } - - private static Outcome requireFailed(Outcome outcome) { - Objects.requireNonNull(outcome, "outcome"); - if (!outcome.isFailed()) { - throw new IllegalArgumentException("only failed outcomes can be converted to OutcomeException"); - } - return outcome; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java b/src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java deleted file mode 100644 index 40a62ae..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeExceptions.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.aalsanie.codes; - -public final class OutcomeExceptions { - private OutcomeExceptions() { - } - - public static OutcomeException toException(Outcome outcome) { - return new OutcomeException(outcome); - } - - public static OutcomeException toException(Outcome outcome, Throwable cause) { - return new OutcomeException(outcome, cause); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeMapper.java b/src/main/java/io/github/aalsanie/codes/OutcomeMapper.java deleted file mode 100644 index 1a8adb9..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeMapper.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.Objects; - -public interface OutcomeMapper { - MappingResult map(OutcomeDefinition definition); - - default MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java b/src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java deleted file mode 100644 index 8654380..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeRegistry.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Objects; - -public final class OutcomeRegistry { - private final Map definitionsByCode; - - private OutcomeRegistry(Map definitions) { - this.definitionsByCode = Collections.unmodifiableMap(new LinkedHashMap<>(definitions)); - } - - public static OutcomeRegistry empty() { - return new OutcomeRegistry(Map.of()); - } - - public static OutcomeRegistry of(OutcomeDefinition... definitions) { - Objects.requireNonNull(definitions, "definitions"); - return empty().withAll(List.of(definitions)); - } - - public static OutcomeRegistry standard() { - return empty().withAll(StandardOutcomes.all); - } - - public int getSize() { - return definitionsByCode.size(); - } - - public boolean contains(OutcomeCode code) { - return definitionsByCode.containsKey(Objects.requireNonNull(code, "code")); - } - - public @Nullable OutcomeDefinition find(OutcomeCode code) { - return definitionsByCode.get(Objects.requireNonNull(code, "code")); - } - - public @Nullable OutcomeDefinition find(String value) { - OutcomeCode code = OutcomeCode.parseOrNull(value); - return code == null ? null : find(code); - } - - public OutcomeDefinition require(OutcomeCode code) { - OutcomeDefinition definition = find(code); - if (definition == null) { - throw new NoSuchElementException("unknown outcome code: " + code); - } - return definition; - } - - public List definitions() { - return Collections.unmodifiableList(new ArrayList<>(definitionsByCode.values())); - } - - public OutcomeRegistry with(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - if (definitionsByCode.containsKey(definition.getCode())) { - throw new IllegalArgumentException("outcome code already registered: " + definition.getCode()); - } - LinkedHashMap next = new LinkedHashMap<>(definitionsByCode); - next.put(definition.getCode(), definition); - return new OutcomeRegistry(next); - } - - public OutcomeRegistry withAll(Iterable definitions) { - Objects.requireNonNull(definitions, "definitions"); - OutcomeRegistry next = this; - for (OutcomeDefinition definition : definitions) { - next = next.with(definition); - } - return next; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/OutcomeState.java b/src/main/java/io/github/aalsanie/codes/OutcomeState.java deleted file mode 100644 index 7d69280..0000000 --- a/src/main/java/io/github/aalsanie/codes/OutcomeState.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.aalsanie.codes; - -public enum OutcomeState { - SUCCEEDED, - PENDING, - FAILED -} diff --git a/src/main/java/io/github/aalsanie/codes/StandardOutcomes.java b/src/main/java/io/github/aalsanie/codes/StandardOutcomes.java deleted file mode 100644 index 36d2630..0000000 --- a/src/main/java/io/github/aalsanie/codes/StandardOutcomes.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.List; - -public final class StandardOutcomes { - public static final String NAMESPACE = "io.github.aalsanie.codes.standard"; - - public static final OutcomeDefinition OK = OutcomeDefinition.standard("OK"); - public static final OutcomeDefinition INVALID_ARGUMENT = OutcomeDefinition.standard("INVALID_ARGUMENT"); - public static final OutcomeDefinition UNAUTHENTICATED = OutcomeDefinition.standard("UNAUTHENTICATED"); - public static final OutcomeDefinition PERMISSION_DENIED = OutcomeDefinition.standard("PERMISSION_DENIED"); - public static final OutcomeDefinition NOT_FOUND = OutcomeDefinition.standard("NOT_FOUND"); - public static final OutcomeDefinition ALREADY_EXISTS = OutcomeDefinition.standard("ALREADY_EXISTS"); - public static final OutcomeDefinition FAILED_PRECONDITION = OutcomeDefinition.standard("FAILED_PRECONDITION"); - public static final OutcomeDefinition OUT_OF_RANGE = OutcomeDefinition.standard("OUT_OF_RANGE"); - public static final OutcomeDefinition RATE_LIMITED = OutcomeDefinition.standard("RATE_LIMITED"); - public static final OutcomeDefinition CANCELLED = OutcomeDefinition.standard("CANCELLED"); - public static final OutcomeDefinition DEADLINE_EXCEEDED = OutcomeDefinition.standard("DEADLINE_EXCEEDED"); - public static final OutcomeDefinition ABORTED = OutcomeDefinition.standard("ABORTED"); - public static final OutcomeDefinition UNIMPLEMENTED = OutcomeDefinition.standard("UNIMPLEMENTED"); - public static final OutcomeDefinition UNAVAILABLE = OutcomeDefinition.standard("UNAVAILABLE"); - public static final OutcomeDefinition INTERNAL = OutcomeDefinition.standard("INTERNAL"); - public static final OutcomeDefinition DATA_LOSS = OutcomeDefinition.standard("DATA_LOSS"); - public static final OutcomeDefinition RESOURCE_EXHAUSTED = OutcomeDefinition.standard("RESOURCE_EXHAUSTED"); - - public static final List all = List.of( - OK, - INVALID_ARGUMENT, - UNAUTHENTICATED, - PERMISSION_DENIED, - NOT_FOUND, - ALREADY_EXISTS, - FAILED_PRECONDITION, - OUT_OF_RANGE, - RATE_LIMITED, - CANCELLED, - DEADLINE_EXCEEDED, - ABORTED, - UNIMPLEMENTED, - UNAVAILABLE, - INTERNAL, - DATA_LOSS, - RESOURCE_EXHAUSTED - ); - - private StandardOutcomes() { - } -} diff --git a/src/main/java/io/github/aalsanie/codes/ValidationResult.java b/src/main/java/io/github/aalsanie/codes/ValidationResult.java deleted file mode 100644 index 479bcf7..0000000 --- a/src/main/java/io/github/aalsanie/codes/ValidationResult.java +++ /dev/null @@ -1,130 +0,0 @@ -package io.github.aalsanie.codes; - -import org.jspecify.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public abstract sealed class ValidationResult permits ValidationResult.Valid, ValidationResult.Invalid { - private ValidationResult() { - } - - public static ValidationResult valid() { - return Valid.INSTANCE; - } - - public static ValidationResult invalid(Issue issue) { - return new Invalid(List.of(Objects.requireNonNull(issue, "issue"))); - } - - public static ValidationResult invalid(List issues) { - return new Invalid(issues); - } - - public static ValidationResult combine(ValidationResult... results) { - Objects.requireNonNull(results, "results"); - return combine(List.of(results)); - } - - public static ValidationResult combine(List results) { - Objects.requireNonNull(results, "results"); - List issues = new ArrayList<>(); - for (ValidationResult result : results) { - Objects.requireNonNull(result, "result"); - issues.addAll(result.issues()); - } - return issues.isEmpty() ? Valid.INSTANCE : new Invalid(issues); - } - - public final boolean isValid() { - return this == Valid.INSTANCE; - } - - public final boolean isInvalid() { - return !isValid(); - } - - public abstract List issues(); - - public final Outcome toOutcome(OutcomeDefinition failureDefinition) { - return toOutcome(failureDefinition, null); - } - - public final Outcome toOutcome( - OutcomeDefinition failureDefinition, - @Nullable String detail - ) { - Objects.requireNonNull(failureDefinition, "failureDefinition"); - if (failureDefinition.getState() != OutcomeState.FAILED) { - throw new IllegalArgumentException("validation failure definition must have FAILED state"); - } - if (isValid()) { - return Outcome.of(StandardOutcomes.OK, detail); - } - return Outcome.of(failureDefinition, detail, issues()); - } - - public static final class Valid extends ValidationResult { - private static final Valid INSTANCE = new Valid(); - - private Valid() { - } - - @Override - public List issues() { - return List.of(); - } - - @Override - public boolean equals(Object other) { - return other instanceof Valid; - } - - @Override - public int hashCode() { - return 0; - } - - @Override - public String toString() { - return "Valid"; - } - } - - public static final class Invalid extends ValidationResult { - private final List issues; - - private Invalid(List issues) { - Objects.requireNonNull(issues, "issues"); - if (issues.isEmpty()) { - throw new IllegalArgumentException("invalid validation result requires at least one issue"); - } - this.issues = List.copyOf(issues); - } - - @Override - public List issues() { - return issues; - } - - public List getIssues() { - return issues; - } - - @Override - public boolean equals(Object other) { - return this == other || (other instanceof Invalid invalid && issues.equals(invalid.issues)); - } - - @Override - public int hashCode() { - return issues.hashCode(); - } - - @Override - public String toString() { - return "Invalid(issues=" + issues + ")"; - } - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java b/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java deleted file mode 100644 index 887ee08..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapper.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.aalsanie.codes.protocol.grpc; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeMapper; -import io.github.aalsanie.codes.StandardOutcomes; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class GrpcOutcomeMapper implements OutcomeMapper { - private final Map mappings; - - private GrpcOutcomeMapper(Map mappings) { - this.mappings = Collections.unmodifiableMap(new LinkedHashMap<>(mappings)); - } - - public static GrpcOutcomeMapper empty() { - return new GrpcOutcomeMapper(Map.of()); - } - - public static GrpcOutcomeMapper standard() { - LinkedHashMap values = new LinkedHashMap<>(); - values.put(StandardOutcomes.OK.getCode(), GrpcStatusCode.OK); - values.put(StandardOutcomes.INVALID_ARGUMENT.getCode(), GrpcStatusCode.INVALID_ARGUMENT); - values.put(StandardOutcomes.UNAUTHENTICATED.getCode(), GrpcStatusCode.UNAUTHENTICATED); - values.put(StandardOutcomes.PERMISSION_DENIED.getCode(), GrpcStatusCode.PERMISSION_DENIED); - values.put(StandardOutcomes.NOT_FOUND.getCode(), GrpcStatusCode.NOT_FOUND); - values.put(StandardOutcomes.ALREADY_EXISTS.getCode(), GrpcStatusCode.ALREADY_EXISTS); - values.put(StandardOutcomes.FAILED_PRECONDITION.getCode(), GrpcStatusCode.FAILED_PRECONDITION); - values.put(StandardOutcomes.OUT_OF_RANGE.getCode(), GrpcStatusCode.OUT_OF_RANGE); - values.put(StandardOutcomes.RATE_LIMITED.getCode(), GrpcStatusCode.RESOURCE_EXHAUSTED); - values.put(StandardOutcomes.CANCELLED.getCode(), GrpcStatusCode.CANCELLED); - values.put(StandardOutcomes.DEADLINE_EXCEEDED.getCode(), GrpcStatusCode.DEADLINE_EXCEEDED); - values.put(StandardOutcomes.ABORTED.getCode(), GrpcStatusCode.ABORTED); - values.put(StandardOutcomes.UNIMPLEMENTED.getCode(), GrpcStatusCode.UNIMPLEMENTED); - values.put(StandardOutcomes.UNAVAILABLE.getCode(), GrpcStatusCode.UNAVAILABLE); - values.put(StandardOutcomes.INTERNAL.getCode(), GrpcStatusCode.INTERNAL); - values.put(StandardOutcomes.DATA_LOSS.getCode(), GrpcStatusCode.DATA_LOSS); - values.put(StandardOutcomes.RESOURCE_EXHAUSTED.getCode(), GrpcStatusCode.RESOURCE_EXHAUSTED); - return new GrpcOutcomeMapper(values); - } - - @Override - public MappingResult map(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - GrpcStatusCode status = mappings.get(definition.getCode()); - return status == null ? MappingResult.unmapped() : MappingResult.mapped(status); - } - - public int getSize() { - return mappings.size(); - } - - public boolean contains(OutcomeCode code) { - return mappings.containsKey(Objects.requireNonNull(code, "code")); - } - - public GrpcOutcomeMapper withMapping(OutcomeDefinition definition, GrpcStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (mappings.containsKey(code)) { - throw new IllegalArgumentException("mapping already exists for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new GrpcOutcomeMapper(next); - } - - public GrpcOutcomeMapper withOverride(OutcomeDefinition definition, GrpcStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (!mappings.containsKey(code)) { - throw new IllegalArgumentException("cannot override missing mapping for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new GrpcOutcomeMapper(next); - } - - public Map mappings() { - return mappings; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java b/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java deleted file mode 100644 index c31a6a6..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/grpc/GrpcStatusCode.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.github.aalsanie.codes.protocol.grpc; - -import org.jspecify.annotations.Nullable; - -import java.util.HashMap; -import java.util.Map; - -public enum GrpcStatusCode { - OK(0), - CANCELLED(1), - UNKNOWN(2), - INVALID_ARGUMENT(3), - DEADLINE_EXCEEDED(4), - NOT_FOUND(5), - ALREADY_EXISTS(6), - PERMISSION_DENIED(7), - RESOURCE_EXHAUSTED(8), - FAILED_PRECONDITION(9), - ABORTED(10), - OUT_OF_RANGE(11), - UNIMPLEMENTED(12), - INTERNAL(13), - UNAVAILABLE(14), - DATA_LOSS(15), - UNAUTHENTICATED(16); - - private static final Map BY_VALUE = createByValue(); - - private final int value; - - GrpcStatusCode(int value) { - this.value = value; - } - - public int getValue() { - return value; - } - - public static @Nullable GrpcStatusCode fromValue(int value) { - return BY_VALUE.get(value); - } - - private static Map createByValue() { - HashMap values = new HashMap<>(); - for (GrpcStatusCode status : values()) { - values.put(status.value, status); - } - return Map.copyOf(values); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java b/src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java deleted file mode 100644 index 0df9dcb..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/grpc/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package io.github.aalsanie.codes.protocol.grpc; - -import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java b/src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java deleted file mode 100644 index 07c13fe..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapper.java +++ /dev/null @@ -1,83 +0,0 @@ -package io.github.aalsanie.codes.protocol.http; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeMapper; -import io.github.aalsanie.codes.StandardOutcomes; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class HttpOutcomeMapper implements OutcomeMapper { - private final Map mappings; - - private HttpOutcomeMapper(Map mappings) { - this.mappings = Collections.unmodifiableMap(new LinkedHashMap<>(mappings)); - } - - public static HttpOutcomeMapper empty() { - return new HttpOutcomeMapper(Map.of()); - } - - public static HttpOutcomeMapper standard() { - LinkedHashMap values = new LinkedHashMap<>(); - values.put(StandardOutcomes.OK.getCode(), HttpStatusCode.OK); - values.put(StandardOutcomes.INVALID_ARGUMENT.getCode(), HttpStatusCode.BAD_REQUEST); - values.put(StandardOutcomes.UNAUTHENTICATED.getCode(), HttpStatusCode.UNAUTHORIZED); - values.put(StandardOutcomes.PERMISSION_DENIED.getCode(), HttpStatusCode.FORBIDDEN); - values.put(StandardOutcomes.NOT_FOUND.getCode(), HttpStatusCode.NOT_FOUND); - values.put(StandardOutcomes.ALREADY_EXISTS.getCode(), HttpStatusCode.CONFLICT); - values.put(StandardOutcomes.OUT_OF_RANGE.getCode(), HttpStatusCode.BAD_REQUEST); - values.put(StandardOutcomes.RATE_LIMITED.getCode(), HttpStatusCode.TOO_MANY_REQUESTS); - values.put(StandardOutcomes.UNIMPLEMENTED.getCode(), HttpStatusCode.NOT_IMPLEMENTED); - values.put(StandardOutcomes.UNAVAILABLE.getCode(), HttpStatusCode.SERVICE_UNAVAILABLE); - values.put(StandardOutcomes.INTERNAL.getCode(), HttpStatusCode.INTERNAL_SERVER_ERROR); - values.put(StandardOutcomes.DATA_LOSS.getCode(), HttpStatusCode.INTERNAL_SERVER_ERROR); - return new HttpOutcomeMapper(values); - } - - @Override - public MappingResult map(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - HttpStatusCode status = mappings.get(definition.getCode()); - return status == null ? MappingResult.unmapped() : MappingResult.mapped(status); - } - - public int getSize() { - return mappings.size(); - } - - public boolean contains(OutcomeCode code) { - return mappings.containsKey(Objects.requireNonNull(code, "code")); - } - - public HttpOutcomeMapper withMapping(OutcomeDefinition definition, HttpStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (mappings.containsKey(code)) { - throw new IllegalArgumentException("mapping already exists for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new HttpOutcomeMapper(next); - } - - public HttpOutcomeMapper withOverride(OutcomeDefinition definition, HttpStatusCode status) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(status, "status"); - OutcomeCode code = definition.getCode(); - if (!mappings.containsKey(code)) { - throw new IllegalArgumentException("cannot override missing mapping for outcome code: " + code); - } - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, status); - return new HttpOutcomeMapper(next); - } - - public Map mappings() { - return mappings; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java b/src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java deleted file mode 100644 index 665a23f..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/http/HttpStatusCode.java +++ /dev/null @@ -1,101 +0,0 @@ -package io.github.aalsanie.codes.protocol.http; - -import java.util.Objects; - -public final class HttpStatusCode implements Comparable { - public static final HttpStatusCode OK = new HttpStatusCode(200); - public static final HttpStatusCode CREATED = new HttpStatusCode(201); - public static final HttpStatusCode ACCEPTED = new HttpStatusCode(202); - public static final HttpStatusCode NO_CONTENT = new HttpStatusCode(204); - public static final HttpStatusCode BAD_REQUEST = new HttpStatusCode(400); - public static final HttpStatusCode UNAUTHORIZED = new HttpStatusCode(401); - public static final HttpStatusCode FORBIDDEN = new HttpStatusCode(403); - public static final HttpStatusCode NOT_FOUND = new HttpStatusCode(404); - public static final HttpStatusCode CONFLICT = new HttpStatusCode(409); - public static final HttpStatusCode PRECONDITION_FAILED = new HttpStatusCode(412); - public static final HttpStatusCode PAYLOAD_TOO_LARGE = new HttpStatusCode(413); - public static final HttpStatusCode TOO_MANY_REQUESTS = new HttpStatusCode(429); - public static final HttpStatusCode INTERNAL_SERVER_ERROR = new HttpStatusCode(500); - public static final HttpStatusCode NOT_IMPLEMENTED = new HttpStatusCode(501); - public static final HttpStatusCode SERVICE_UNAVAILABLE = new HttpStatusCode(503); - public static final HttpStatusCode GATEWAY_TIMEOUT = new HttpStatusCode(504); - - private final int value; - - private HttpStatusCode(int value) { - this.value = value; - } - - public static HttpStatusCode of(int value) { - if (value < 100 || value > 599) { - throw new IllegalArgumentException("HTTP status code must be between 100 and 599"); - } - return switch (value) { - case 200 -> OK; - case 201 -> CREATED; - case 202 -> ACCEPTED; - case 204 -> NO_CONTENT; - case 400 -> BAD_REQUEST; - case 401 -> UNAUTHORIZED; - case 403 -> FORBIDDEN; - case 404 -> NOT_FOUND; - case 409 -> CONFLICT; - case 412 -> PRECONDITION_FAILED; - case 413 -> PAYLOAD_TOO_LARGE; - case 429 -> TOO_MANY_REQUESTS; - case 500 -> INTERNAL_SERVER_ERROR; - case 501 -> NOT_IMPLEMENTED; - case 503 -> SERVICE_UNAVAILABLE; - case 504 -> GATEWAY_TIMEOUT; - default -> new HttpStatusCode(value); - }; - } - - public int getValue() { - return value; - } - - public int getFamily() { - return value / 100; - } - - public boolean isInformational() { - return getFamily() == 1; - } - - public boolean isSuccessful() { - return getFamily() == 2; - } - - public boolean isRedirection() { - return getFamily() == 3; - } - - public boolean isClientError() { - return getFamily() == 4; - } - - public boolean isServerError() { - return getFamily() == 5; - } - - @Override - public int compareTo(HttpStatusCode other) { - return Integer.compare(value, Objects.requireNonNull(other, "other").value); - } - - @Override - public boolean equals(Object other) { - return this == other || (other instanceof HttpStatusCode status && value == status.value); - } - - @Override - public int hashCode() { - return value; - } - - @Override - public String toString() { - return Integer.toString(value); - } -} diff --git a/src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java b/src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java deleted file mode 100644 index 89f85d9..0000000 --- a/src/main/java/io/github/aalsanie/codes/protocol/http/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package io.github.aalsanie.codes.protocol.http; - -import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java b/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java index 048470a..7f3b711 100644 --- a/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java +++ b/src/test/java/io/github/aalsanie/codes/ApiSnapshot.java @@ -1,179 +1,12 @@ package io.github.aalsanie.codes; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.Type; -import java.lang.reflect.TypeVariable; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; +import io.github.aalsanie.codes.testing.PublicApiSnapshot; final class ApiSnapshot { - private ApiSnapshot() { } static String create() { - return publicTypes().stream() - .sorted(Comparator.comparing(Class::getName)) - .map(ApiSnapshot::typeSnapshot) - .collect(Collectors.joining("\n\n")); - } - - private static List> publicTypes() { - try { - Path classesRoot = Path.of( - Issue.class.getProtectionDomain() - .getCodeSource() - .getLocation() - .toURI() - ); - - try (var paths = Files.walk(classesRoot)) { - return paths - .filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(".class")) - .map(classesRoot::relativize) - .map(Path::toString) - .map(name -> name.replace('\\', '.').replace('/', '.')) - .map(name -> name.substring(0, name.length() - ".class".length())) - .filter(name -> name.startsWith("io.github.aalsanie.codes.")) - .filter(name -> !name.endsWith("package-info")) - .map(ApiSnapshot::loadClass) - .filter(type -> Modifier.isPublic(type.getModifiers())) - .collect(Collectors.toList()); - } - } catch (IOException | URISyntaxException exception) { - throw new IllegalStateException("Failed to discover public API types", exception); - } - } - - private static Class loadClass(String name) { - try { - return Class.forName(name, false, Issue.class.getClassLoader()); - } catch (ClassNotFoundException exception) { - throw new IllegalStateException("Failed to load API type: " + name, exception); - } - } - - private static String typeSnapshot(Class type) { - List lines = new ArrayList<>(); - lines.add(typeDeclaration(type)); - - for (Field field : type.getDeclaredFields()) { - if (Modifier.isPublic(field.getModifiers()) && !field.isSynthetic()) { - lines.add(" field " + modifiers(field.getModifiers()) + typeName(field.getGenericType()) + " " + field.getName()); - } - } - for (Constructor constructor : type.getDeclaredConstructors()) { - if (Modifier.isPublic(constructor.getModifiers()) && !constructor.isSynthetic()) { - lines.add(" ctor " + modifiers(constructor.getModifiers()) + parameters(constructor.getGenericParameterTypes())); - } - } - for (Method method : type.getDeclaredMethods()) { - if (Modifier.isPublic(method.getModifiers()) && !method.isSynthetic() && !method.isBridge()) { - lines.add( - " method " + modifiers(method.getModifiers()) - + typeName(method.getGenericReturnType()) + " " + method.getName() - + parameters(method.getGenericParameterTypes()) - ); - } - } - - String header = lines.remove(0); - lines.sort(String::compareTo); - return header + (lines.isEmpty() ? "" : "\n" + String.join("\n", lines)); - } - - private static String typeDeclaration(Class type) { - String kind; - if (type.isEnum()) { - kind = "enum"; - } else if (type.isInterface()) { - kind = "interface"; - } else { - kind = "class"; - } - - StringBuilder result = new StringBuilder(); - result.append(modifiers(type.getModifiers())).append(kind).append(' ').append(type.getName()); - - if (type.getTypeParameters().length > 0) { - result.append('<'); - result.append(java.util.Arrays.stream(type.getTypeParameters()) - .map(ApiSnapshot::typeParameter) - .collect(Collectors.joining(", "))); - result.append('>'); - } - - if (!type.isInterface() && !type.isEnum()) { - Type superclass = type.getGenericSuperclass(); - if (superclass != null && superclass != Object.class) { - result.append(" extends ").append(typeName(superclass)); - } - } - - Type[] interfaces = type.getGenericInterfaces(); - if (interfaces.length > 0) { - result.append(type.isInterface() ? " extends " : " implements "); - result.append(java.util.Arrays.stream(interfaces) - .map(ApiSnapshot::typeName) - .sorted() - .collect(Collectors.joining(", "))); - } - - if (type.isSealed()) { - result.append(" permits "); - result.append(java.util.Arrays.stream(type.getPermittedSubclasses()) - .map(Class::getName) - .sorted() - .collect(Collectors.joining(", "))); - } - - return result.toString(); - } - - private static String typeParameter(TypeVariable variable) { - Type[] bounds = variable.getBounds(); - if (bounds.length == 1 && bounds[0] == Object.class) { - return variable.getName(); - } - return variable.getName() + " extends " - + java.util.Arrays.stream(bounds).map(ApiSnapshot::typeName).collect(Collectors.joining(" & ")); - } - - private static String modifiers(int value) { - List values = new ArrayList<>(); - if (Modifier.isPublic(value)) { - values.add("public"); - } - if (Modifier.isProtected(value)) { - values.add("protected"); - } - if (Modifier.isStatic(value)) { - values.add("static"); - } - if (Modifier.isAbstract(value)) { - values.add("abstract"); - } - if (Modifier.isFinal(value)) { - values.add("final"); - } - return values.isEmpty() ? "" : String.join(" ", values) + " "; - } - - private static String parameters(Type[] types) { - return "(" + java.util.Arrays.stream(types).map(ApiSnapshot::typeName).collect(Collectors.joining(", ")) + ")"; - } - - private static String typeName(Type type) { - return type.getTypeName().replace('$', '.'); + return PublicApiSnapshot.create(ProblemType.class, "io.github.aalsanie.codes"); } } diff --git a/src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java b/src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java deleted file mode 100644 index 36e2e47..0000000 --- a/src/test/java/io/github/aalsanie/codes/IssueOutcomeTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.github.aalsanie.codes; - -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class IssueOutcomeTest { - @Test - void createsIssueVariantsWithValueEquality() { - OutcomeCode code = OutcomeCode.of("com.example.validation", "INVALID_EMAIL"); - assertEquals(Issue.of("Invalid."), Issue.of("Invalid.")); - assertEquals("Invalid.", Issue.of("Invalid.").toString()); - assertEquals("email: Invalid.", Issue.at("email", "Invalid.").toString()); - assertEquals(code + " Invalid.", Issue.coded(code, "Invalid.").toString()); - assertEquals(code + " email: Invalid.", Issue.at("email", code, "Invalid.").toString()); - assertEquals(code, Issue.at("email", code, "Invalid.").getCode()); - assertEquals("email", Issue.at("email", code, "Invalid.").getPath()); - assertNull(Issue.of("Invalid.").getCode()); - assertNull(Issue.of("Invalid.").getPath()); - Issue issue = Issue.at("email", code, "Invalid."); - - assertEquals(issue, issue); - assertNotEquals("not-an-issue", issue); - assertNotEquals( - issue, - Issue.at( - "email", - OutcomeCode.of("com.example.validation", "OTHER"), - "Invalid." - ) - ); - assertNotEquals(issue, Issue.at("name", code, "Invalid.")); - assertNotEquals(issue, Issue.at("email", code, "Different.")); - assertEquals( - issue.hashCode(), - Issue.at("email", code, "Invalid.").hashCode() - ); - } - - @Test - void rejectsInvalidIssueTextAndPath() { - assertThrows(NullPointerException.class, () -> Issue.of(null)); - assertThrows(IllegalArgumentException.class, () -> Issue.of(" ")); - assertThrows(IllegalArgumentException.class, () -> Issue.of("x".repeat(1025))); - assertThrows(IllegalArgumentException.class, () -> Issue.of("bad\0message")); - assertThrows(IllegalArgumentException.class, () -> Issue.at(" ", "Bad.")); - assertThrows(IllegalArgumentException.class, () -> Issue.at("x".repeat(257), "Bad.")); - assertThrows(IllegalArgumentException.class, () -> Issue.at("bad\0path", "Bad.")); - assertThrows(NullPointerException.class, () -> Issue.coded(null, "Bad.")); - assertThrows(NullPointerException.class, () -> Issue.at("field", null, "Bad.")); - assertThrows(NullPointerException.class, () -> Issue.at(null, "Bad.")); - assertThrows( - NullPointerException.class, - () -> Issue.at(null, OutcomeCode.of("com.example", "BAD"), "Bad.") - ); - } - - @Test - void outcomeDerivesSemanticsAndDefensivelyCopiesIssues() { - ArrayList source = new ArrayList<>(); - source.add(Issue.at("email", "Invalid.")); - Outcome outcome = Outcome.of(StandardOutcomes.INVALID_ARGUMENT, "request=42", source); - source.clear(); - - assertEquals(StandardOutcomes.INVALID_ARGUMENT, outcome.getDefinition()); - assertEquals(StandardOutcomes.INVALID_ARGUMENT.getCode(), outcome.getCode()); - assertEquals(OutcomeState.FAILED, outcome.getState()); - assertEquals(outcome.getDefaultMessage(), outcome.getMessage()); - assertEquals("request=42", outcome.getDetail()); - assertEquals(1, outcome.getIssues().size()); - assertTrue(outcome.isFailed()); - assertFalse(outcome.isSuccessful()); - assertFalse(outcome.isPending()); - assertTrue(outcome.isTerminal()); - assertFalse(outcome.toString().contains("request=42")); - assertThrows(UnsupportedOperationException.class, () -> outcome.getIssues().add(Issue.of("x"))); - } - - @Test - void successPendingAndValidationRulesAreEnforced() { - Outcome success = Outcome.of(StandardOutcomes.OK); - assertTrue(success.isSuccessful()); - assertTrue(success.isTerminal()); - assertNull(success.getDetail()); - - OutcomeDefinition pendingDefinition = OutcomeDefinition.custom( - "com.example.jobs", "JOB_PROCESSING", OutcomeState.PENDING, "Job processing." - ); - Outcome pending = Outcome.of(pendingDefinition, "job=7"); - assertTrue(pending.isPending()); - assertFalse(pending.isTerminal()); - - assertThrows( - IllegalArgumentException.class, - () -> Outcome.of(StandardOutcomes.OK, null, List.of(Issue.of("No."))) - ); - assertThrows(IllegalArgumentException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, " ")); - assertThrows(IllegalArgumentException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, "x".repeat(4097))); - assertThrows(IllegalArgumentException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, "bad\0detail")); - assertThrows(NullPointerException.class, () -> Outcome.of(null)); - assertThrows(NullPointerException.class, () -> Outcome.of(StandardOutcomes.INTERNAL, null, null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/JavaInteropTest.java b/src/test/java/io/github/aalsanie/codes/JavaInteropTest.java deleted file mode 100644 index 9a3f12c..0000000 --- a/src/test/java/io/github/aalsanie/codes/JavaInteropTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.aalsanie.codes; - -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -final class JavaInteropTest { - @Test - void createsAndMapsOutcomeFromJava() { - Outcome outcome = Outcome.of(StandardOutcomes.NOT_FOUND, "customer 123", List.of()); - require(outcome.isFailed()); - require(StandardOutcomes.NOT_FOUND.getDefaultMessage().equals(outcome.getMessage())); - require(HttpStatusCode.NOT_FOUND.equals(HttpOutcomeMapper.standard().map(outcome).orNull())); - require(GrpcStatusCode.NOT_FOUND.equals(GrpcOutcomeMapper.standard().map(outcome).orNull())); - } - - @Test - void standardCollectionsCannotBeMutatedFromJava() { - expectUnsupported(() -> StandardOutcomes.all.clear()); - expectUnsupported(() -> OutcomeRegistry.standard().definitions().clear()); - expectUnsupported(() -> HttpOutcomeMapper.standard().mappings().clear()); - } - - @Test - void outcomeDefensivelyCopiesMutableJavaList() { - ArrayList source = new ArrayList<>(); - source.add(Issue.of("Bad.")); - Outcome outcome = Outcome.of(StandardOutcomes.INTERNAL, null, source); - source.clear(); - require(outcome.getIssues().size() == 1); - expectUnsupported(() -> outcome.getIssues().clear()); - } - - @Test - void customDefinitionCannotClaimLibraryNamespace() { - expectIllegalArgument(() -> OutcomeDefinition.custom( - StandardOutcomes.NAMESPACE, - "OK", - OutcomeState.FAILED, - "Forged." - )); - } - - @Test - void validationResultIsUsableFromJava() { - ValidationResult result = ValidationResult.combine(List.of( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")) - )); - require(result.isInvalid()); - require(result.issues().size() == 1); - require( - result.toOutcome(StandardOutcomes.INVALID_ARGUMENT).getDefinition() - == StandardOutcomes.INVALID_ARGUMENT - ); - } - - @Test - void outcomeCodeRoundTripsFromJava() { - OutcomeCode code = OutcomeCode.of("com.example", "CODE"); - require(code.equals(OutcomeCode.parse(code.getValue()))); - require(OutcomeCode.parseOrNull("bad") == null); - } - - private static void require(boolean value) { - if (!value) throw new AssertionError("condition failed"); - } - - private static void expectUnsupported(Runnable action) { - try { - action.run(); - throw new AssertionError("expected UnsupportedOperationException"); - } catch (UnsupportedOperationException expected) { - } - } - - private static void expectIllegalArgument(Runnable action) { - try { - action.run(); - throw new AssertionError("expected IllegalArgumentException"); - } catch (IllegalArgumentException expected) { - } - } -} diff --git a/src/test/java/io/github/aalsanie/codes/MappingResultTest.java b/src/test/java/io/github/aalsanie/codes/MappingResultTest.java deleted file mode 100644 index 8626bf8..0000000 --- a/src/test/java/io/github/aalsanie/codes/MappingResultTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.aalsanie.codes; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class MappingResultTest { - @Test - void mappedCarriesValueAndFolds() { - MappingResult result = MappingResult.mapped("ok"); - assertTrue(result.isMapped()); - assertFalse(result.isUnmapped()); - assertEquals("ok", result.orNull()); - assertEquals(2, result.fold(String::length, () -> -1)); - assertEquals(MappingResult.mapped("ok"), result); - assertEquals(result.hashCode(), MappingResult.mapped("ok").hashCode()); - assertEquals("Mapped(value=ok)", result.toString()); - assertThrows(NullPointerException.class, () -> MappingResult.mapped(null)); - assertThrows(NullPointerException.class, () -> result.fold(null, () -> -1)); - assertThrows(NullPointerException.class, () -> result.fold(String::length, null)); - assertEquals(result, result); - assertNotEquals("ok", result); - assertNotEquals(result, MappingResult.mapped("different")); - } - - @Test - void unmappedIsSingletonLikeAndFolds() { - MappingResult result = MappingResult.unmapped(); - assertFalse(result.isMapped()); - assertTrue(result.isUnmapped()); - assertNull(result.orNull()); - assertEquals(-1, result.fold(String::length, () -> -1)); - assertEquals(MappingResult.unmapped(), result); - assertEquals(0, result.hashCode()); - assertEquals("Unmapped", result.toString()); - assertNotEquals("Unmapped", result); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java b/src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java deleted file mode 100644 index 59c5982..0000000 --- a/src/test/java/io/github/aalsanie/codes/NullabilityContractTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package io.github.aalsanie.codes; - -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Method; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -class NullabilityContractTest { - - @Test - void publicPackagesAreNullMarked() { - assertTrue( - Outcome.class.getPackage().isAnnotationPresent(NullMarked.class) - ); - assertTrue( - HttpStatusCode.class.getPackage().isAnnotationPresent(NullMarked.class) - ); - assertTrue( - GrpcStatusCode.class.getPackage().isAnnotationPresent(NullMarked.class) - ); - } - - @Test - void nullableReturnTypesRemainAnnotated() throws NoSuchMethodException { - assertNullableReturn( - Outcome.class.getMethod("getDetail") - ); - assertNullableReturn( - Issue.class.getMethod("getCode") - ); - assertNullableReturn( - Issue.class.getMethod("getPath") - ); - assertNullableReturn( - OutcomeCode.class.getMethod("parseOrNull", String.class) - ); - assertNullableReturn( - OutcomeRegistry.class.getMethod("find", OutcomeCode.class) - ); - assertNullableReturn( - OutcomeRegistry.class.getMethod("find", String.class) - ); - assertNullableReturn( - MappingResult.class.getMethod("orNull") - ); - assertNullableReturn( - GrpcStatusCode.class.getMethod("fromValue", int.class) - ); - } - - private static void assertNullableReturn(Method method) { - assertTrue( - method.getAnnotatedReturnType().isAnnotationPresent(Nullable.class), - () -> method + " must retain @Nullable on its return type" - ); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java b/src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java deleted file mode 100644 index 96d9988..0000000 --- a/src/test/java/io/github/aalsanie/codes/OutcomeCodeTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class OutcomeCodeTest { - @Test - void createsAndParsesValidCodes() { - OutcomeCode code = OutcomeCode.of("com.example.payments", "PAYMENT_DECLINED"); - assertEquals("com.example.payments", code.getNamespace()); - assertEquals("PAYMENT_DECLINED", code.getName()); - assertEquals("com.example.payments:PAYMENT_DECLINED", code.getValue()); - assertEquals(code.getValue(), code.toString()); - assertEquals(code, OutcomeCode.parse(code.getValue())); - assertEquals(code.hashCode(), OutcomeCode.parse(code.getValue()).hashCode()); - } - - @Test - void rejectsInvalidNamespacesAndNames() { - assertThrows(NullPointerException.class, () -> OutcomeCode.of(null, "CODE")); - assertThrows(NullPointerException.class, () -> OutcomeCode.of("com.example", null)); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("a".repeat(129), "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com..example", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("a".repeat(64), "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("1example", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example-", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("Com.example", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example_foo", "CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "A".repeat(65))); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "bad")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.of("com.example", "BAD-NAME")); - } - - @Test - void parseRejectsMalformedValues() { - assertThrows(NullPointerException.class, () -> OutcomeCode.parse(null)); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse("com.example")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse(":CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse("com:example:CODE")); - assertThrows(IllegalArgumentException.class, () -> OutcomeCode.parse("com.example:")); - assertThrows( - NullPointerException.class, - () -> OutcomeCode.parseOrNull(null) - ); - assertNull(OutcomeCode.parseOrNull("bad")); - } - - @Test - void identityAndOrderingUseBothParts() { - OutcomeCode a = OutcomeCode.of("com.example", "A"); - OutcomeCode b = OutcomeCode.of("com.example", "B"); - OutcomeCode otherNamespace = OutcomeCode.of("org.example", "A"); - assertTrue(a.compareTo(b) < 0); - assertNotEquals(a, b); - assertNotEquals(a, otherNamespace); - assertNotEquals(a, "com.example:A"); - assertThrows(NullPointerException.class, () -> a.compareTo(null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java b/src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java deleted file mode 100644 index d756fd1..0000000 --- a/src/test/java/io/github/aalsanie/codes/OutcomeDefinitionTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class OutcomeDefinitionTest { - @Test - void createsCustomDefinition() { - OutcomeDefinition definition = OutcomeDefinition.custom( - "com.example.orders", - "ORDER_REJECTED", - OutcomeState.FAILED, - "Order rejected." - ); - assertEquals("com.example.orders:ORDER_REJECTED", definition.getCode().getValue()); - assertEquals(OutcomeState.FAILED, definition.getState()); - assertEquals("Order rejected.", definition.getDefaultMessage()); - assertTrue(definition.toString().contains("ORDER_REJECTED")); - } - - @Test - void customDefinitionRejectsReservedNamespaceAndBadMessages() { - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom(StandardOutcomes.NAMESPACE, "CUSTOM", OutcomeState.FAILED, "No.") - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("io.github.aalsanie.codes.child", "CUSTOM", OutcomeState.FAILED, "No.") - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("com.example", "CUSTOM", OutcomeState.FAILED, " ") - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("com.example", "CUSTOM", OutcomeState.FAILED, "x".repeat(1025)) - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeDefinition.custom("com.example", "CUSTOM", OutcomeState.FAILED, "bad\0message") - ); - assertThrows(NullPointerException.class, () -> OutcomeDefinition.custom((OutcomeCode) null, OutcomeState.FAILED, "No.")); - assertThrows(NullPointerException.class, () -> OutcomeDefinition.custom(OutcomeCode.of("com.example", "X"), null, "No.")); - } - - @Test - void standardDefinitionIsSingleton() { - assertSame(StandardOutcomes.NOT_FOUND, OutcomeDefinition.standard("NOT_FOUND")); - assertThrows(IllegalStateException.class, () -> OutcomeDefinition.standard("NOPE")); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java b/src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java deleted file mode 100644 index 32f0f19..0000000 --- a/src/test/java/io/github/aalsanie/codes/RegistryMappingExceptionTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.NoSuchElementException; -import org.junit.jupiter.api.Test; - -class RegistryMappingExceptionTest { - @Test - void registryIsImmutableAndKeyedByCode() { - OutcomeDefinition custom = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - OutcomeRegistry empty = OutcomeRegistry.empty(); - OutcomeRegistry registry = empty.with(custom); - - assertEquals(0, empty.getSize()); - assertEquals(1, registry.getSize()); - assertTrue(registry.contains(custom.getCode())); - assertSame(custom, registry.find(custom.getCode())); - assertSame(custom, registry.find(custom.getCode().getValue())); - assertNull(registry.find("invalid")); - assertSame(custom, registry.require(custom.getCode())); - assertEquals(List.of(custom), registry.definitions()); - assertThrows(UnsupportedOperationException.class, () -> registry.definitions().add(StandardOutcomes.OK)); - assertThrows(IllegalArgumentException.class, () -> registry.with(custom)); - assertThrows(NoSuchElementException.class, () -> registry.require(StandardOutcomes.NOT_FOUND.getCode())); - } - - @Test - void registryFactoriesComposeDefinitions() { - OutcomeDefinition first = OutcomeDefinition.custom("com.example", "A", OutcomeState.FAILED, "A."); - OutcomeDefinition second = OutcomeDefinition.custom("com.example", "B", OutcomeState.FAILED, "B."); - assertEquals(2, OutcomeRegistry.of(first, second).getSize()); - assertEquals(StandardOutcomes.all.size(), OutcomeRegistry.standard().getSize()); - assertThrows(NullPointerException.class, () -> OutcomeRegistry.of((OutcomeDefinition[]) null)); - assertThrows(NullPointerException.class, () -> OutcomeRegistry.empty().with(null)); - assertThrows(NullPointerException.class, () -> OutcomeRegistry.empty().withAll(null)); - } - - @Test - void exceptionRequiresFailedOutcomeAndPreservesCause() { - Outcome failure = Outcome.of(StandardOutcomes.INTERNAL); - RuntimeException cause = new RuntimeException("cause"); - OutcomeException exception = new OutcomeException(failure, cause); - assertSame(failure, exception.getOutcome()); - assertSame(cause, exception.getCause()); - assertEquals(failure.getMessage(), exception.getMessage()); - assertSame(failure, OutcomeExceptions.toException(failure).getOutcome()); - assertSame(cause, OutcomeExceptions.toException(failure, cause).getCause()); - assertThrows(IllegalArgumentException.class, () -> new OutcomeException(Outcome.of(StandardOutcomes.OK))); - assertThrows(NullPointerException.class, () -> new OutcomeException(null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java b/src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java deleted file mode 100644 index 2e225f2..0000000 --- a/src/test/java/io/github/aalsanie/codes/SemanticCompatibilityTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.Arrays; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.Map; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; - -class SemanticCompatibilityTest { - @Test - void standardOutcomeSemanticsMatchSnapshot() throws IOException { - String actual = StandardOutcomes.all.stream() - .sorted(Comparator.comparing(definition -> definition.getCode().getValue())) - .map(definition -> definition.getCode().getValue() + "|" + definition.getState().name()) - .collect(Collectors.joining("\n")); - assertEquals(readSnapshot("standard-outcomes.snapshot"), actual); - } - - @Test - void standardHttpMappingsMatchSnapshot() throws IOException { - String actual = HttpOutcomeMapper.standard().mappings().entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(entry -> entry.getKey().getValue() + "|" + entry.getValue().getValue()) - .collect(Collectors.joining("\n")); - assertEquals(readSnapshot("http-mappings.snapshot"), actual); - } - - @Test - void standardGrpcMappingsMatchSnapshot() throws IOException { - String actual = GrpcOutcomeMapper.standard().mappings().entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(entry -> entry.getKey().getValue() + "|" + entry.getValue().name()) - .collect(Collectors.joining("\n")); - assertEquals(readSnapshot("grpc-mappings.snapshot"), actual); - } - - @Test - void publicHttpStatusCodesMatchSnapshot() throws IOException { - String actual = Arrays.stream(HttpStatusCode.class.getFields()) - .filter(field -> Modifier.isStatic(field.getModifiers())) - .filter(field -> field.getType() == HttpStatusCode.class) - .sorted(Comparator.comparing(Field::getName)) - .map(field -> field.getName() + "|" + readHttpStatusValue(field)) - .collect(Collectors.joining("\n")); - - assertEquals(readSnapshot("http-status-codes.snapshot"), actual); - } - - @Test - void grpcStatusCodesMatchSnapshot() throws IOException { - String actual = Arrays.stream(GrpcStatusCode.values()) - .map(status -> status.name() + "|" + status.getValue()) - .collect(Collectors.joining("\n")); - - assertEquals(readSnapshot("grpc-status-codes.snapshot"), actual); - } - - @Test - void standardNamespaceMatchesSnapshot() throws IOException { - assertEquals( - StandardOutcomes.NAMESPACE, - readSnapshot("standard-namespace.snapshot") - ); - } - - private static String readSnapshot(String name) throws IOException { - return Files.readString(Path.of("compatibility", name)).replace("\r\n", "\n").stripTrailing(); - } - - private static int readHttpStatusValue(Field field) { - try { - return ((HttpStatusCode) field.get(null)).getValue(); - } catch (IllegalAccessException exception) { - throw new IllegalStateException(exception); - } - } -} diff --git a/src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java b/src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java deleted file mode 100644 index 063fba6..0000000 --- a/src/test/java/io/github/aalsanie/codes/StandardOutcomesTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.HashSet; -import org.junit.jupiter.api.Test; - -class StandardOutcomesTest { - @Test - void standardCatalogHasStableShape() { - assertEquals(17, StandardOutcomes.all.size()); - assertEquals(17, new HashSet<>(StandardOutcomes.all.stream().map(OutcomeDefinition::getCode).toList()).size()); - assertSame(StandardOutcomes.OK, StandardOutcomes.all.get(0)); - assertEquals(OutcomeState.SUCCEEDED, StandardOutcomes.OK.getState()); - assertTrue( - StandardOutcomes.all.stream() - .filter(definition -> definition != StandardOutcomes.OK) - .allMatch(definition -> definition.getState() == OutcomeState.FAILED) - ); - assertTrue(StandardOutcomes.all.stream().allMatch(definition -> definition.getCode().getNamespace().equals(StandardOutcomes.NAMESPACE))); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/ValidationResultTest.java b/src/test/java/io/github/aalsanie/codes/ValidationResultTest.java deleted file mode 100644 index 2fac042..0000000 --- a/src/test/java/io/github/aalsanie/codes/ValidationResultTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.github.aalsanie.codes; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -class ValidationResultTest { - @Test - void validIsSingletonAndConvertsToOk() { - ValidationResult valid = ValidationResult.valid(); - assertSame(valid, ValidationResult.valid()); - assertTrue(valid.isValid()); - assertFalse(valid.isInvalid()); - assertTrue(valid.issues().isEmpty()); - assertEquals("Valid", valid.toString()); - assertEquals(StandardOutcomes.OK, valid.toOutcome(StandardOutcomes.INVALID_ARGUMENT).getDefinition()); - } - - @Test - void invalidCopiesIssuesAndConvertsToExplicitFailure() { - ArrayList issues = new ArrayList<>(); - issues.add(Issue.at("email", "Invalid.")); - ValidationResult result = ValidationResult.invalid(issues); - issues.clear(); - - assertTrue(result.isInvalid()); - assertEquals(1, result.issues().size()); - assertEquals(result, ValidationResult.invalid(Issue.at("email", "Invalid."))); - assertEquals(result.hashCode(), ValidationResult.invalid(Issue.at("email", "Invalid.")).hashCode()); - assertTrue(result.toString().contains("Invalid")); - - Outcome outcome = result.toOutcome(StandardOutcomes.INVALID_ARGUMENT, "request validation failed"); - assertEquals(StandardOutcomes.INVALID_ARGUMENT, outcome.getDefinition()); - assertEquals("request validation failed", outcome.getDetail()); - assertEquals(result.issues(), outcome.getIssues()); - } - - @Test - void combinesIndependentResults() { - ValidationResult result = ValidationResult.combine( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")), - ValidationResult.invalid(Issue.at("name", "Required.")) - ); - assertEquals(2, result.issues().size()); - assertTrue(ValidationResult.combine(List.of()).isValid()); - assertThrows(NullPointerException.class, () -> ValidationResult.combine((ValidationResult[]) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.combine((List) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.combine(List.of(ValidationResult.valid(), null))); - } - - @Test - void rejectsInvalidPoliciesAndEmptyInvalidResults() { - OutcomeDefinition success = OutcomeDefinition.custom( - "com.example", "CREATED", OutcomeState.SUCCEEDED, "Created." - ); - assertThrows(IllegalArgumentException.class, () -> ValidationResult.valid().toOutcome(success)); - assertThrows(IllegalArgumentException.class, () -> ValidationResult.invalid(List.of())); - assertThrows(NullPointerException.class, () -> ValidationResult.invalid((Issue) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.invalid((List) null)); - assertThrows(NullPointerException.class, () -> ValidationResult.valid().toOutcome(null)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java b/src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java deleted file mode 100644 index 85649e8..0000000 --- a/src/test/java/io/github/aalsanie/codes/protocol/grpc/GrpcOutcomeMapperTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.github.aalsanie.codes.protocol.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import org.junit.jupiter.api.Test; - -class GrpcOutcomeMapperTest { - @Test - void standardMapperCoversAllStandardOutcomes() { - GrpcOutcomeMapper mapper = GrpcOutcomeMapper.standard(); - assertEquals(StandardOutcomes.all.size(), mapper.getSize()); - assertTrue(StandardOutcomes.all.stream().allMatch(definition -> mapper.map(definition).isMapped())); - assertSame(GrpcStatusCode.NOT_FOUND, mapper.map(StandardOutcomes.NOT_FOUND).orNull()); - assertSame(GrpcStatusCode.RESOURCE_EXHAUSTED, mapper.map(StandardOutcomes.RATE_LIMITED).orNull()); - assertSame(GrpcStatusCode.RESOURCE_EXHAUSTED, mapper.map(StandardOutcomes.RESOURCE_EXHAUSTED).orNull()); - } - - @Test - void customMappingsAreImmutableAndExplicit() { - OutcomeDefinition custom = OutcomeDefinition.custom( - "com.example", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - GrpcOutcomeMapper base = GrpcOutcomeMapper.standard(); - GrpcOutcomeMapper extended = base.withMapping(custom, GrpcStatusCode.FAILED_PRECONDITION); - assertFalse(base.contains(custom.getCode())); - assertTrue(extended.contains(custom.getCode())); - assertSame(GrpcStatusCode.FAILED_PRECONDITION, extended.map(custom).orNull()); - assertSame(GrpcStatusCode.UNKNOWN, base.withOverride(StandardOutcomes.INTERNAL, GrpcStatusCode.UNKNOWN).map(StandardOutcomes.INTERNAL).orNull()); - assertThrows(IllegalArgumentException.class, () -> base.withMapping(StandardOutcomes.OK, GrpcStatusCode.OK)); - assertThrows(IllegalArgumentException.class, () -> base.withOverride(custom, GrpcStatusCode.UNKNOWN)); - assertThrows(UnsupportedOperationException.class, () -> base.mappings().clear()); - } - - @Test - void statusCodesRoundTripByNumericValue() { - for (GrpcStatusCode code : GrpcStatusCode.values()) { - assertSame(code, GrpcStatusCode.fromValue(code.getValue())); - } - assertNull(GrpcStatusCode.fromValue(-1)); - assertNull(GrpcStatusCode.fromValue(17)); - } -} diff --git a/src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java b/src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java deleted file mode 100644 index c1553b3..0000000 --- a/src/test/java/io/github/aalsanie/codes/protocol/http/HttpOutcomeMapperTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package io.github.aalsanie.codes.protocol.http; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class HttpOutcomeMapperTest { - @Test - void standardMappingsAreConservative() { - HttpOutcomeMapper mapper = HttpOutcomeMapper.standard(); - assertEquals(12, mapper.getSize()); - assertSame(HttpStatusCode.OK, mapper.map(StandardOutcomes.OK).orNull()); - assertSame(HttpStatusCode.BAD_REQUEST, mapper.map(StandardOutcomes.INVALID_ARGUMENT).orNull()); - assertSame(HttpStatusCode.UNAUTHORIZED, mapper.map(StandardOutcomes.UNAUTHENTICATED).orNull()); - assertSame(HttpStatusCode.FORBIDDEN, mapper.map(StandardOutcomes.PERMISSION_DENIED).orNull()); - assertSame(HttpStatusCode.NOT_FOUND, mapper.map(Outcome.of(StandardOutcomes.NOT_FOUND)).orNull()); - assertSame(HttpStatusCode.CONFLICT, mapper.map(StandardOutcomes.ALREADY_EXISTS).orNull()); - assertSame(HttpStatusCode.BAD_REQUEST, mapper.map(StandardOutcomes.OUT_OF_RANGE).orNull()); - assertSame(HttpStatusCode.TOO_MANY_REQUESTS, mapper.map(StandardOutcomes.RATE_LIMITED).orNull()); - assertSame(HttpStatusCode.NOT_IMPLEMENTED, mapper.map(StandardOutcomes.UNIMPLEMENTED).orNull()); - assertSame(HttpStatusCode.SERVICE_UNAVAILABLE, mapper.map(StandardOutcomes.UNAVAILABLE).orNull()); - assertSame(HttpStatusCode.INTERNAL_SERVER_ERROR, mapper.map(StandardOutcomes.INTERNAL).orNull()); - assertSame(HttpStatusCode.INTERNAL_SERVER_ERROR, mapper.map(StandardOutcomes.DATA_LOSS).orNull()); - assertTrue(mapper.map(StandardOutcomes.FAILED_PRECONDITION).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.CANCELLED).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.DEADLINE_EXCEEDED).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.ABORTED).isUnmapped()); - assertTrue(mapper.map(StandardOutcomes.RESOURCE_EXHAUSTED).isUnmapped()); - assertSame(HttpStatusCode.OK, HttpStatusCode.of(200)); - assertSame(HttpStatusCode.CREATED, HttpStatusCode.of(201)); - assertSame(HttpStatusCode.ACCEPTED, HttpStatusCode.of(202)); - assertSame(HttpStatusCode.NO_CONTENT, HttpStatusCode.of(204)); - assertSame(HttpStatusCode.BAD_REQUEST, HttpStatusCode.of(400)); - assertSame(HttpStatusCode.UNAUTHORIZED, HttpStatusCode.of(401)); - assertSame(HttpStatusCode.FORBIDDEN, HttpStatusCode.of(403)); - assertSame(HttpStatusCode.NOT_FOUND, HttpStatusCode.of(404)); - assertSame(HttpStatusCode.CONFLICT, HttpStatusCode.of(409)); - assertSame(HttpStatusCode.PRECONDITION_FAILED, HttpStatusCode.of(412)); - assertSame(HttpStatusCode.PAYLOAD_TOO_LARGE, HttpStatusCode.of(413)); - assertSame(HttpStatusCode.TOO_MANY_REQUESTS, HttpStatusCode.of(429)); - assertSame(HttpStatusCode.INTERNAL_SERVER_ERROR, HttpStatusCode.of(500)); - assertSame(HttpStatusCode.NOT_IMPLEMENTED, HttpStatusCode.of(501)); - assertSame(HttpStatusCode.SERVICE_UNAVAILABLE, HttpStatusCode.of(503)); - assertSame(HttpStatusCode.GATEWAY_TIMEOUT, HttpStatusCode.of(504)); - assertFalse(HttpStatusCode.OK.isInformational()); - assertFalse(HttpStatusCode.BAD_REQUEST.isSuccessful()); - assertFalse(HttpStatusCode.OK.isRedirection()); - assertFalse(HttpStatusCode.OK.isClientError()); - assertFalse(HttpStatusCode.BAD_REQUEST.isServerError()); - assertEquals(HttpStatusCode.OK, HttpStatusCode.OK); - assertNotEquals(HttpStatusCode.OK, HttpStatusCode.CREATED); - assertEquals(404, HttpStatusCode.NOT_FOUND.hashCode()); - } - - @Test - void customMappingsAreImmutableAndExplicit() { - OutcomeDefinition custom = OutcomeDefinition.custom( - "com.example.payments", "PAYMENT_DECLINED", OutcomeState.FAILED, "Declined." - ); - HttpOutcomeMapper base = HttpOutcomeMapper.standard(); - HttpOutcomeMapper extended = base.withMapping(custom, HttpStatusCode.of(422)); - assertFalse(base.contains(custom.getCode())); - assertTrue(extended.contains(custom.getCode())); - assertEquals(422, extended.map(custom).orNull().getValue()); - assertEquals(HttpStatusCode.of(410), base.withOverride(StandardOutcomes.NOT_FOUND, HttpStatusCode.of(410)).map(StandardOutcomes.NOT_FOUND).orNull()); - assertThrows(IllegalArgumentException.class, () -> base.withMapping(StandardOutcomes.NOT_FOUND, HttpStatusCode.NOT_FOUND)); - assertThrows(IllegalArgumentException.class, () -> base.withOverride(custom, HttpStatusCode.BAD_REQUEST)); - assertThrows(UnsupportedOperationException.class, () -> base.mappings().clear()); - } - - @Test - void statusCodeValidatesAndClassifies() { - assertTrue(HttpStatusCode.of(199).isInformational()); - assertTrue(HttpStatusCode.OK.isSuccessful()); - assertTrue(HttpStatusCode.of(302).isRedirection()); - assertTrue(HttpStatusCode.BAD_REQUEST.isClientError()); - assertTrue(HttpStatusCode.INTERNAL_SERVER_ERROR.isServerError()); - assertEquals(4, HttpStatusCode.NOT_FOUND.getFamily()); - assertEquals("404", HttpStatusCode.NOT_FOUND.toString()); - assertEquals(HttpStatusCode.of(418), HttpStatusCode.of(418)); - assertTrue(HttpStatusCode.OK.compareTo(HttpStatusCode.BAD_REQUEST) < 0); - assertThrows(IllegalArgumentException.class, () -> HttpStatusCode.of(99)); - assertThrows(IllegalArgumentException.class, () -> HttpStatusCode.of(600)); - assertThrows(NullPointerException.class, () -> HttpStatusCode.OK.compareTo(null)); - } -} From 518d0f43a8cccbbf9188a8e2c78cab6555639c11 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:25:35 +0300 Subject: [PATCH 03/50] Rebuild Spring problem detail bridge --- codes-spring/build.gradle.kts | 75 ++++++++++------------------------- 1 file changed, 21 insertions(+), 54 deletions(-) diff --git a/codes-spring/build.gradle.kts b/codes-spring/build.gradle.kts index e2603a3..5abb18b 100644 --- a/codes-spring/build.gradle.kts +++ b/codes-spring/build.gradle.kts @@ -8,12 +8,13 @@ group = providers.gradleProperty("GROUP").get() version = providers.gradleProperty("VERSION_NAME").get() val artifactId = "codes-spring" -val pomName = "Codes Spring Adapter" -val pomDescription = "Spring Framework HTTP boundary adapter for Codes application outcomes." +val pomName = "Codes Spring" +val pomDescription = "Spring Framework bridge for Codes RFC 9457 problem types." +val springFrameworkVersion = providers.gradleProperty("springFrameworkOverride") + .orElse(providers.gradleProperty("springFrameworkVersion")) val expectedPomDependencies = listOf( "${project.group}:codes:${project.version}:compile", - "org.springframework:spring-web:${providers.gradleProperty("springFrameworkVersion").get()}:compile", -).sorted() +) java { toolchain { @@ -29,31 +30,24 @@ tasks.withType().configureEach { dependencies { compileOnly("org.jspecify:jspecify:1.0.0") - api(project(":")) - api("org.springframework:spring-web:${providers.gradleProperty("springFrameworkVersion").get()}") + compileOnly("org.springframework:spring-web:${springFrameworkVersion.get()}") testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.springframework:spring-webmvc:${providers.gradleProperty("springFrameworkVersion").get()}") - testImplementation("org.springframework:spring-webflux:${providers.gradleProperty("springFrameworkVersion").get()}") - testImplementation("org.springframework:spring-test:${providers.gradleProperty("springFrameworkVersion").get()}") - testImplementation("tools.jackson.core:jackson-databind:${providers.gradleProperty("jackson3Version").get()}") - testImplementation("jakarta.servlet:jakarta.servlet-api:6.1.0") + testImplementation("org.jspecify:jspecify:1.0.0") + testImplementation("org.springframework:spring-web:${springFrameworkVersion.get()}") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +sourceSets.test { + java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) +} + tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) - systemProperty( - "codes.apiSnapshot", - rootProject.file("api/codes-spring.api").absolutePath, - ) - systemProperty( - "codes.springHttpSnapshot", - rootProject.file("compatibility/spring-http-problems.snapshot").absolutePath, - ) + systemProperty("codes.apiSnapshot", rootProject.file("api/codes-spring.api").absolutePath) } jacoco { @@ -74,30 +68,21 @@ tasks.jacocoTestCoverageVerification { rule { limit { counter = "LINE" - minimum = "0.90".toBigDecimal() + minimum = "0.95".toBigDecimal() } limit { counter = "BRANCH" - minimum = "0.80".toBigDecimal() + minimum = "0.90".toBigDecimal() } } } } -sourceSets.test { - java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) -} - tasks.register("verifyPublishedPomContract") { group = "verification" - description = "Verifies the Codes Spring POM metadata and direct dependency budget." - + description = "Verifies the Codes Spring POM metadata and dependency budget." dependsOn("generatePomFileForMavenPublication") - val artifactId = artifactId - val pomName = pomName - val pomDescription = pomDescription - val expectedPomDependencies = expectedPomDependencies val pomFile = layout.buildDirectory.file("publications/maven/pom-default.xml") inputs.file(pomFile) @@ -113,24 +98,18 @@ tasks.register("verifyPublishedPomContract") { .first { it.nodeName == name } .textContent - check(directText("name") == pomName) { - "Unexpected $artifactId POM name: ${directText("name")}" - } - check(directText("description") == pomDescription) { - "Unexpected $artifactId POM description: ${directText("description")}" - } + check(directText("name") == pomName) + check(directText("description") == pomDescription) val dependencies = document.getElementsByTagName("dependency") val actual = (0 until dependencies.length).map { index -> val dependency = dependencies.item(index) as org.w3c.dom.Element fun value(name: String): String = dependency.getElementsByTagName(name).item(0).textContent - listOf("groupId", "artifactId", "version", "scope") - .joinToString(":") { value(it) } + listOf("groupId", "artifactId", "version", "scope").joinToString(":") { value(it) } }.sorted() - check(actual == expectedPomDependencies) { - "$artifactId direct dependency budget changed. " + - "Expected $expectedPomDependencies, found $actual." + check(actual == expectedPomDependencies.sorted()) { + "$artifactId dependency budget changed. Expected ${expectedPomDependencies.sorted()}, found $actual." } } } @@ -171,15 +150,3 @@ mavenPublishing { signAllPublications() } } - -tasks.configureEach { - if (name.contains("MavenCentral", ignoreCase = true)) { - doFirst { - val required = listOf("mavenCentralUsername", "mavenCentralPassword", "signingInMemoryKey") - val missing = required.filterNot { providers.gradleProperty(it).isPresent } - require(missing.isEmpty()) { - "Maven Central publication requires Gradle properties: ${missing.joinToString()}" - } - } - } -} From a67aff0ec7fd976eb738061f6df905b19e99e87c Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:25:50 +0300 Subject: [PATCH 04/50] Rebuild Spring problem detail bridge --- .../aalsanie/codes/spring/ProblemDetails.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java new file mode 100644 index 0000000..f95114b --- /dev/null +++ b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java @@ -0,0 +1,25 @@ +package io.github.aalsanie.codes.spring; + +import io.github.aalsanie.codes.ProblemType; +import java.util.Objects; +import org.springframework.http.ProblemDetail; + +public final class ProblemDetails { + private ProblemDetails() { + } + + public static ProblemDetail forType(ProblemType problemType) { + Objects.requireNonNull(problemType, "problemType"); + ProblemDetail problemDetail = ProblemDetail.forStatus(problemType.getStatus()); + problemDetail.setType(problemType.getType()); + problemDetail.setTitle(problemType.getTitle()); + return problemDetail; + } + + public static ProblemDetail forTypeAndDetail(ProblemType problemType, String detail) { + Objects.requireNonNull(detail, "detail"); + ProblemDetail problemDetail = forType(problemType); + problemDetail.setDetail(detail); + return problemDetail; + } +} From 3b6a074ec4880a2c357c94e422c6e796abf1eb84 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:26:27 +0300 Subject: [PATCH 05/50] Update Spring API contract --- .../java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java index 810c460..56e6dbf 100644 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java +++ b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ApiSnapshotTest.java @@ -17,7 +17,7 @@ void publicApiMatchesSnapshot() throws IOException { assertEquals( expected, - PublicApiSnapshot.create(OutcomeProblemDetailMapper.class, "io.github.aalsanie.codes.spring") + PublicApiSnapshot.create(ProblemDetails.class, "io.github.aalsanie.codes.spring") ); } } From ad1c2311d171e8a008c504ad5d13d735b5730171 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:26:38 +0300 Subject: [PATCH 06/50] Update Spring API contract --- .../codes/spring/ProblemDetailsTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java new file mode 100644 index 0000000..7811d34 --- /dev/null +++ b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java @@ -0,0 +1,52 @@ +package io.github.aalsanie.codes.spring; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.aalsanie.codes.ProblemType; +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.springframework.http.ProblemDetail; + +class ProblemDetailsTest { + private static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + + @Test + void createsProblemDetailFromReusableType() { + ProblemDetail problem = ProblemDetails.forType(ORDER_NOT_FOUND); + + assertEquals(ORDER_NOT_FOUND.getType(), problem.getType()); + assertEquals(ORDER_NOT_FOUND.getStatus(), problem.getStatus()); + assertEquals(ORDER_NOT_FOUND.getTitle(), problem.getTitle()); + assertNull(problem.getDetail()); + assertNull(problem.getInstance()); + assertNull(problem.getProperties()); + } + + @Test + void createsProblemDetailWithOccurrenceDetail() { + ProblemDetail problem = ProblemDetails.forTypeAndDetail( + ORDER_NOT_FOUND, + "Order o-123 was not found." + ); + + assertEquals(ORDER_NOT_FOUND.getType(), problem.getType()); + assertEquals(ORDER_NOT_FOUND.getStatus(), problem.getStatus()); + assertEquals(ORDER_NOT_FOUND.getTitle(), problem.getTitle()); + assertEquals("Order o-123 was not found.", problem.getDetail()); + } + + @Test + void rejectsNullArguments() { + assertThrows(NullPointerException.class, () -> ProblemDetails.forType(null)); + assertThrows( + NullPointerException.class, + () -> ProblemDetails.forTypeAndDetail(ORDER_NOT_FOUND, null) + ); + } +} From 62692fc6dd42d3f5b51a1950649fe80e872c4b98 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:26:55 +0300 Subject: [PATCH 07/50] Update Spring API snapshot --- api/codes-spring.api | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/api/codes-spring.api b/api/codes-spring.api index 7867066..d636dc1 100644 --- a/api/codes-spring.api +++ b/api/codes-spring.api @@ -1,33 +1,3 @@ -public final class io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper - ctor public (io.github.aalsanie.codes.spring.SpringHttpStatusMapper, io.github.aalsanie.codes.spring.SpringOutcomeExposure) - ctor public (io.github.aalsanie.codes.spring.SpringHttpStatusMapper, io.github.aalsanie.codes.spring.SpringOutcomeExposure, io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper) - field public static final java.lang.String CODE_PROPERTY - field public static final java.lang.String ISSUES_PROPERTY - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public static io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper safeDefaults() - -public final class io.github.aalsanie.codes.spring.SpringHttpStatusMapper - ctor public (io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public static io.github.aalsanie.codes.spring.SpringHttpStatusMapper standard() - -public final class io.github.aalsanie.codes.spring.SpringOutcomeExceptions - method public static io.github.aalsanie.codes.MappingResult toErrorResponseException(io.github.aalsanie.codes.Outcome, io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper) - -public final class io.github.aalsanie.codes.spring.SpringOutcomeExposure extends java.lang.Record - ctor public (boolean, boolean, boolean) - method public boolean exposeDetail() - method public boolean exposeIssues() - method public boolean exposeMessage() - method public final boolean equals(java.lang.Object) - method public final int hashCode() - method public final java.lang.String toString() - method public static io.github.aalsanie.codes.spring.SpringOutcomeExposure publicErrors() - method public static io.github.aalsanie.codes.spring.SpringOutcomeExposure safeDefaults() - -public final class io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper withMapping(io.github.aalsanie.codes.OutcomeDefinition, java.net.URI) - method public static io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper empty() +public final class io.github.aalsanie.codes.spring.ProblemDetails + method public static org.springframework.http.ProblemDetail forType(io.github.aalsanie.codes.ProblemType) + method public static org.springframework.http.ProblemDetail forTypeAndDetail(io.github.aalsanie.codes.ProblemType, java.lang.String) From 3ed593912313f9b427acd2f7eb55c9b3f3d33c0f Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:27:02 +0300 Subject: [PATCH 08/50] Remove outcome Spring adapters --- .../spring/OutcomeProblemDetailMapper.java | 136 ------------------ 1 file changed, 136 deletions(-) delete mode 100644 codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java deleted file mode 100644 index 4d58621..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapper.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import java.net.URI; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.jspecify.annotations.Nullable; -import org.springframework.http.ProblemDetail; - -/** - * Maps failed Codes outcomes to Spring {@link ProblemDetail} instances. - * - *

The stable Codes identity is always exposed as {@code code}. An RFC 9457 problem - * {@code type} is set only when the application explicitly supplies an owned URI mapping. - * For a mapped problem type, the reusable outcome message may be exposed as the stable - * {@code title}. Occurrence-specific {@link Outcome#getDetail()} is exposed only as RFC - * {@code detail}; it is never moved to a custom extension field. - */ -public final class OutcomeProblemDetailMapper { - public static final String CODE_PROPERTY = "code"; - public static final String ISSUES_PROPERTY = "issues"; - - private final SpringHttpStatusMapper statusMapper; - private final SpringOutcomeExposure exposure; - private final SpringProblemTypeUriMapper problemTypeMapper; - - public OutcomeProblemDetailMapper( - SpringHttpStatusMapper statusMapper, - SpringOutcomeExposure exposure - ) { - this(statusMapper, exposure, SpringProblemTypeUriMapper.empty()); - } - - public OutcomeProblemDetailMapper( - SpringHttpStatusMapper statusMapper, - SpringOutcomeExposure exposure, - SpringProblemTypeUriMapper problemTypeMapper - ) { - this.statusMapper = Objects.requireNonNull(statusMapper, "statusMapper"); - this.exposure = Objects.requireNonNull(exposure, "exposure"); - this.problemTypeMapper = Objects.requireNonNull(problemTypeMapper, "problemTypeMapper"); - } - - public static OutcomeProblemDetailMapper safeDefaults() { - return new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - SpringOutcomeExposure.safeDefaults(), - SpringProblemTypeUriMapper.empty() - ); - } - - public MappingResult map(Outcome outcome) { - Objects.requireNonNull(outcome, "outcome"); - if (!outcome.isFailed()) { - throw new IllegalArgumentException("ProblemDetail is only produced for failed outcomes"); - } - - return statusMapper.map(outcome).fold( - status -> MappingResult.mapped(createProblemDetail(outcome, status.value())), - MappingResult::unmapped - ); - } - - private ProblemDetail createProblemDetail(Outcome outcome, int status) { - URI problemType = problemTypeMapper.map(outcome).orNull(); - ProblemDetail problem = createProblemDetail(status, problemType); - - problem.setProperty(CODE_PROPERTY, outcome.getCode().getValue()); - - if (problemType != null) { - problem.setType(problemType); - if (exposure.exposeMessage()) { - problem.setTitle(outcome.getMessage()); - } - } - - if (exposure.exposeDetail() && outcome.getDetail() != null) { - problem.setDetail(outcome.getDetail()); - } - if (exposure.exposeIssues() && !outcome.getIssues().isEmpty()) { - problem.setProperty(ISSUES_PROPERTY, toIssuePayload(outcome.getIssues())); - } - return problem; - } - - private ProblemDetail createProblemDetail(int status, @Nullable URI problemType) { - if (problemType != null && !exposure.exposeMessage()) { - return new ProblemDetailWithoutDefaultTitle(status); - } - return ProblemDetail.forStatus(status); - } - - private static List> toIssuePayload(List issues) { - List> values = new ArrayList<>(issues.size()); - for (Issue issue : issues) { - LinkedHashMap value = new LinkedHashMap<>(); - if (issue.getCode() != null) { - value.put("code", issue.getCode().getValue()); - } - if (issue.getPath() != null) { - value.put("path", issue.getPath()); - } - value.put("message", issue.getMessage()); - values.add(Map.copyOf(value)); - } - return List.copyOf(values); - } - - /** - * Spring's default {@link ProblemDetail#getTitle()} synthesizes the HTTP reason phrase when - * no title was explicitly configured. That behavior is correct for implicit {@code about:blank} - * but not for an application-owned problem type whose stable title is intentionally hidden. - */ - private static final class ProblemDetailWithoutDefaultTitle extends ProblemDetail { - private @Nullable String explicitTitle; - - private ProblemDetailWithoutDefaultTitle(int status) { - super(status); - } - - @Override - public void setTitle(@Nullable String title) { - explicitTitle = title; - } - - @Override - public @Nullable String getTitle() { - return explicitTitle; - } - } -} From c5758fec18d6ff7609b7cb401559c17af8aad280 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:27:15 +0300 Subject: [PATCH 09/50] Remove obsolete Spring adapter --- .../codes/spring/SpringHttpStatusMapper.java | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java deleted file mode 100644 index 7a6bfbb..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import java.util.Objects; -import org.springframework.http.HttpStatusCode; - -public final class SpringHttpStatusMapper { - private final HttpOutcomeMapper mapper; - - public SpringHttpStatusMapper(HttpOutcomeMapper mapper) { - this.mapper = Objects.requireNonNull(mapper, "mapper"); - } - - public static SpringHttpStatusMapper standard() { - return new SpringHttpStatusMapper(HttpOutcomeMapper.standard()); - } - - public MappingResult map(OutcomeDefinition definition) { - return mapper.map(Objects.requireNonNull(definition, "definition")) - .fold( - status -> MappingResult.mapped(HttpStatusCode.valueOf(status.getValue())), - MappingResult::unmapped - ); - } - - public MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } -} From b113551fd0a3e9bef96e83aa3d92688a74fe5784 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:28:03 +0300 Subject: [PATCH 10/50] Remove obsolete adapters --- api/codes-grpc-java.api | 24 - codes-grpc-java/build.gradle.kts | 184 -------- .../codes/grpc/GoogleRpcOutcomeMapper.java | 181 -------- .../codes/grpc/GrpcOutcomeExceptions.java | 24 - .../codes/grpc/GrpcOutcomeExposure.java | 15 - .../aalsanie/codes/grpc/GrpcStatusMapper.java | 32 -- .../aalsanie/codes/grpc/package-info.java | 4 - .../aalsanie/codes/grpc/ApiSnapshotTest.java | 23 - .../grpc/GoogleRpcOutcomeMapperTest.java | 294 ------------- .../codes/grpc/GrpcStatusMapperTest.java | 29 -- ...pcStatusRuntimeExceptionRoundTripTest.java | 240 ---------- .../codes/spring/SpringOutcomeExceptions.java | 34 -- .../codes/spring/SpringOutcomeExposure.java | 15 - .../spring/SpringProblemTypeUriMapper.java | 61 --- .../OutcomeProblemDetailMapperTest.java | 133 ------ .../spring/SpringHttpGoldenResponseTest.java | 412 ------------------ .../spring/SpringHttpStatusMapperTest.java | 29 -- .../spring/SpringOutcomeExceptionsTest.java | 53 --- .../SpringProblemTypeUriMapperTest.java | 64 --- .../SpringStackCompatibilityContractTest.java | 45 -- compatibility/grpc-google-rpc-status.snapshot | 38 -- compatibility/grpc-mappings.snapshot | 17 - compatibility/grpc-status-codes.snapshot | 17 - docs/integration-grpc.md | 108 ----- reference/grpc-orders/build.gradle.kts | 59 --- .../example/orders/grpc/OrderOutcomes.java | 16 - .../com/example/orders/grpc/OrderRecord.java | 4 - .../com/example/orders/grpc/OrderResult.java | 14 - .../com/example/orders/grpc/OrderService.java | 44 -- .../orders/grpc/OrdersGrpcApplication.java | 19 - .../orders/grpc/OrdersGrpcService.java | 44 -- .../orders/grpc/OutcomeGrpcBoundary.java | 34 -- .../grpc-orders/src/main/proto/orders.proto | 28 -- .../orders/grpc/GrpcReferenceTest.java | 54 --- 34 files changed, 2392 deletions(-) delete mode 100644 api/codes-grpc-java.api delete mode 100644 codes-grpc-java/build.gradle.kts delete mode 100644 codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java delete mode 100644 codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java delete mode 100644 codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java delete mode 100644 codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java delete mode 100644 codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java delete mode 100644 codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java delete mode 100644 codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java delete mode 100644 codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java delete mode 100644 codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java delete mode 100644 codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java delete mode 100644 codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java delete mode 100644 codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java delete mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java delete mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java delete mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java delete mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java delete mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java delete mode 100644 codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java delete mode 100644 compatibility/grpc-google-rpc-status.snapshot delete mode 100644 compatibility/grpc-mappings.snapshot delete mode 100644 compatibility/grpc-status-codes.snapshot delete mode 100644 docs/integration-grpc.md delete mode 100644 reference/grpc-orders/build.gradle.kts delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java delete mode 100644 reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java delete mode 100644 reference/grpc-orders/src/main/proto/orders.proto delete mode 100644 reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java diff --git a/api/codes-grpc-java.api b/api/codes-grpc-java.api deleted file mode 100644 index c2b5402..0000000 --- a/api/codes-grpc-java.api +++ /dev/null @@ -1,24 +0,0 @@ -public final class io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper - ctor public (io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper, io.github.aalsanie.codes.grpc.GrpcOutcomeExposure) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public static io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper safeDefaults() - -public final class io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions - method public static io.github.aalsanie.codes.MappingResult toStatusRuntimeException(io.github.aalsanie.codes.Outcome, io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper) - -public final class io.github.aalsanie.codes.grpc.GrpcOutcomeExposure extends java.lang.Record - ctor public (boolean, boolean, boolean) - method public boolean exposeDetail() - method public boolean exposeIssues() - method public boolean exposeMessage() - method public final boolean equals(java.lang.Object) - method public final int hashCode() - method public final java.lang.String toString() - method public static io.github.aalsanie.codes.grpc.GrpcOutcomeExposure publicErrors() - method public static io.github.aalsanie.codes.grpc.GrpcOutcomeExposure safeDefaults() - -public final class io.github.aalsanie.codes.grpc.GrpcStatusMapper - ctor public (io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.Outcome) - method public io.github.aalsanie.codes.MappingResult map(io.github.aalsanie.codes.OutcomeDefinition) - method public static io.github.aalsanie.codes.grpc.GrpcStatusMapper standard() diff --git a/codes-grpc-java/build.gradle.kts b/codes-grpc-java/build.gradle.kts deleted file mode 100644 index 9f855b8..0000000 --- a/codes-grpc-java/build.gradle.kts +++ /dev/null @@ -1,184 +0,0 @@ -plugins { - `java-library` - jacoco - id("com.vanniktech.maven.publish") -} - -group = providers.gradleProperty("GROUP").get() -version = providers.gradleProperty("VERSION_NAME").get() - -val artifactId = "codes-grpc-java" -val pomName = "Codes gRPC Java Adapter" -val pomDescription = "gRPC Java and google.rpc boundary adapter for Codes application outcomes." -val expectedPomDependencies = listOf( - "${project.group}:codes:${project.version}:compile", - "com.google.api.grpc:proto-google-common-protos:${providers.gradleProperty("protoGoogleCommonProtosVersion").get()}:compile", - "io.grpc:grpc-api:${providers.gradleProperty("grpcVersion").get()}:compile", - "io.grpc:grpc-protobuf:${providers.gradleProperty("grpcVersion").get()}:runtime", -).sorted() - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} - -tasks.withType().configureEach { - options.release.set(17) - options.encoding = "UTF-8" - options.compilerArgs.addAll(listOf("-Xlint:all,-serial", "-Werror")) -} - -dependencies { - compileOnly("org.jspecify:jspecify:1.0.0") - - api(project(":")) - api("io.grpc:grpc-api:${providers.gradleProperty("grpcVersion").get()}") - api("com.google.api.grpc:proto-google-common-protos:${providers.gradleProperty("protoGoogleCommonProtosVersion").get()}") - implementation("io.grpc:grpc-protobuf:${providers.gradleProperty("grpcVersion").get()}") - - testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) - testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.test { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) - systemProperty( - "codes.apiSnapshot", - rootProject.file("api/codes-grpc-java.api").absolutePath, - ) - systemProperty( - "codes.grpcStatusSnapshot", - rootProject.file("compatibility/grpc-google-rpc-status.snapshot").absolutePath, - ) -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - html.required.set(true) - xml.required.set(true) - } -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { - rule { - limit { - counter = "LINE" - minimum = "0.90".toBigDecimal() - } - limit { - counter = "BRANCH" - minimum = "0.80".toBigDecimal() - } - } - } -} - -sourceSets.test { - java.srcDir(rootProject.file("testing/api-snapshot/src/main/java")) -} - -tasks.register("verifyPublishedPomContract") { - group = "verification" - description = "Verifies the Codes gRPC Java POM metadata and direct dependency budget." - - dependsOn("generatePomFileForMavenPublication") - - val artifactId = artifactId - val pomName = pomName - val pomDescription = pomDescription - val expectedPomDependencies = expectedPomDependencies - val pomFile = layout.buildDirectory.file("publications/maven/pom-default.xml") - inputs.file(pomFile) - - doLast { - val document = javax.xml.parsers.DocumentBuilderFactory - .newInstance() - .newDocumentBuilder() - .parse(pomFile.get().asFile) - val projectElement = document.documentElement - - fun directText(name: String): String = (0 until projectElement.childNodes.length) - .map(projectElement.childNodes::item) - .first { it.nodeName == name } - .textContent - - check(directText("name") == pomName) { - "Unexpected $artifactId POM name: ${directText("name")}" - } - check(directText("description") == pomDescription) { - "Unexpected $artifactId POM description: ${directText("description")}" - } - - val dependencies = document.getElementsByTagName("dependency") - val actual = (0 until dependencies.length).map { index -> - val dependency = dependencies.item(index) as org.w3c.dom.Element - fun value(name: String): String = dependency.getElementsByTagName(name).item(0).textContent - listOf("groupId", "artifactId", "version", "scope") - .joinToString(":") { value(it) } - }.sorted() - - check(actual == expectedPomDependencies) { - "$artifactId direct dependency budget changed. " + - "Expected $expectedPomDependencies, found $actual." - } - } -} - -tasks.check { - dependsOn(tasks.jacocoTestCoverageVerification) - dependsOn(tasks.named("verifyPublishedPomContract")) -} - -tasks.jar { - manifest { - attributes["Automatic-Module-Name"] = "io.github.aalsanie.codes.grpc" - } -} - -tasks.withType().configureEach { - isPreserveFileTimestamps = false - isReproducibleFileOrder = true -} - -val signingConfigured = providers.gradleProperty("signingInMemoryKey").isPresent - -mavenPublishing { - coordinates( - providers.gradleProperty("GROUP").get(), - artifactId, - providers.gradleProperty("VERSION_NAME").get(), - ) - - pom { - name.set(pomName) - description.set(pomDescription) - } - - publishToMavenCentral(automaticRelease = true) - - if (signingConfigured) { - signAllPublications() - } -} - -tasks.configureEach { - if (name.contains("MavenCentral", ignoreCase = true)) { - doFirst { - val required = listOf("mavenCentralUsername", "mavenCentralPassword", "signingInMemoryKey") - val missing = required.filterNot { providers.gradleProperty(it).isPresent } - require(missing.isEmpty()) { - "Maven Central publication requires Gradle properties: ${missing.joinToString()}" - } - } - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java deleted file mode 100644 index 348de95..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapper.java +++ /dev/null @@ -1,181 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import com.google.protobuf.Any; -import com.google.rpc.BadRequest; -import com.google.rpc.DebugInfo; -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -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; - -/** - * Maps failed Codes outcomes to {@code google.rpc.Status}. - * - *

The Codes identity is represented losslessly as {@link ErrorInfo#getDomain() domain} = - * {@link OutcomeCode#getNamespace() namespace} and {@link ErrorInfo#getReason() reason} = - * {@link OutcomeCode#getName() name}. Codes never truncates or normalizes identity to make it fit - * the Google RPC contract. A mapped outcome whose name cannot be represented as an - * {@code ErrorInfo.reason} is rejected. - * - *

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. - * - *

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; - private static final String GOOGLE_REASON_EXPRESSION = "[A-Z][A-Z0-9_]+[A-Z0-9]"; - private static final Pattern GOOGLE_REASON_PATTERN = - Pattern.compile(GOOGLE_REASON_EXPRESSION); - - private final GrpcOutcomeMapper mapper; - private final GrpcOutcomeExposure exposure; - - public GoogleRpcOutcomeMapper(GrpcOutcomeMapper mapper, GrpcOutcomeExposure exposure) { - this.mapper = Objects.requireNonNull(mapper, "mapper"); - this.exposure = Objects.requireNonNull(exposure, "exposure"); - } - - public static GoogleRpcOutcomeMapper safeDefaults() { - return new GoogleRpcOutcomeMapper( - GrpcOutcomeMapper.standard(), - GrpcOutcomeExposure.safeDefaults() - ); - } - - public MappingResult map(Outcome outcome) { - Objects.requireNonNull(outcome, "outcome"); - if (!outcome.isFailed()) { - throw new IllegalArgumentException( - "google.rpc.Status error details are only produced for failed outcomes" - ); - } - - return mapper.map(outcome).fold( - status -> MappingResult.mapped(createStatus(outcome, status)), - MappingResult::unmapped - ); - } - - 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.getValue()) - .setMessage(exposure.exposeMessage() ? outcome.getMessage() : outcomeCode.getValue()) - .addDetails(Any.pack( - ErrorInfo.newBuilder() - .setDomain(outcomeCode.getNamespace()) - .setReason(outcomeCode.getName()) - .build() - )); - - if (exposure.exposeDetail() && outcome.getDetail() != null) { - status.addDetails(Any.pack( - DebugInfo.newBuilder() - .setDetail(outcome.getDetail()) - .build() - )); - } - - if (exposure.exposeIssues() && !outcome.getIssues().isEmpty()) { - status.addDetails(Any.pack(toBadRequest(outcome, statusCode))); - } - - return status.build(); - } - - 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()); - - OutcomeCode issueCode = issue.getCode(); - if (issueCode != null) { - requireFieldViolationIdentity(outcomeCode, issueCode); - violation.setReason(issueCode.getName()); - } - - request.addFieldViolations(violation); - } - - 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 - ) { - if (!outcomeCode.getNamespace().equals(issueCode.getNamespace())) { - throw new IllegalArgumentException( - "issue code '" + issueCode.getValue() - + "' cannot be represented losslessly as google.rpc.BadRequest.FieldViolation" - + ".reason under ErrorInfo.domain '" + outcomeCode.getNamespace() - + "'; coded issues must use the same namespace as the enclosing outcome" - ); - } - - requireGoogleReason( - issueCode, - "issue code", - "google.rpc.BadRequest.FieldViolation.reason" - ); - } - - private static void requireGoogleReason( - OutcomeCode code, - String label, - String target - ) { - String reason = code.getName(); - if (reason.length() <= MAX_GOOGLE_REASON_LENGTH - && GOOGLE_REASON_PATTERN.matcher(reason).matches()) { - return; - } - - throw new IllegalArgumentException( - label + " '" + code.getValue() + "' cannot be represented losslessly as " + target - + "; reason must be at most " + MAX_GOOGLE_REASON_LENGTH - + " characters and match " + GOOGLE_REASON_EXPRESSION - ); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java deleted file mode 100644 index 8db8eeb..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExceptions.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.grpc.StatusRuntimeException; -import io.grpc.protobuf.StatusProto; -import java.util.Objects; - -public final class GrpcOutcomeExceptions { - private GrpcOutcomeExceptions() { - } - - public static MappingResult toStatusRuntimeException( - Outcome outcome, - GoogleRpcOutcomeMapper mapper - ) { - Objects.requireNonNull(outcome, "outcome"); - Objects.requireNonNull(mapper, "mapper"); - return mapper.map(outcome).fold( - status -> MappingResult.mapped(StatusProto.toStatusRuntimeException(status)), - MappingResult::unmapped - ); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java deleted file mode 100644 index 85925cf..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcOutcomeExposure.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -public record GrpcOutcomeExposure( - boolean exposeMessage, - boolean exposeDetail, - boolean exposeIssues -) { - public static GrpcOutcomeExposure safeDefaults() { - return new GrpcOutcomeExposure(false, false, false); - } - - public static GrpcOutcomeExposure publicErrors() { - return new GrpcOutcomeExposure(true, false, true); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java deleted file mode 100644 index 1b86d2c..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/GrpcStatusMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.grpc.Status; -import java.util.Objects; - -public final class GrpcStatusMapper { - private final GrpcOutcomeMapper mapper; - - public GrpcStatusMapper(GrpcOutcomeMapper mapper) { - this.mapper = Objects.requireNonNull(mapper, "mapper"); - } - - public static GrpcStatusMapper standard() { - return new GrpcStatusMapper(GrpcOutcomeMapper.standard()); - } - - public MappingResult map(OutcomeDefinition definition) { - return mapper.map(Objects.requireNonNull(definition, "definition")) - .fold( - status -> MappingResult.mapped(Status.fromCodeValue(status.getValue())), - MappingResult::unmapped - ); - } - - public MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } -} diff --git a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java b/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java deleted file mode 100644 index 87762a0..0000000 --- a/codes-grpc-java/src/main/java/io/github/aalsanie/codes/grpc/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NullMarked -package io.github.aalsanie.codes.grpc; - -import org.jspecify.annotations.NullMarked; diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java deleted file mode 100644 index 1700531..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/ApiSnapshotTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.github.aalsanie.codes.testing.PublicApiSnapshot; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; - -class ApiSnapshotTest { - @Test - void publicApiMatchesSnapshot() throws IOException { - String expected = Files.readString(Path.of(System.getProperty("codes.apiSnapshot"))) - .replace("\r\n", "\n") - .stripTrailing(); - - assertEquals( - expected, - PublicApiSnapshot.create(GoogleRpcOutcomeMapper.class, "io.github.aalsanie.codes.grpc") - ); - } -} diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java deleted file mode 100644 index 1202815..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GoogleRpcOutcomeMapperTest.java +++ /dev/null @@ -1,294 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.rpc.BadRequest; -import com.google.rpc.DebugInfo; -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import java.util.List; -import org.junit.jupiter.api.Test; - -class GoogleRpcOutcomeMapperTest { - private static final String APP_NAMESPACE = "com.example.checkout"; - - private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - APP_NAMESPACE, - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "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(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT) - .withMapping(RANGE_INVALID, GrpcStatusCode.OUT_OF_RANGE); - - @Test - void safeDefaultsExposeOnlyMachineIdentity() throws Exception { - Outcome outcome = paymentDeclinedOutcome(); - GoogleRpcOutcomeMapper mapper = mapper(GrpcOutcomeExposure.safeDefaults()); - - com.google.rpc.Status status = mapper.map(outcome).orNull(); - - assertNotNull(status); - assertEquals(9, status.getCode()); - assertEquals(outcome.getCode().getValue(), status.getMessage()); - assertEquals(1, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals(outcome.getCode().getNamespace(), info.getDomain()); - assertEquals(outcome.getCode().getName(), info.getReason()); - } - - @Test - 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("CHECKOUT_INVALID", info.getReason()); - - BadRequest request = status.getDetails(1).unpack(BadRequest.class); - assertEquals(2, request.getFieldViolationsCount()); - - BadRequest.FieldViolation coded = request.getFieldViolations(0); - assertEquals("paymentMethod", coded.getField()); - assertEquals("PAYMENT_METHOD_INVALID", coded.getReason()); - assertEquals("Payment method is invalid.", coded.getDescription()); - - BadRequest.FieldViolation uncoded = request.getFieldViolations(1); - assertEquals("amount", uncoded.getField()); - assertEquals("", uncoded.getReason()); - assertEquals("Amount must be positive.", uncoded.getDescription()); - } - - @Test - void explicitDetailExposureAddsDebugInfo() throws Exception { - Outcome outcome = validationOutcome(); - GoogleRpcOutcomeMapper mapper = mapper(new GrpcOutcomeExposure(true, true, true)); - - com.google.rpc.Status status = mapper.map(outcome).orNull(); - - assertNotNull(status); - assertEquals(3, status.getDetailsCount()); - DebugInfo debugInfo = status.getDetails(1).unpack(DebugInfo.class); - assertEquals("gateway_token=secret-123", debugInfo.getDetail()); - 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( - "com.example.compatibility", - "A".repeat(64), - OutcomeState.FAILED, - "Incompatible reason." - ); - GoogleRpcOutcomeMapper mapper = mapperFor( - incompatible, - GrpcOutcomeExposure.safeDefaults() - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper.map(Outcome.of(incompatible)) - ); - - assertTrue(exception.getMessage().contains("at most 63")); - assertTrue(exception.getMessage().contains(incompatible.getCode().getValue())); - } - - @Test - void rejectsTrailingUnderscoreThatCannotBeErrorInfoReason() { - OutcomeDefinition incompatible = OutcomeDefinition.custom( - "com.example.compatibility", - "PAYMENT_DECLINED_", - OutcomeState.FAILED, - "Incompatible reason." - ); - GoogleRpcOutcomeMapper mapper = mapperFor( - incompatible, - GrpcOutcomeExposure.safeDefaults() - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper.map(Outcome.of(incompatible)) - ); - - assertTrue(exception.getMessage().contains("[A-Z][A-Z0-9_]+[A-Z0-9]")); - } - - @Test - void rejectsCodedIssueFromDifferentDomainInsteadOfChangingItsIdentity() { - OutcomeCode differentDomain = OutcomeCode.of( - "com.example.validation", - "PAYMENT_METHOD_INVALID" - ); - Outcome outcome = Outcome.of( - CHECKOUT_INVALID, - null, - List.of( - Issue.at( - "paymentMethod", - differentDomain, - "Payment method is invalid." - ) - ) - ); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> mapper(GrpcOutcomeExposure.publicErrors()).map(outcome) - ); - - assertTrue(exception.getMessage().contains("same namespace")); - assertTrue(exception.getMessage().contains(differentDomain.getValue())); - } - - @Test - void richErrorMapperRejectsSuccessfulOutcomes() { - assertThrows( - IllegalArgumentException.class, - () -> GoogleRpcOutcomeMapper.safeDefaults().map( - Outcome.of(io.github.aalsanie.codes.StandardOutcomes.OK) - ) - ); - } - - @Test - void exposurePoliciesAreExplicit() { - assertEquals( - new GrpcOutcomeExposure(false, false, false), - GrpcOutcomeExposure.safeDefaults() - ); - assertEquals( - new GrpcOutcomeExposure(true, false, true), - GrpcOutcomeExposure.publicErrors() - ); - assertFalse(GrpcOutcomeExposure.safeDefaults().exposeIssues()); - assertTrue(GrpcOutcomeExposure.publicErrors().exposeIssues()); - } - - private static Outcome validationOutcome() { - return Outcome.of( - CHECKOUT_INVALID, - "gateway_token=secret-123", - List.of( - Issue.at( - "paymentMethod", - PAYMENT_METHOD_INVALID, - "Payment method is invalid." - ), - Issue.at("amount", "Amount must be positive.") - ) - ); - } - - 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); - } - - private static GoogleRpcOutcomeMapper mapperFor( - OutcomeDefinition definition, - GrpcOutcomeExposure exposure - ) { - return new GoogleRpcOutcomeMapper( - GrpcOutcomeMapper.standard() - .withMapping(definition, GrpcStatusCode.INTERNAL), - exposure - ); - } -} diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java deleted file mode 100644 index 13143a3..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusMapperTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.grpc.Status; -import org.junit.jupiter.api.Test; - -class GrpcStatusMapperTest { - @Test - void mapsCoreGrpcStatusToGrpcJava() { - assertEquals(Status.Code.NOT_FOUND, GrpcStatusMapper.standard().map(StandardOutcomes.NOT_FOUND).orNull().getCode()); - } - - @Test - void preservesApplicationMappingPolicy() { - OutcomeDefinition rejected = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - GrpcOutcomeMapper core = GrpcOutcomeMapper.standard().withMapping(rejected, GrpcStatusCode.FAILED_PRECONDITION); - assertEquals(Status.Code.FAILED_PRECONDITION, new GrpcStatusMapper(core).map(rejected).orNull().getCode()); - assertTrue(GrpcStatusMapper.standard().map(rejected).isUnmapped()); - } -} diff --git a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java b/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java deleted file mode 100644 index 8e44c92..0000000 --- a/codes-grpc-java/src/test/java/io/github/aalsanie/codes/grpc/GrpcStatusRuntimeExceptionRoundTripTest.java +++ /dev/null @@ -1,240 +0,0 @@ -package io.github.aalsanie.codes.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.protobuf.Any; -import com.google.protobuf.InvalidProtocolBufferException; -import com.google.rpc.BadRequest; -import com.google.rpc.DebugInfo; -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.grpc.StatusRuntimeException; -import io.grpc.protobuf.StatusProto; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; - -class GrpcStatusRuntimeExceptionRoundTripTest { - private static final String APP_NAMESPACE = "com.example.checkout"; - 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 CHECKOUT_INVALID = OutcomeDefinition.custom( - APP_NAMESPACE, - "CHECKOUT_INVALID", - OutcomeState.FAILED, - "Checkout request 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(CHECKOUT_INVALID, GrpcStatusCode.INVALID_ARGUMENT); - - @Test - void decodedStatusRuntimeExceptionsMatchGoldenContract() throws Exception { - Outcome outcome = testOutcome(); - - com.google.rpc.Status safe = roundTrip( - outcome, - new GoogleRpcOutcomeMapper(APP_MAPPER, GrpcOutcomeExposure.safeDefaults()) - ); - com.google.rpc.Status publicErrors = roundTrip( - outcome, - new GoogleRpcOutcomeMapper(APP_MAPPER, GrpcOutcomeExposure.publicErrors()) - ); - com.google.rpc.Status explicit = roundTrip( - outcome, - new GoogleRpcOutcomeMapper( - APP_MAPPER, - new GrpcOutcomeExposure(true, true, true) - ) - ); - - assertIdentity(safe, outcome.getCode()); - assertIdentity(publicErrors, outcome.getCode()); - assertIdentity(explicit, outcome.getCode()); - - assertProtectedPayloadAbsent(safe); - assertProtectedDetailAbsent(publicErrors); - assertEquals(PROTECTED_DETAIL, debugInfo(explicit).orElseThrow().getDetail()); - - String actual = String.join( - "\n\n", - snapshot("safe", safe), - snapshot("public", publicErrors), - snapshot("explicit", explicit) - ); - - String expected = Files.readString( - Path.of(System.getProperty("codes.grpcStatusSnapshot")) - ).replace("\r\n", "\n").stripTrailing(); - - assertEquals(expected, actual); - } - - private static com.google.rpc.Status roundTrip( - Outcome outcome, - GoogleRpcOutcomeMapper mapper - ) { - StatusRuntimeException exception = GrpcOutcomeExceptions - .toStatusRuntimeException(outcome, mapper) - .orNull(); - - assertNotNull(exception); - assertEquals( - io.grpc.Status.Code.INVALID_ARGUMENT, - exception.getStatus().getCode() - ); - - com.google.rpc.Status decoded = StatusProto.fromThrowable(exception); - assertNotNull(decoded); - return decoded; - } - - private static void assertIdentity( - com.google.rpc.Status status, - OutcomeCode expected - ) throws InvalidProtocolBufferException { - ErrorInfo info = errorInfo(status); - OutcomeCode decoded = OutcomeCode.of(info.getDomain(), info.getReason()); - assertEquals(expected, decoded); - } - - private static void assertProtectedPayloadAbsent(com.google.rpc.Status status) { - assertEquals(1, status.getDetailsCount()); - assertTrue(debugInfo(status).isEmpty()); - assertTrue(badRequest(status).isEmpty()); - - String serialized = new String( - status.toByteArray(), - StandardCharsets.ISO_8859_1 - ); - assertFalse(serialized.contains(PROTECTED_DETAIL)); - assertFalse(serialized.contains(PROTECTED_ISSUE_MESSAGE)); - } - - private static void assertProtectedDetailAbsent(com.google.rpc.Status status) { - assertTrue(debugInfo(status).isEmpty()); - - String serialized = new String( - status.toByteArray(), - StandardCharsets.ISO_8859_1 - ); - assertFalse(serialized.contains(PROTECTED_DETAIL)); - } - - private static String snapshot( - String name, - com.google.rpc.Status status - ) throws InvalidProtocolBufferException { - ErrorInfo info = errorInfo(status); - Optional debugInfo = debugInfo(status); - Optional badRequest = badRequest(status); - - StringBuilder result = new StringBuilder(); - result.append('[').append(name).append("]\n") - .append("code=").append(status.getCode()).append('\n') - .append("message=").append(status.getMessage()).append('\n') - .append("details=").append(status.getDetailsCount()).append('\n') - .append("identity.domain=").append(info.getDomain()).append('\n') - .append("identity.reason=").append(info.getReason()).append('\n') - .append("debug=") - .append(debugInfo.map(DebugInfo::getDetail).orElse("")) - .append('\n'); - - if (badRequest.isEmpty()) { - result.append("issues=0"); - return result.toString(); - } - - BadRequest request = badRequest.orElseThrow(); - result.append("issues=").append(request.getFieldViolationsCount()); - - for (int index = 0; index < request.getFieldViolationsCount(); index++) { - BadRequest.FieldViolation violation = request.getFieldViolations(index); - result.append('\n') - .append("issue.").append(index).append(".field=") - .append(orAbsent(violation.getField())) - .append('\n') - .append("issue.").append(index).append(".reason=") - .append(orAbsent(violation.getReason())) - .append('\n') - .append("issue.").append(index).append(".description=") - .append(violation.getDescription()); - } - - return result.toString(); - } - - private static ErrorInfo errorInfo( - com.google.rpc.Status status - ) throws InvalidProtocolBufferException { - for (Any detail : status.getDetailsList()) { - if (detail.is(ErrorInfo.class)) { - return detail.unpack(ErrorInfo.class); - } - } - throw new IllegalStateException("decoded google.rpc.Status is missing ErrorInfo"); - } - - private static Optional debugInfo(com.google.rpc.Status status) { - for (Any detail : status.getDetailsList()) { - if (detail.is(DebugInfo.class)) { - try { - return Optional.of(detail.unpack(DebugInfo.class)); - } catch (InvalidProtocolBufferException exception) { - throw new IllegalStateException("invalid DebugInfo detail", exception); - } - } - } - return Optional.empty(); - } - - private static Optional badRequest(com.google.rpc.Status status) { - for (Any detail : status.getDetailsList()) { - if (detail.is(BadRequest.class)) { - try { - return Optional.of(detail.unpack(BadRequest.class)); - } catch (InvalidProtocolBufferException exception) { - throw new IllegalStateException("invalid BadRequest detail", exception); - } - } - } - return Optional.empty(); - } - - private static String orAbsent(String value) { - return value.isEmpty() ? "" : value; - } - - private static Outcome testOutcome() { - return Outcome.of( - CHECKOUT_INVALID, - PROTECTED_DETAIL, - List.of( - Issue.at( - "paymentMethod", - PAYMENT_METHOD_INVALID, - PROTECTED_ISSUE_MESSAGE - ), - Issue.at("amount", "Amount must be positive.") - ) - ); - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java deleted file mode 100644 index af35a09..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptions.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import java.util.Objects; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.ErrorResponseException; - -/** - * Thin Spring exception bridge for failed Codes outcomes. - */ -public final class SpringOutcomeExceptions { - private SpringOutcomeExceptions() { - } - - public static MappingResult toErrorResponseException( - Outcome outcome, - OutcomeProblemDetailMapper mapper - ) { - Objects.requireNonNull(outcome, "outcome"); - Objects.requireNonNull(mapper, "mapper"); - - return mapper.map(outcome).fold( - problem -> MappingResult.mapped( - new ErrorResponseException( - HttpStatusCode.valueOf(problem.getStatus()), - problem, - null - ) - ), - MappingResult::unmapped - ); - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java deleted file mode 100644 index 456f782..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringOutcomeExposure.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.aalsanie.codes.spring; - -public record SpringOutcomeExposure( - boolean exposeMessage, - boolean exposeDetail, - boolean exposeIssues -) { - public static SpringOutcomeExposure safeDefaults() { - return new SpringOutcomeExposure(false, false, false); - } - - public static SpringOutcomeExposure publicErrors() { - return new SpringOutcomeExposure(true, false, true); - } -} diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java deleted file mode 100644 index f5f415e..0000000 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapper.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import java.net.URI; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Immutable application-owned mapping from Codes outcome identity to RFC 9457 problem type URI. - * - *

Codes does not assign or invent problem type URIs. Applications opt in by registering the - * URIs whose ownership and lifecycle they control. The RFC-defined {@code about:blank} type is - * implicit and therefore cannot be registered as an application-owned problem type. - */ -public final class SpringProblemTypeUriMapper { - private static final URI ABOUT_BLANK = URI.create("about:blank"); - - private final Map mappings; - - private SpringProblemTypeUriMapper(Map mappings) { - this.mappings = Collections.unmodifiableMap(new LinkedHashMap<>(mappings)); - } - - public static SpringProblemTypeUriMapper empty() { - return new SpringProblemTypeUriMapper(Map.of()); - } - - public MappingResult map(OutcomeDefinition definition) { - Objects.requireNonNull(definition, "definition"); - URI type = mappings.get(definition.getCode()); - return type == null ? MappingResult.unmapped() : MappingResult.mapped(type); - } - - public MappingResult map(Outcome outcome) { - return map(Objects.requireNonNull(outcome, "outcome").getDefinition()); - } - - public SpringProblemTypeUriMapper withMapping(OutcomeDefinition definition, URI type) { - Objects.requireNonNull(definition, "definition"); - Objects.requireNonNull(type, "type"); - if (ABOUT_BLANK.equals(type)) { - throw new IllegalArgumentException( - "about:blank is the implicit RFC 9457 default and cannot be application-owned" - ); - } - - OutcomeCode code = definition.getCode(); - if (mappings.containsKey(code)) { - throw new IllegalArgumentException("problem type mapping already exists for outcome code: " + code); - } - - LinkedHashMap next = new LinkedHashMap<>(mappings); - next.put(code, type); - return new SpringProblemTypeUriMapper(next); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java deleted file mode 100644 index 4c5c3bc..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/OutcomeProblemDetailMapperTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import java.net.URI; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.http.ProblemDetail; - -class OutcomeProblemDetailMapperTest { - private static final URI ABOUT_BLANK = URI.create("about:blank"); - - @Test - void safeDefaultsExposeOnlyRfcDefaultsAndMachineCode() { - Outcome outcome = Outcome.of( - StandardOutcomes.NOT_FOUND, - "database shard=7 customerId=42" - ); - - ProblemDetail problem = OutcomeProblemDetailMapper.safeDefaults().map(outcome).orNull(); - - assertEquals(404, problem.getStatus()); - assertAboutBlank(problem.getType()); - assertEquals("Not Found", problem.getTitle()); - assertNull(problem.getDetail()); - assertEquals(outcome.getCode().getValue(), problem.getProperties().get("code")); - assertFalse(problem.getProperties().containsKey("outcomeDetail")); - assertFalse(problem.getProperties().containsKey("issues")); - } - - @Test - void applicationOwnedTypeUsesStableMessageAsTitleAndOccurrenceDetailAsRfcDetail() { - Outcome outcome = Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - "The email address cannot receive mail.", - List.of(Issue.at("email", "Invalid email address.")) - ); - URI type = URI.create("https://api.example.test/problems/invalid-argument"); - OutcomeProblemDetailMapper mapper = new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - new SpringOutcomeExposure(true, true, true), - SpringProblemTypeUriMapper.empty() - .withMapping(StandardOutcomes.INVALID_ARGUMENT, type) - ); - - ProblemDetail problem = mapper.map(outcome).orNull(); - - assertEquals(type, problem.getType()); - assertEquals(outcome.getMessage(), problem.getTitle()); - assertEquals(outcome.getDetail(), problem.getDetail()); - assertFalse(problem.getProperties().containsKey("outcomeDetail")); - - List issues = (List) problem.getProperties().get("issues"); - assertEquals(1, issues.size()); - Map issue = (Map) issues.get(0); - assertEquals("email", issue.get("path")); - assertEquals("Invalid email address.", issue.get("message")); - } - - @Test - void applicationOwnedTypeDoesNotReuseHttpStatusTitleWhenMessageIsHidden() { - Outcome outcome = Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - "sensitive occurrence detail" - ); - URI type = URI.create("https://api.example.test/problems/invalid-argument"); - OutcomeProblemDetailMapper mapper = new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - SpringOutcomeExposure.safeDefaults(), - SpringProblemTypeUriMapper.empty() - .withMapping(StandardOutcomes.INVALID_ARGUMENT, type) - ); - - ProblemDetail problem = mapper.map(outcome).orNull(); - - assertEquals(type, problem.getType()); - assertNull(problem.getTitle()); - assertNull(problem.getDetail()); - assertEquals(outcome.getCode().getValue(), problem.getProperties().get("code")); - } - - @Test - void aboutBlankKeepsHttpTitleEvenWhenPublicMessageExposureIsEnabled() { - Outcome outcome = Outcome.of(StandardOutcomes.INVALID_ARGUMENT); - OutcomeProblemDetailMapper mapper = new OutcomeProblemDetailMapper( - SpringHttpStatusMapper.standard(), - SpringOutcomeExposure.publicErrors() - ); - - ProblemDetail problem = mapper.map(outcome).orNull(); - - assertAboutBlank(problem.getType()); - assertEquals("Bad Request", problem.getTitle()); - assertEquals(outcome.getCode().getValue(), problem.getProperties().get("code")); - } - - @Test - void preservesExplicitUnmappedResultAndRejectsSuccessProblems() { - assertTrue( - OutcomeProblemDetailMapper.safeDefaults() - .map(Outcome.of(StandardOutcomes.FAILED_PRECONDITION)) - .isUnmapped() - ); - assertThrows( - IllegalArgumentException.class, - () -> OutcomeProblemDetailMapper.safeDefaults().map(Outcome.of(StandardOutcomes.OK)) - ); - } - - @Test - void namedExposurePoliciesRemainConservative() { - assertEquals( - new SpringOutcomeExposure(false, false, false), - SpringOutcomeExposure.safeDefaults() - ); - assertEquals( - new SpringOutcomeExposure(true, false, true), - SpringOutcomeExposure.publicErrors() - ); - } - - private static void assertAboutBlank(URI type) { - assertTrue(type == null || ABOUT_BLANK.equals(type)); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java deleted file mode 100644 index 0b2b914..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpGoldenResponseTest.java +++ /dev/null @@ -1,412 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.test.web.reactive.server.EntityExchangeResult; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.test.web.servlet.client.MockMvcWebTestClient; -import org.springframework.web.ErrorResponseException; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RestController; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.json.JsonMapper; - -class SpringHttpGoldenResponseTest { - private static final String ABSENT = ""; - private static final String ABOUT_BLANK = "about:blank"; - private static final JsonMapper JSON = JsonMapper.builder().build(); - - private static final List GOLDEN_CASES = List.of( - "safe", - "public", - "custom", - "validation", - "unmapped", - "sensitive-detail", - "custom-safe" - ); - - private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - "com.example.checkout", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." - ); - - private static final SpringHttpStatusMapper STATUS_MAPPER = new SpringHttpStatusMapper( - HttpOutcomeMapper.standard() - .withMapping(PAYMENT_DECLINED, HttpStatusCode.of(422)) - ); - - private static final SpringProblemTypeUriMapper TYPE_MAPPER = - SpringProblemTypeUriMapper.empty() - .withMapping( - StandardOutcomes.INVALID_ARGUMENT, - URI.create("https://api.example.test/problems/invalid-argument") - ) - .withMapping( - PAYMENT_DECLINED, - URI.create("https://api.example.test/problems/payment-declined") - ); - - private static final OutcomeProblemDetailMapper PUBLIC_MAPPER = - new OutcomeProblemDetailMapper( - STATUS_MAPPER, - SpringOutcomeExposure.publicErrors(), - TYPE_MAPPER - ); - - private static final OutcomeProblemDetailMapper DETAIL_MAPPER = - new OutcomeProblemDetailMapper( - STATUS_MAPPER, - new SpringOutcomeExposure(true, true, false), - TYPE_MAPPER - ); - - private static final OutcomeProblemDetailMapper CUSTOM_SAFE_MAPPER = - new OutcomeProblemDetailMapper( - STATUS_MAPPER, - SpringOutcomeExposure.safeDefaults(), - TYPE_MAPPER - ); - - @Test - void mvcRenderedHttpResponsesMatchGoldenContract() throws Exception { - WebTestClient client = MockMvcWebTestClient - .bindToController(new WireController()) - .controllerAdvice(new MvcProblemAdvice()) - .build(); - - assertGoldenResponses("mvc", client); - } - - @Test - void webFluxRenderedHttpResponsesMatchGoldenContract() throws Exception { - WebTestClient client = WebTestClient - .bindToController(new WireController()) - .controllerAdvice(new WebFluxProblemAdvice()) - .build(); - - assertGoldenResponses("webflux", client); - } - - private static void assertGoldenResponses(String stack, WebTestClient client) throws Exception { - Map> expected = readGoldenContract(); - assertEquals(GOLDEN_CASES, List.copyOf(expected.keySet()), stack + " golden case order"); - - for (String caseName : GOLDEN_CASES) { - assertResponse(stack, caseName, expected.get(caseName), client); - } - } - - private static void assertResponse( - String stack, - String caseName, - Map expected, - WebTestClient client - ) throws Exception { - String context = stack + "/" + caseName; - String path = "/" + caseName; - - EntityExchangeResult response = client.get() - .uri(path) - .accept(MediaType.APPLICATION_PROBLEM_JSON) - .exchange() - .expectBody() - .returnResult(); - - int expectedStatus = Integer.parseInt(required(expected, "status")); - assertEquals(expectedStatus, response.getStatus().value(), context + " HTTP status"); - - MediaType contentType = response.getResponseHeaders().getContentType(); - assertNotNull(contentType, context + " content type"); - MediaType expectedContentType = MediaType.parseMediaType(required(expected, "contentType")); - assertTrue( - expectedContentType.isCompatibleWith(contentType), - () -> context + " content type expected " + expectedContentType + " but was " + contentType - ); - - byte[] responseBody = Objects.requireNonNull( - response.getResponseBody(), - context + " response body" - ); - String rawBody = new String(responseBody, StandardCharsets.UTF_8); - JsonNode body = Objects.requireNonNull(JSON.readTree(responseBody), context + " JSON body"); - - assertEquals(expectedStatus, body.path("status").asInt(), context + " problem status"); - assertProblemType(body, required(expected, "type"), context); - assertTextField(body, "title", required(expected, "title"), context); - assertTextField(body, "detail", required(expected, "detail"), context); - assertTextField(body, "instance", required(expected, "instance"), context); - assertTextField(body, "code", required(expected, "code"), context); - - assertFalse(body.has("properties"), context + " must flatten ProblemDetail extensions"); - assertFalse(body.has("outcomeDetail"), context + " must use RFC detail"); - - assertIssues(body, expected, context); - assertSensitiveValuesAreNotRendered(caseName, rawBody, context); - } - - private static void assertProblemType(JsonNode body, String expected, String context) { - JsonNode type = body.get("type"); - - if (ABOUT_BLANK.equals(expected)) { - if (type != null) { - assertFalse(type.isNull(), context + " type must be absent or about:blank"); - assertEquals(ABOUT_BLANK, type.stringValue(), context + " problem type"); - } - return; - } - - assertNotNull(type, context + " problem type"); - assertFalse(type.isNull(), context + " problem type"); - assertEquals(expected, type.stringValue(), context + " problem type"); - } - - private static void assertTextField( - JsonNode body, - String field, - String expected, - String context - ) { - JsonNode value = body.get(field); - - if (ABSENT.equals(expected)) { - assertTrue( - value == null, - () -> context + " expected absent '" + field + "' but body contained " + value - ); - return; - } - - assertNotNull(value, context + " missing '" + field + "'"); - assertFalse(value.isNull(), context + " null '" + field + "'"); - assertEquals(expected, value.stringValue(), context + " '" + field + "'"); - } - - private static void assertIssues( - JsonNode body, - Map expected, - String context - ) { - int expectedCount = Integer.parseInt(required(expected, "issues")); - JsonNode issues = body.get("issues"); - - if (expectedCount == 0) { - assertTrue(issues == null, context + " issues must be absent"); - return; - } - - assertNotNull(issues, context + " missing issues"); - assertTrue(issues.isArray(), context + " issues must be a JSON array"); - assertEquals(expectedCount, issues.size(), context + " issue count"); - - for (int index = 0; index < expectedCount; index++) { - JsonNode issue = issues.get(index); - assertNotNull(issue, context + " missing issue " + index); - assertTextField( - issue, - "code", - required(expected, "issue." + index + ".code"), - context + " issue " + index - ); - assertTextField( - issue, - "path", - required(expected, "issue." + index + ".path"), - context + " issue " + index - ); - assertTextField( - issue, - "message", - required(expected, "issue." + index + ".message"), - context + " issue " + index - ); - } - } - - private static void assertSensitiveValuesAreNotRendered( - String caseName, - String rawBody, - String context - ) { - String sensitiveValue = switch (caseName) { - case "safe" -> "database shard=7 customerId=42"; - case "validation" -> "raw request body contained account metadata"; - case "sensitive-detail" -> "gateway_token=secret-123"; - case "custom-safe" -> "gateway_token=custom-safe-secret"; - default -> null; - }; - - if (sensitiveValue != null) { - assertFalse( - rawBody.contains(sensitiveValue), - context + " leaked sensitive occurrence detail" - ); - } - } - - private static Map> readGoldenContract() throws IOException { - List lines = Files.readAllLines( - Path.of(System.getProperty("codes.springHttpSnapshot")) - ); - LinkedHashMap> result = new LinkedHashMap<>(); - Map current = null; - - for (String rawLine : lines) { - String line = rawLine.strip(); - if (line.isEmpty() || line.startsWith("#")) { - continue; - } - - if (line.startsWith("[") && line.endsWith("]")) { - String name = line.substring(1, line.length() - 1); - if (name.isBlank() || result.containsKey(name)) { - throw new IllegalStateException("Invalid duplicate or blank golden case: " + line); - } - current = new LinkedHashMap<>(); - result.put(name, current); - continue; - } - - if (current == null) { - throw new IllegalStateException("Golden property appears before a case: " + line); - } - - int separator = line.indexOf('='); - if (separator <= 0) { - throw new IllegalStateException("Invalid golden property: " + line); - } - - String key = line.substring(0, separator); - String value = line.substring(separator + 1); - if (current.put(key, value) != null) { - throw new IllegalStateException("Duplicate golden property: " + key); - } - } - - return result; - } - - private static String required(Map values, String key) { - String value = values.get(key); - if (value == null) { - throw new IllegalStateException("Missing golden property: " + key); - } - return value; - } - - @RestController - static final class WireController { - @GetMapping("/{caseName}") - Object render(@PathVariable("caseName") String caseName) { - return switch (caseName) { - case "safe" -> throw mappedException( - Outcome.of( - StandardOutcomes.NOT_FOUND, - "database shard=7 customerId=42" - ), - OutcomeProblemDetailMapper.safeDefaults() - ); - case "public" -> throw mappedException( - Outcome.of(StandardOutcomes.INVALID_ARGUMENT), - PUBLIC_MAPPER - ); - case "custom" -> throw mappedException( - Outcome.of( - PAYMENT_DECLINED, - "Ask the customer to use another payment method." - ), - DETAIL_MAPPER - ); - case "validation" -> throw mappedException( - Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - "raw request body contained account metadata", - List.of( - Issue.at("email", "Invalid email address."), - Issue.at("quantity", "Must be greater than zero.") - ) - ), - PUBLIC_MAPPER - ); - case "unmapped" -> unmappedResponse( - Outcome.of(StandardOutcomes.FAILED_PRECONDITION) - ); - case "sensitive-detail" -> throw mappedException( - Outcome.of(PAYMENT_DECLINED, "gateway_token=secret-123"), - PUBLIC_MAPPER - ); - case "custom-safe" -> throw mappedException( - Outcome.of(PAYMENT_DECLINED, "gateway_token=custom-safe-secret"), - CUSTOM_SAFE_MAPPER - ); - default -> throw new IllegalArgumentException("Unknown golden case: " + caseName); - }; - } - - private static ErrorResponseException mappedException( - Outcome outcome, - OutcomeProblemDetailMapper mapper - ) { - MappingResult result = - SpringOutcomeExceptions.toErrorResponseException(outcome, mapper); - ErrorResponseException exception = result.orNull(); - if (exception == null) { - throw new IllegalStateException("Expected mapped Spring error response for " + outcome.getCode()); - } - return exception; - } - - private static ResponseEntity unmappedResponse(Outcome outcome) { - MappingResult result = - SpringOutcomeExceptions.toErrorResponseException(outcome, PUBLIC_MAPPER); - if (result.isMapped()) { - throw new IllegalStateException("Expected unmapped Spring error response for " + outcome.getCode()); - } - - ProblemDetail fallback = ProblemDetail.forStatus(500); - fallback.setProperty( - OutcomeProblemDetailMapper.CODE_PROPERTY, - outcome.getCode().getValue() - ); - return ResponseEntity.internalServerError().body(fallback); - } - } - - @ControllerAdvice - static final class MvcProblemAdvice - extends org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler { - } - - @ControllerAdvice - static final class WebFluxProblemAdvice - extends org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler { - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java deleted file mode 100644 index 022adc9..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringHttpStatusMapperTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import org.junit.jupiter.api.Test; - -class SpringHttpStatusMapperTest { - @Test - void mapsCoreHttpStatusToSpringStatus() { - SpringHttpStatusMapper mapper = SpringHttpStatusMapper.standard(); - assertEquals(404, mapper.map(StandardOutcomes.NOT_FOUND).orNull().value()); - assertTrue(mapper.map(StandardOutcomes.FAILED_PRECONDITION).isUnmapped()); - } - - @Test - void acceptsApplicationHttpPolicy() { - OutcomeDefinition rejected = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "Rejected." - ); - HttpOutcomeMapper core = HttpOutcomeMapper.standard().withMapping(rejected, HttpStatusCode.of(422)); - assertEquals(422, new SpringHttpStatusMapper(core).map(rejected).orNull().value()); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java deleted file mode 100644 index 6d68610..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringOutcomeExceptionsTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import org.junit.jupiter.api.Test; -import org.springframework.web.ErrorResponseException; - -class SpringOutcomeExceptionsTest { - @Test - void convertsMappedFailureWithoutChangingProblemBody() { - Outcome outcome = Outcome.of(StandardOutcomes.NOT_FOUND); - MappingResult result = - SpringOutcomeExceptions.toErrorResponseException( - outcome, - OutcomeProblemDetailMapper.safeDefaults() - ); - - ErrorResponseException exception = result.orNull(); - - assertEquals(404, exception.getStatusCode().value()); - assertEquals(404, exception.getBody().getStatus()); - assertEquals( - outcome.getCode().getValue(), - exception.getBody().getProperties().get(OutcomeProblemDetailMapper.CODE_PROPERTY) - ); - } - - @Test - void preservesUnmappedOutcome() { - assertTrue( - SpringOutcomeExceptions.toErrorResponseException( - Outcome.of(StandardOutcomes.FAILED_PRECONDITION), - OutcomeProblemDetailMapper.safeDefaults() - ).isUnmapped() - ); - } - - @Test - void rejectsSuccessfulOutcome() { - assertThrows( - IllegalArgumentException.class, - () -> SpringOutcomeExceptions.toErrorResponseException( - Outcome.of(StandardOutcomes.OK), - OutcomeProblemDetailMapper.safeDefaults() - ) - ); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java deleted file mode 100644 index d308ad5..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringProblemTypeUriMapperTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; -import java.net.URI; -import org.junit.jupiter.api.Test; - -class SpringProblemTypeUriMapperTest { - private static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - "com.example.checkout", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." - ); - - @Test - void emptyMapperIsExplicitlyUnmapped() { - assertTrue(SpringProblemTypeUriMapper.empty().map(PAYMENT_DECLINED).isUnmapped()); - } - - @Test - void mapsDefinitionsAndOccurrencesByStableOutcomeCode() { - URI type = URI.create("https://api.example.test/problems/payment-declined"); - SpringProblemTypeUriMapper mapper = SpringProblemTypeUriMapper.empty() - .withMapping(PAYMENT_DECLINED, type); - OutcomeDefinition sameIdentity = OutcomeDefinition.custom( - "com.example.checkout", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "A different human-readable message." - ); - - assertEquals(type, mapper.map(sameIdentity).orNull()); - assertEquals(type, mapper.map(Outcome.of(sameIdentity)).orNull()); - } - - @Test - void aboutBlankCannotBeRegisteredAsApplicationOwnedProblemType() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> SpringProblemTypeUriMapper.empty() - .withMapping(PAYMENT_DECLINED, URI.create("about:blank")) - ); - - assertTrue(exception.getMessage().contains("about:blank")); - } - - @Test - void duplicateApplicationMappingIsRejected() { - URI type = URI.create("https://api.example.test/problems/payment-declined"); - SpringProblemTypeUriMapper mapper = SpringProblemTypeUriMapper.empty() - .withMapping(PAYMENT_DECLINED, type); - - assertThrows( - IllegalArgumentException.class, - () -> mapper.withMapping(PAYMENT_DECLINED, type) - ); - } -} diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java deleted file mode 100644 index 83e37c9..0000000 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/SpringStackCompatibilityContractTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.aalsanie.codes.spring; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.ErrorResponseException; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.server.ServerWebExchange; - -class SpringStackCompatibilityContractTest { - @Test - void mvcResponseEntityExceptionHandlerAcceptsTheAdapterBridgeType() throws Exception { - assertErrorResponseHook( - org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.class, - WebRequest.class - ); - } - - @Test - void webFluxResponseEntityExceptionHandlerAcceptsTheAdapterBridgeType() throws Exception { - assertErrorResponseHook( - org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler.class, - ServerWebExchange.class - ); - } - - private static void assertErrorResponseHook( - Class handlerType, - Class requestType - ) throws NoSuchMethodException { - Method method = handlerType.getDeclaredMethod( - "handleErrorResponseException", - ErrorResponseException.class, - HttpHeaders.class, - HttpStatusCode.class, - requestType - ); - - assertTrue(Modifier.isProtected(method.getModifiers())); - } -} diff --git a/compatibility/grpc-google-rpc-status.snapshot b/compatibility/grpc-google-rpc-status.snapshot deleted file mode 100644 index 37a000e..0000000 --- a/compatibility/grpc-google-rpc-status.snapshot +++ /dev/null @@ -1,38 +0,0 @@ -[safe] -code=3 -message=com.example.checkout:CHECKOUT_INVALID -details=1 -identity.domain=com.example.checkout -identity.reason=CHECKOUT_INVALID -debug= -issues=0 - -[public] -code=3 -message=Checkout request is invalid. -details=2 -identity.domain=com.example.checkout -identity.reason=CHECKOUT_INVALID -debug= -issues=2 -issue.0.field=paymentMethod -issue.0.reason=PAYMENT_METHOD_INVALID -issue.0.description=Payment method is invalid. -issue.1.field=amount -issue.1.reason= -issue.1.description=Amount must be positive. - -[explicit] -code=3 -message=Checkout request is invalid. -details=3 -identity.domain=com.example.checkout -identity.reason=CHECKOUT_INVALID -debug=gateway_token=secret-123 -issues=2 -issue.0.field=paymentMethod -issue.0.reason=PAYMENT_METHOD_INVALID -issue.0.description=Payment method is invalid. -issue.1.field=amount -issue.1.reason= -issue.1.description=Amount must be positive. diff --git a/compatibility/grpc-mappings.snapshot b/compatibility/grpc-mappings.snapshot deleted file mode 100644 index d650fc4..0000000 --- a/compatibility/grpc-mappings.snapshot +++ /dev/null @@ -1,17 +0,0 @@ -io.github.aalsanie.codes.standard:ABORTED|ABORTED -io.github.aalsanie.codes.standard:ALREADY_EXISTS|ALREADY_EXISTS -io.github.aalsanie.codes.standard:CANCELLED|CANCELLED -io.github.aalsanie.codes.standard:DATA_LOSS|DATA_LOSS -io.github.aalsanie.codes.standard:DEADLINE_EXCEEDED|DEADLINE_EXCEEDED -io.github.aalsanie.codes.standard:FAILED_PRECONDITION|FAILED_PRECONDITION -io.github.aalsanie.codes.standard:INTERNAL|INTERNAL -io.github.aalsanie.codes.standard:INVALID_ARGUMENT|INVALID_ARGUMENT -io.github.aalsanie.codes.standard:NOT_FOUND|NOT_FOUND -io.github.aalsanie.codes.standard:OK|OK -io.github.aalsanie.codes.standard:OUT_OF_RANGE|OUT_OF_RANGE -io.github.aalsanie.codes.standard:PERMISSION_DENIED|PERMISSION_DENIED -io.github.aalsanie.codes.standard:RATE_LIMITED|RESOURCE_EXHAUSTED -io.github.aalsanie.codes.standard:RESOURCE_EXHAUSTED|RESOURCE_EXHAUSTED -io.github.aalsanie.codes.standard:UNAUTHENTICATED|UNAUTHENTICATED -io.github.aalsanie.codes.standard:UNAVAILABLE|UNAVAILABLE -io.github.aalsanie.codes.standard:UNIMPLEMENTED|UNIMPLEMENTED \ No newline at end of file diff --git a/compatibility/grpc-status-codes.snapshot b/compatibility/grpc-status-codes.snapshot deleted file mode 100644 index 0ec68b1..0000000 --- a/compatibility/grpc-status-codes.snapshot +++ /dev/null @@ -1,17 +0,0 @@ -OK|0 -CANCELLED|1 -UNKNOWN|2 -INVALID_ARGUMENT|3 -DEADLINE_EXCEEDED|4 -NOT_FOUND|5 -ALREADY_EXISTS|6 -PERMISSION_DENIED|7 -RESOURCE_EXHAUSTED|8 -FAILED_PRECONDITION|9 -ABORTED|10 -OUT_OF_RANGE|11 -UNIMPLEMENTED|12 -INTERNAL|13 -UNAVAILABLE|14 -DATA_LOSS|15 -UNAUTHENTICATED|16 diff --git a/docs/integration-grpc.md b/docs/integration-grpc.md deleted file mode 100644 index df8e981..0000000 --- a/docs/integration-grpc.md +++ /dev/null @@ -1,108 +0,0 @@ -# gRPC Integration - -This example adds Codes at a gRPC server boundary while the service keeps its existing domain model. - -## 1. Add the adapter - -```kotlin -dependencies { - implementation("io.github.aalsanie:codes-grpc-java:0.4.0-RC1") -} -``` - -Codes `0.4.x` declares gRPC Java 1.75.0 as its compatibility floor. - -## 2. Define the stable application outcome - -```java -final class CheckoutOutcomes { - static final OutcomeDefinition CHECKOUT_INVALID = OutcomeDefinition.custom( - "com.example.checkout", - "CHECKOUT_INVALID", - OutcomeState.FAILED, - "Checkout request is invalid." - ); - - private CheckoutOutcomes() { - } -} -``` - -For `ErrorInfo.reason`, the outcome name must satisfy the Google RPC reason contract: at most 63 characters and UPPER_SNAKE_CASE without a trailing underscore. Codes rejects a lossy mapping rather than changing the identity. - -## 3. Configure the gRPC mapping - -```java -GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() - .withMapping( - CheckoutOutcomes.CHECKOUT_INVALID, - GrpcStatusCode.INVALID_ARGUMENT - ); - -GoogleRpcOutcomeMapper errors = new GoogleRpcOutcomeMapper( - grpc, - GrpcOutcomeExposure.publicErrors() -); -``` - -`publicErrors()` exposes the reusable message and request-field issues. It does not expose occurrence `detail`. - -## 4. Send the error through the existing service - -```java -Outcome outcome = Outcome.of( - CheckoutOutcomes.CHECKOUT_INVALID, - null, - List.of(Issue.at("paymentMethod", "Payment method is invalid.")) -); - -StatusRuntimeException error = GrpcOutcomeExceptions - .toStatusRuntimeException(outcome, errors) - .orNull(); - -if (error == null) { - throw new IllegalStateException("CHECKOUT_INVALID has no gRPC mapping"); -} - -responseObserver.onError(error); -``` - -The client receives a real `google.rpc.Status` in the gRPC trailers. - -## Decode it - -```java -com.google.rpc.Status status = StatusProto.fromThrowable(error); - -ErrorInfo identity = status.getDetailsList().stream() - .filter(any -> any.is(ErrorInfo.class)) - .findFirst() - .orElseThrow() - .unpack(ErrorInfo.class); - -assert identity.getDomain().equals("com.example.checkout"); -assert identity.getReason().equals("CHECKOUT_INVALID"); - -BadRequest request = status.getDetailsList().stream() - .filter(any -> any.is(BadRequest.class)) - .findFirst() - .orElseThrow() - .unpack(BadRequest.class); - -assert request.getFieldViolations(0).getField().equals("paymentMethod"); -``` - -Structured issues are carried in `google.rpc.BadRequest` only when the mapped gRPC status is `INVALID_ARGUMENT` or `OUT_OF_RANGE`. Every exposed issue must have a path that the application intends as a request-field path. Codes rejects incompatible or pathless issue exposure instead of changing its meaning. - -Occurrence detail is carried only when `exposeDetail` is explicitly enabled. - -The application identity therefore survives: - -```text -com.example.checkout:CHECKOUT_INVALID - -> gRPC INVALID_ARGUMENT - -> ErrorInfo.domain = com.example.checkout - -> ErrorInfo.reason = CHECKOUT_INVALID -``` - -For outcomes such as `FAILED_PRECONDITION`, Codes does not coerce generic `Issue` values into `PreconditionFailure`; that type has different semantics and requires information the core `Issue` model does not claim to contain. diff --git a/reference/grpc-orders/build.gradle.kts b/reference/grpc-orders/build.gradle.kts deleted file mode 100644 index 917221e..0000000 --- a/reference/grpc-orders/build.gradle.kts +++ /dev/null @@ -1,59 +0,0 @@ -plugins { - java - application - id("com.google.protobuf") version "0.10.0" -} - -version = "0.4.0-reference" - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} - -tasks.withType().configureEach { - options.release.set(17) - options.encoding = "UTF-8" - options.compilerArgs.add("-Xlint:all,-serial") -} - -val grpcVersion = providers.gradleProperty("grpcVersion").get() - -dependencies { - implementation(project(":codes-grpc-java")) - implementation("io.grpc:grpc-netty-shaded:$grpcVersion") - implementation("io.grpc:grpc-protobuf:$grpcVersion") - implementation("io.grpc:grpc-stub:$grpcVersion") - compileOnly("javax.annotation:javax.annotation-api:1.3.2") - - testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) - testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -protobuf { - protoc { - artifact = "com.google.protobuf:protoc:${providers.gradleProperty("protobufVersion").get()}" - } - plugins { - create("grpc") { - artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" - } - } - generateProtoTasks { - all().configureEach { - plugins { - create("grpc") - } - } - } -} - -tasks.test { - useJUnitPlatform() -} - -application { - mainClass.set("com.example.orders.grpc.OrdersGrpcApplication") -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java deleted file mode 100644 index 7e293f2..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderOutcomes.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; - -final class OrderOutcomes { - static final OutcomeDefinition ORDER_CREATED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_CREATED", OutcomeState.SUCCEEDED, "The order was created." - ); - static final OutcomeDefinition ORDER_REJECTED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "The order was rejected." - ); - - private OrderOutcomes() { - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java deleted file mode 100644 index 43d7efb..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderRecord.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.orders.grpc; - -record OrderRecord(String orderId, String customerId, String product, String status) { -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java deleted file mode 100644 index 482c6e0..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderResult.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.Outcome; -import java.util.Objects; - -record OrderResult(OrderRecord value, Outcome outcome) { - OrderResult { - Objects.requireNonNull(outcome, "outcome"); - } - - static OrderResult failure(Outcome outcome) { - return new OrderResult(null, outcome); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java deleted file mode 100644 index 6295679..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrderService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.ValidationResult; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -final class OrderService { - private final Map orders = new ConcurrentHashMap<>(); - - OrderResult create(String orderId, String customerId, String product) { - ValidationResult validation = ValidationResult.combine( - required("orderId", orderId), - required("customerId", customerId), - required("product", product) - ); - if (validation.isInvalid()) { - return OrderResult.failure(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT)); - } - if ("blocked".equalsIgnoreCase(product)) { - return OrderResult.failure(Outcome.of(OrderOutcomes.ORDER_REJECTED)); - } - OrderRecord order = new OrderRecord(orderId, customerId, product, "CREATED"); - if (orders.putIfAbsent(orderId, order) != null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.ALREADY_EXISTS)); - } - return new OrderResult(order, Outcome.of(OrderOutcomes.ORDER_CREATED)); - } - - OrderResult find(String orderId) { - OrderRecord order = orders.get(orderId); - return order == null - ? OrderResult.failure(Outcome.of(StandardOutcomes.NOT_FOUND)) - : new OrderResult(order, Outcome.of(StandardOutcomes.OK)); - } - - private static ValidationResult required(String path, String value) { - return value == null || value.isBlank() - ? ValidationResult.invalid(Issue.at(path, "The value is required.")) - : ValidationResult.valid(); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java deleted file mode 100644 index 83d5be9..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcApplication.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.orders.grpc; - -import io.grpc.Server; -import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; -import java.io.IOException; - -public final class OrdersGrpcApplication { - private OrdersGrpcApplication() { - } - - public static void main(String[] args) throws IOException, InterruptedException { - Server server = NettyServerBuilder.forPort(9090) - .addService(new OrdersGrpcService(new OrderService(), new OutcomeGrpcBoundary())) - .build() - .start(); - Runtime.getRuntime().addShutdownHook(new Thread(server::shutdown)); - server.awaitTermination(); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java deleted file mode 100644 index 004da58..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OrdersGrpcService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.orders.grpc; - -import io.grpc.stub.StreamObserver; - -final class OrdersGrpcService extends OrdersGrpc.OrdersImplBase { - private final OrderService service; - private final OutcomeGrpcBoundary boundary; - - OrdersGrpcService(OrderService service, OutcomeGrpcBoundary boundary) { - this.service = service; - this.boundary = boundary; - } - - @Override - public void createOrder(CreateOrderRequest request, StreamObserver responseObserver) { - OrderResult result = service.create(request.getOrderId(), request.getCustomerId(), request.getProduct()); - if (result.outcome().isFailed()) { - responseObserver.onError(boundary.toException(result.outcome())); - return; - } - responseObserver.onNext(toReply(result.value())); - responseObserver.onCompleted(); - } - - @Override - public void getOrder(GetOrderRequest request, StreamObserver responseObserver) { - OrderResult result = service.find(request.getOrderId()); - if (result.outcome().isFailed()) { - responseObserver.onError(boundary.toException(result.outcome())); - return; - } - responseObserver.onNext(toReply(result.value())); - responseObserver.onCompleted(); - } - - private static OrderReply toReply(OrderRecord order) { - return OrderReply.newBuilder() - .setOrderId(order.orderId()) - .setCustomerId(order.customerId()) - .setProduct(order.product()) - .setStatus(order.status()) - .build(); - } -} diff --git a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java b/reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java deleted file mode 100644 index a7f16df..0000000 --- a/reference/grpc-orders/src/main/java/com/example/orders/grpc/OutcomeGrpcBoundary.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.orders.grpc; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; -import io.github.aalsanie.codes.grpc.GrpcOutcomeExposure; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.grpc.StatusRuntimeException; - -final class OutcomeGrpcBoundary { - private final GoogleRpcOutcomeMapper mapper = new GoogleRpcOutcomeMapper( - GrpcOutcomeMapper.standard() - .withMapping( - OrderOutcomes.ORDER_REJECTED, - GrpcStatusCode.FAILED_PRECONDITION - ), - new GrpcOutcomeExposure(false, false, true) - ); - - StatusRuntimeException toException(Outcome outcome) { - StatusRuntimeException exception = GrpcOutcomeExceptions - .toStatusRuntimeException(outcome, mapper) - .orNull(); - - if (exception == null) { - throw new IllegalStateException( - "No gRPC mapping configured for outcome: " + outcome.getCode() - ); - } - - return exception; - } -} diff --git a/reference/grpc-orders/src/main/proto/orders.proto b/reference/grpc-orders/src/main/proto/orders.proto deleted file mode 100644 index 2c29f88..0000000 --- a/reference/grpc-orders/src/main/proto/orders.proto +++ /dev/null @@ -1,28 +0,0 @@ -syntax = "proto3"; - -package example.orders.v1; - -option java_multiple_files = true; -option java_package = "com.example.orders.grpc"; - -service Orders { - rpc CreateOrder(CreateOrderRequest) returns (OrderReply); - rpc GetOrder(GetOrderRequest) returns (OrderReply); -} - -message CreateOrderRequest { - string order_id = 1; - string customer_id = 2; - string product = 3; -} - -message GetOrderRequest { - string order_id = 1; -} - -message OrderReply { - string order_id = 1; - string customer_id = 2; - string product = 3; - string status = 4; -} diff --git a/reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java b/reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java deleted file mode 100644 index 0b73323..0000000 --- a/reference/grpc-orders/src/test/java/com/example/orders/grpc/GrpcReferenceTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.orders.grpc; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import com.google.rpc.BadRequest; -import com.google.rpc.ErrorInfo; -import io.grpc.StatusRuntimeException; -import io.grpc.protobuf.StatusProto; -import org.junit.jupiter.api.Test; - -class GrpcReferenceTest { - private final OrderService service = new OrderService(); - private final OutcomeGrpcBoundary boundary = new OutcomeGrpcBoundary(); - - @Test - void validationUsesAdapterIdentityAndStructuredIssues() throws Exception { - OrderResult result = service.create("", "", "book"); - StatusRuntimeException exception = boundary.toException(result.outcome()); - - com.google.rpc.Status status = StatusProto.fromThrowable(exception); - assertNotNull(status); - assertEquals(3, status.getCode()); - assertEquals(result.outcome().getCode().getValue(), status.getMessage()); - assertEquals(2, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals(result.outcome().getCode().getNamespace(), info.getDomain()); - assertEquals(result.outcome().getCode().getName(), info.getReason()); - - BadRequest badRequest = status.getDetails(1).unpack(BadRequest.class); - assertEquals(2, badRequest.getFieldViolationsCount()); - assertEquals("orderId", badRequest.getFieldViolations(0).getField()); - assertEquals("customerId", badRequest.getFieldViolations(1).getField()); - } - - @Test - void customDomainFailureKeepsItsIdentityThroughAdapter() throws Exception { - OrderResult result = service.create("o-1", "c-1", "blocked"); - - com.google.rpc.Status status = StatusProto.fromThrowable( - boundary.toException(result.outcome()) - ); - - assertNotNull(status); - assertEquals(9, status.getCode()); - assertEquals(result.outcome().getCode().getValue(), status.getMessage()); - assertEquals(1, status.getDetailsCount()); - - ErrorInfo info = status.getDetails(0).unpack(ErrorInfo.class); - assertEquals("com.example.orders", info.getDomain()); - assertEquals("ORDER_REJECTED", info.getReason()); - } -} From 52426bd3bd11e11e005bb9d0543819d37b77ac0b Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:29:25 +0300 Subject: [PATCH 11/50] Simplify Spring reference application --- .../example/orders/CreateOrderRequest.java | 4 -- .../main/java/com/example/orders/Order.java | 7 -- .../com/example/orders/OrderController.java | 45 +++++++----- .../com/example/orders/OrderOutcomes.java | 19 ----- .../example/orders/OrderProblemHandler.java | 26 +++++++ .../com/example/orders/OrderProblems.java | 47 +++++++++++++ .../java/com/example/orders/OrderResult.java | 18 ----- .../java/com/example/orders/OrderService.java | 58 ---------------- .../example/orders/OutcomeHttpBoundary.java | 66 ------------------ .../example/orders/OrderReferenceTest.java | 69 ++++--------------- 10 files changed, 114 insertions(+), 245 deletions(-) delete mode 100644 reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java delete mode 100644 reference/spring-orders/src/main/java/com/example/orders/Order.java delete mode 100644 reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java create mode 100644 reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java create mode 100644 reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java delete mode 100644 reference/spring-orders/src/main/java/com/example/orders/OrderResult.java delete mode 100644 reference/spring-orders/src/main/java/com/example/orders/OrderService.java delete mode 100644 reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java diff --git a/reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java b/reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java deleted file mode 100644 index d545ae5..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/CreateOrderRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.orders; - -public record CreateOrderRequest(String orderId, String customerId, String product) { -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/Order.java b/reference/spring-orders/src/main/java/com/example/orders/Order.java deleted file mode 100644 index aad8334..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/Order.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.orders; - -public record Order(String orderId, String customerId, String product, String status) { - Order withStatus(String nextStatus) { - return new Order(orderId, customerId, product, nextStatus); - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java index 44ece8c..1b1b20a 100644 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java @@ -1,36 +1,47 @@ package com.example.orders; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/orders") class OrderController { - private final OrderService service; - private final OutcomeHttpBoundary boundary; - OrderController(OrderService service, OutcomeHttpBoundary boundary) { - this.service = service; - this.boundary = boundary; + @GetMapping("/{orderId}") + String find(@PathVariable String orderId) { + throw new OrderNotFoundException(orderId); } - @PostMapping - ResponseEntity create(@RequestBody CreateOrderRequest request) { - return boundary.toResponse(service.create(request)); + @GetMapping("/archive/{orderId}") + String findArchived(@PathVariable String orderId) { + throw new ArchivedOrderNotFoundException(orderId); } +} - @GetMapping("/{orderId}") - ResponseEntity find(@PathVariable String orderId) { - return boundary.toResponse(service.find(orderId)); +final class OrderNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final String orderId; + + OrderNotFoundException(String orderId) { + this.orderId = orderId; + } + + String orderId() { + return orderId; + } +} + +final class ArchivedOrderNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final String orderId; + + ArchivedOrderNotFoundException(String orderId) { + this.orderId = orderId; } - @PostMapping("/{orderId}/process") - ResponseEntity process(@PathVariable String orderId) { - return boundary.toResponse(service.process(orderId)); + String orderId() { + return orderId; } } diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java b/reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java deleted file mode 100644 index 2c556e1..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderOutcomes.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeState; - -final class OrderOutcomes { - static final OutcomeDefinition ORDER_CREATED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_CREATED", OutcomeState.SUCCEEDED, "The order was created." - ); - static final OutcomeDefinition ORDER_PROCESSING = OutcomeDefinition.custom( - "com.example.orders", "ORDER_PROCESSING", OutcomeState.PENDING, "The order is processing." - ); - static final OutcomeDefinition ORDER_REJECTED = OutcomeDefinition.custom( - "com.example.orders", "ORDER_REJECTED", OutcomeState.FAILED, "The order was rejected." - ); - - private OrderOutcomes() { - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java b/reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java new file mode 100644 index 0000000..9885513 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderProblemHandler.java @@ -0,0 +1,26 @@ +package com.example.orders; + +import io.github.aalsanie.codes.spring.ProblemDetails; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +class OrderProblemHandler { + + @ExceptionHandler(OrderNotFoundException.class) + ProblemDetail handleOrderNotFound(OrderNotFoundException exception) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order " + exception.orderId() + " was not found." + ); + } + + @ExceptionHandler(ArchivedOrderNotFoundException.class) + ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException exception) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Archived order " + exception.orderId() + " was not found." + ); + } +} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java b/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java new file mode 100644 index 0000000..5dbb63e --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java @@ -0,0 +1,47 @@ +package com.example.orders; + +import io.github.aalsanie.codes.ProblemType; +import java.net.URI; + +enum OrderProblems implements ProblemType { + ORDER_NOT_FOUND( + "https://api.example.com/problems/order-not-found", + 404, + "Order not found" + ), + ORDER_ALREADY_CANCELLED( + "https://api.example.com/problems/order-already-cancelled", + 409, + "Order already cancelled" + ), + INVALID_ORDER_STATE( + "https://api.example.com/problems/invalid-order-state", + 409, + "Invalid order state" + ); + + private final URI type; + private final int status; + private final String title; + + OrderProblems(String type, int status, String title) { + this.type = URI.create(type); + this.status = status; + this.title = title; + } + + @Override + public URI getType() { + return type; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getTitle() { + return title; + } +} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderResult.java b/reference/spring-orders/src/main/java/com/example/orders/OrderResult.java deleted file mode 100644 index db72606..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.Outcome; -import java.util.Objects; - -record OrderResult(T value, Outcome outcome) { - OrderResult { - Objects.requireNonNull(outcome, "outcome"); - } - - static OrderResult of(T value, Outcome outcome) { - return new OrderResult<>(value, outcome); - } - - static OrderResult failure(Outcome outcome) { - return new OrderResult<>(null, outcome); - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderService.java b/reference/spring-orders/src/main/java/com/example/orders/OrderService.java deleted file mode 100644 index 1a28f6e..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.ValidationResult; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Service; - -@Service -class OrderService { - private final Map orders = new ConcurrentHashMap<>(); - - OrderResult create(CreateOrderRequest request) { - ValidationResult validation = ValidationResult.combine( - required("orderId", request.orderId()), - required("customerId", request.customerId()), - required("product", request.product()) - ); - if (validation.isInvalid()) { - return OrderResult.failure(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT)); - } - - Order order = new Order(request.orderId(), request.customerId(), request.product(), "CREATED"); - if (orders.putIfAbsent(order.orderId(), order) != null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.ALREADY_EXISTS)); - } - return OrderResult.of(order, Outcome.of(OrderOutcomes.ORDER_CREATED)); - } - - OrderResult find(String orderId) { - Order order = orders.get(orderId); - if (order == null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.NOT_FOUND)); - } - return OrderResult.of(order, Outcome.of(StandardOutcomes.OK)); - } - - OrderResult process(String orderId) { - Order current = orders.get(orderId); - if (current == null) { - return OrderResult.failure(Outcome.of(StandardOutcomes.NOT_FOUND)); - } - if ("blocked".equalsIgnoreCase(current.product())) { - return OrderResult.failure(Outcome.of(OrderOutcomes.ORDER_REJECTED)); - } - Order processing = current.withStatus("PROCESSING"); - orders.put(orderId, processing); - return OrderResult.of(processing, Outcome.of(OrderOutcomes.ORDER_PROCESSING)); - } - - private static ValidationResult required(String path, String value) { - return value == null || value.isBlank() - ? ValidationResult.invalid(Issue.at(path, "The value is required.")) - : ValidationResult.valid(); - } -} diff --git a/reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java b/reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java deleted file mode 100644 index 187e10e..0000000 --- a/reference/spring-orders/src/main/java/com/example/orders/OutcomeHttpBoundary.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.orders; - -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; -import io.github.aalsanie.codes.spring.SpringHttpStatusMapper; -import io.github.aalsanie.codes.spring.SpringOutcomeExposure; -import io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper; -import java.net.URI; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -class OutcomeHttpBoundary { - private final SpringHttpStatusMapper statusMapper; - private final OutcomeProblemDetailMapper problemMapper; - - OutcomeHttpBoundary() { - HttpOutcomeMapper httpMapper = HttpOutcomeMapper.standard() - .withMapping(OrderOutcomes.ORDER_CREATED, HttpStatusCode.CREATED) - .withMapping(OrderOutcomes.ORDER_PROCESSING, HttpStatusCode.ACCEPTED) - .withMapping(OrderOutcomes.ORDER_REJECTED, HttpStatusCode.of(422)) - .withMapping(StandardOutcomes.FAILED_PRECONDITION, HttpStatusCode.CONFLICT); - - statusMapper = new SpringHttpStatusMapper(httpMapper); - problemMapper = new OutcomeProblemDetailMapper( - statusMapper, - SpringOutcomeExposure.publicErrors(), - SpringProblemTypeUriMapper.empty() - .withMapping( - StandardOutcomes.INVALID_ARGUMENT, - URI.create("https://api.example.com/problems/invalid-argument") - ) - .withMapping( - OrderOutcomes.ORDER_REJECTED, - URI.create("https://api.example.com/problems/order-rejected") - ) - ); - } - - ResponseEntity toResponse(OrderResult result) { - Outcome outcome = result.outcome(); - org.springframework.http.HttpStatusCode status = statusMapper.map(outcome).orNull(); - if (status == null) { - return unmapped(outcome); - } - if (!outcome.isFailed()) { - return ResponseEntity.status(status).body(result.value()); - } - - ProblemDetail problem = problemMapper.map(outcome).orNull(); - if (problem == null) { - throw new IllegalStateException("mapped HTTP status produced an unmapped problem detail"); - } - return ResponseEntity.status(status).body(problem); - } - - private static ResponseEntity unmapped(Outcome outcome) { - ProblemDetail problem = ProblemDetail.forStatus(500); - problem.setProperty(OutcomeProblemDetailMapper.CODE_PROPERTY, outcome.getCode().getValue()); - return ResponseEntity.internalServerError().body(problem); - } -} diff --git a/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java b/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java index 4332730..af3b02c 100644 --- a/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java +++ b/reference/spring-orders/src/test/java/com/example/orders/OrderReferenceTest.java @@ -1,68 +1,25 @@ package com.example.orders; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import io.github.aalsanie.codes.StandardOutcomes; -import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; class OrderReferenceTest { - private final OrderService service = new OrderService(); - private final OutcomeHttpBoundary boundary = new OutcomeHttpBoundary(); + private final OrderProblemHandler handler = new OrderProblemHandler(); @Test - void createUsesCustomSuccessOutcomeAndHttp201() { - ResponseEntity response = boundary.toResponse( - service.create(new CreateOrderRequest("o-1", "c-1", "book")) - ); - assertEquals(201, response.getStatusCode().value()); - assertTrue(response.getBody() instanceof Order); - } - - @Test - void validationUsesApplicationOwnedProblemTypeAndAdapterExposurePolicy() { - ResponseEntity response = boundary.toResponse( - service.create(new CreateOrderRequest("", "", "book")) - ); - - assertEquals(400, response.getStatusCode().value()); - ProblemDetail problem = (ProblemDetail) response.getBody(); - assertEquals( - URI.create("https://api.example.com/problems/invalid-argument"), - problem.getType() - ); - assertEquals(StandardOutcomes.INVALID_ARGUMENT.getDefaultMessage(), problem.getTitle()); - assertEquals( - StandardOutcomes.INVALID_ARGUMENT.getCode().getValue(), - problem.getProperties().get("code") - ); - assertEquals(2, ((java.util.List) problem.getProperties().get("issues")).size()); - assertNull(problem.getDetail()); - } - - @Test - void pendingAndDomainFailureKeepApplicationIdentity() { - boundary.toResponse(service.create(new CreateOrderRequest("o-2", "c-1", "book"))); - ResponseEntity processing = boundary.toResponse(service.process("o-2")); - assertEquals(202, processing.getStatusCode().value()); - - boundary.toResponse(service.create(new CreateOrderRequest("o-3", "c-1", "blocked"))); - ResponseEntity rejected = boundary.toResponse(service.process("o-3")); - assertEquals(422, rejected.getStatusCode().value()); - - ProblemDetail problem = (ProblemDetail) rejected.getBody(); - assertEquals( - URI.create("https://api.example.com/problems/order-rejected"), - problem.getType() - ); - assertEquals(OrderOutcomes.ORDER_REJECTED.getDefaultMessage(), problem.getTitle()); - assertEquals( - OrderOutcomes.ORDER_REJECTED.getCode().getValue(), - problem.getProperties().get("code") - ); + void differentApplicationExceptionsReuseTheSameProblemType() { + ProblemDetail current = handler.handleOrderNotFound(new OrderNotFoundException("o-1")); + ProblemDetail archived = handler.handleArchivedOrderNotFound(new ArchivedOrderNotFoundException("o-2")); + + assertEquals(OrderProblems.ORDER_NOT_FOUND.getType(), current.getType()); + assertEquals(OrderProblems.ORDER_NOT_FOUND.getType(), archived.getType()); + assertEquals(OrderProblems.ORDER_NOT_FOUND.getTitle(), current.getTitle()); + assertEquals(OrderProblems.ORDER_NOT_FOUND.getTitle(), archived.getTitle()); + assertEquals(404, current.getStatus()); + assertEquals(404, archived.getStatus()); + assertEquals("Order o-1 was not found.", current.getDetail()); + assertEquals("Archived order o-2 was not found.", archived.getDetail()); } } From 2b9065481f535d2be2c93c227362385205b001bf Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:31:41 +0300 Subject: [PATCH 12/50] Update Java publication consumers --- smoke-test-java/build.gradle.kts | 27 ++---- smoke-test-java/src/main/java/SmokeJava.java | 97 +++---------------- smoke-test-maven-java/pom.xml | 36 +------ .../src/main/java/SmokeMavenJava.java | 26 +++-- 4 files changed, 36 insertions(+), 150 deletions(-) diff --git a/smoke-test-java/build.gradle.kts b/smoke-test-java/build.gradle.kts index ea49585..6faae3b 100644 --- a/smoke-test-java/build.gradle.kts +++ b/smoke-test-java/build.gradle.kts @@ -4,35 +4,20 @@ plugins { java } -val codesVersion = Properties().run { - rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } - getProperty("VERSION_NAME") ?: error("VERSION_NAME is missing from gradle.properties") +val properties = Properties().apply { + rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use(::load) } - +val codesVersion = properties.getProperty("VERSION_NAME") +val defaultSpringVersion = properties.getProperty("springFrameworkVersion") +val springVersion = providers.gradleProperty("springFrameworkOverride").orElse(defaultSpringVersion) val consumerJavaVersion = providers.gradleProperty("consumerJavaVersion") .orElse("17") .map(String::toInt) -val springFrameworkOverride = providers.gradleProperty("springFrameworkOverride").orNull -val grpcVersionOverride = providers.gradleProperty("grpcVersionOverride").orNull - dependencies { implementation("io.github.aalsanie:codes:$codesVersion") implementation("io.github.aalsanie:codes-spring:$codesVersion") - implementation("io.github.aalsanie:codes-grpc-java:$codesVersion") -} - -configurations.configureEach { - resolutionStrategy.eachDependency { - if (springFrameworkOverride != null && requested.group == "org.springframework") { - useVersion(springFrameworkOverride) - because("Codes Spring compatibility matrix") - } - if (grpcVersionOverride != null && requested.group == "io.grpc") { - useVersion(grpcVersionOverride) - because("Codes gRPC compatibility matrix") - } - } + implementation("org.springframework:spring-web:${springVersion.get()}") } java { diff --git a/smoke-test-java/src/main/java/SmokeJava.java b/smoke-test-java/src/main/java/SmokeJava.java index c7cf68c..9bb4d8a 100644 --- a/smoke-test-java/src/main/java/SmokeJava.java +++ b/smoke-test-java/src/main/java/SmokeJava.java @@ -1,99 +1,32 @@ -import com.google.rpc.ErrorInfo; -import io.github.aalsanie.codes.Issue; -import io.github.aalsanie.codes.MappingResult; -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.OutcomeCode; -import io.github.aalsanie.codes.OutcomeDefinition; -import io.github.aalsanie.codes.OutcomeRegistry; -import io.github.aalsanie.codes.OutcomeState; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.ValidationResult; -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; -import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.grpc.GrpcStatusCode; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpStatusCode; -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; -import io.github.aalsanie.codes.spring.SpringOutcomeExceptions; -import io.grpc.StatusRuntimeException; -import java.util.List; +import io.github.aalsanie.codes.ProblemType; +import io.github.aalsanie.codes.spring.ProblemDetails; +import java.net.URI; import org.springframework.http.ProblemDetail; -import org.springframework.web.ErrorResponseException; public final class SmokeJava { private SmokeJava() { } - public static void main(String[] args) throws Exception { - OutcomeCode code = OutcomeCode.of("com.example.orders", "ORDER_REJECTED"); - OutcomeDefinition rejected = OutcomeDefinition.custom( - code, - OutcomeState.FAILED, - "Order rejected." + public static void main(String[] args) { + ProblemType type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" ); - Outcome outcome = Outcome.of( - rejected, - "internal=42", - List.of(Issue.at("items[0]", "Unavailable.")) + ProblemDetail problem = ProblemDetails.forTypeAndDetail( + type, + "Order o-123 was not found." ); - HttpOutcomeMapper http = HttpOutcomeMapper.standard() - .withMapping(rejected, HttpStatusCode.of(422)); - GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() - .withMapping(rejected, GrpcStatusCode.FAILED_PRECONDITION); - require(http.map(outcome).orNull().getValue() == 422); - require(grpc.map(outcome).orNull() == GrpcStatusCode.FAILED_PRECONDITION); - - Outcome standardFailure = Outcome.of(StandardOutcomes.NOT_FOUND); - - ProblemDetail problem = OutcomeProblemDetailMapper.safeDefaults() - .map(standardFailure) - .orNull(); + require(problem.getType().equals(type.getType())); require(problem.getStatus() == 404); - - ErrorResponseException springException = SpringOutcomeExceptions - .toErrorResponseException( - standardFailure, - OutcomeProblemDetailMapper.safeDefaults() - ) - .orNull(); - require(springException.getStatusCode().value() == 404); - - com.google.rpc.Status rpcStatus = GoogleRpcOutcomeMapper.safeDefaults() - .map(standardFailure) - .orNull(); - require(rpcStatus.getCode() == io.grpc.Status.Code.NOT_FOUND.value()); - - ErrorInfo identity = rpcStatus.getDetails(0).unpack(ErrorInfo.class); - require(identity.getDomain().equals(standardFailure.getCode().getNamespace())); - require(identity.getReason().equals(standardFailure.getCode().getName())); - - StatusRuntimeException grpcException = GrpcOutcomeExceptions - .toStatusRuntimeException( - standardFailure, - GoogleRpcOutcomeMapper.safeDefaults() - ) - .orNull(); - require(grpcException.getStatus().getCode() == io.grpc.Status.Code.NOT_FOUND); - - ValidationResult validation = ValidationResult.combine( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")) - ); - require(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT).getIssues().size() == 1); - - OutcomeRegistry registry = OutcomeRegistry.standard().with(rejected); - require(registry.require(code) == rejected); - MappingResult mapped = MappingResult.mapped(42); - require(mapped.fold(value -> value, () -> -1) == 42); - - System.out.println("Pure Java consumer smoke test passed for all Codes artifacts."); + require(problem.getTitle().equals("Order not found")); + require(problem.getDetail().equals("Order o-123 was not found.")); } private static void require(boolean condition) { if (!condition) { - throw new AssertionError("Pure Java consumer assertion failed."); + throw new AssertionError("Java consumer assertion failed."); } } } diff --git a/smoke-test-maven-java/pom.xml b/smoke-test-maven-java/pom.xml index da35d16..9105fc8 100644 --- a/smoke-test-maven-java/pom.xml +++ b/smoke-test-maven-java/pom.xml @@ -1,20 +1,14 @@ - + 4.0.0 io.github.aalsanie.smoke codes-maven-java-smoke 1.0.0 - 17 - UTF-8 - 0.4.0-SNAPSHOT + 0.4.0 7.0.9 - 1.83.1 - @@ -24,16 +18,8 @@ pom import - - io.grpc - grpc-bom - ${grpc.version} - pom - import - - io.github.aalsanie @@ -46,26 +32,12 @@ ${codes.version} - io.github.aalsanie - codes-grpc-java - ${codes.version} + org.springframework + spring-web - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.1 - - ${maven.compiler.release} - - -Xlint:all,-serial - -Werror - - - org.codehaus.mojo exec-maven-plugin diff --git a/smoke-test-maven-java/src/main/java/SmokeMavenJava.java b/smoke-test-maven-java/src/main/java/SmokeMavenJava.java index 526f13a..847b426 100644 --- a/smoke-test-maven-java/src/main/java/SmokeMavenJava.java +++ b/smoke-test-maven-java/src/main/java/SmokeMavenJava.java @@ -1,25 +1,21 @@ -import io.github.aalsanie.codes.Outcome; -import io.github.aalsanie.codes.StandardOutcomes; -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper; -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; +import io.github.aalsanie.codes.ProblemType; +import io.github.aalsanie.codes.spring.ProblemDetails; +import java.net.URI; +import org.springframework.http.ProblemDetail; public final class SmokeMavenJava { private SmokeMavenJava() { } public static void main(String[] args) { - Outcome outcome = Outcome.of(StandardOutcomes.NOT_FOUND); - if (HttpOutcomeMapper.standard().map(outcome).orNull().getValue() != 404) { + ProblemType type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" + ); + ProblemDetail problem = ProblemDetails.forType(type); + if (!type.getType().equals(problem.getType()) || problem.getStatus() != 404) { throw new AssertionError("Maven Java consumer mapping failed"); } - if (OutcomeProblemDetailMapper.safeDefaults().map(outcome).orNull().getStatus() != 404) { - throw new AssertionError("Maven Java Spring adapter mapping failed"); - } - if (GoogleRpcOutcomeMapper.safeDefaults().map(outcome).orNull().getCode() - != io.grpc.Status.Code.NOT_FOUND.value()) { - throw new AssertionError("Maven Java gRPC adapter mapping failed"); - } - System.out.println("Maven Java consumer smoke test passed for all Codes artifacts."); } } From 71dfce0ddccedced84c645d451327d990e507a1f Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:32:23 +0300 Subject: [PATCH 13/50] Update Kotlin publication consumers --- compatibility/matrix.json | 17 +---- smoke-test-kotlin/build.gradle.kts | 10 +-- .../src/main/kotlin/SmokeKotlin.kt | 64 ++++--------------- smoke-test-maven-kotlin/pom.xml | 24 ++----- .../src/main/kotlin/SmokeMavenKotlin.kt | 31 ++++----- 5 files changed, 35 insertions(+), 111 deletions(-) diff --git a/compatibility/matrix.json b/compatibility/matrix.json index 5d97255..d2eb423 100644 --- a/compatibility/matrix.json +++ b/compatibility/matrix.json @@ -8,24 +8,11 @@ "6.0.0", "7.0.9" ], - "grpc": [ - "1.75.0", - "1.83.1" - ], "kotlin": { "versions": [ "1.9.24", - "2.0.21", - "2.1.21", - "2.2.20", "2.4.10" ], - "spring": "6.0.0", - "grpc": "1.75.0" - }, - "os": [ - "ubuntu-latest", - "windows-latest", - "macos-latest" - ] + "spring": "6.0.0" + } } diff --git a/smoke-test-kotlin/build.gradle.kts b/smoke-test-kotlin/build.gradle.kts index e274015..e2fd159 100644 --- a/smoke-test-kotlin/build.gradle.kts +++ b/smoke-test-kotlin/build.gradle.kts @@ -5,11 +5,11 @@ plugins { kotlin("jvm") version "2.4.10" } -val codesVersion = Properties().run { - rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } - getProperty("VERSION_NAME") ?: error("VERSION_NAME is missing from gradle.properties") +val properties = Properties().apply { + rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use(::load) } - +val codesVersion = properties.getProperty("VERSION_NAME") +val springVersion = properties.getProperty("springFrameworkVersion") val consumerJavaVersion = providers.gradleProperty("consumerJavaVersion") .orElse("17") .map(String::toInt) @@ -17,7 +17,7 @@ val consumerJavaVersion = providers.gradleProperty("consumerJavaVersion") dependencies { implementation("io.github.aalsanie:codes:$codesVersion") implementation("io.github.aalsanie:codes-spring:$codesVersion") - implementation("io.github.aalsanie:codes-grpc-java:$codesVersion") + implementation("org.springframework:spring-web:$springVersion") } kotlin { diff --git a/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt b/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt index 318323b..d0db167 100644 --- a/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt +++ b/smoke-test-kotlin/src/main/kotlin/SmokeKotlin.kt @@ -1,57 +1,17 @@ -import com.google.rpc.ErrorInfo -import io.github.aalsanie.codes.Issue -import io.github.aalsanie.codes.Outcome -import io.github.aalsanie.codes.OutcomeCode -import io.github.aalsanie.codes.OutcomeDefinition -import io.github.aalsanie.codes.OutcomeRegistry -import io.github.aalsanie.codes.OutcomeState -import io.github.aalsanie.codes.StandardOutcomes -import io.github.aalsanie.codes.ValidationResult -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper -import io.github.aalsanie.codes.protocol.http.HttpStatusCode -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper +import io.github.aalsanie.codes.ProblemType +import io.github.aalsanie.codes.spring.ProblemDetails +import java.net.URI fun main() { - val rejected = OutcomeDefinition.custom( - "com.example.orders", - "ORDER_REJECTED", - OutcomeState.FAILED, - "Order rejected.", + val type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found", ) - val outcome = Outcome.of( - rejected, - "internal=42", - listOf(Issue.at("items[0]", "Unavailable.")), - ) - val detail: String? = outcome.detail - val issueCode: OutcomeCode? = outcome.issues.first().code - val mapper = HttpOutcomeMapper.standard().withMapping(rejected, HttpStatusCode.of(422)) - val mappedStatus: HttpStatusCode? = mapper.map(outcome).orNull() - val missingDefinition: OutcomeDefinition? = - OutcomeRegistry.empty().find("com.example:MISSING") - check(mappedStatus?.value == 422) - check(detail == "internal=42") - check(issueCode == null) - check(missingDefinition == null) - check(outcome.code.value == "com.example.orders:ORDER_REJECTED") - - val standardFailure = Outcome.of(StandardOutcomes.NOT_FOUND) - val problem = OutcomeProblemDetailMapper.safeDefaults().map(standardFailure).orNull() - check(problem?.status == 404) - - val rpcStatus = GoogleRpcOutcomeMapper.safeDefaults().map(standardFailure).orNull() - check(rpcStatus?.code == io.grpc.Status.Code.NOT_FOUND.value()) - val identity = rpcStatus!!.detailsList.first().unpack(ErrorInfo::class.java) - check(identity.domain == standardFailure.code.namespace) - check(identity.reason == standardFailure.code.name) - - val validation = ValidationResult.combine( - ValidationResult.valid(), - ValidationResult.invalid(Issue.at("email", "Invalid.")), - ) - check(validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT).issues.size == 1) - check(OutcomeRegistry.standard().with(rejected).find(rejected.code) === rejected) + val problem = ProblemDetails.forTypeAndDetail(type, "Order o-123 was not found.") - println("Kotlin consumer smoke test passed for all Codes artifacts.") + check(problem.type == type.type) + check(problem.status == 404) + check(problem.title == "Order not found") + check(problem.detail == "Order o-123 was not found.") } diff --git a/smoke-test-maven-kotlin/pom.xml b/smoke-test-maven-kotlin/pom.xml index a32afb7..887ed4f 100644 --- a/smoke-test-maven-kotlin/pom.xml +++ b/smoke-test-maven-kotlin/pom.xml @@ -1,20 +1,14 @@ - + 4.0.0 io.github.aalsanie.smoke codes-maven-kotlin-smoke 1.0.0 - - UTF-8 - 0.4.0-SNAPSHOT + 0.4.0 2.4.10 6.0.0 - 1.75.0 - @@ -24,16 +18,8 @@ pom import - - io.grpc - grpc-bom - ${grpc.version} - pom - import - - io.github.aalsanie @@ -46,9 +32,8 @@ ${codes.version} - io.github.aalsanie - codes-grpc-java - ${codes.version} + org.springframework + spring-web org.jetbrains.kotlin @@ -56,7 +41,6 @@ ${kotlin.version} - ${project.basedir}/src/main/kotlin diff --git a/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt b/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt index 610c8fb..8bbd553 100644 --- a/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt +++ b/smoke-test-maven-kotlin/src/main/kotlin/SmokeMavenKotlin.kt @@ -1,22 +1,15 @@ -import com.google.rpc.ErrorInfo -import io.github.aalsanie.codes.Outcome -import io.github.aalsanie.codes.StandardOutcomes -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper -import io.github.aalsanie.codes.protocol.http.HttpOutcomeMapper -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper +import io.github.aalsanie.codes.ProblemType +import io.github.aalsanie.codes.spring.ProblemDetails +import java.net.URI fun main() { - val outcome = Outcome.of(StandardOutcomes.NOT_FOUND) - - check(HttpOutcomeMapper.standard().map(outcome).orNull()?.value == 404) - check(OutcomeProblemDetailMapper.safeDefaults().map(outcome).orNull()?.status == 404) - - val rpcStatus = GoogleRpcOutcomeMapper.safeDefaults().map(outcome).orNull() - check(rpcStatus?.code == io.grpc.Status.Code.NOT_FOUND.value()) - - val identity = rpcStatus!!.detailsList.first().unpack(ErrorInfo::class.java) - check(identity.domain == outcome.code.namespace) - check(identity.reason == outcome.code.name) - - println("Maven Kotlin consumer smoke test passed for all Codes artifacts.") + val type = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found", + ) + val problem = ProblemDetails.forType(type) + + check(problem.type == type.type) + check(problem.status == 404) } From 37adcf7fdb99e4ad79d730099490c98f6f6fc076 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:33:28 +0300 Subject: [PATCH 14/50] Update publication verification --- scripts/prepare-compatibility-repository.sh | 1 - scripts/verify-local-publications.sh | 14 ++++---------- 2 files changed, 4 insertions(+), 11 deletions(-) mode change 100644 => 100755 scripts/prepare-compatibility-repository.sh mode change 100644 => 100755 scripts/verify-local-publications.sh diff --git a/scripts/prepare-compatibility-repository.sh b/scripts/prepare-compatibility-repository.sh old mode 100644 new mode 100755 index d3bb282..4ddf2db --- a/scripts/prepare-compatibility-repository.sh +++ b/scripts/prepare-compatibility-repository.sh @@ -16,7 +16,6 @@ mkdir -p "$repository" "-Dmaven.repo.local=$repository" \ :publishToMavenLocal \ :codes-spring:publishToMavenLocal \ - :codes-grpc-java:publishToMavenLocal \ --stacktrace MAVEN_REPO_LOCAL="$repository" sh "$root_dir/scripts/verify-local-publications.sh" diff --git a/scripts/verify-local-publications.sh b/scripts/verify-local-publications.sh old mode 100644 new mode 100755 index 8b6e66d..4ddf7fb --- a/scripts/verify-local-publications.sh +++ b/scripts/verify-local-publications.sh @@ -6,19 +6,13 @@ version="$(sh "$root_dir/scripts/version.sh")" repository="${MAVEN_REPO_LOCAL:-$HOME/.m2/repository}" group_path="io/github/aalsanie" -for artifact in codes codes-spring codes-grpc-java; do +for artifact in codes codes-spring; do artifact_dir="$repository/$group_path/$artifact/$version" jar="$artifact_dir/$artifact-$version.jar" pom="$artifact_dir/$artifact-$version.pom" - test -s "$jar" || { - echo "Missing local publication JAR: $jar" >&2 - exit 1 - } - test -s "$pom" || { - echo "Missing local publication POM: $pom" >&2 - exit 1 - } + test -s "$jar" || { echo "Missing local publication JAR: $jar" >&2; exit 1; } + test -s "$pom" || { echo "Missing local publication POM: $pom" >&2; exit 1; } done -echo "Verified Maven Local publications for codes, codes-spring, and codes-grpc-java ($version)." +echo "Verified Maven Local publications for codes and codes-spring ($version)." From 78c7de4049f20f3668f467ea6fc4e4286f7fb6b8 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:34:35 +0300 Subject: [PATCH 15/50] Update publication contracts --- scripts/verify-published-pom-contracts.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/scripts/verify-published-pom-contracts.py b/scripts/verify-published-pom-contracts.py index e425529..d10a19a 100644 --- a/scripts/verify-published-pom-contracts.py +++ b/scripts/verify-published-pom-contracts.py @@ -27,16 +27,9 @@ def properties(path: Path) -> dict[str, str]: expected = { "codes": [], - "codes-spring": sorted([ + "codes-spring": [ ("io.github.aalsanie", "codes", version, "compile"), - ("org.springframework", "spring-web", props["springFrameworkVersion"], "compile"), - ]), - "codes-grpc-java": sorted([ - ("io.github.aalsanie", "codes", version, "compile"), - ("com.google.api.grpc", "proto-google-common-protos", props["protoGoogleCommonProtosVersion"], "compile"), - ("io.grpc", "grpc-api", props["grpcVersion"], "compile"), - ("io.grpc", "grpc-protobuf", props["grpcVersion"], "runtime"), - ]), + ], } namespace = {"m": "http://maven.apache.org/POM/4.0.0"} @@ -65,6 +58,7 @@ def value(name: str) -> str: )) actual.sort() + expected_dependencies.sort() if actual != expected_dependencies: raise SystemExit( f"{artifact} published POM dependency budget changed.\n" From 49c19ee2f97a809426db82ca76ceceb8a55b3b79 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:34:47 +0300 Subject: [PATCH 16/50] Update compatibility policy --- scripts/verify-compatibility-matrix.py | 65 ++++++++------------------ 1 file changed, 20 insertions(+), 45 deletions(-) diff --git a/scripts/verify-compatibility-matrix.py b/scripts/verify-compatibility-matrix.py index 1f3698b..231adde 100644 --- a/scripts/verify-compatibility-matrix.py +++ b/scripts/verify-compatibility-matrix.py @@ -3,50 +3,25 @@ import json from pathlib import Path -ROOT = Path(__file__).resolve().parent.parent - - -def properties(path: Path) -> dict[str, str]: - result: dict[str, str] = {} - for raw in path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - key, value = line.split("=", 1) - result[key] = value - return result - - -matrix = json.loads((ROOT / "compatibility" / "matrix.json").read_text(encoding="utf-8")) -gradle = properties(ROOT / "gradle.properties") - -spring_floor = "6.0.0" -grpc_floor = "1.75.0" -kotlin_versions = ["1.9.24", "2.0.21", "2.1.21", "2.2.20", "2.4.10"] - -expected_axes = { - "java": ["17", "21", "25"], - "spring": [spring_floor, gradle["springFrameworkVersion"]], - "grpc": [grpc_floor, gradle["grpcVersion"]], - "kotlin": { - "versions": kotlin_versions, - "spring": spring_floor, - "grpc": grpc_floor, - }, - "os": ["ubuntu-latest", "windows-latest", "macos-latest"], -} - -if matrix != expected_axes: - raise SystemExit( - "compatibility/matrix.json drifted from the declared 0.4.x support policy.\n" - f"Expected: {expected_axes}\n" - f"Actual: {matrix}" - ) - -if matrix["kotlin"]["spring"] != matrix["spring"][0]: - raise SystemExit("Kotlin/JSpecify Spring version must be the declared Spring compatibility floor.") - -if matrix["kotlin"]["grpc"] != matrix["grpc"][0]: - raise SystemExit("Kotlin/JSpecify gRPC version must be the declared gRPC compatibility floor.") +root = Path(__file__).resolve().parent.parent +matrix = json.loads((root / "compatibility/matrix.json").read_text(encoding="utf-8")) + +required_keys = {"java", "spring", "kotlin"} +if set(matrix) != required_keys: + raise SystemExit(f"Compatibility matrix keys must be {sorted(required_keys)}") + +if matrix["java"] != ["17", "21", "25"]: + raise SystemExit("Java compatibility matrix must cover 17, 21, and 25.") + +if len(matrix["spring"]) < 2: + raise SystemExit("Spring compatibility matrix must include a floor and current version.") + +kotlin = matrix["kotlin"] +if set(kotlin) != {"versions", "spring"}: + raise SystemExit("Kotlin compatibility policy must define versions and spring.") +if not kotlin["versions"]: + raise SystemExit("Kotlin compatibility matrix must not be empty.") +if kotlin["spring"] not in matrix["spring"]: + raise SystemExit("Kotlin compatibility Spring version must be in the Spring matrix.") print("Compatibility matrix policy verified.") From eeed5074f6fe819852f07bb5085ed469b8ac468a Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:35:22 +0300 Subject: [PATCH 17/50] Simplify Kotlin compatibility check --- scripts/verify-kotlin-nullability.sh | 119 +-------------------------- 1 file changed, 3 insertions(+), 116 deletions(-) diff --git a/scripts/verify-kotlin-nullability.sh b/scripts/verify-kotlin-nullability.sh index b187f27..f5a1b34 100644 --- a/scripts/verify-kotlin-nullability.sh +++ b/scripts/verify-kotlin-nullability.sh @@ -3,139 +3,26 @@ set -eu root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" pom="$root_dir/smoke-test-maven-kotlin/pom.xml" -source_dir="$root_dir/smoke-test-maven-kotlin/src/main/kotlin" matrix_file="$root_dir/compatibility/matrix.json" -kotlin_version="${1:?Usage: verify-kotlin-nullability.sh [spring-version] [grpc-version]}" +kotlin_version="${1:?Usage: verify-kotlin-nullability.sh [spring-version]}" codes_version="$(sh "$root_dir/scripts/version.sh")" repository="${MAVEN_REPO_LOCAL:-$HOME/.m2/repository}" -configured_versions="$(python - "$matrix_file" "$kotlin_version" <<'PY' -import json -import sys -from pathlib import Path - -matrix = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) -kotlin_version = sys.argv[2] -policy = matrix["kotlin"] - -if kotlin_version not in policy["versions"]: - raise SystemExit(f"Unsupported Kotlin compatibility version: {kotlin_version}") - -print(policy["spring"]) -print(policy["grpc"]) -PY -)" - -configured_spring="$(printf '%s\n' "$configured_versions" | sed -n '1p')" -configured_grpc="$(printf '%s\n' "$configured_versions" | sed -n '2p')" - +configured_spring="$(python -c 'import json,sys; m=json.load(open(sys.argv[1])); v=sys.argv[2]; p=m["kotlin"]; assert v in p["versions"], v; print(p["spring"])' "$matrix_file" "$kotlin_version")" spring_version="${2:-$configured_spring}" -grpc_version="${3:-$configured_grpc}" if [ "$spring_version" != "$configured_spring" ]; then echo "Kotlin/JSpecify Spring version must match compatibility policy: $configured_spring" >&2 exit 1 fi -if [ "$grpc_version" != "$configured_grpc" ]; then - echo "Kotlin/JSpecify gRPC version must match compatibility policy: $configured_grpc" >&2 - exit 1 -fi - -core_nullable_probe="$source_dir/CoreNullableReturnProbe.kt" -core_null_marked_probe="$source_dir/CoreNullMarkedProbe.kt" -spring_null_marked_probe="$source_dir/SpringNullMarkedProbe.kt" -grpc_null_marked_probe="$source_dir/GrpcNullMarkedProbe.kt" -output_file="$(mktemp)" - -cleanup() { - rm -f \ - "$core_nullable_probe" \ - "$core_null_marked_probe" \ - "$spring_null_marked_probe" \ - "$grpc_null_marked_probe" \ - "$output_file" -} -trap cleanup EXIT HUP INT TERM - mvn --batch-mode --no-transfer-progress \ "-Dmaven.repo.local=$repository" \ -f "$pom" \ "-Dcodes.version=$codes_version" \ "-Dkotlin.version=$kotlin_version" \ "-Dspring.framework.version=$spring_version" \ - "-Dgrpc.version=$grpc_version" \ clean verify -expect_compile_failure() { - probe_file="$1" - : > "$output_file" - - set +e - mvn --batch-mode --no-transfer-progress --offline \ - "-Dmaven.repo.local=$repository" \ - -f "$pom" \ - "-Dcodes.version=$codes_version" \ - "-Dkotlin.version=$kotlin_version" \ - "-Dspring.framework.version=$spring_version" \ - "-Dgrpc.version=$grpc_version" \ - clean compile >"$output_file" 2>&1 - status=$? - set -e - - cat "$output_file" - - if [ "$status" -eq 0 ]; then - echo "Expected Kotlin compilation to reject $(basename "$probe_file")." >&2 - exit 1 - fi - - if ! grep -Fq "$(basename "$probe_file")" "$output_file"; then - echo "Kotlin compilation failed for an unrelated reason while checking $(basename "$probe_file")." >&2 - exit 1 - fi - - rm -f "$probe_file" -} - -cat > "$core_nullable_probe" <<'EOF' -import io.github.aalsanie.codes.Outcome -import io.github.aalsanie.codes.StandardOutcomes - -fun coreNullableReturnProbe() { - val outcome = Outcome.of(StandardOutcomes.NOT_FOUND) - val detail: String = outcome.detail - println(detail) -} -EOF -expect_compile_failure "$core_nullable_probe" - -cat > "$core_null_marked_probe" <<'EOF' -import io.github.aalsanie.codes.Outcome - -fun coreNullMarkedProbe() { - Outcome.of(null) -} -EOF -expect_compile_failure "$core_null_marked_probe" - -cat > "$spring_null_marked_probe" <<'EOF' -import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper - -fun springNullMarkedProbe() { - OutcomeProblemDetailMapper.safeDefaults().map(null) -} -EOF -expect_compile_failure "$spring_null_marked_probe" - -cat > "$grpc_null_marked_probe" <<'EOF' -import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper - -fun grpcNullMarkedProbe() { - GoogleRpcOutcomeMapper.safeDefaults().map(null) -} -EOF -expect_compile_failure "$grpc_null_marked_probe" - -echo "Kotlin $kotlin_version JSpecify contracts verified for core, Spring, and gRPC against Spring $spring_version and gRPC $grpc_version." +echo "Kotlin $kotlin_version consumer verified with strict JSpecify against Spring $spring_version." From bd33bcca215160f4e50dbf6285bacc41eb8b5f2e Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:35:35 +0300 Subject: [PATCH 18/50] Update local publication script --- scripts/prepare-compatibility-repository.ps1 | 43 +++++--------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/scripts/prepare-compatibility-repository.ps1 b/scripts/prepare-compatibility-repository.ps1 index 9d3965d..1364530 100644 --- a/scripts/prepare-compatibility-repository.ps1 +++ b/scripts/prepare-compatibility-repository.ps1 @@ -1,53 +1,32 @@ param( - [Parameter(Mandatory = $false)] - [string]$Repository = "build\compatibility-maven" + [Parameter(Mandatory = $true)] + [string]$Repository ) $ErrorActionPreference = "Stop" +$rootDir = Split-Path -Parent $PSScriptRoot -$Root = Split-Path -Parent $PSScriptRoot if (-not [System.IO.Path]::IsPathRooted($Repository)) { - $Repository = Join-Path $Root $Repository + $Repository = Join-Path $rootDir $Repository } -$Repository = [System.IO.Path]::GetFullPath($Repository) if (Test-Path $Repository) { Remove-Item -Recurse -Force $Repository } New-Item -ItemType Directory -Force -Path $Repository | Out-Null -& (Join-Path $Root "gradlew.bat") ` +& (Join-Path $rootDir "gradlew.bat") ` "-Dmaven.repo.local=$Repository" ` :publishToMavenLocal ` :codes-spring:publishToMavenLocal ` - :codes-grpc-java:publishToMavenLocal ` --stacktrace +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -if ($LASTEXITCODE -ne 0) { - throw "Compatibility publication failed with exit code $LASTEXITCODE" -} - -$VersionLine = Get-Content (Join-Path $Root "gradle.properties") | - Where-Object { $_ -like "VERSION_NAME=*" } | - Select-Object -First 1 -$Version = $VersionLine.Substring("VERSION_NAME=".Length) - -foreach ($Artifact in @("codes", "codes-spring", "codes-grpc-java")) { - $ArtifactDir = Join-Path $Repository "io\github\aalsanie\$Artifact\$Version" - $Jar = Join-Path $ArtifactDir "$Artifact-$Version.jar" - $Pom = Join-Path $ArtifactDir "$Artifact-$Version.pom" +$env:MAVEN_REPO_LOCAL = $Repository +sh (Join-Path $rootDir "scripts/verify-local-publications.sh") +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if (-not (Test-Path $Jar) -or (Get-Item $Jar).Length -eq 0) { - throw "Missing compatibility publication JAR: $Jar" - } - if (-not (Test-Path $Pom) -or (Get-Item $Pom).Length -eq 0) { - throw "Missing compatibility publication POM: $Pom" - } -} - -python (Join-Path $Root "scripts\verify-published-pom-contracts.py") $Repository -if ($LASTEXITCODE -ne 0) { - throw "Published POM verification failed with exit code $LASTEXITCODE" -} +python (Join-Path $rootDir "scripts/verify-published-pom-contracts.py") $Repository +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Output $Repository From d38178d88dc6d8ecf407af3fa911d7d99712462c Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:36:16 +0300 Subject: [PATCH 19/50] Remove obsolete compatibility paths --- .github/workflows/release-candidate.yml | 180 --------- .../integration-reliability-platform.patch | 159 -------- compatibility/external/manifest.json | 24 -- .../external/patient-mgmt-microservices.patch | 365 ------------------ compatibility/http-mappings.snapshot | 12 - compatibility/http-status-codes.snapshot | 16 - compatibility/spring-http-problems.snapshot | 79 ---- compatibility/standard-namespace.snapshot | 1 - compatibility/standard-outcomes.snapshot | 17 - docs/protocol-mappings.md | 61 --- scripts/test-release-promotion-policy.py | 144 ------- scripts/verify-rc-integrations.sh | 216 ----------- scripts/verify-release-promotion.py | 232 ----------- 13 files changed, 1506 deletions(-) delete mode 100644 .github/workflows/release-candidate.yml delete mode 100644 compatibility/external/integration-reliability-platform.patch delete mode 100644 compatibility/external/manifest.json delete mode 100644 compatibility/external/patient-mgmt-microservices.patch delete mode 100644 compatibility/http-mappings.snapshot delete mode 100644 compatibility/http-status-codes.snapshot delete mode 100644 compatibility/spring-http-problems.snapshot delete mode 100644 compatibility/standard-namespace.snapshot delete mode 100644 compatibility/standard-outcomes.snapshot delete mode 100644 docs/protocol-mappings.md delete mode 100644 scripts/test-release-promotion-policy.py delete mode 100644 scripts/verify-rc-integrations.sh delete mode 100644 scripts/verify-release-promotion.py diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml deleted file mode 100644 index 8e8d678..0000000 --- a/.github/workflows/release-candidate.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: Release Candidate - -on: - push: - tags: - - "v*.*.*-RC*" - -permissions: - contents: read - -concurrency: - group: maven-central-release - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - timeout-minutes: 90 - outputs: - version: ${{ steps.version.outputs.value }} - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 0 - persist-credentials: false - - - id: version - name: Verify release-candidate tag - shell: bash - run: | - set -euo pipefail - - version="$(sh ./scripts/version.sh)" - - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-RC[1-9][0-9]*$ ]] || { - echo "Invalid release-candidate version: $version" - exit 1 - } - - test "${GITHUB_REF_NAME}" = "v${version}" - - git fetch --no-tags origin main - - test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = \ - "$(git rev-parse "origin/main^{commit}")" - - echo "value=$version" >> "$GITHUB_OUTPUT" - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "17" - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 - with: - cache-provider: basic - - - name: Verify release-candidate tree - run: | - python scripts/verify-compatibility-matrix.py - ./gradlew clean verifyAll --stacktrace - - - name: Publish clean release-candidate artifacts - run: bash scripts/prepare-compatibility-repository.sh build/compatibility-maven - - - name: Verify published-artifact consumers before Central - shell: bash - env: - MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven - run: | - set -euo pipefail - version="$(sh ./scripts/version.sh)" - - ./gradlew \ - -p smoke-test-java \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - clean check \ - --stacktrace - - ./gradlew \ - -p smoke-test-kotlin \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - clean check \ - --stacktrace - - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify - - bash ./scripts/verify-kotlin-nullability.sh 2.4.10 - - - name: Publish all Codes artifacts to Maven Central - run: >- - ./gradlew - :publishToMavenCentral - :codes-spring:publishToMavenCentral - :codes-grpc-java:publishToMavenCentral - --no-configuration-cache - --stacktrace - env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} - ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} - - external-integrations: - needs: publish - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "21" - - - name: Verify external integrations from Maven Central - run: >- - bash scripts/verify-rc-integrations.sh - "${{ needs.publish.outputs.version }}" - build/reports/rc-integrations.md - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - if: always() - with: - name: rc-external-integrations - path: | - build/reports/rc-integrations.md - build/rc-integrations/*.log - if-no-files-found: warn - retention-days: 30 - - github-prerelease: - needs: [publish, external-integrations] - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - name: Create GitHub prerelease - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - version="${GITHUB_REF_NAME#v}" - - awk -v version="$version" ' - $0 == "## " version { - capture = 1 - next - } - capture && /^## / { - exit - } - capture { - print - } - ' CHANGELOG.md > release-notes.md - - test -s release-notes.md - - gh release create "$GITHUB_REF_NAME" \ - --verify-tag \ - --prerelease \ - --title "Codes $version" \ - --notes-file release-notes.md diff --git a/compatibility/external/integration-reliability-platform.patch b/compatibility/external/integration-reliability-platform.patch deleted file mode 100644 index 505165b..0000000 --- a/compatibility/external/integration-reliability-platform.patch +++ /dev/null @@ -1,159 +0,0 @@ ---- a/build.gradle.kts -+++ b/build.gradle.kts -@@ -6,6 +6,8 @@ - - group = "io.github.aalsanie" - version = "0.0.1-SNAPSHOT" -+ -+val codesVersion = providers.gradleProperty("codesVersion").orElse("0.4.0-RC1") - - java { - toolchain { -@@ -18,6 +20,7 @@ - } - - dependencies { -+ implementation("io.github.aalsanie:codes-spring:${codesVersion.get()}") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-validation") ---- a/src/main/java/io/github/aalsanie/irp/common/api/GlobalExceptionHandler.java -+++ b/src/main/java/io/github/aalsanie/irp/common/api/GlobalExceptionHandler.java -@@ -1,5 +1,8 @@ - package io.github.aalsanie.irp.common.api; - -+import io.github.aalsanie.codes.Outcome; -+import io.github.aalsanie.codes.StandardOutcomes; -+import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; - import io.github.aalsanie.irp.connections.DuplicateConnectionException; - import io.github.aalsanie.irp.events.DuplicateInboundEventException; - import io.github.aalsanie.irp.events.EventNotFoundException; -@@ -7,6 +10,7 @@ - import io.github.aalsanie.irp.events.InvalidEventProcessingStatus; - import jakarta.servlet.http.HttpServletRequest; - import org.springframework.http.HttpStatus; -+import org.springframework.http.ProblemDetail; - import org.springframework.http.ResponseEntity; - import org.springframework.web.bind.annotation.ExceptionHandler; - import org.springframework.web.bind.annotation.RestControllerAdvice; -@@ -15,6 +19,8 @@ - - @RestControllerAdvice - public class GlobalExceptionHandler { -+ private static final OutcomeProblemDetailMapper CODES_PROBLEMS = -+ OutcomeProblemDetailMapper.safeDefaults(); - - @ExceptionHandler(value = {DuplicateConnectionException.class}) - public ResponseEntity handleException(DuplicateConnectionException exception, -@@ -53,14 +59,16 @@ - } - - @ExceptionHandler(value = {DuplicateInboundEventException.class}) -- public ResponseEntity handleException(DuplicateInboundEventException exception, HttpServletRequest request) { -- HttpStatus status = HttpStatus.CONFLICT; -- ApiErrorResponse response = new ApiErrorResponse(Instant.now(), -- status.value(), -- exception.getMessage(), -- status.getReasonPhrase(), -- request.getRequestURI()); -- return ResponseEntity.status(HttpStatus.CONFLICT).body(response); -+ public ResponseEntity handleException(DuplicateInboundEventException exception) { -+ ProblemDetail problem = CODES_PROBLEMS -+ .map(Outcome.of(StandardOutcomes.ALREADY_EXISTS)) -+ .orNull(); -+ -+ if (problem == null) { -+ throw new IllegalStateException("ALREADY_EXISTS has no HTTP mapping", exception); -+ } -+ -+ return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(value = {InvalidEventProcessingStatus.class}) ---- /dev/null -+++ b/src/test/java/io/github/aalsanie/irp/CodesSpringIntegrationTest.java -@@ -0,0 +1,84 @@ -+package io.github.aalsanie.irp; -+ -+import io.github.aalsanie.irp.connections.ConnectionStatus; -+import io.github.aalsanie.irp.connections.IntegrationConnection; -+import io.github.aalsanie.irp.connections.IntegrationConnectionRepository; -+import io.github.aalsanie.irp.events.EventRepository; -+import org.junit.jupiter.api.BeforeEach; -+import org.junit.jupiter.api.Test; -+import org.springframework.beans.factory.annotation.Autowired; -+import org.springframework.boot.test.context.SpringBootTest; -+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -+import org.springframework.context.annotation.Import; -+import org.springframework.http.MediaType; -+import org.springframework.test.web.servlet.MockMvc; -+ -+import java.time.Instant; -+import java.util.UUID; -+ -+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -+ -+@SpringBootTest -+@AutoConfigureMockMvc -+@Import(TestcontainersConfiguration.class) -+class CodesSpringIntegrationTest { -+ -+ @Autowired -+ private MockMvc mockMvc; -+ -+ @Autowired -+ private EventRepository eventRepository; -+ -+ @Autowired -+ private IntegrationConnectionRepository connectionRepository; -+ -+ @BeforeEach -+ void setUp() { -+ eventRepository.deleteAll(); -+ connectionRepository.deleteAll(); -+ } -+ -+ @Test -+ void duplicateInboundEventKeepsStableCodesIdentityWithoutOccurrenceDetail() -+ throws Exception { -+ IntegrationConnection connection = connectionRepository.saveAndFlush( -+ new IntegrationConnection( -+ UUID.randomUUID(), -+ "integration-test", -+ "stripe", -+ ConnectionStatus.ACTIVE, -+ Instant.now() -+ ) -+ ); -+ -+ String request = """ -+ { -+ "externalEventId": "evt_codes", -+ "eventType": "payment.succeeded", -+ "payload": { -+ "paymentId": "pay_codes" -+ } -+ } -+ """; -+ -+ String endpoint = "/api/v1/connections/" + connection.getId() + "/events"; -+ -+ mockMvc.perform(post(endpoint) -+ .contentType(MediaType.APPLICATION_JSON) -+ .content(request)) -+ .andExpect(status().isCreated()); -+ -+ mockMvc.perform(post(endpoint) -+ .contentType(MediaType.APPLICATION_JSON) -+ .content(request)) -+ .andExpect(status().isConflict()) -+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) -+ .andExpect(jsonPath("$.status").value(409)) -+ .andExpect(jsonPath("$.code") -+ .value("io.github.aalsanie.codes.standard:ALREADY_EXISTS")) -+ .andExpect(jsonPath("$.detail").doesNotExist()); -+ } -+} diff --git a/compatibility/external/manifest.json b/compatibility/external/manifest.json deleted file mode 100644 index d05a599..0000000 --- a/compatibility/external/manifest.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "integrations": [ - { - "id": "integration-reliability-platform", - "repository": "https://github.com/aalsanie/integration-reliability-platform.git", - "commit": "1f99ad8ec4b0b7da2a20ab20101a6adb677cb735", - "patch": "compatibility/external/integration-reliability-platform.patch", - "boundary": "Spring MVC", - "scope": "replace one handwritten duplicate-event HTTP mapping with codes-spring", - "dependency_notes": "The application uses Spring Boot 4.1.0 and owns Spring dependency management.", - "expected_identity": "io.github.aalsanie.codes.standard:ALREADY_EXISTS" - }, - { - "id": "patient-mgmt-microservices", - "repository": "https://github.com/pratham2402/patient-mgmt-microservices.git", - "commit": "26645990986a4f17b755ec17ed390c9e90112d36", - "patch": "compatibility/external/patient-mgmt-microservices.patch", - "boundary": "Spring MVC + gRPC Java", - "scope": "exercise INVALID_ARGUMENT through Spring MVC and gRPC Java", - "dependency_notes": "The repository pins gRPC 1.69.0; the integration patch updates that line to the Codes 1.75.0 compatibility floor.", - "expected_identity": "io.github.aalsanie.codes.standard:INVALID_ARGUMENT" - } - ] -} diff --git a/compatibility/external/patient-mgmt-microservices.patch b/compatibility/external/patient-mgmt-microservices.patch deleted file mode 100644 index 7f78791..0000000 --- a/compatibility/external/patient-mgmt-microservices.patch +++ /dev/null @@ -1,365 +0,0 @@ ---- a/patient-service/pom.xml -+++ b/patient-service/pom.xml -@@ -28,8 +28,14 @@ - - - 21 -+ 0.4.0-RC1 - - -+ -+ io.github.aalsanie -+ codes-spring -+ ${codes.version} -+ - - org.springframework.boot - spring-boot-starter-data-jpa ---- a/patient-service/src/main/java/com/pm/patientservice/exception/GlobalExceptionHandler.java -+++ b/patient-service/src/main/java/com/pm/patientservice/exception/GlobalExceptionHandler.java -@@ -1,31 +1,57 @@ - package com.pm.patientservice.exception; - --import jakarta.validation.constraints.Email; -+import io.github.aalsanie.codes.Issue; -+import io.github.aalsanie.codes.Outcome; -+import io.github.aalsanie.codes.StandardOutcomes; -+import io.github.aalsanie.codes.spring.OutcomeProblemDetailMapper; -+import io.github.aalsanie.codes.spring.SpringHttpStatusMapper; -+import io.github.aalsanie.codes.spring.SpringOutcomeExposure; -+import io.github.aalsanie.codes.spring.SpringProblemTypeUriMapper; - import org.slf4j.Logger; - import org.slf4j.LoggerFactory; -+import org.springframework.http.ProblemDetail; - import org.springframework.http.ResponseEntity; - import org.springframework.web.bind.MethodArgumentNotValidException; - import org.springframework.web.bind.annotation.ControllerAdvice; - import org.springframework.web.bind.annotation.ExceptionHandler; - -+import java.util.ArrayList; - import java.util.HashMap; -+import java.util.List; - import java.util.Map; - - @ControllerAdvice - public class GlobalExceptionHandler { - - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); -+ private static final OutcomeProblemDetailMapper VALIDATION_PROBLEMS = -+ new OutcomeProblemDetailMapper( -+ SpringHttpStatusMapper.standard(), -+ SpringOutcomeExposure.publicErrors(), -+ SpringProblemTypeUriMapper.empty() -+ ); - - @ExceptionHandler(MethodArgumentNotValidException.class) -- public ResponseEntity> handleValidationException( -+ public ResponseEntity handleValidationException( - MethodArgumentNotValidException ex) { - -- Map errors = new HashMap<>(); -+ List issues = new ArrayList<>(); -+ ex.getBindingResult().getFieldErrors().forEach(error -> { -+ String message = error.getDefaultMessage() == null -+ ? "Invalid value." -+ : error.getDefaultMessage(); -+ issues.add(Issue.at(error.getField(), message)); -+ }); - -- ex.getBindingResult().getFieldErrors().forEach( -- error -> errors.put(error.getField(), error.getDefaultMessage())); -+ ProblemDetail problem = VALIDATION_PROBLEMS -+ .map(Outcome.of(StandardOutcomes.INVALID_ARGUMENT, null, issues)) -+ .orNull(); - -- return ResponseEntity.badRequest().body(errors); -+ if (problem == null) { -+ throw new IllegalStateException("INVALID_ARGUMENT has no HTTP mapping"); -+ } -+ -+ return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(EmailAlreadyExistsException.class) ---- /dev/null -+++ b/patient-service/src/test/java/com/pm/patientservice/exception/CodesValidationBoundaryTest.java -@@ -0,0 +1,55 @@ -+package com.pm.patientservice.exception; -+ -+import jakarta.validation.Valid; -+import jakarta.validation.constraints.NotBlank; -+import org.junit.jupiter.api.Test; -+import org.springframework.beans.factory.annotation.Autowired; -+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -+import org.springframework.context.annotation.Import; -+import org.springframework.http.MediaType; -+import org.springframework.web.bind.annotation.PostMapping; -+import org.springframework.web.bind.annotation.RequestBody; -+import org.springframework.web.bind.annotation.RestController; -+import org.springframework.test.web.servlet.MockMvc; -+ -+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -+ -+@WebMvcTest(controllers = CodesValidationBoundaryTest.TestController.class) -+@Import(GlobalExceptionHandler.class) -+class CodesValidationBoundaryTest { -+ -+ @Autowired -+ private MockMvc mockMvc; -+ -+ @Test -+ void validationKeepsCodesIdentityAndStructuredIssuesAtHttpBoundary() -+ throws Exception { -+ mockMvc.perform(post("/codes-test/validate") -+ .contentType(MediaType.APPLICATION_JSON) -+ .content(""" -+ { -+ "email": "" -+ } -+ """)) -+ .andExpect(status().isBadRequest()) -+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) -+ .andExpect(jsonPath("$.status").value(400)) -+ .andExpect(jsonPath("$.code") -+ .value("io.github.aalsanie.codes.standard:INVALID_ARGUMENT")) -+ .andExpect(jsonPath("$.issues[0].path").value("email")) -+ .andExpect(jsonPath("$.detail").doesNotExist()); -+ } -+ -+ @RestController -+ static class TestController { -+ @PostMapping("/codes-test/validate") -+ void validate(@Valid @RequestBody TestRequest request) { -+ } -+ } -+ -+ record TestRequest(@NotBlank String email) { -+ } -+} ---- a/billing-service/pom.xml -+++ b/billing-service/pom.xml -@@ -28,8 +28,26 @@ - - - 21 -+ 0.4.0-RC1 -+ 1.75.0 - -+ -+ -+ -+ io.grpc -+ grpc-bom -+ ${grpc.version} -+ pom -+ import -+ -+ -+ - -+ -+ io.github.aalsanie -+ codes-grpc-java -+ ${codes.version} -+ - - org.springframework.boot - spring-boot-starter-web -@@ -45,17 +63,17 @@ - - io.grpc - grpc-netty-shaded -- 1.69.0 -+ ${grpc.version} - - - io.grpc - grpc-protobuf -- 1.69.0 -+ ${grpc.version} - - - io.grpc - grpc-stub -- 1.69.0 -+ ${grpc.version} - - - org.apache.tomcat -@@ -103,7 +121,7 @@ - com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier} - grpc-java - -- io.grpc:protoc-gen-grpc-java:1.68.1:exe:${os.detected.classifier} -+ io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} - - - ---- a/billing-service/src/main/java/com/pm/billingservice/grpc/BillingGrpcService.java -+++ b/billing-service/src/main/java/com/pm/billingservice/grpc/BillingGrpcService.java -@@ -1,21 +1,58 @@ - package com.pm.billingservice.grpc; - -+import billing.BillingRequest; - import billing.BillingResponse; - import billing.BillingServiceGrpc.BillingServiceImplBase; -+import io.github.aalsanie.codes.Issue; -+import io.github.aalsanie.codes.Outcome; -+import io.github.aalsanie.codes.StandardOutcomes; -+import io.github.aalsanie.codes.grpc.GoogleRpcOutcomeMapper; -+import io.github.aalsanie.codes.grpc.GrpcOutcomeExceptions; -+import io.github.aalsanie.codes.grpc.GrpcOutcomeExposure; -+import io.github.aalsanie.codes.protocol.grpc.GrpcOutcomeMapper; -+import io.grpc.StatusRuntimeException; - import io.grpc.stub.StreamObserver; - import net.devh.boot.grpc.server.service.GrpcService; - import org.slf4j.Logger; - import org.slf4j.LoggerFactory; - -+import java.util.ArrayList; -+import java.util.List; -+ - @GrpcService - public class BillingGrpcService extends BillingServiceImplBase { - private static final Logger log = LoggerFactory.getLogger(BillingGrpcService.class); -+ private static final GoogleRpcOutcomeMapper ERRORS = -+ new GoogleRpcOutcomeMapper( -+ GrpcOutcomeMapper.standard(), -+ GrpcOutcomeExposure.publicErrors() -+ ); - - @Override -- public void createBillingAccount(billing.BillingRequest billingRequest, -+ public void createBillingAccount(BillingRequest billingRequest, - StreamObserver responseObserver) { - - log.info("createBillingAccount request received {}", billingRequest.toString()); -+ -+ List issues = validationIssues(billingRequest); -+ if (!issues.isEmpty()) { -+ Outcome outcome = Outcome.of( -+ StandardOutcomes.INVALID_ARGUMENT, -+ null, -+ issues -+ ); -+ -+ StatusRuntimeException error = GrpcOutcomeExceptions -+ .toStatusRuntimeException(outcome, ERRORS) -+ .orNull(); -+ -+ if (error == null) { -+ throw new IllegalStateException("INVALID_ARGUMENT has no gRPC mapping"); -+ } -+ -+ responseObserver.onError(error); -+ return; -+ } - - // Business logic - e.g. save to database, perform calculations, etc - -@@ -27,4 +64,20 @@ - responseObserver.onNext(response); - responseObserver.onCompleted(); - } -+ -+ private static List validationIssues(BillingRequest request) { -+ List issues = new ArrayList<>(); -+ -+ if (request.getPatientId().isBlank()) { -+ issues.add(Issue.at("patientId", "Patient id is required.")); -+ } -+ if (request.getName().isBlank()) { -+ issues.add(Issue.at("name", "Name is required.")); -+ } -+ if (request.getEmail().isBlank()) { -+ issues.add(Issue.at("email", "Email is required.")); -+ } -+ -+ return issues; -+ } - } ---- /dev/null -+++ b/billing-service/src/test/java/com/pm/billingservice/grpc/BillingGrpcServiceCodesTest.java -@@ -0,0 +1,79 @@ -+package com.pm.billingservice.grpc; -+ -+import billing.BillingRequest; -+import billing.BillingResponse; -+import com.google.protobuf.Any; -+import com.google.rpc.BadRequest; -+import com.google.rpc.ErrorInfo; -+import io.grpc.StatusRuntimeException; -+import io.grpc.protobuf.StatusProto; -+import io.grpc.stub.StreamObserver; -+import org.junit.jupiter.api.Test; -+ -+import java.util.concurrent.atomic.AtomicReference; -+ -+import static org.junit.jupiter.api.Assertions.assertEquals; -+import static org.junit.jupiter.api.Assertions.assertInstanceOf; -+import static org.junit.jupiter.api.Assertions.assertNotNull; -+import static org.junit.jupiter.api.Assertions.assertNull; -+ -+class BillingGrpcServiceCodesTest { -+ -+ @Test -+ void invalidRequestKeepsCodesIdentityAndStructuredIssuesInTrailers() -+ throws Exception { -+ AtomicReference failure = new AtomicReference<>(); -+ AtomicReference response = new AtomicReference<>(); -+ -+ StreamObserver observer = new StreamObserver<>() { -+ @Override -+ public void onNext(BillingResponse value) { -+ response.set(value); -+ } -+ -+ @Override -+ public void onError(Throwable throwable) { -+ failure.set(throwable); -+ } -+ -+ @Override -+ public void onCompleted() { -+ } -+ }; -+ -+ new BillingGrpcService().createBillingAccount( -+ BillingRequest.getDefaultInstance(), -+ observer -+ ); -+ -+ assertNull(response.get()); -+ -+ StatusRuntimeException exception = -+ assertInstanceOf(StatusRuntimeException.class, failure.get()); -+ -+ com.google.rpc.Status status = StatusProto.fromThrowable(exception); -+ assertNotNull(status); -+ assertEquals(3, status.getCode()); -+ -+ ErrorInfo identity = null; -+ BadRequest badRequest = null; -+ -+ for (Any detail : status.getDetailsList()) { -+ if (detail.is(ErrorInfo.class)) { -+ identity = detail.unpack(ErrorInfo.class); -+ } else if (detail.is(BadRequest.class)) { -+ badRequest = detail.unpack(BadRequest.class); -+ } -+ } -+ -+ assertNotNull(identity); -+ assertEquals("io.github.aalsanie.codes.standard", identity.getDomain()); -+ assertEquals("INVALID_ARGUMENT", identity.getReason()); -+ -+ assertNotNull(badRequest); -+ assertEquals(3, badRequest.getFieldViolationsCount()); -+ assertEquals("patientId", badRequest.getFieldViolations(0).getField()); -+ assertEquals("name", badRequest.getFieldViolations(1).getField()); -+ assertEquals("email", badRequest.getFieldViolations(2).getField()); -+ } -+} diff --git a/compatibility/http-mappings.snapshot b/compatibility/http-mappings.snapshot deleted file mode 100644 index 3056870..0000000 --- a/compatibility/http-mappings.snapshot +++ /dev/null @@ -1,12 +0,0 @@ -io.github.aalsanie.codes.standard:ALREADY_EXISTS|409 -io.github.aalsanie.codes.standard:DATA_LOSS|500 -io.github.aalsanie.codes.standard:INTERNAL|500 -io.github.aalsanie.codes.standard:INVALID_ARGUMENT|400 -io.github.aalsanie.codes.standard:NOT_FOUND|404 -io.github.aalsanie.codes.standard:OK|200 -io.github.aalsanie.codes.standard:OUT_OF_RANGE|400 -io.github.aalsanie.codes.standard:PERMISSION_DENIED|403 -io.github.aalsanie.codes.standard:RATE_LIMITED|429 -io.github.aalsanie.codes.standard:UNAUTHENTICATED|401 -io.github.aalsanie.codes.standard:UNAVAILABLE|503 -io.github.aalsanie.codes.standard:UNIMPLEMENTED|501 \ No newline at end of file diff --git a/compatibility/http-status-codes.snapshot b/compatibility/http-status-codes.snapshot deleted file mode 100644 index 808ec01..0000000 --- a/compatibility/http-status-codes.snapshot +++ /dev/null @@ -1,16 +0,0 @@ -ACCEPTED|202 -BAD_REQUEST|400 -CONFLICT|409 -CREATED|201 -FORBIDDEN|403 -GATEWAY_TIMEOUT|504 -INTERNAL_SERVER_ERROR|500 -NOT_FOUND|404 -NOT_IMPLEMENTED|501 -NO_CONTENT|204 -OK|200 -PAYLOAD_TOO_LARGE|413 -PRECONDITION_FAILED|412 -SERVICE_UNAVAILABLE|503 -TOO_MANY_REQUESTS|429 -UNAUTHORIZED|401 diff --git a/compatibility/spring-http-problems.snapshot b/compatibility/spring-http-problems.snapshot deleted file mode 100644 index be29da1..0000000 --- a/compatibility/spring-http-problems.snapshot +++ /dev/null @@ -1,79 +0,0 @@ -# Rendered Spring HTTP problem contract. -# The same cases are exercised through MVC and WebFlux. -# JSON member ordering is intentionally not part of the contract. - -[safe] -status=404 -contentType=application/problem+json -type=about:blank -title=Not Found -detail= -instance=/safe -code=io.github.aalsanie.codes.standard:NOT_FOUND -issues=0 - -[public] -status=400 -contentType=application/problem+json -type=https://api.example.test/problems/invalid-argument -title=An argument supplied to the operation is invalid. -detail= -instance=/public -code=io.github.aalsanie.codes.standard:INVALID_ARGUMENT -issues=0 - -[custom] -status=422 -contentType=application/problem+json -type=https://api.example.test/problems/payment-declined -title=The payment was declined. -detail=Ask the customer to use another payment method. -instance=/custom -code=com.example.checkout:PAYMENT_DECLINED -issues=0 - -[validation] -status=400 -contentType=application/problem+json -type=https://api.example.test/problems/invalid-argument -title=An argument supplied to the operation is invalid. -detail= -instance=/validation -code=io.github.aalsanie.codes.standard:INVALID_ARGUMENT -issues=2 -issue.0.code= -issue.0.path=email -issue.0.message=Invalid email address. -issue.1.code= -issue.1.path=quantity -issue.1.message=Must be greater than zero. - -[unmapped] -status=500 -contentType=application/problem+json -type=about:blank -title=Internal Server Error -detail= -instance=/unmapped -code=io.github.aalsanie.codes.standard:FAILED_PRECONDITION -issues=0 - -[sensitive-detail] -status=422 -contentType=application/problem+json -type=https://api.example.test/problems/payment-declined -title=The payment was declined. -detail= -instance=/sensitive-detail -code=com.example.checkout:PAYMENT_DECLINED -issues=0 - -[custom-safe] -status=422 -contentType=application/problem+json -type=https://api.example.test/problems/payment-declined -title= -detail= -instance=/custom-safe -code=com.example.checkout:PAYMENT_DECLINED -issues=0 diff --git a/compatibility/standard-namespace.snapshot b/compatibility/standard-namespace.snapshot deleted file mode 100644 index f724b15..0000000 --- a/compatibility/standard-namespace.snapshot +++ /dev/null @@ -1 +0,0 @@ -io.github.aalsanie.codes.standard diff --git a/compatibility/standard-outcomes.snapshot b/compatibility/standard-outcomes.snapshot deleted file mode 100644 index b79e985..0000000 --- a/compatibility/standard-outcomes.snapshot +++ /dev/null @@ -1,17 +0,0 @@ -io.github.aalsanie.codes.standard:ABORTED|FAILED -io.github.aalsanie.codes.standard:ALREADY_EXISTS|FAILED -io.github.aalsanie.codes.standard:CANCELLED|FAILED -io.github.aalsanie.codes.standard:DATA_LOSS|FAILED -io.github.aalsanie.codes.standard:DEADLINE_EXCEEDED|FAILED -io.github.aalsanie.codes.standard:FAILED_PRECONDITION|FAILED -io.github.aalsanie.codes.standard:INTERNAL|FAILED -io.github.aalsanie.codes.standard:INVALID_ARGUMENT|FAILED -io.github.aalsanie.codes.standard:NOT_FOUND|FAILED -io.github.aalsanie.codes.standard:OK|SUCCEEDED -io.github.aalsanie.codes.standard:OUT_OF_RANGE|FAILED -io.github.aalsanie.codes.standard:PERMISSION_DENIED|FAILED -io.github.aalsanie.codes.standard:RATE_LIMITED|FAILED -io.github.aalsanie.codes.standard:RESOURCE_EXHAUSTED|FAILED -io.github.aalsanie.codes.standard:UNAUTHENTICATED|FAILED -io.github.aalsanie.codes.standard:UNAVAILABLE|FAILED -io.github.aalsanie.codes.standard:UNIMPLEMENTED|FAILED \ No newline at end of file diff --git a/docs/protocol-mappings.md b/docs/protocol-mappings.md deleted file mode 100644 index a3ff566..0000000 --- a/docs/protocol-mappings.md +++ /dev/null @@ -1,61 +0,0 @@ -# Protocol mappings - -Standard mappings provided by `HttpOutcomeMapper.standard()` and `GrpcOutcomeMapper.standard()`. - -Mappings convert application outcomes to protocol status. They do not change outcome identity or lifecycle state. - -| Outcome | HTTP | gRPC | -|---|---|---| -| `OK` | `200 OK` | `OK` | -| `INVALID_ARGUMENT` | `400 Bad Request` | `INVALID_ARGUMENT` | -| `UNAUTHENTICATED` | `401 Unauthorized` | `UNAUTHENTICATED` | -| `PERMISSION_DENIED` | `403 Forbidden` | `PERMISSION_DENIED` | -| `NOT_FOUND` | `404 Not Found` | `NOT_FOUND` | -| `ALREADY_EXISTS` | `409 Conflict` | `ALREADY_EXISTS` | -| `FAILED_PRECONDITION` | unmapped | `FAILED_PRECONDITION` | -| `OUT_OF_RANGE` | `400 Bad Request` | `OUT_OF_RANGE` | -| `RATE_LIMITED` | `429 Too Many Requests` | `RESOURCE_EXHAUSTED` | -| `CANCELLED` | unmapped | `CANCELLED` | -| `DEADLINE_EXCEEDED` | unmapped | `DEADLINE_EXCEEDED` | -| `ABORTED` | unmapped | `ABORTED` | -| `UNIMPLEMENTED` | `501 Not Implemented` | `UNIMPLEMENTED` | -| `UNAVAILABLE` | `503 Service Unavailable` | `UNAVAILABLE` | -| `INTERNAL` | `500 Internal Server Error` | `INTERNAL` | -| `DATA_LOSS` | `500 Internal Server Error` | `DATA_LOSS` | -| `RESOURCE_EXHAUSTED` | unmapped | `RESOURCE_EXHAUSTED` | - -## Why some HTTP mappings are absent - -Codes supplies a standard HTTP mapping only when the status is broadly applicable. - -`FAILED_PRECONDITION` is not universally HTTP `412 Precondition Failed`; HTTP 412 has conditional-request semantics. `DEADLINE_EXCEEDED` is not universally `504 Gateway Timeout`; HTTP 504 describes a gateway or proxy timing out while waiting for an upstream server. - -Applications map such outcomes according to their own HTTP contract. - -## Custom mappings - -```java -HttpOutcomeMapper mapper = HttpOutcomeMapper.standard() - .withMapping(paymentDeclined, HttpStatusCode.of(422)); -``` - -Override an existing mapping: - -```java -HttpOutcomeMapper mapper = HttpOutcomeMapper.standard() - .withOverride(StandardOutcomes.NOT_FOUND, HttpStatusCode.of(410)); -``` - -`withMapping` rejects duplicate mappings. `withOverride` rejects outcomes that are not already mapped. - -HTTP status constants such as `CREATED`, `ACCEPTED`, `NO_CONTENT`, and `PAYLOAD_TOO_LARGE` remain available for explicit application mappings even though those names are not standard application outcomes. - -## gRPC structured issues - -`GoogleRpcOutcomeMapper` always preserves the stable Codes identity in `ErrorInfo.domain` and `ErrorInfo.reason` for mapped failures. - -When issue exposure is enabled, Codes uses `google.rpc.BadRequest` only for outcomes mapped to gRPC `INVALID_ARGUMENT` or `OUT_OF_RANGE`, matching the standard Google RPC error-detail semantics. Every exposed issue must have a path that the application intends as a request-field path. - -If an outcome with issues is mapped to another gRPC status while issue exposure is enabled, the adapter rejects the mapping instead of emitting a misleading `BadRequest`. Pathless issues are rejected for the same reason. - -A coded issue can populate `BadRequest.FieldViolation.reason` only when its namespace matches the enclosing outcome namespace, because the reason is scoped by the enclosing `ErrorInfo.domain`. diff --git a/scripts/test-release-promotion-policy.py b/scripts/test-release-promotion-policy.py deleted file mode 100644 index 9586324..0000000 --- a/scripts/test-release-promotion-policy.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import unittest -from pathlib import Path - -SCRIPT = Path(__file__).with_name("verify-release-promotion.py") -SPEC = importlib.util.spec_from_file_location("verify_release_promotion", SCRIPT) -if SPEC is None or SPEC.loader is None: - raise RuntimeError(f"Cannot load {SCRIPT}") -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class ReleasePromotionPolicyTest(unittest.TestCase): - def test_selects_highest_numeric_rc(self): - tag, version = MODULE.select_latest_rc( - "0.4.0", - ["v0.4.0-RC1", "v0.4.0-RC10", "v0.4.0-RC2", "v0.5.0-RC9"], - ) - self.assertEqual("v0.4.0-RC10", tag) - self.assertEqual("0.4.0-RC10", version) - - def test_requires_rc_for_same_final_version(self): - with self.assertRaisesRegex(MODULE.PromotionError, "No release candidate"): - MODULE.select_latest_rc("0.4.0", ["v0.3.1", "v0.5.0-RC1"]) - - def test_accepts_exact_version_only_promotion(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: f"before\nversion={rc_version}\nafter\n" - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: value.replace(rc_version, final_version) - for path, value in rc_contents.items() - } - - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES), - rc_contents, - final_contents, - ) - - def test_rejects_product_code_change(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: rc_version - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: final_version - for path in MODULE.PROMOTION_FILES - } - - with self.assertRaisesRegex(MODULE.PromotionError, "unexpected changes"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES) | {"src/main/java/Changed.java"}, - rc_contents, - final_contents, - ) - - def test_rejects_missing_version_promotion_file(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: rc_version - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: final_version - for path in MODULE.PROMOTION_FILES - } - changed = set(MODULE.PROMOTION_FILES) - changed.remove("README.md") - - with self.assertRaisesRegex(MODULE.PromotionError, "missing version-only changes"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - changed, - rc_contents, - final_contents, - ) - - def test_rejects_non_version_edit_in_allowed_file(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: f"version={rc_version}\n" - for path in MODULE.PROMOTION_FILES - } - final_contents = { - path: value.replace(rc_version, final_version) - for path, value in rc_contents.items() - } - final_contents["README.md"] += "extra release edit\n" - - with self.assertRaisesRegex(MODULE.PromotionError, "changes other than replacing"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES), - rc_contents, - final_contents, - ) - - def test_requires_rc_version_token_in_every_promotion_file(self): - rc_version = "0.4.0-RC1" - final_version = "0.4.0" - rc_contents = { - path: rc_version - for path in MODULE.PROMOTION_FILES - } - rc_contents["README.md"] = "no version here" - final_contents = { - path: value.replace(rc_version, final_version) - for path, value in rc_contents.items() - } - - with self.assertRaisesRegex(MODULE.PromotionError, "policy is stale"): - MODULE.validate_promotion_contents( - rc_version, - final_version, - set(MODULE.PROMOTION_FILES), - rc_contents, - final_contents, - ) - - def test_rejects_structural_changes(self): - with self.assertRaisesRegex(MODULE.PromotionError, "must not create, delete, rename"): - MODULE.validate_no_structural_changes( - " mode change 100644 => 100755 README.md\n" - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/verify-rc-integrations.sh b/scripts/verify-rc-integrations.sh deleted file mode 100644 index 5e2be7d..0000000 --- a/scripts/verify-rc-integrations.sh +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -manifest="$root_dir/compatibility/external/manifest.json" -version="${1:?usage: verify-rc-integrations.sh [report]}" -report="${2:-$root_dir/build/reports/rc-integrations.md}" -work_dir="${RC_INTEGRATION_WORK_DIR:-$root_dir/build/rc-integrations}" - -if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-RC[1-9][0-9]*$ ]]; then - echo "Invalid release-candidate version: $version" >&2 - exit 1 -fi - -central_base="https://repo.maven.apache.org/maven2/io/github/aalsanie" -for artifact in codes codes-spring codes-grpc-java; do - url="$central_base/$artifact/$version/$artifact-$version.pom" - found=false - - for attempt in $(seq 1 40); do - if curl --silent --show-error --fail --head "$url" >/dev/null 2>&1; then - found=true - break - fi - sleep 15 - done - - if [[ "$found" != true ]]; then - echo "Published artifact did not become visible on Maven Central: $url" >&2 - exit 1 - fi -done - -rm -rf "$work_dir" -mkdir -p "$work_dir" "$(dirname "$report")" - -{ - echo "# Codes $version external integration verification" - echo - echo "Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" - echo - echo "Each integration uses a pinned external repository, applies an application-only patch, resolves Codes from Maven Central, and runs the relevant build and tests." - echo -} > "$report" - -overall=0 - -integration_value() { - local integration_id="$1" - local field="$2" - python - "$manifest" "$integration_id" "$field" <<'PY' -import json -import sys -from pathlib import Path - -manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) -integration = next(item for item in manifest["integrations"] if item["id"] == sys.argv[2]) -print(integration[sys.argv[3]]) -PY -} - -run_irp() { - local id="integration-reliability-platform" - local repo commit checkout patch_file start end elapsed status log - - repo="$(integration_value "$id" repository)" - commit="$(integration_value "$id" commit)" - patch_file="$root_dir/$(integration_value "$id" patch)" - checkout="$work_dir/$id" - log="$work_dir/$id.log" - - git clone --quiet --filter=blob:none --no-checkout "$repo" "$checkout" - git -C "$checkout" checkout --quiet --detach "$commit" - git -C "$checkout" apply --check "$patch_file" - git -C "$checkout" apply "$patch_file" - - start="$(date +%s)" - status="PASS" - - if ! ( - cd "$checkout" - bash ./gradlew \ - "-PcodesVersion=$version" \ - clean test \ - --no-build-cache \ - --stacktrace - - bash ./gradlew \ - "-PcodesVersion=$version" \ - dependencyInsight \ - --dependency codes-spring \ - --configuration runtimeClasspath \ - --no-build-cache - ) >"$log" 2>&1; then - status="FAIL" - overall=1 - fi - - end="$(date +%s)" - elapsed="$((end - start))" - - { - echo "## $id" - echo - echo "- Repository: \`$repo\`" - echo "- Commit: \`$commit\`" - echo "- Boundary: $(integration_value "$id" boundary)" - echo "- Scope: $(integration_value "$id" scope)" - echo "- Result: **$status**" - echo "- Clean build/test time: ${elapsed}s" - echo "- Dependency notes: $(integration_value "$id" dependency_notes)" - echo "- Expected identity: \`$(integration_value "$id" expected_identity)\`" - echo - echo "Patch statistics:" - echo - echo '```text' - git -C "$checkout" apply --stat "$patch_file" - echo '```' - echo - echo "Build log: \`$(basename "$log")\`" - echo - } >> "$report" -} - -run_patient() { - local id="patient-mgmt-microservices" - local repo commit checkout patch_file start end elapsed status log - - repo="$(integration_value "$id" repository)" - commit="$(integration_value "$id" commit)" - patch_file="$root_dir/$(integration_value "$id" patch)" - checkout="$work_dir/$id" - log="$work_dir/$id.log" - - git clone --quiet --filter=blob:none --no-checkout "$repo" "$checkout" - git -C "$checkout" checkout --quiet --detach "$commit" - git -C "$checkout" apply --check "$patch_file" - git -C "$checkout" apply "$patch_file" - - start="$(date +%s)" - status="PASS" - - if ! ( - cd "$checkout/patient-service" - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - clean test - - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - dependency:tree \ - "-Dincludes=io.github.aalsanie:*" - - cd "$checkout/billing-service" - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - clean test - - bash ./mvnw \ - --batch-mode \ - --no-transfer-progress \ - "-Dcodes.version=$version" \ - dependency:tree \ - "-Dincludes=io.github.aalsanie:*,io.grpc:*" - ) >"$log" 2>&1; then - status="FAIL" - overall=1 - fi - - end="$(date +%s)" - elapsed="$((end - start))" - - { - echo "## $id" - echo - echo "- Repository: \`$repo\`" - echo "- Commit: \`$commit\`" - echo "- Boundary: $(integration_value "$id" boundary)" - echo "- Scope: $(integration_value "$id" scope)" - echo "- Result: **$status**" - echo "- Clean build/test time: ${elapsed}s" - echo "- Dependency notes: $(integration_value "$id" dependency_notes)" - echo "- Expected identity: \`$(integration_value "$id" expected_identity)\`" - echo - echo "Patch statistics:" - echo - echo '```text' - git -C "$checkout" apply --stat "$patch_file" - echo '```' - echo - echo "Build log: \`$(basename "$log")\`" - echo - } >> "$report" -} - -run_irp -run_patient - -{ - echo "## Result" - echo - if [[ "$overall" -eq 0 ]]; then - echo "**PASS** — both external integrations built successfully and verified the expected boundary identity from Maven Central." - else - echo "**FAIL** — at least one external integration failed." - fi -} >> "$report" - -cat "$report" -exit "$overall" diff --git a/scripts/verify-release-promotion.py b/scripts/verify-release-promotion.py deleted file mode 100644 index 457dab0..0000000 --- a/scripts/verify-release-promotion.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -WORKFLOW_FILE = "release-candidate.yml" -PROMOTION_FILES = ( - "CHANGELOG.md", - "README.md", - "docs/integration-grpc.md", - "docs/integration-spring.md", - "gradle.properties", -) - - -class PromotionError(RuntimeError): - pass - - -def run_git(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-C", str(ROOT), *args], - check=check, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - -def git_line(*args: str) -> str: - return run_git(*args).stdout.strip() - - -def git_file(ref: str, path: str) -> str: - return run_git("show", f"{ref}:{path}").stdout - - -def property_value(text: str, key: str) -> str: - prefix = f"{key}=" - matches = [line[len(prefix):] for line in text.splitlines() if line.startswith(prefix)] - if len(matches) != 1 or not matches[0]: - raise PromotionError(f"Expected exactly one non-empty {key} property") - return matches[0] - - -def select_latest_rc(final_version: str, tags: list[str]) -> tuple[str, str]: - pattern = re.compile(rf"^v{re.escape(final_version)}-RC([1-9][0-9]*)$") - candidates: list[tuple[int, str]] = [] - for tag in tags: - match = pattern.fullmatch(tag) - if match: - candidates.append((int(match.group(1)), tag)) - - if not candidates: - raise PromotionError(f"No release candidate tag found for {final_version}") - - _, tag = max(candidates) - return tag, tag.removeprefix("v") - - -def validate_promotion_contents( - rc_version: str, - final_version: str, - changed_paths: set[str], - rc_contents: dict[str, str], - final_contents: dict[str, str], -) -> None: - expected_paths = set(PROMOTION_FILES) - if changed_paths != expected_paths: - unexpected = sorted(changed_paths - expected_paths) - missing = sorted(expected_paths - changed_paths) - details: list[str] = [] - if unexpected: - details.append("unexpected changes: " + ", ".join(unexpected)) - if missing: - details.append("missing version-only changes: " + ", ".join(missing)) - raise PromotionError("RC-to-final promotion is not version-only; " + "; ".join(details)) - - for path in PROMOTION_FILES: - rc_text = rc_contents[path] - final_text = final_contents[path] - if rc_version not in rc_text: - raise PromotionError( - f"Promotion policy is stale: {path} does not contain {rc_version} at the RC tag" - ) - expected = rc_text.replace(rc_version, final_version) - if final_text != expected: - raise PromotionError( - f"{path} contains changes other than replacing {rc_version} with {final_version}" - ) - - -def validate_no_structural_changes(summary: str) -> None: - if summary.strip(): - raise PromotionError( - "RC-to-final promotion must not create, delete, rename, or change file modes: " - + summary.strip().replace("\n", "; ") - ) - - -def github_json(url: str, token: str) -> object: - request = urllib.request.Request( - url, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "User-Agent": "codes-release-promotion", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - try: - with urllib.request.urlopen(request, timeout=20) as response: - return json.load(response) - except urllib.error.HTTPError as error: - raise PromotionError(f"GitHub API request failed with HTTP {error.code}: {url}") from error - except urllib.error.URLError as error: - raise PromotionError(f"GitHub API request failed: {url}: {error.reason}") from error - - -def verify_remote_rc(rc_tag: str, rc_sha: str) -> None: - repository = os.environ.get("GITHUB_REPOSITORY") - token = os.environ.get("GITHUB_TOKEN") - api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") - if not repository: - raise PromotionError("GITHUB_REPOSITORY is required") - if not token: - raise PromotionError("GITHUB_TOKEN is required") - - query = urllib.parse.urlencode( - { - "head_sha": rc_sha, - "per_page": "100", - } - ) - workflow = urllib.parse.quote(WORKFLOW_FILE, safe="") - runs_url = f"{api_url}/repos/{repository}/actions/workflows/{workflow}/runs?{query}" - runs_payload = github_json(runs_url, token) - if not isinstance(runs_payload, dict): - raise PromotionError("Unexpected GitHub workflow-runs response") - - runs = runs_payload.get("workflow_runs") - if not isinstance(runs, list): - raise PromotionError("GitHub workflow-runs response is missing workflow_runs") - - successful = [ - run for run in runs - if isinstance(run, dict) - and run.get("head_sha") == rc_sha - and run.get("event") == "push" - and run.get("conclusion") == "success" - ] - if not successful: - raise PromotionError( - f"No successful {WORKFLOW_FILE} push workflow exists for {rc_tag} ({rc_sha})" - ) - - encoded_tag = urllib.parse.quote(rc_tag, safe="") - release_url = f"{api_url}/repos/{repository}/releases/tags/{encoded_tag}" - release = github_json(release_url, token) - if not isinstance(release, dict): - raise PromotionError("Unexpected GitHub release response") - if release.get("tag_name") != rc_tag: - raise PromotionError(f"GitHub prerelease tag does not match {rc_tag}") - if release.get("draft") is not False or release.get("prerelease") is not True: - raise PromotionError(f"{rc_tag} must exist as a published GitHub prerelease") - - -def verify() -> None: - final_properties = (ROOT / "gradle.properties").read_text(encoding="utf-8") - final_version = property_value(final_properties, "VERSION_NAME") - if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", final_version): - raise PromotionError(f"Final version is not a release version: {final_version}") - - tags = [line for line in git_line("tag", "--list", f"v{final_version}-RC*").splitlines() if line] - rc_tag, rc_version = select_latest_rc(final_version, tags) - - ancestry = run_git("merge-base", "--is-ancestor", rc_tag, "HEAD", check=False) - if ancestry.returncode != 0: - raise PromotionError(f"Latest release candidate {rc_tag} is not an ancestor of HEAD") - - rc_sha = git_line("rev-parse", f"{rc_tag}^{{commit}}") - final_sha = git_line("rev-parse", "HEAD^{commit}") - if rc_sha == final_sha: - raise PromotionError("Final release must be a version-only promotion commit after the RC") - - validate_no_structural_changes(run_git("diff", "--summary", rc_tag, "HEAD").stdout) - - changed = { - line for line in git_line("diff", "--name-only", rc_tag, "HEAD").splitlines() if line - } - rc_contents = {path: git_file(rc_tag, path) for path in PROMOTION_FILES} - final_contents = { - path: (ROOT / path).read_text(encoding="utf-8") - for path in PROMOTION_FILES - } - validate_promotion_contents( - rc_version, - final_version, - changed, - rc_contents, - final_contents, - ) - - verify_remote_rc(rc_tag, rc_sha) - - print( - f"Verified {rc_tag} ({rc_sha}) -> v{final_version} ({final_sha}) " - "as a successful version-only RC promotion." - ) - - -def main() -> int: - try: - verify() - return 0 - except PromotionError as error: - print(f"Release promotion verification failed: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) From 07c2133e361dfb69b49e86ecba1b431095fe66e3 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:37:08 +0300 Subject: [PATCH 20/50] Rewrite documentation for problem types --- README.md | 214 ++++++++++++++++++++++++------------------------------ 1 file changed, 93 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 4f6766c..f53b3b2 100644 --- a/README.md +++ b/README.md @@ -4,167 +4,139 @@ [![CI](https://github.com/aalsanie/codes/actions/workflows/ci.yml/badge.svg)](https://github.com/aalsanie/codes/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -Codes provides stable application outcome identities and explicit boundary mappings for JVM applications. Applications keep their existing domain result or error model; Codes gives the system one machine identity for an outcome while HTTP and gRPC remain boundary decisions. - -```text -com.example.orders:ORDER_NOT_FOUND - -> HTTP 404 - -> gRPC NOT_FOUND - -> metric label - -> log identity - -> test assertion +Codes is a tiny Java library for reusable RFC 9457 problem type definitions. + +Spring provides `ProblemDetail` for an individual error occurrence. Codes provides the stable definition that can be reused across controllers, exception handlers, tests, and documentation. + +```java +ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" +); ``` -The same `OutcomeCode` can be used wherever application code, observability, and protocol boundaries need to agree on the outcome. +With Spring: -## Install +```java +ProblemDetail problem = ProblemDetails.forType(ORDER_NOT_FOUND); +``` -`0.4.0-RC1` is the current pre-release on Maven Central. +or with occurrence-specific detail: -Core: +```java +ProblemDetail problem = ProblemDetails.forTypeAndDetail( + ORDER_NOT_FOUND, + "Order o-123 was not found." +); +``` -```kotlin -dependencies { - implementation("io.github.aalsanie:codes:0.4.0-RC1") +The resulting problem keeps the reusable definition stable: + +```json +{ + "type": "https://api.example.com/problems/order-not-found", + "title": "Order not found", + "status": 404, + "detail": "Order o-123 was not found." } ``` -Spring: +## Install + +Core: ```kotlin dependencies { - implementation("io.github.aalsanie:codes-spring:0.4.0-RC1") + implementation("io.github.aalsanie:codes:0.4.0") } ``` -gRPC Java: +Spring: ```kotlin dependencies { - implementation("io.github.aalsanie:codes-grpc-java:0.4.0-RC1") + implementation("io.github.aalsanie:codes-spring:0.4.0") } ``` -All artifacts require Java 17+. The core artifact has zero runtime dependencies. The Spring and gRPC artifacts depend only on the boundary libraries they adapt. - -For a boundary-first walkthrough: - -* [Spring integration](docs/integration-spring.md) -* [gRPC integration](docs/integration-grpc.md) - -## Custom outcomes - -```java -OutcomeDefinition paymentDeclined = OutcomeDefinition.custom( - "com.example.payments", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." -); - -Outcome outcome = Outcome.of(paymentDeclined); - -HttpOutcomeMapper http = HttpOutcomeMapper.standard() - .withMapping(paymentDeclined, HttpStatusCode.of(422)); - -GrpcOutcomeMapper grpc = GrpcOutcomeMapper.standard() - .withMapping(paymentDeclined, GrpcStatusCode.FAILED_PRECONDITION); -``` - -`OutcomeCode` is the stable machine identity. Protocol mappings do not change that identity. - -## Standard outcomes - -```text -OK - -INVALID_ARGUMENT -UNAUTHENTICATED -PERMISSION_DENIED -NOT_FOUND -ALREADY_EXISTS -FAILED_PRECONDITION -OUT_OF_RANGE -RATE_LIMITED -CANCELLED -DEADLINE_EXCEEDED -ABORTED -UNIMPLEMENTED -UNAVAILABLE -INTERNAL -DATA_LOSS -RESOURCE_EXHAUSTED -``` - -`OK` is the standard successful outcome. Applications define domain-specific success, pending, and failure outcomes when the standard catalog does not match the operation. - -## Runtime occurrences +All artifacts require Java 17+. -```java -Outcome outcome = Outcome.of( - StandardOutcomes.NOT_FOUND, - "customerId=123" -); - -System.out.println(outcome.getCode()); -System.out.println(outcome.getMessage()); -System.out.println(outcome.getDetail()); -``` +The core artifact has no runtime dependencies and publishes no Maven dependencies. `codes-spring` depends on the core artifact but does not impose a Spring Framework version; the application supplies Spring Web. -`message` comes from the reusable definition. `detail` belongs to one occurrence. +## Define a problem catalog -## Structured issues +Applications can use the factory for a small number of problem types or implement `ProblemType` directly for a reusable catalog: ```java -ValidationResult validation = ValidationResult.invalid( - Issue.at("email", "Invalid email address.") -); - -Outcome outcome = validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT); -``` - -`ValidationResult` is a small convenience for aggregating issues and converting them into an outcome. - -## HTTP - -```java -HttpStatusCode status = HttpOutcomeMapper.standard() - .map(StandardOutcomes.NOT_FOUND) - .orNull(); - -assert status == HttpStatusCode.NOT_FOUND; +enum OrderProblems implements ProblemType { + ORDER_NOT_FOUND( + "https://api.example.com/problems/order-not-found", + 404, + "Order not found" + ), + ORDER_ALREADY_CANCELLED( + "https://api.example.com/problems/order-already-cancelled", + 409, + "Order already cancelled" + ); + + private final URI type; + private final int status; + private final String title; + + OrderProblems(String type, int status, String title) { + this.type = URI.create(type); + this.status = status; + this.title = title; + } + + public URI getType() { + return type; + } + + public int getStatus() { + return status; + } + + public String getTitle() { + return title; + } +} ``` -Some standard outcomes are intentionally left unmapped for HTTP when the correct status depends on the application. - -## gRPC +Different application exceptions can then reuse the same public problem type: ```java -GrpcStatusCode status = GrpcOutcomeMapper.standard() - .map(StandardOutcomes.NOT_FOUND) - .orNull(); +@ExceptionHandler(OrderNotFoundException.class) +ProblemDetail handleOrderNotFound(OrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order " + ex.orderId() + " was not found." + ); +} -assert status == GrpcStatusCode.NOT_FOUND; +@ExceptionHandler(ArchivedOrderNotFoundException.class) +ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Archived order " + ex.orderId() + " was not found." + ); +} ``` -The standard gRPC mapper covers all standard outcomes. - -## Kotlin +Codes does not own exception handling, controller advice, localization, extension properties, or request-specific data. Those remain application and Spring concerns. -Java getters and static factories are directly usable as Kotlin properties and calls: +## Do I need Codes? -```kotlin -val outcome = Outcome.of(StandardOutcomes.NOT_FOUND, "customerId=123") -val status = HttpOutcomeMapper.standard().map(outcome).orNull() +If an application only creates one or two `ProblemDetail` instances directly, a local helper may be simpler. -check(outcome.code == StandardOutcomes.NOT_FOUND.code) -check(status?.value == 404) -``` +Codes is useful when problem types are part of the API contract and need to be defined once and reused consistently across multiple handlers, modules, tests, or documentation. ## Reference +* [Spring integration](docs/integration-spring.md) * [Semantic contract](docs/semantic-contract.md) -* [HTTP and gRPC mappings](docs/protocol-mappings.md) * [Compatibility policy](docs/compatibility-policy.md) ## License From 9fa35208d62bc5a883d78fd39cf19dc0e084cac8 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:37:31 +0300 Subject: [PATCH 21/50] Rewrite documentation for problem types --- CHANGELOG.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ae6ca..9641cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,28 @@ # Changelog -## Unreleased +## 0.4.0 + +### Added + +* Added `ProblemType` as the reusable RFC 9457 problem type definition. +* Added `ProblemDetails` as the minimal Spring bridge for creating native `ProblemDetail` instances. + +### Changed + +* Refocused Codes on reusable RFC 9457 problem types. +* Kept the core artifact dependency-free. +* Made Spring Framework an application-provided integration dependency instead of a transitive dependency of `codes-spring`. +* Reduced the verified compatibility matrix to the Java, Spring, Kotlin, Gradle, and Maven combinations relevant to the new API. + +### Removed + +* Removed the generic outcome model, standard outcome catalog, registries, validation aggregation, exception helpers, and protocol-neutral status mappings. +* Removed the gRPC artifact and integration. +* Removed release-candidate promotion requirements tied to the superseded 0.4.0-RC1 API. + +### Breaking + +`0.4.0` is intentionally source and binary incompatible with the 0.4.0-RC1 outcome-oriented API. Applications using pre-0.4 outcome APIs must migrate to `ProblemType` only where they represent reusable HTTP problem definitions. ## 0.4.0-RC1 From 3b242047a42f0a7c4e14ff55a6690e24e5de2730 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:37:46 +0300 Subject: [PATCH 22/50] Rewrite problem type documentation --- docs/semantic-contract.md | 172 +++++++------------------------------- 1 file changed, 28 insertions(+), 144 deletions(-) diff --git a/docs/semantic-contract.md b/docs/semantic-contract.md index d315017..4e617fd 100644 --- a/docs/semantic-contract.md +++ b/docs/semantic-contract.md @@ -1,164 +1,48 @@ # Semantic contract -This document defines the semantics that Codes treats as part of its public contract. +Codes models reusable RFC 9457 problem type definitions. -## Core model +A `ProblemType` contains: -Codes provides a common vocabulary for application outcomes: +* a non-empty URI reference returned by `getType()`; +* an HTTP status between 100 and 599; +* a non-blank human-readable title. -* `OutcomeCode`: machine-readable identity -* `OutcomeState`: lifecycle state -* `OutcomeDefinition`: reusable outcome definition -* `Outcome`: runtime occurrence -* `Issue`: structured occurrence-level issue -* `OutcomeMapper` and `MappingResult`: boundary mapping -* `OutcomeRegistry`: immutable definition lookup and composition -* the standard outcome catalog -* standard HTTP and gRPC mappings - -Applications keep their own domain error or result model and use Codes where a shared outcome identity is useful. - -## Identity - -`OutcomeCode` is the machine identity of an outcome. - -```text -com.example.payments:PAYMENT_DECLINED -``` - -The namespace and name form the identity used for registry lookup, mappings, structured logging, observability, and other machine-readable uses. - -Human-readable messages are not identifiers and must not be used for branching or matching. - -## Lifecycle state - -### `SUCCEEDED` - -The modeled operation reached a successful terminal outcome. - -Transport status, response shape, resource creation, and follow-up work are separate concerns. - -### `PENDING` - -The modeled operation has not reached a terminal outcome. - -Applications define pending outcomes when that distinction is meaningful to the operation. - -### `FAILED` - -The modeled operation ended without producing a successful result for the caller. - -`FAILED` is not a transactional guarantee. External state may already have changed before a timeout, cancellation, or other failure was observed. - -## Definition and occurrence - -`OutcomeDefinition` contains: - -```text -code -state -defaultMessage -``` - -`Outcome` contains: - -```text -definition -detail -issues -``` - -`detail` and `issues` belong to one occurrence. `Outcome.toString()` and `OutcomeException.getMessage()` do not include occurrence `detail`. - -## Standard outcome catalog - -An outcome belongs in the standard catalog when its meaning: - -* is independent of a specific protocol, framework, or serialization format; -* applies across unrelated application domains; -* provides a useful distinction to callers, mapping policy, or observability; -* represents a distinct application outcome rather than only a transport representation. - -| Outcome | State | Meaning | -|---|---|---| -| `OK` | `SUCCEEDED` | The operation completed successfully. | -| `INVALID_ARGUMENT` | `FAILED` | One or more arguments are invalid independent of current application state. | -| `UNAUTHENTICATED` | `FAILED` | The operation requires an authenticated identity and no valid identity is available. | -| `PERMISSION_DENIED` | `FAILED` | The caller is not permitted to perform the operation. | -| `NOT_FOUND` | `FAILED` | A required or requested application resource or entity cannot be found. | -| `ALREADY_EXISTS` | `FAILED` | The operation conflicts with a resource or entity that already exists. | -| `FAILED_PRECONDITION` | `FAILED` | Required application state or a precondition is not satisfied. | -| `OUT_OF_RANGE` | `FAILED` | A supplied value is outside the valid range for the operation. | -| `RATE_LIMITED` | `FAILED` | A caller or workload exceeded an allowed operation rate. | -| `CANCELLED` | `FAILED` | The operation was cancelled before a successful result was produced. | -| `DEADLINE_EXCEEDED` | `FAILED` | The operation did not produce a successful result before its deadline. | -| `ABORTED` | `FAILED` | The operation was aborted because of a conflict or coordination condition. | -| `UNIMPLEMENTED` | `FAILED` | The requested operation or capability is not implemented or supported. | -| `UNAVAILABLE` | `FAILED` | A required capability is temporarily unavailable. | -| `INTERNAL` | `FAILED` | An internal implementation or invariant failure prevented a successful result. | -| `DATA_LOSS` | `FAILED` | Unrecoverable data loss or corruption was detected. | -| `RESOURCE_EXHAUSTED` | `FAILED` | A required capacity, quota, or other resource limit was exhausted. | - -Applications define more specific outcomes where the distinction matters. - -## Structured issues - -`Issue` contains an optional code, optional application-defined path, and required message. Issue path syntax is owned by the application. - -## Validation - -`ValidationResult` aggregates independent issues and can convert them to an outcome. Conversion requires the failure definition explicitly. +The type URI is the stable machine-readable identity of the problem type. ```java -validation.toOutcome(StandardOutcomes.INVALID_ARGUMENT); -``` - -A valid result produces `StandardOutcomes.OK`. An invalid result produces the supplied failed definition. The supplied definition is validated even for a valid result so invalid conversion policy fails immediately. - -## Protocol mappings - -`OutcomeMapper` maps an application outcome definition to a boundary representation. - -```text -NOT_FOUND -> HTTP 404 -NOT_FOUND -> gRPC NOT_FOUND +ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" +); ``` -Different outcomes can map to the same boundary value while keeping distinct application identities. - -`MappingResult.Unmapped` means the mapper has no policy for the outcome. Applications can add or override mappings explicitly. - -## Namespace contract - -Custom codes use `namespace:NAME`. +## Definition and occurrence -Namespaces: +`ProblemType` represents stable definition metadata. -* contain lowercase ASCII letters, digits, and hyphens; -* use dot-separated segments; -* start each segment with a lowercase ASCII letter; -* end each segment with a lowercase ASCII letter or digit. +It does not contain occurrence-specific information such as: -Names use uppercase ASCII letters, digits, and underscores. +* detail; +* instance; +* request data; +* exception data; +* trace information; +* extension properties. -`io.github.aalsanie.codes` and its child namespaces are reserved. Applications should use namespaces they control. Syntax validation does not verify namespace ownership. +Those values belong to the individual RFC 9457 problem occurrence. -## Compatibility +For Spring applications, `codes-spring` copies the stable definition into a native Spring `ProblemDetail`. Spring and the application remain responsible for the rest of the response lifecycle. -The machine-readable semantic contract includes: +## Identity -* `OutcomeCode` identity; -* standard outcome membership; -* standard `OutcomeState` assignments; -* built-in HTTP mappings; -* built-in gRPC mappings. +Changing a problem type URI changes the public machine identity of the problem. -Human-readable text may evolve without changing outcome identity: +Changing the Java exception or controller path that produces the problem does not require changing the problem type URI. -* `OutcomeDefinition.defaultMessage`; -* `Outcome.detail`; -* `Issue.message`. +The title is a stable human-readable summary. Applications may localize it at the occurrence layer when required. -Consumers must use `OutcomeCode`, not human-readable text, when behavior depends on identity. +## Scope -Runtime, framework, language, and build-tool support is documented in the [compatibility policy](compatibility-policy.md). +Codes does not provide exception mapping, validation aggregation, registries, workflow states, or protocol-neutral status abstractions. From 768979218220cef3f2823a2c470e4e5a88f7aeb4 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:38:01 +0300 Subject: [PATCH 23/50] Rewrite problem type documentation --- docs/integration-spring.md | 134 ++++++++++++++++--------------------- 1 file changed, 58 insertions(+), 76 deletions(-) diff --git a/docs/integration-spring.md b/docs/integration-spring.md index e386aa6..4a99bb0 100644 --- a/docs/integration-spring.md +++ b/docs/integration-spring.md @@ -1,110 +1,92 @@ # Spring Integration -This example adds Codes at the HTTP boundary while the application keeps its existing exception and domain model. +`codes-spring` converts a reusable Codes `ProblemType` into Spring Framework's native `ProblemDetail`. -## 1. Add the adapter +## Dependency ```kotlin dependencies { - implementation("io.github.aalsanie:codes-spring:0.4.0-RC1") + implementation("io.github.aalsanie:codes-spring:0.4.0") } ``` -The adapter supports the declared Spring 6 and Spring 7 compatibility lines. +The application supplies Spring Web. `codes-spring` does not impose a Spring Framework version transitively. -## 2. Define the stable application outcome +## Define problem types ```java -final class PaymentOutcomes { - static final OutcomeDefinition PAYMENT_DECLINED = OutcomeDefinition.custom( - "com.example.payments", - "PAYMENT_DECLINED", - OutcomeState.FAILED, - "The payment was declined." +enum OrderProblems implements ProblemType { + ORDER_NOT_FOUND( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" ); - private PaymentOutcomes() { + private final URI type; + private final int status; + private final String title; + + OrderProblems(URI type, int status, String title) { + this.type = type; + this.status = status; + this.title = title; + } + + public URI getType() { + return type; + } + + public int getStatus() { + return status; + } + + public String getTitle() { + return title; } } ``` -`com.example.payments:PAYMENT_DECLINED` is the identity. HTTP 422 is a boundary decision. - -## 3. Configure the HTTP boundary explicitly +## Create a problem occurrence ```java -HttpOutcomeMapper http = HttpOutcomeMapper.standard() - .withMapping(PaymentOutcomes.PAYMENT_DECLINED, HttpStatusCode.of(422)); - -OutcomeProblemDetailMapper problems = new OutcomeProblemDetailMapper( - new SpringHttpStatusMapper(http), - SpringOutcomeExposure.publicErrors(), - SpringProblemTypeUriMapper.empty().withMapping( - PaymentOutcomes.PAYMENT_DECLINED, - URI.create("https://api.example.com/problems/payment-declined") - ) +ProblemDetail problem = ProblemDetails.forType( + OrderProblems.ORDER_NOT_FOUND ); ``` -The URI above belongs to the example application. Codes does not invent or own problem-type URIs. - -`publicErrors()` exposes the reusable outcome message and structured issues but not occurrence `detail`. Use `safeDefaults()` when even those fields should remain hidden. - -## 4. Use it from the application's existing exception boundary +For an occurrence-specific detail: ```java -@RestControllerAdvice -final class PaymentExceptionHandler { - private final OutcomeProblemDetailMapper problems; - - PaymentExceptionHandler(OutcomeProblemDetailMapper problems) { - this.problems = problems; - } - - @ExceptionHandler(PaymentDeclinedException.class) - ResponseEntity paymentDeclined() { - Outcome outcome = Outcome.of(PaymentOutcomes.PAYMENT_DECLINED); - ProblemDetail problem = problems.map(outcome).orNull(); - - if (problem == null) { - throw new IllegalStateException("PAYMENT_DECLINED has no HTTP mapping"); - } - - return ResponseEntity.status(problem.getStatus()).body(problem); - } -} +ProblemDetail problem = ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order o-123 was not found." +); ``` -The application decides which exception maps to which outcome. - -## Result +The bridge sets only `type`, `status`, `title`, and the explicitly supplied `detail`. -A rendered response is an RFC 9457 problem document with the stable Codes identity: - -```json -{ - "type": "https://api.example.com/problems/payment-declined", - "title": "The payment was declined.", - "status": 422, - "code": "com.example.payments:PAYMENT_DECLINED" -} -``` +It does not set the request-specific `instance`, add extension properties, map exceptions, or register controller advice. -Occurrence detail is absent unless the application explicitly opts into it. +## Exception handlers -## Validation issues +Different internal exceptions can intentionally expose the same public problem type: ```java -Outcome invalid = Outcome.of( - StandardOutcomes.INVALID_ARGUMENT, - null, - List.of( - Issue.at("email", "Invalid email address."), - Issue.at("quantity", "Must be greater than zero.") - ) -); -``` +@ExceptionHandler(OrderNotFoundException.class) +ProblemDetail handleOrderNotFound(OrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Order " + ex.orderId() + " was not found." + ); +} -With `SpringOutcomeExposure.publicErrors()`, those issues are emitted as structured `issues` while `code` remains `io.github.aalsanie.codes.standard:INVALID_ARGUMENT`. +@ExceptionHandler(ArchivedOrderNotFoundException.class) +ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException ex) { + return ProblemDetails.forTypeAndDetail( + OrderProblems.ORDER_NOT_FOUND, + "Archived order " + ex.orderId() + " was not found." + ); +} +``` -That is the complete integration: stable outcome definition, explicit protocol mapping, explicit exposure policy, existing application boundary. +Spring remains responsible for rendering the `ProblemDetail` through MVC or WebFlux. From 2ad2105109580a0b7047816d374cbb35bb9043c5 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:38:14 +0300 Subject: [PATCH 24/50] Rewrite problem type documentation --- docs/compatibility-policy.md | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md index 3251359..6e93c4b 100644 --- a/docs/compatibility-policy.md +++ b/docs/compatibility-policy.md @@ -2,9 +2,11 @@ Codes is pre-1.0. Minor releases may contain source or binary breaking changes. Breaking changes are documented in `CHANGELOG.md`. -All published Codes artifacts target Java 17 and support Java and Kotlin consumers. +All published artifacts target Java 17 and support Java and Kotlin consumers. -The `codes` core artifact has zero runtime dependencies. `codes-spring` and `codes-grpc-java` depend on the boundary libraries they adapt. +The `codes` core artifact has no runtime dependencies and publishes no Maven dependencies. + +`codes-spring` depends on `codes` and compiles against Spring Web, but it does not publish Spring Framework as a transitive dependency. Applications supply their own supported Spring version. ## 0.4.x compatibility @@ -12,27 +14,17 @@ The `0.4.x` line is verified with: * Java 17, 21, and 25; * Spring Framework 6.0.0 and 7.0.9; -* gRPC Java 1.75.0 and 1.83.1; -* Kotlin 1.9.24, 2.0.21, 2.1.21, 2.2.20, and 2.4.10; -* Gradle and Maven consumers; -* Linux, Windows, and macOS. - -Spring Framework 6.0.0 is the Codes compatibility floor for the Spring 6 generation. Spring 7.0.9 is the current Spring 7 verification baseline. +* Kotlin 1.9.24 and 2.4.10; +* Gradle and Maven consumers. -gRPC Java 1.75.0 is the Codes compatibility floor. gRPC Java 1.83.1 is the current verification baseline. +Spring Framework 6.0.0 is the compatibility floor. Spring Framework 7.0.9 is the current verification baseline. These are Codes compatibility statements, not upstream maintenance or security-support declarations. ## Public contract -The machine-readable contract includes: - -* `OutcomeCode` identity; -* standard outcome membership; -* standard `OutcomeState` assignments; -* built-in HTTP mappings; -* built-in gRPC mappings. +The machine-readable identity of a `ProblemType` is its RFC 9457 type URI. -The Spring adapter preserves Codes identity in the RFC 9457 `code` extension. The gRPC adapter preserves identity in `google.rpc.ErrorInfo.domain` and `ErrorInfo.reason`. +The stable definition also contains the associated HTTP status and title. -Human-readable messages are not machine identity and may change without changing `OutcomeCode`. +Occurrence-specific detail, instance, and extension properties are not part of the Codes core contract. From 46ebc7e9b1529d11590761f4c009b3c39e1439af Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:38:24 +0300 Subject: [PATCH 25/50] Update compatibility documentation --- compatibility/README.md | 64 ++++++++--------------------------------- 1 file changed, 12 insertions(+), 52 deletions(-) diff --git a/compatibility/README.md b/compatibility/README.md index b7085f8..2da07d5 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -1,57 +1,17 @@ -# Compatibility contracts +# Compatibility -These fixtures and matrix definitions protect Codes' semantic, boundary, publication, and consumer contracts independently from the Java API snapshots under `api/`. +Compatibility verification uses clean locally published Maven artifacts rather than Gradle project dependencies. -They cover: +The matrix covers: -* standard outcome codes and states; -* built-in HTTP mappings; -* built-in gRPC mappings; -* rendered Spring RFC 9457 HTTP problem responses; -* decoded `google.rpc.Status` payloads after `StatusRuntimeException` trailer round trips; -* published-artifact compatibility across supported framework, JDK, language, build-tool, and operating-system combinations. +* Java 17, 21, and 25; +* Spring Framework 6.0.0 and 7.0.9; +* Kotlin 1.9.24 and 2.4.10; +* Gradle and Maven consumers. -Core semantic snapshots do not freeze human-readable messages, occurrence details, or issue paths. +The checks also verify that: -`spring-http-problems.snapshot` is intentionally a boundary golden fixture. The same contract is rendered through Spring MVC and Spring WebFlux and verifies HTTP status, `application/problem+json`, problem type semantics, title, occurrence detail, request `instance`, top-level `code` and `issues`, application fallback for unmapped outcomes, and sensitive-detail non-disclosure. JSON member ordering is intentionally not frozen. - -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 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. - -A snapshot change represents a contract change and must be reviewed together with the code that requires it. - -## Production compatibility matrix - -`compatibility/matrix.json` is the executable support policy for `0.4.x`. - -Current matrix: - -* Java: 17, 21, 25. -* Spring Framework: 6.0.0 and 7.0.9. -* gRPC Java: 1.75.0 and 1.83.1. -* Kotlin compiler consumers: 1.9.24, 2.0.21, 2.1.21, 2.2.20, 2.4.10. -* Kotlin/JSpecify compiler matrix framework baseline: Spring 6.0.0 and gRPC 1.75.0. -* Build tools: Gradle and Maven. -* Consumer languages: Java and Kotlin. -* Operating systems: Linux, Windows, macOS. - -Spring Framework 6.0.0 is the Codes `0.4.x` binary compatibility floor for the Spring 6 generation. Spring 7.0.9 is the current Spring 7 baseline used to build the adapter. This compatibility floor is not a statement about upstream maintenance or security support for old Spring releases. - -Codes explicitly supports gRPC Java 1.75.0 as its `0.4.x` compatibility floor and 1.83.1 as the current build baseline. The minimum is a Codes support policy, not an upstream gRPC LTS declaration. - -The Kotlin/JSpecify compiler matrix intentionally runs all supported Kotlin compiler versions against the minimum supported Spring and gRPC framework versions. This isolates Codes' published JSpecify metadata contract from the Kotlin metadata version used internally by the current Spring release. Current-framework Kotlin consumption remains covered separately by the Gradle Kotlin smoke consumer on the current Spring/gRPC baselines. Do not use Kotlin's metadata-version bypass flags to make an older compiler consume a newer framework's incompatible Kotlin metadata. - -Framework/JDK compatibility is exercised as a full Cartesian product: - -```text -3 Java versions × 2 Spring versions × 2 gRPC versions = 12 published-artifact jobs -``` - -Every compatibility job starts with an empty Maven repository, publishes `codes`, `codes-spring`, and `codes-grpc-java` into that repository, verifies the published POMs, and then runs an external consumer. Gradle consumer repositories exclude `io.github.aalsanie` from Maven Central so Codes artifacts cannot be accidentally resolved remotely. - -For HTTP, absence from `http-mappings.snapshot` means `HttpOutcomeMapper.standard()` returns `Unmapped`. +* the core artifact publishes no dependencies; +* `codes-spring` publishes only its dependency on `codes`; +* Java and Kotlin consumers can create a `ProblemType`; +* Spring consumers can convert it to a native `ProblemDetail`. From 6ab579f3a177318763dce12ba1b538a397cd150d Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:38:45 +0300 Subject: [PATCH 26/50] Simplify CI for problem types --- .github/workflows/ci.yml | 112 +++------------------------------------ 1 file changed, 6 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f04f71..31612cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,19 +21,19 @@ 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 java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic - - name: Verify core, adapters, and references + - name: Verify compatibility policy + run: python scripts/verify-compatibility-matrix.py + + - name: Verify library and reference application run: ./gradlew clean verifyAll --stacktrace - name: Publish clean compatibility artifacts @@ -58,13 +58,8 @@ jobs: - name: Verify Maven Java consumer shell: bash run: | - set -euo pipefail version="$(sh ./scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify + mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" clean verify - name: Verify Maven Kotlin consumer env: @@ -78,100 +73,5 @@ jobs: path: | build/reports/jacoco codes-spring/build/reports/jacoco - codes-grpc-java/build/reports/jacoco if-no-files-found: warn retention-days: 14 - - kotlin-consumers: - runs-on: ubuntu-latest - timeout-minutes: 25 - strategy: - fail-fast: false - matrix: - kotlin: ["1.9.24", "2.0.21", "2.1.21", "2.2.20", "2.4.10"] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "17" - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - cache-provider: basic - - - name: Publish all artifacts locally - run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven - - - name: Verify Kotlin consumer - env: - MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven - run: sh ./scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" - - kotlin-consumers-gate: - name: Kotlin consumers gate - if: always() - needs: [kotlin-consumers] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Verify Kotlin consumer matrix - env: - RESULT: ${{ needs.kotlin-consumers.result }} - shell: bash - run: | - if [ "$RESULT" != "success" ]; then - echo "Kotlin consumer matrix result: $RESULT" - exit 1 - fi - - runtime-jdks: - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - java: ["21", "25"] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: ${{ matrix.java }} - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - cache-provider: basic - - - name: Test core on runtime JDK - run: ./gradlew :test --stacktrace - - operating-systems: - runs-on: ${{ matrix.os }} - timeout-minutes: 25 - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "17" - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - cache-provider: basic - - - name: Test core and adapters - run: ./gradlew :test :codes-spring:test :codes-grpc-java:test --stacktrace From f2b08a6292df687f8be3674576a975d643b2ac28 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:39:20 +0300 Subject: [PATCH 27/50] Simplify compatibility verification --- .github/workflows/compatibility.yml | 235 +++++----------------------- 1 file changed, 36 insertions(+), 199 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 6f5fc4d..65ca833 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -13,57 +13,9 @@ concurrency: cancel-in-progress: true jobs: - matrix: - name: Matrix policy - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - adapter: ${{ steps.matrix.outputs.adapter }} - kotlin: ${{ steps.matrix.outputs.kotlin }} - os: ${{ steps.matrix.outputs.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - name: Verify matrix policy - run: python scripts/verify-compatibility-matrix.py - - - id: matrix - name: Export matrices - shell: bash - run: | - python - <<'PY' >> "$GITHUB_OUTPUT" - import json - from pathlib import Path - - matrix = json.loads(Path("compatibility/matrix.json").read_text()) - compact = lambda value: json.dumps(value, separators=(",", ":")) - - print("adapter=" + compact({ - "java": matrix["java"], - "spring": matrix["spring"], - "grpc": matrix["grpc"], - })) - kotlin = matrix["kotlin"] - print("kotlin=" + compact({ - "include": [ - { - "kotlin": version, - "spring": kotlin["spring"], - "grpc": kotlin["grpc"], - } - for version in kotlin["versions"] - ] - })) - print("os=" + compact({"os": matrix["os"]})) - PY - contracts: - name: Artifact contracts and coverage runs-on: ubuntu-latest - timeout-minutes: 35 - needs: [matrix] + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -78,39 +30,31 @@ jobs: with: cache-provider: basic - - name: Verify API, wire, POM, and coverage contracts + - name: Verify compatibility policy + run: python scripts/verify-compatibility-matrix.py + + - name: Verify contracts run: ./gradlew clean verifyAll --stacktrace - name: Build clean publication repository run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven - - name: Upload clean publication repository - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: compatibility-publications path: build/compatibility-maven if-no-files-found: error retention-days: 1 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - if: always() - with: - name: compatibility-coverage - path: | - build/reports/jacoco - codes-spring/build/reports/jacoco - codes-grpc-java/build/reports/jacoco - if-no-files-found: error - retention-days: 14 - - adapter-versions: - name: JDK ${{ matrix.java }} / Spring ${{ matrix.spring }} / gRPC ${{ matrix.grpc }} - needs: [matrix, contracts] + java-spring: + needs: contracts runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: ${{ fromJSON(needs.matrix.outputs.adapter) }} + matrix: + java: ["17", "21", "25"] + spring: ["6.0.0", "7.0.9"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -125,12 +69,18 @@ jobs: with: cache-provider: basic - - name: Download clean publication repository - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 with: name: compatibility-publications path: build/compatibility-maven + - name: Test Spring bridge against selected Spring version + run: >- + ./gradlew + :codes-spring:test + -PspringFrameworkOverride=${{ matrix.spring }} + --stacktrace + - name: Verify Gradle Java consumer run: >- ./gradlew @@ -138,7 +88,6 @@ jobs: -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven -PconsumerJavaVersion=${{ matrix.java }} -PspringFrameworkOverride=${{ matrix.spring }} - -PgrpcVersionOverride=${{ matrix.grpc }} clean check --stacktrace @@ -146,23 +95,16 @@ jobs: shell: bash run: | version="$(sh scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - "-Dmaven.compiler.release=${{ matrix.java }}" \ - "-Dspring.framework.version=${{ matrix.spring }}" \ - "-Dgrpc.version=${{ matrix.grpc }}" \ - clean verify + mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" "-Dmaven.compiler.release=${{ matrix.java }}" "-Dspring.framework.version=${{ matrix.spring }}" clean verify - kotlin-nullability: - name: Kotlin ${{ matrix.kotlin }} / Spring ${{ matrix.spring }} / gRPC ${{ matrix.grpc }} JSpecify - needs: [matrix, contracts] + kotlin: + needs: contracts runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: ${{ fromJSON(needs.matrix.outputs.kotlin) }} + matrix: + kotlin: ["1.9.24", "2.4.10"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -173,135 +115,30 @@ jobs: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 - with: - cache-provider: basic - - - name: Download clean publication repository - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 with: name: compatibility-publications path: build/compatibility-maven - - name: Verify Kotlin JSpecify consumer + - name: Verify Kotlin consumer env: MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven - run: >- - sh scripts/verify-kotlin-nullability.sh - "${{ matrix.kotlin }}" - "${{ matrix.spring }}" - "${{ matrix.grpc }}" - - operating-systems: - name: Published consumers / ${{ matrix.os }} - needs: [matrix, contracts] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.matrix.outputs.os) }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 - with: - distribution: temurin - java-version: "17" - - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 - with: - cache-provider: basic - - - name: Download clean publication repository - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 - with: - name: compatibility-publications - path: build/compatibility-maven - - - name: Verify Gradle Java and Kotlin consumers (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - repo="${GITHUB_WORKSPACE}/build/compatibility-maven" - ./gradlew -p smoke-test-java "-Dmaven.repo.local=$repo" clean check --stacktrace - ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=$repo" clean check --stacktrace - - - name: Verify Gradle Java and Kotlin consumers (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $repo = Join-Path $env:GITHUB_WORKSPACE "build\compatibility-maven" - - .\gradlew.bat -p smoke-test-java "-Dmaven.repo.local=$repo" clean check --stacktrace - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - .\gradlew.bat -p smoke-test-kotlin "-Dmaven.repo.local=$repo" clean check --stacktrace - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify Maven Java consumer (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - repo="${GITHUB_WORKSPACE}/build/compatibility-maven" - version="$(sh scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=$repo" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify - - - name: Verify Maven Java consumer (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $repo = Join-Path $env:GITHUB_WORKSPACE "build\compatibility-maven" - $version = (Get-Content .\gradle.properties | - Where-Object { $_ -like "VERSION_NAME=*" } | - Select-Object -First 1).Substring("VERSION_NAME=".Length) - mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=$repo" ` - -f smoke-test-maven-java/pom.xml ` - "-Dcodes.version=$version" ` - clean verify - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + run: sh scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" compatibility-gate: name: Compatibility gate if: always() - needs: - - matrix - - contracts - - adapter-versions - - kotlin-nullability - - operating-systems + needs: [contracts, java-spring, kotlin] runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Require complete compatibility matrix - env: - MATRIX_RESULT: ${{ needs.matrix.result }} - CONTRACTS_RESULT: ${{ needs.contracts.result }} - ADAPTER_RESULT: ${{ needs.adapter-versions.result }} - KOTLIN_RESULT: ${{ needs.kotlin-nullability.result }} - OS_RESULT: ${{ needs.operating-systems.result }} + - name: Require all compatibility checks shell: bash + env: + CONTRACTS: ${{ needs.contracts.result }} + JAVA_SPRING: ${{ needs.java-spring.result }} + KOTLIN: ${{ needs.kotlin.result }} run: | - set -euo pipefail - - for entry in \ - "matrix:$MATRIX_RESULT" \ - "contracts:$CONTRACTS_RESULT" \ - "adapter-versions:$ADAPTER_RESULT" \ - "kotlin-nullability:$KOTLIN_RESULT" \ - "operating-systems:$OS_RESULT" - do - name="${entry%%:*}" - result="${entry#*:}" - if [ "$result" != "success" ]; then - echo "$name result: $result" - exit 1 - fi - done - - echo "Complete Codes compatibility matrix passed from clean publication artifacts." + test "$CONTRACTS" = "success" + test "$JAVA_SPRING" = "success" + test "$KOTLIN" = "success" From e003fa53115e5a4377eb74962aa4c3e72f774ff0 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:39:44 +0300 Subject: [PATCH 28/50] Simplify final release workflow --- .github/workflows/release.yml | 61 +++++++++-------------------------- 1 file changed, 15 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6be7545..c7a2140 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,9 @@ on: push: tags: - "v*.*.*" - - "!v*.*.*-RC*" + - "!v*.*.*-*" permissions: - actions: read contents: read concurrency: @@ -31,30 +30,18 @@ jobs: set -euo pipefail version="$(sh ./scripts/version.sh)" - - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { - echo "Invalid release version: $version" - exit 1 - } - + [[ "$version" =~ ^[0-9]+.[0-9]+.[0-9]+$ ]] test "${GITHUB_REF_NAME}" = "v${version}" git fetch --no-tags origin main - - 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 + test "$(git rev-parse "${GITHUB_SHA}^{commit}")" = "$(git rev-parse "origin/main^{commit}")" - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 with: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic @@ -63,47 +50,32 @@ jobs: python scripts/verify-compatibility-matrix.py ./gradlew clean verifyAll --stacktrace - - name: Publish clean release artifacts - run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + - name: Build clean release artifacts + run: sh scripts/prepare-compatibility-repository.sh build/release-maven - - name: Verify Gradle Java consumer - run: >- - ./gradlew - -p smoke-test-java - -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven - clean check - --stacktrace + - name: Verify Gradle consumers + shell: bash + run: | + ./gradlew -p smoke-test-java "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/release-maven" clean check --stacktrace - - name: Verify Gradle Kotlin consumer - run: >- - ./gradlew - -p smoke-test-kotlin - -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven - clean check - --stacktrace + ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/release-maven" clean check --stacktrace - name: Verify Maven consumers shell: bash env: - MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven + MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/release-maven run: | - set -euo pipefail version="$(sh ./scripts/version.sh)" - mvn --batch-mode --no-transfer-progress \ - "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" \ - -f smoke-test-maven-java/pom.xml \ - "-Dcodes.version=$version" \ - clean verify + mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" clean verify sh ./scripts/verify-kotlin-nullability.sh 2.4.10 - - name: Publish all Codes artifacts to Maven Central + - name: Publish to Maven Central run: >- ./gradlew :publishToMavenCentral :codes-spring:publishToMavenCentral - :codes-grpc-java:publishToMavenCentral --no-configuration-cache --stacktrace env: @@ -148,7 +120,4 @@ jobs: test -s release-notes.md - gh release create "$GITHUB_REF_NAME" \ - --verify-tag \ - --title "Codes $version" \ - --notes-file release-notes.md + gh release create "$GITHUB_REF_NAME" --verify-tag --title "Codes $version" --notes-file release-notes.md From ec2b6d70e9649a3fa5b58fbf8575e52869f46db7 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:42:06 +0300 Subject: [PATCH 29/50] Strengthen problem type tests --- .../aalsanie/codes/ProblemTypeTest.java | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java index 0813e8e..42d72d0 100644 --- a/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java +++ b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java @@ -25,8 +25,14 @@ void createsReusableProblemType() { @Test void supportsNonEmptyUriReferences() { - assertEquals(URI.create("about:blank"), ProblemType.of(URI.create("about:blank"), 500, "Error").getType()); - assertEquals(URI.create("order-not-found"), ProblemType.of(URI.create("order-not-found"), 404, "Missing").getType()); + assertEquals( + URI.create("about:blank"), + ProblemType.of(URI.create("about:blank"), 500, "Error").getType() + ); + assertEquals( + URI.create("order-not-found"), + ProblemType.of(URI.create("order-not-found"), 404, "Missing").getType() + ); } @Test @@ -43,11 +49,42 @@ void validatesDefinition() { void factoryValuesHaveValueSemantics() { ProblemType first = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); ProblemType second = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); - ProblemType different = ProblemType.of(URI.create("urn:problem:missing"), 409, "Missing"); + assertEquals(first, first); assertEquals(first, second); assertEquals(first.hashCode(), second.hashCode()); - assertNotEquals(first, different); + assertNotEquals(first, "not a problem type"); + assertNotEquals(first, ProblemType.of(URI.create("urn:problem:missing"), 409, "Missing")); + assertNotEquals(first, ProblemType.of(URI.create("urn:problem:other"), 404, "Missing")); + assertNotEquals(first, ProblemType.of(URI.create("urn:problem:missing"), 404, "Different")); assertTrue(first.toString().contains("urn:problem:missing")); } + + @Test + void applicationsCanImplementProblemTypeDirectly() { + ProblemType problem = OrderProblems.ORDER_NOT_FOUND; + + assertEquals(URI.create("https://api.example.com/problems/order-not-found"), problem.getType()); + assertEquals(404, problem.getStatus()); + assertEquals("Order not found", problem.getTitle()); + } + + private enum OrderProblems implements ProblemType { + ORDER_NOT_FOUND; + + @Override + public URI getType() { + return URI.create("https://api.example.com/problems/order-not-found"); + } + + @Override + public int getStatus() { + return 404; + } + + @Override + public String getTitle() { + return "Order not found"; + } + } } From c5eaec85b4f2407bddf12b78aa7f5692e5e96e16 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:44:03 +0300 Subject: [PATCH 30/50] Fix reference application warnings --- .../com/example/orders/OrderController.java | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java index 1b1b20a..e98b466 100644 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java @@ -19,29 +19,3 @@ String findArchived(@PathVariable String orderId) { throw new ArchivedOrderNotFoundException(orderId); } } - -final class OrderNotFoundException extends RuntimeException { - private static final long serialVersionUID = 1L; - private final String orderId; - - OrderNotFoundException(String orderId) { - this.orderId = orderId; - } - - String orderId() { - return orderId; - } -} - -final class ArchivedOrderNotFoundException extends RuntimeException { - private static final long serialVersionUID = 1L; - private final String orderId; - - ArchivedOrderNotFoundException(String orderId) { - this.orderId = orderId; - } - - String orderId() { - return orderId; - } -} From e8996507bad05a3d1d6876a584c727783e8abe98 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:44:10 +0300 Subject: [PATCH 31/50] Fix reference application warnings --- .../example/orders/OrderNotFoundException.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java b/reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java new file mode 100644 index 0000000..a6468b1 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderNotFoundException.java @@ -0,0 +1,15 @@ +package com.example.orders; + +final class OrderNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String orderId; + + OrderNotFoundException(String orderId) { + this.orderId = orderId; + } + + String orderId() { + return orderId; + } +} From c8c9f72b74466576cecdfdd42ece3c7c8f559a6e Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:44:18 +0300 Subject: [PATCH 32/50] Fix reference application warnings --- .../orders/ArchivedOrderNotFoundException.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java diff --git a/reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java b/reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java new file mode 100644 index 0000000..b995ab8 --- /dev/null +++ b/reference/spring-orders/src/main/java/com/example/orders/ArchivedOrderNotFoundException.java @@ -0,0 +1,15 @@ +package com.example.orders; + +final class ArchivedOrderNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String orderId; + + ArchivedOrderNotFoundException(String orderId) { + this.orderId = orderId; + } + + String orderId() { + return orderId; + } +} From da271d463fa32650bd3430daa46008bd6b4170c9 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:48:18 +0300 Subject: [PATCH 33/50] Tighten Spring bridge tests --- .../aalsanie/codes/spring/ProblemDetailsTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java index 7811d34..553747c 100644 --- a/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java +++ b/codes-spring/src/test/java/io/github/aalsanie/codes/spring/ProblemDetailsTest.java @@ -3,8 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.aalsanie.codes.ProblemType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.http.ProblemDetail; @@ -49,4 +52,13 @@ void rejectsNullArguments() { () -> ProblemDetails.forTypeAndDetail(ORDER_NOT_FOUND, null) ); } + + @Test + void cannotBeConstructedThroughThePublicApi() throws Exception { + Constructor constructor = ProblemDetails.class.getDeclaredConstructor(); + + assertTrue(Modifier.isPrivate(constructor.getModifiers())); + constructor.setAccessible(true); + constructor.newInstance(); + } } From 6e641bc47bdf1e9d95ef00323c90e3442f77fbdf Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:48:43 +0300 Subject: [PATCH 34/50] Harden consumer builds --- smoke-test-java/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smoke-test-java/build.gradle.kts b/smoke-test-java/build.gradle.kts index 6faae3b..68d6a03 100644 --- a/smoke-test-java/build.gradle.kts +++ b/smoke-test-java/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } val properties = Properties().apply { - rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use(::load) + rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } } val codesVersion = properties.getProperty("VERSION_NAME") val defaultSpringVersion = properties.getProperty("springFrameworkVersion") From 1544c39dc71ebb66b1c87a3b93fe4daaf71e1db1 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:48:45 +0300 Subject: [PATCH 35/50] Harden consumer builds --- smoke-test-kotlin/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smoke-test-kotlin/build.gradle.kts b/smoke-test-kotlin/build.gradle.kts index e2fd159..0e4dce0 100644 --- a/smoke-test-kotlin/build.gradle.kts +++ b/smoke-test-kotlin/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } val properties = Properties().apply { - rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use(::load) + rootProject.projectDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } } val codesVersion = properties.getProperty("VERSION_NAME") val springVersion = properties.getProperty("springFrameworkVersion") From a664c02ac92876a37098e73f15b9567f30132c16 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:49:12 +0300 Subject: [PATCH 36/50] Harden CI commands --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31612cf..bf89bbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: - name: Verify Maven Java consumer shell: bash run: | + set -euo pipefail version="$(sh ./scripts/version.sh)" mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" clean verify From 4c4fe61dfaf29207d2364881f69cfa316a141ef6 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:49:35 +0300 Subject: [PATCH 37/50] Harden compatibility commands --- .github/workflows/compatibility.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 65ca833..3b7aabf 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -94,6 +94,7 @@ jobs: - name: Verify Maven Java consumer shell: bash run: | + set -euo pipefail version="$(sh scripts/version.sh)" mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" "-Dmaven.compiler.release=${{ matrix.java }}" "-Dspring.framework.version=${{ matrix.spring }}" clean verify @@ -139,6 +140,7 @@ jobs: JAVA_SPRING: ${{ needs.java-spring.result }} KOTLIN: ${{ needs.kotlin.result }} run: | + set -euo pipefail test "$CONTRACTS" = "success" test "$JAVA_SPRING" = "success" test "$KOTLIN" = "success" From 799b2e78a71553531a4b076ef225ab21397a460c Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:49:56 +0300 Subject: [PATCH 38/50] Harden release commands --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7a2140..2ecad30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,7 @@ jobs: - name: Verify release tree run: | + set -euo pipefail python scripts/verify-compatibility-matrix.py ./gradlew clean verifyAll --stacktrace @@ -56,6 +57,8 @@ jobs: - name: Verify Gradle consumers shell: bash run: | + set -euo pipefail + ./gradlew -p smoke-test-java "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/release-maven" clean check --stacktrace ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/release-maven" clean check --stacktrace @@ -65,6 +68,7 @@ jobs: env: MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/release-maven run: | + set -euo pipefail version="$(sh ./scripts/version.sh)" mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${MAVEN_REPO_LOCAL}" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" clean verify From 916e4c7c748e764641bd41964edb03e7f3c3e5eb Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:54:13 +0300 Subject: [PATCH 39/50] Fix Spring POM verification task --- codes-spring/build.gradle.kts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/codes-spring/build.gradle.kts b/codes-spring/build.gradle.kts index 5abb18b..76b888b 100644 --- a/codes-spring/build.gradle.kts +++ b/codes-spring/build.gradle.kts @@ -81,8 +81,13 @@ tasks.jacocoTestCoverageVerification { tasks.register("verifyPublishedPomContract") { group = "verification" description = "Verifies the Codes Spring POM metadata and dependency budget." + dependsOn("generatePomFileForMavenPublication") + val artifactId = artifactId + val pomName = pomName + val pomDescription = pomDescription + val expectedPomDependencies = expectedPomDependencies val pomFile = layout.buildDirectory.file("publications/maven/pom-default.xml") inputs.file(pomFile) @@ -98,8 +103,12 @@ tasks.register("verifyPublishedPomContract") { .first { it.nodeName == name } .textContent - check(directText("name") == pomName) - check(directText("description") == pomDescription) + check(directText("name") == pomName) { + "Unexpected $artifactId POM name: ${directText("name")}" + } + check(directText("description") == pomDescription) { + "Unexpected $artifactId POM description: ${directText("description")}" + } val dependencies = document.getElementsByTagName("dependency") val actual = (0 until dependencies.length).map { index -> @@ -109,7 +118,8 @@ tasks.register("verifyPublishedPomContract") { }.sorted() check(actual == expectedPomDependencies.sorted()) { - "$artifactId dependency budget changed. Expected ${expectedPomDependencies.sorted()}, found $actual." + "$artifactId dependency budget changed. " + + "Expected ${expectedPomDependencies.sorted()}, found $actual." } } } From d0260289b0dbca25b1ef1a41c61e12b9b398f7de Mon Sep 17 00:00:00 2001 From: aalsanie Date: Mon, 5 Oct 2026 18:54:40 +0300 Subject: [PATCH 40/50] Update CodeQL build targets --- .github/workflows/codeql.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bbdff24..ce2533c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,7 +29,7 @@ jobs: distribution: temurin java-version: "17" - - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 with: cache-provider: basic @@ -43,9 +43,7 @@ jobs: ./gradlew clean :testClasses :codes-spring:testClasses - :codes-grpc-java:testClasses :reference-spring-orders:testClasses - :reference-grpc-orders:testClasses --stacktrace - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 From aae53b1b54385fe1f040c2de30da7f5a04925700 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:32:41 +0300 Subject: [PATCH 41/50] Harden problem type invariants --- api/codes.api | 8 +- .../aalsanie/codes/DefaultProblemType.java | 65 --------------- .../io/github/aalsanie/codes/ProblemType.java | 71 ++++++++++++++-- .../aalsanie/codes/ProblemTypeTest.java | 83 ++++++++++--------- 4 files changed, 112 insertions(+), 115 deletions(-) delete mode 100644 src/main/java/io/github/aalsanie/codes/DefaultProblemType.java diff --git a/api/codes.api b/api/codes.api index 7ed2cfb..7eb6df8 100644 --- a/api/codes.api +++ b/api/codes.api @@ -1,5 +1,5 @@ -public abstract interface io.github.aalsanie.codes.ProblemType - method public abstract int getStatus() - method public abstract java.lang.String getTitle() - method public abstract java.net.URI getType() +public final class io.github.aalsanie.codes.ProblemType + method public int getStatus() + method public java.lang.String getTitle() + method public java.net.URI getType() method public static io.github.aalsanie.codes.ProblemType of(java.net.URI, int, java.lang.String) diff --git a/src/main/java/io/github/aalsanie/codes/DefaultProblemType.java b/src/main/java/io/github/aalsanie/codes/DefaultProblemType.java deleted file mode 100644 index a9e7e04..0000000 --- a/src/main/java/io/github/aalsanie/codes/DefaultProblemType.java +++ /dev/null @@ -1,65 +0,0 @@ -package io.github.aalsanie.codes; - -import java.net.URI; -import java.util.Objects; - -final class DefaultProblemType implements ProblemType { - private final URI type; - private final int status; - private final String title; - - private DefaultProblemType(URI type, int status, String title) { - this.type = type; - this.status = status; - this.title = title; - } - - static ProblemType create(URI type, int status, String title) { - Objects.requireNonNull(type, "type"); - Objects.requireNonNull(title, "title"); - if (type.toString().isEmpty()) { - throw new IllegalArgumentException("type must not be empty"); - } - if (status < 100 || status > 599) { - throw new IllegalArgumentException("status must be between 100 and 599"); - } - if (title.isBlank()) { - throw new IllegalArgumentException("title must not be blank"); - } - return new DefaultProblemType(type, status, title); - } - - @Override - public URI getType() { - return type; - } - - @Override - public int getStatus() { - return status; - } - - @Override - public String getTitle() { - return title; - } - - @Override - public boolean equals(Object other) { - return this == other - || (other instanceof DefaultProblemType that - && status == that.status - && type.equals(that.type) - && title.equals(that.title)); - } - - @Override - public int hashCode() { - return Objects.hash(type, status, title); - } - - @Override - public String toString() { - return "ProblemType(type=" + type + ", status=" + status + ", title=" + title + ")"; - } -} diff --git a/src/main/java/io/github/aalsanie/codes/ProblemType.java b/src/main/java/io/github/aalsanie/codes/ProblemType.java index f7bc9fa..7d9a61e 100644 --- a/src/main/java/io/github/aalsanie/codes/ProblemType.java +++ b/src/main/java/io/github/aalsanie/codes/ProblemType.java @@ -1,16 +1,75 @@ package io.github.aalsanie.codes; import java.net.URI; +import java.util.Objects; -public interface ProblemType { +public final class ProblemType { + private static final String ABOUT_SCHEME = "about"; + private static final String BLANK_SCHEME_SPECIFIC_PART = "blank"; - URI getType(); + private final URI type; + private final int status; + private final String title; - int getStatus(); + private ProblemType(URI type, int status, String title) { + this.type = type; + this.status = status; + this.title = title; + } + + public static ProblemType of(URI type, int status, String title) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(title, "title"); + + if (!type.isAbsolute()) { + throw new IllegalArgumentException("type must be an absolute URI"); + } + if (isAboutBlank(type)) { + throw new IllegalArgumentException("type must not be about:blank"); + } + if (status < 100 || status > 599) { + throw new IllegalArgumentException("status must be between 100 and 599"); + } + if (title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + + return new ProblemType(type, status, title); + } + + public URI getType() { + return type; + } + + public int getStatus() { + return status; + } + + public String getTitle() { + return title; + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof ProblemType that + && status == that.status + && type.equals(that.type) + && title.equals(that.title)); + } + + @Override + public int hashCode() { + return Objects.hash(type, status, title); + } - String getTitle(); + @Override + public String toString() { + return "ProblemType(type=" + type + ", status=" + status + ", title=" + title + ")"; + } - static ProblemType of(URI type, int status, String title) { - return DefaultProblemType.create(type, status, title); + private static boolean isAboutBlank(URI type) { + return ABOUT_SCHEME.equalsIgnoreCase(type.getScheme()) + && BLANK_SCHEME_SPECIFIC_PART.equalsIgnoreCase(type.getSchemeSpecificPart()); } } diff --git a/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java index 42d72d0..962fcb2 100644 --- a/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java +++ b/src/test/java/io/github/aalsanie/codes/ProblemTypeTest.java @@ -24,29 +24,60 @@ void createsReusableProblemType() { } @Test - void supportsNonEmptyUriReferences() { + void supportsAbsoluteNonHttpUris() { assertEquals( - URI.create("about:blank"), - ProblemType.of(URI.create("about:blank"), 500, "Error").getType() + URI.create("urn:example:problem:order-not-found"), + ProblemType.of( + URI.create("urn:example:problem:order-not-found"), + 404, + "Order not found" + ).getType() ); assertEquals( - URI.create("order-not-found"), - ProblemType.of(URI.create("order-not-found"), 404, "Missing").getType() + URI.create("tag:example.com,2026:order-not-found"), + ProblemType.of( + URI.create("tag:example.com,2026:order-not-found"), + 404, + "Order not found" + ).getType() ); } @Test - void validatesDefinition() { + void rejectsInvalidDefinitions() { assertThrows(NullPointerException.class, () -> ProblemType.of(null, 404, "Missing")); - assertThrows(NullPointerException.class, () -> ProblemType.of(URI.create("urn:test"), 404, null)); - assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create(""), 404, "Missing")); - assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create("urn:test"), 99, "Missing")); - assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create("urn:test"), 600, "Missing")); - assertThrows(IllegalArgumentException.class, () -> ProblemType.of(URI.create("urn:test"), 400, " ")); + assertThrows( + NullPointerException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 404, null) + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("order-not-found"), 404, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("about:blank"), 404, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("ABOUT:blank"), 404, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 99, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 600, "Missing") + ); + assertThrows( + IllegalArgumentException.class, + () -> ProblemType.of(URI.create("urn:example:problem:missing"), 400, " ") + ); } @Test - void factoryValuesHaveValueSemantics() { + void hasValueSemantics() { ProblemType first = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); ProblemType second = ProblemType.of(URI.create("urn:problem:missing"), 404, "Missing"); @@ -59,32 +90,4 @@ void factoryValuesHaveValueSemantics() { assertNotEquals(first, ProblemType.of(URI.create("urn:problem:missing"), 404, "Different")); assertTrue(first.toString().contains("urn:problem:missing")); } - - @Test - void applicationsCanImplementProblemTypeDirectly() { - ProblemType problem = OrderProblems.ORDER_NOT_FOUND; - - assertEquals(URI.create("https://api.example.com/problems/order-not-found"), problem.getType()); - assertEquals(404, problem.getStatus()); - assertEquals("Order not found", problem.getTitle()); - } - - private enum OrderProblems implements ProblemType { - ORDER_NOT_FOUND; - - @Override - public URI getType() { - return URI.create("https://api.example.com/problems/order-not-found"); - } - - @Override - public int getStatus() { - return 404; - } - - @Override - public String getTitle() { - return "Order not found"; - } - } } From 7b2fe4f48425f2bcc24ee3cb5ac58ac49c485ddd Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:33:43 +0300 Subject: [PATCH 42/50] Add Spring wire contract tests --- reference/spring-orders/build.gradle.kts | 2 + .../com/example/orders/OrderProblems.java | 44 +++++------------ .../example/orders/OrderHttpContractTest.java | 49 +++++++++++++++++++ 3 files changed, 63 insertions(+), 32 deletions(-) create mode 100644 reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java diff --git a/reference/spring-orders/build.gradle.kts b/reference/spring-orders/build.gradle.kts index 4b439e4..248277c 100644 --- a/reference/spring-orders/build.gradle.kts +++ b/reference/spring-orders/build.gradle.kts @@ -23,6 +23,8 @@ dependencies { testImplementation(platform("org.junit:junit-bom:${providers.gradleProperty("junitVersion").get()}")) testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.springframework.boot:spring-boot-starter-test:${providers.gradleProperty("springBootVersion").get()}") + testImplementation("org.springframework.boot:spring-boot-starter-webflux:${providers.gradleProperty("springBootVersion").get()}") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java b/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java index 5dbb63e..5eb6388 100644 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderProblems.java @@ -3,45 +3,25 @@ import io.github.aalsanie.codes.ProblemType; import java.net.URI; -enum OrderProblems implements ProblemType { - ORDER_NOT_FOUND( - "https://api.example.com/problems/order-not-found", +final class OrderProblems { + static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), 404, "Order not found" - ), - ORDER_ALREADY_CANCELLED( - "https://api.example.com/problems/order-already-cancelled", + ); + + static final ProblemType ORDER_ALREADY_CANCELLED = ProblemType.of( + URI.create("https://api.example.com/problems/order-already-cancelled"), 409, "Order already cancelled" - ), - INVALID_ORDER_STATE( - "https://api.example.com/problems/invalid-order-state", + ); + + static final ProblemType INVALID_ORDER_STATE = ProblemType.of( + URI.create("https://api.example.com/problems/invalid-order-state"), 409, "Invalid order state" ); - private final URI type; - private final int status; - private final String title; - - OrderProblems(String type, int status, String title) { - this.type = URI.create(type); - this.status = status; - this.title = title; - } - - @Override - public URI getType() { - return type; - } - - @Override - public int getStatus() { - return status; - } - - @Override - public String getTitle() { - return title; + private OrderProblems() { } } diff --git a/reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java b/reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java new file mode 100644 index 0000000..66dc48c --- /dev/null +++ b/reference/spring-orders/src/test/java/com/example/orders/OrderHttpContractTest.java @@ -0,0 +1,49 @@ +package com.example.orders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class OrderHttpContractTest { + + @Test + void mvcRendersProblemDetailContract() throws Exception { + MockMvc mvc = MockMvcBuilders.standaloneSetup(new OrderController()) + .setControllerAdvice(new OrderProblemHandler()) + .build(); + + mvc.perform(get("/orders/o-123").accept(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("https://api.example.com/problems/order-not-found")) + .andExpect(jsonPath("$.title").value("Order not found")) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.detail").value("Order o-123 was not found.")); + } + + @Test + void webFluxRendersProblemDetailContract() { + WebTestClient client = WebTestClient.bindToController(new OrderController()) + .controllerAdvice(new OrderProblemHandler()) + .build(); + + client.get() + .uri("/orders/o-123") + .accept(MediaType.APPLICATION_PROBLEM_JSON) + .exchange() + .expectStatus().isNotFound() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON) + .expectBody() + .jsonPath("$.type").isEqualTo("https://api.example.com/problems/order-not-found") + .jsonPath("$.title").isEqualTo("Order not found") + .jsonPath("$.status").isEqualTo(404) + .jsonPath("$.detail").isEqualTo("Order o-123 was not found."); + } +} From f0160507cfdde7cdc96a2baccaeb545788b7adce Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:34:52 +0300 Subject: [PATCH 43/50] Update 0.4.0 documentation --- CHANGELOG.md | 6 +++++- README.md | 39 ++++++++++++-------------------------- docs/integration-spring.md | 29 ++++++---------------------- docs/migration-to-0.4.md | 34 +++++++++++++++++++++++++++++++++ docs/semantic-contract.md | 4 +++- 5 files changed, 60 insertions(+), 52 deletions(-) create mode 100644 docs/migration-to-0.4.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9641cb6..95d4a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ * Added `ProblemType` as the reusable RFC 9457 problem type definition. * Added `ProblemDetails` as the minimal Spring bridge for creating native `ProblemDetail` instances. +* Added MVC and WebFlux wire-contract verification for rendered problem responses. +* Added a migration guide for 0.3.x and 0.4.0-RC1 users. ### Changed * Refocused Codes on reusable RFC 9457 problem types. +* Made `ProblemType` an immutable value with one validated creation path. +* Required absolute problem type URIs and rejected `about:blank`, which remains a native HTTP status-only problem. * Kept the core artifact dependency-free. * Made Spring Framework an application-provided integration dependency instead of a transitive dependency of `codes-spring`. * Reduced the verified compatibility matrix to the Java, Spring, Kotlin, Gradle, and Maven combinations relevant to the new API. @@ -22,7 +26,7 @@ ### Breaking -`0.4.0` is intentionally source and binary incompatible with the 0.4.0-RC1 outcome-oriented API. Applications using pre-0.4 outcome APIs must migrate to `ProblemType` only where they represent reusable HTTP problem definitions. +`0.4.0` is intentionally source and binary incompatible with the 0.4.0-RC1 outcome-oriented API. Applications using pre-0.4 outcome APIs should migrate only reusable HTTP problem definitions to `ProblemType`; other domain concerns remain application-owned. ## 0.4.0-RC1 diff --git a/README.md b/README.md index f53b3b2..011ba20 100644 --- a/README.md +++ b/README.md @@ -66,45 +66,29 @@ The core artifact has no runtime dependencies and publishes no Maven dependencie ## Define a problem catalog -Applications can use the factory for a small number of problem types or implement `ProblemType` directly for a reusable catalog: +A problem type is an immutable value. Define reusable application problem types as constants: ```java -enum OrderProblems implements ProblemType { - ORDER_NOT_FOUND( - "https://api.example.com/problems/order-not-found", +final class OrderProblems { + static final ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), 404, "Order not found" - ), - ORDER_ALREADY_CANCELLED( - "https://api.example.com/problems/order-already-cancelled", + ); + + static final ProblemType ORDER_ALREADY_CANCELLED = ProblemType.of( + URI.create("https://api.example.com/problems/order-already-cancelled"), 409, "Order already cancelled" ); - private final URI type; - private final int status; - private final String title; - - OrderProblems(String type, int status, String title) { - this.type = URI.create(type); - this.status = status; - this.title = title; - } - - public URI getType() { - return type; - } - - public int getStatus() { - return status; - } - - public String getTitle() { - return title; + private OrderProblems() { } } ``` +Codes requires an absolute type URI, rejects `about:blank`, requires an HTTP status from 100 through 599, and requires a non-blank title. Use Spring's native `ProblemDetail.forStatus(...)` for status-only `about:blank` responses. + Different application exceptions can then reuse the same public problem type: ```java @@ -138,6 +122,7 @@ Codes is useful when problem types are part of the API contract and need to be d * [Spring integration](docs/integration-spring.md) * [Semantic contract](docs/semantic-contract.md) * [Compatibility policy](docs/compatibility-policy.md) +* [Migration to 0.4.0](docs/migration-to-0.4.md) ## License diff --git a/docs/integration-spring.md b/docs/integration-spring.md index 4a99bb0..2a91fed 100644 --- a/docs/integration-spring.md +++ b/docs/integration-spring.md @@ -15,33 +15,14 @@ The application supplies Spring Web. `codes-spring` does not impose a Spring Fra ## Define problem types ```java -enum OrderProblems implements ProblemType { - ORDER_NOT_FOUND( +final class OrderProblems { + static final ProblemType ORDER_NOT_FOUND = ProblemType.of( URI.create("https://api.example.com/problems/order-not-found"), 404, "Order not found" ); - private final URI type; - private final int status; - private final String title; - - OrderProblems(URI type, int status, String title) { - this.type = type; - this.status = status; - this.title = title; - } - - public URI getType() { - return type; - } - - public int getStatus() { - return status; - } - - public String getTitle() { - return title; + private OrderProblems() { } } ``` @@ -65,7 +46,9 @@ ProblemDetail problem = ProblemDetails.forTypeAndDetail( The bridge sets only `type`, `status`, `title`, and the explicitly supplied `detail`. -It does not set the request-specific `instance`, add extension properties, map exceptions, or register controller advice. +Do not populate `detail` from `exception.getMessage()` unless that message was explicitly designed for public API consumption. Internal exception text can expose implementation details, identifiers, or other sensitive information. + +The bridge does not set the request-specific `instance`, add extension properties, map exceptions, or register controller advice. ## Exception handlers diff --git a/docs/migration-to-0.4.md b/docs/migration-to-0.4.md new file mode 100644 index 0000000..46a16dd --- /dev/null +++ b/docs/migration-to-0.4.md @@ -0,0 +1,34 @@ +# Migrating to 0.4.0 + +Codes 0.4.0 narrows the library to reusable RFC 9457 problem type definitions. + +## From 0.3.x + +The outcome model has been removed. + +Stay on 0.3.1 if your application needs Codes to model domain outcomes, validation aggregation, registries, or protocol-neutral mappings. Codes 0.4.x is not a replacement for an application's domain result or error model. + +Migrate only reusable HTTP problem definitions to `ProblemType`. + +For example: + +```java +ProblemType ORDER_NOT_FOUND = ProblemType.of( + URI.create("https://api.example.com/problems/order-not-found"), + 404, + "Order not found" +); +``` + +Application exceptions, domain results, validation models, and workflow states remain application-owned. + +## From 0.4.0-RC1 + +The RC1 outcome-oriented Spring and gRPC APIs are superseded. + +* `Outcome`, `OutcomeDefinition`, `OutcomeCode`, registries, validation helpers, and status mappers have no 0.4.0 replacement. +* `codes-grpc-java` has no continuation in 0.4.0. +* Migrate only stable public HTTP problem definitions to `ProblemType`. +* Use Spring's native `ProblemDetail`, `ErrorResponse`, `@ExceptionHandler`, and related APIs for occurrence handling. + +There is intentionally no mechanical one-to-one migration for most RC1 types because those concerns are no longer owned by Codes. diff --git a/docs/semantic-contract.md b/docs/semantic-contract.md index 4e617fd..5eb3786 100644 --- a/docs/semantic-contract.md +++ b/docs/semantic-contract.md @@ -4,10 +4,12 @@ Codes models reusable RFC 9457 problem type definitions. A `ProblemType` contains: -* a non-empty URI reference returned by `getType()`; +* an absolute type URI; * an HTTP status between 100 and 599; * a non-blank human-readable title. +`about:blank` is not a Codes problem type. It represents a status-only problem with no additional semantics beyond the HTTP status and is handled directly by HTTP frameworks such as Spring. + The type URI is the stable machine-readable identity of the problem type. ```java From 942dca93bd4232e712aca192baf817cffaa8b6d6 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:35:57 +0300 Subject: [PATCH 44/50] Harden compatibility and release checks --- .github/workflows/compatibility.yml | 68 +++++++++++++++++++++----- .github/workflows/release.yml | 2 +- scripts/verify-compatibility-matrix.py | 52 ++++++++++++-------- 3 files changed, 89 insertions(+), 33 deletions(-) mode change 100644 => 100755 scripts/verify-compatibility-matrix.py diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 3b7aabf..5852c19 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -13,7 +13,52 @@ concurrency: cancel-in-progress: true jobs: + matrix: + name: Matrix policy + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + java-spring: ${{ steps.matrix.outputs.java-spring }} + kotlin: ${{ steps.matrix.outputs.kotlin }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Verify matrix policy + run: python scripts/verify-compatibility-matrix.py + + - id: matrix + name: Export matrices + shell: bash + run: | + python - <<'PY' >> "$GITHUB_OUTPUT" + import json + from pathlib import Path + + matrix = json.loads(Path("compatibility/matrix.json").read_text(encoding="utf-8")) + compact = lambda value: json.dumps(value, separators=(",", ":")) + + print("java-spring=" + compact({ + "java": matrix["java"], + "spring": matrix["spring"], + })) + + kotlin = matrix["kotlin"] + print("kotlin=" + compact({ + "include": [ + { + "kotlin": version, + "spring": kotlin["spring"], + } + for version in kotlin["versions"] + ] + })) + PY + contracts: + name: Artifact contracts and coverage + needs: [matrix] runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -30,9 +75,6 @@ jobs: with: cache-provider: basic - - name: Verify compatibility policy - run: python scripts/verify-compatibility-matrix.py - - name: Verify contracts run: ./gradlew clean verifyAll --stacktrace @@ -47,14 +89,12 @@ jobs: retention-days: 1 java-spring: - needs: contracts + needs: [matrix, contracts] runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: - java: ["17", "21", "25"] - spring: ["6.0.0", "7.0.9"] + matrix: ${{ fromJSON(needs.matrix.outputs.java-spring) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -99,13 +139,12 @@ jobs: mvn --batch-mode --no-transfer-progress "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" -f smoke-test-maven-java/pom.xml "-Dcodes.version=$version" "-Dmaven.compiler.release=${{ matrix.java }}" "-Dspring.framework.version=${{ matrix.spring }}" clean verify kotlin: - needs: contracts + needs: [matrix, contracts] runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: - kotlin: ["1.9.24", "2.4.10"] + matrix: ${{ fromJSON(needs.matrix.outputs.kotlin) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -124,23 +163,28 @@ jobs: - name: Verify Kotlin consumer env: MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven - run: sh scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" + run: >- + sh scripts/verify-kotlin-nullability.sh + "${{ matrix.kotlin }}" + "${{ matrix.spring }}" compatibility-gate: name: Compatibility gate if: always() - needs: [contracts, java-spring, kotlin] + needs: [matrix, contracts, java-spring, kotlin] runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Require all compatibility checks shell: bash env: + MATRIX: ${{ needs.matrix.result }} CONTRACTS: ${{ needs.contracts.result }} JAVA_SPRING: ${{ needs.java-spring.result }} KOTLIN: ${{ needs.kotlin.result }} run: | set -euo pipefail + test "$MATRIX" = "success" test "$CONTRACTS" = "success" test "$JAVA_SPRING" = "success" test "$KOTLIN" = "success" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ecad30..d11c949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: set -euo pipefail version="$(sh ./scripts/version.sh)" - [[ "$version" =~ ^[0-9]+.[0-9]+.[0-9]+$ ]] + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] test "${GITHUB_REF_NAME}" = "v${version}" git fetch --no-tags origin main diff --git a/scripts/verify-compatibility-matrix.py b/scripts/verify-compatibility-matrix.py old mode 100644 new mode 100755 index 231adde..357a238 --- a/scripts/verify-compatibility-matrix.py +++ b/scripts/verify-compatibility-matrix.py @@ -3,25 +3,37 @@ import json from pathlib import Path -root = Path(__file__).resolve().parent.parent -matrix = json.loads((root / "compatibility/matrix.json").read_text(encoding="utf-8")) - -required_keys = {"java", "spring", "kotlin"} -if set(matrix) != required_keys: - raise SystemExit(f"Compatibility matrix keys must be {sorted(required_keys)}") - -if matrix["java"] != ["17", "21", "25"]: - raise SystemExit("Java compatibility matrix must cover 17, 21, and 25.") - -if len(matrix["spring"]) < 2: - raise SystemExit("Spring compatibility matrix must include a floor and current version.") - -kotlin = matrix["kotlin"] -if set(kotlin) != {"versions", "spring"}: - raise SystemExit("Kotlin compatibility policy must define versions and spring.") -if not kotlin["versions"]: - raise SystemExit("Kotlin compatibility matrix must not be empty.") -if kotlin["spring"] not in matrix["spring"]: - raise SystemExit("Kotlin compatibility Spring version must be in the Spring matrix.") +ROOT = Path(__file__).resolve().parent.parent + + +def properties(path: Path) -> dict[str, str]: + result: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + key, value = line.split("=", 1) + result[key] = value + return result + + +matrix = json.loads((ROOT / "compatibility" / "matrix.json").read_text(encoding="utf-8")) +gradle = properties(ROOT / "gradle.properties") + +expected = { + "java": ["17", "21", "25"], + "spring": ["6.0.0", gradle["springFrameworkVersion"]], + "kotlin": { + "versions": ["1.9.24", "2.4.10"], + "spring": "6.0.0", + }, +} + +if matrix != expected: + raise SystemExit( + "compatibility/matrix.json drifted from the declared support policy.\n" + f"Expected: {expected}\n" + f"Actual: {matrix}" + ) print("Compatibility matrix policy verified.") From 939358eb70ae7ff08655f99d8465c26e284c96fd Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:36:41 +0300 Subject: [PATCH 45/50] Fix compatibility matrix output --- .github/workflows/compatibility.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 5852c19..5c20fdd 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - java-spring: ${{ steps.matrix.outputs.java-spring }} + java_spring: ${{ steps.matrix.outputs.java_spring }} kotlin: ${{ steps.matrix.outputs.kotlin }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -39,7 +39,7 @@ jobs: matrix = json.loads(Path("compatibility/matrix.json").read_text(encoding="utf-8")) compact = lambda value: json.dumps(value, separators=(",", ":")) - print("java-spring=" + compact({ + print("java_spring=" + compact({ "java": matrix["java"], "spring": matrix["spring"], })) @@ -94,7 +94,7 @@ jobs: timeout-minutes: 25 strategy: fail-fast: false - matrix: ${{ fromJSON(needs.matrix.outputs.java-spring) }} + matrix: ${{ fromJSON(needs.matrix.outputs.java_spring) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: From 68d187b98b93c3ea7674c78ada825e5db092e5e9 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:38:46 +0300 Subject: [PATCH 46/50] Update problem type API snapshot --- api/codes.api | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/codes.api b/api/codes.api index 7eb6df8..e96ee95 100644 --- a/api/codes.api +++ b/api/codes.api @@ -1,5 +1,8 @@ public final class io.github.aalsanie.codes.ProblemType + method public boolean equals(java.lang.Object) method public int getStatus() + method public int hashCode() method public java.lang.String getTitle() + method public java.lang.String toString() method public java.net.URI getType() method public static io.github.aalsanie.codes.ProblemType of(java.net.URI, int, java.lang.String) From 656c0ff2904c100546d812bf6d75119232c9ddcf Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:41:12 +0300 Subject: [PATCH 47/50] Fix explicit path variable binding --- .../src/main/java/com/example/orders/OrderController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java index e98b466..fdf6210 100644 --- a/reference/spring-orders/src/main/java/com/example/orders/OrderController.java +++ b/reference/spring-orders/src/main/java/com/example/orders/OrderController.java @@ -10,12 +10,12 @@ class OrderController { @GetMapping("/{orderId}") - String find(@PathVariable String orderId) { + String find(@PathVariable("orderId") String orderId) { throw new OrderNotFoundException(orderId); } @GetMapping("/archive/{orderId}") - String findArchived(@PathVariable String orderId) { + String findArchived(@PathVariable("orderId") String orderId) { throw new ArchivedOrderNotFoundException(orderId); } } From 2e46c9de4daf5ff931795a6889ac55aae74240fd Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 10:49:30 +0300 Subject: [PATCH 48/50] Restore required compatibility checks --- .github/workflows/ci.yml | 169 ++++++++++++++++++++++++- compatibility/README.md | 3 +- compatibility/matrix.json | 6 +- docs/compatibility-policy.md | 3 +- scripts/verify-compatibility-matrix.py | 1 + 5 files changed, 176 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf89bbb..4f5c4c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,46 @@ concurrency: cancel-in-progress: true jobs: + matrix: + name: CI matrix policy + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + runtime_jdks: ${{ steps.matrix.outputs.runtime_jdks }} + kotlin: ${{ steps.matrix.outputs.kotlin }} + os: ${{ steps.matrix.outputs.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Verify compatibility policy + run: python scripts/verify-compatibility-matrix.py + + - id: matrix + name: Export required check matrices + shell: bash + run: | + python - <<'PY' >> "$GITHUB_OUTPUT" + import json + from pathlib import Path + + matrix = json.loads(Path("compatibility/matrix.json").read_text(encoding="utf-8")) + compact = lambda value: json.dumps(value, separators=(",", ":")) + + print("runtime_jdks=" + compact({ + "java": [version for version in matrix["java"] if version != "17"] + })) + print("kotlin=" + compact({ + "kotlin": matrix["kotlin"]["versions"] + })) + print("os=" + compact({ + "os": matrix["os"] + })) + PY + verify: + needs: [matrix] runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -30,9 +69,6 @@ jobs: with: cache-provider: basic - - name: Verify compatibility policy - run: python scripts/verify-compatibility-matrix.py - - name: Verify library and reference application run: ./gradlew clean verifyAll --stacktrace @@ -76,3 +112,130 @@ jobs: codes-spring/build/reports/jacoco if-no-files-found: warn retention-days: 14 + + kotlin-consumers: + needs: [matrix] + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix.outputs.kotlin) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-provider: basic + + - name: Publish clean compatibility artifacts + run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + + - name: Verify Kotlin consumer + env: + MAVEN_REPO_LOCAL: ${{ github.workspace }}/build/compatibility-maven + run: sh scripts/verify-kotlin-nullability.sh "${{ matrix.kotlin }}" + + kotlin-consumers-gate: + name: Kotlin consumers gate + if: always() + needs: [matrix, kotlin-consumers] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require Kotlin consumer matrix + shell: bash + env: + MATRIX_RESULT: ${{ needs.matrix.result }} + KOTLIN_RESULT: ${{ needs.kotlin-consumers.result }} + run: | + set -euo pipefail + test "$MATRIX_RESULT" = "success" + test "$KOTLIN_RESULT" = "success" + + runtime-jdks: + needs: [matrix] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix.outputs.runtime_jdks) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-provider: basic + + - name: Publish clean compatibility artifacts + run: sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + + - name: Verify Java consumer on runtime JDK + run: >- + ./gradlew + -p smoke-test-java + -Dmaven.repo.local=${{ github.workspace }}/build/compatibility-maven + -PconsumerJavaVersion=${{ matrix.java }} + clean check + --stacktrace + + operating-systems: + needs: [matrix] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix.outputs.os) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-provider: basic + + - name: Verify published consumers on Unix + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + sh scripts/prepare-compatibility-repository.sh build/compatibility-maven + + ./gradlew -p smoke-test-java "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" clean check --stacktrace + + ./gradlew -p smoke-test-kotlin "-Dmaven.repo.local=${GITHUB_WORKSPACE}/build/compatibility-maven" clean check --stacktrace + + - name: Verify published consumers on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $repo = Join-Path $env:GITHUB_WORKSPACE "build\compatibility-maven" + + .\gradlew.bat "-Dmaven.repo.local=$repo" :publishToMavenLocal :codes-spring:publishToMavenLocal --stacktrace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + .\gradlew.bat -p smoke-test-java "-Dmaven.repo.local=$repo" clean check --stacktrace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + .\gradlew.bat -p smoke-test-kotlin "-Dmaven.repo.local=$repo" clean check --stacktrace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/compatibility/README.md b/compatibility/README.md index 2da07d5..a39c7c3 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -7,7 +7,8 @@ The matrix covers: * Java 17, 21, and 25; * Spring Framework 6.0.0 and 7.0.9; * Kotlin 1.9.24 and 2.4.10; -* Gradle and Maven consumers. +* Gradle and Maven consumers; +* Windows and macOS published-consumer checks. The checks also verify that: diff --git a/compatibility/matrix.json b/compatibility/matrix.json index d2eb423..548094a 100644 --- a/compatibility/matrix.json +++ b/compatibility/matrix.json @@ -14,5 +14,9 @@ "2.4.10" ], "spring": "6.0.0" - } + }, + "os": [ + "windows-latest", + "macos-latest" + ] } diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md index 6e93c4b..52b848d 100644 --- a/docs/compatibility-policy.md +++ b/docs/compatibility-policy.md @@ -15,7 +15,8 @@ The `0.4.x` line is verified with: * Java 17, 21, and 25; * Spring Framework 6.0.0 and 7.0.9; * Kotlin 1.9.24 and 2.4.10; -* Gradle and Maven consumers. +* Gradle and Maven consumers; +* published consumers on Windows and macOS in addition to the primary Linux CI environment. Spring Framework 6.0.0 is the compatibility floor. Spring Framework 7.0.9 is the current verification baseline. diff --git a/scripts/verify-compatibility-matrix.py b/scripts/verify-compatibility-matrix.py index 357a238..7cf0357 100755 --- a/scripts/verify-compatibility-matrix.py +++ b/scripts/verify-compatibility-matrix.py @@ -27,6 +27,7 @@ def properties(path: Path) -> dict[str, str]: "versions": ["1.9.24", "2.4.10"], "spring": "6.0.0", }, + "os": ["windows-latest", "macos-latest"], } if matrix != expected: From f54e149bf2c203ac15ef52f7d52e5577aaaa4e11 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 11:01:43 +0300 Subject: [PATCH 49/50] Refine 0.4.0 documentation --- CHANGELOG.md | 1 - README.md | 7 +++---- docs/migration-to-0.4.md | 34 ---------------------------------- 3 files changed, 3 insertions(+), 39 deletions(-) delete mode 100644 docs/migration-to-0.4.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d4a07..3b19d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ * Added `ProblemType` as the reusable RFC 9457 problem type definition. * Added `ProblemDetails` as the minimal Spring bridge for creating native `ProblemDetail` instances. * Added MVC and WebFlux wire-contract verification for rendered problem responses. -* Added a migration guide for 0.3.x and 0.4.0-RC1 users. ### Changed diff --git a/README.md b/README.md index 011ba20..35a4831 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/aalsanie/codes/actions/workflows/ci.yml/badge.svg)](https://github.com/aalsanie/codes/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -Codes is a tiny Java library for reusable RFC 9457 problem type definitions. +Codes is a lightweight Java library for reusable RFC 9457 problem type definitions. Spring provides `ProblemDetail` for an individual error occurrence. Codes provides the stable definition that can be reused across controllers, exception handlers, tests, and documentation. @@ -109,9 +109,9 @@ ProblemDetail handleArchivedOrderNotFound(ArchivedOrderNotFoundException ex) { } ``` -Codes does not own exception handling, controller advice, localization, extension properties, or request-specific data. Those remain application and Spring concerns. +The library does not own exception handling, controller advice, localization, extension properties, or request-specific data. Those remain application and Spring concerns. -## Do I need Codes? +## When to use Codes If an application only creates one or two `ProblemDetail` instances directly, a local helper may be simpler. @@ -122,7 +122,6 @@ Codes is useful when problem types are part of the API contract and need to be d * [Spring integration](docs/integration-spring.md) * [Semantic contract](docs/semantic-contract.md) * [Compatibility policy](docs/compatibility-policy.md) -* [Migration to 0.4.0](docs/migration-to-0.4.md) ## License diff --git a/docs/migration-to-0.4.md b/docs/migration-to-0.4.md deleted file mode 100644 index 46a16dd..0000000 --- a/docs/migration-to-0.4.md +++ /dev/null @@ -1,34 +0,0 @@ -# Migrating to 0.4.0 - -Codes 0.4.0 narrows the library to reusable RFC 9457 problem type definitions. - -## From 0.3.x - -The outcome model has been removed. - -Stay on 0.3.1 if your application needs Codes to model domain outcomes, validation aggregation, registries, or protocol-neutral mappings. Codes 0.4.x is not a replacement for an application's domain result or error model. - -Migrate only reusable HTTP problem definitions to `ProblemType`. - -For example: - -```java -ProblemType ORDER_NOT_FOUND = ProblemType.of( - URI.create("https://api.example.com/problems/order-not-found"), - 404, - "Order not found" -); -``` - -Application exceptions, domain results, validation models, and workflow states remain application-owned. - -## From 0.4.0-RC1 - -The RC1 outcome-oriented Spring and gRPC APIs are superseded. - -* `Outcome`, `OutcomeDefinition`, `OutcomeCode`, registries, validation helpers, and status mappers have no 0.4.0 replacement. -* `codes-grpc-java` has no continuation in 0.4.0. -* Migrate only stable public HTTP problem definitions to `ProblemType`. -* Use Spring's native `ProblemDetail`, `ErrorResponse`, `@ExceptionHandler`, and related APIs for occurrence handling. - -There is intentionally no mechanical one-to-one migration for most RC1 types because those concerns are no longer owned by Codes. From c2dc38fa0358e9f33859077c9f276d4edeaed7f7 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 6 Oct 2026 11:32:12 +0300 Subject: [PATCH 50/50] Align repository guidance with 0.4.0 --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- .github/ISSUE_TEMPLATE/feature.yml | 8 ++-- CONTRIBUTING.md | 38 ++++++++----------- SECURITY.md | 20 +++++----- .../aalsanie/codes/spring/ProblemDetails.java | 18 +++++++++ .../io/github/aalsanie/codes/ProblemType.java | 22 +++++++++++ 6 files changed, 71 insertions(+), 37 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 2c02269..280e8c0 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -7,7 +7,7 @@ body: id: version attributes: label: Codes version - placeholder: 0.3.0 + placeholder: 0.4.0 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index efd5f60..68c4f79 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -1,5 +1,5 @@ name: Change request -description: Propose a core semantic, mapping, or integration change. +description: Propose a problem type or Spring integration change. title: "request: " labels: [enhancement] body: @@ -13,7 +13,7 @@ body: - type: textarea id: current attributes: - label: Current integration + label: Current approach description: Show how the problem is handled without the proposed change. validations: required: true @@ -28,5 +28,7 @@ body: attributes: label: Contract impact options: - - label: This changes the standard outcome catalog, state semantics, or a built-in mapping. + - label: This changes ProblemType validation or semantics. - label: This changes the public Java API. + - label: This changes Spring ProblemDetail conversion behavior. + - label: This changes compatibility, dependency, or publication behavior. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51eccdc..9a375dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,25 +2,26 @@ Keep changes focused, tested, and justified. -## Before changing contracts +## Contracts -Codes has public contracts across three artifacts: +Codes publishes two artifacts: - `codes` - `codes-spring` -- `codes-grpc-java` -Changes to standard outcome membership, standard outcome state, or built-in HTTP/gRPC mappings change the semantic contract. +The core contract is the `ProblemType` value: an absolute RFC 9457 type URI, HTTP status, and non-blank title. -Public API changes require updating the matching snapshot under `api/`. +The Spring contract is the explicit conversion from a `ProblemType` to Spring's native `ProblemDetail`. -Spring or gRPC wire changes require updating the matching snapshot under `compatibility/`. +Public Java API changes require updating the matching snapshot under `api/`. -Do not update a snapshot just because a test failed. Understand the change and explain why the contract should move. +Changes that affect rendered Spring problem responses require updating the MVC and WebFlux contract tests in the Spring reference application. -The core artifact must remain free of runtime dependencies. +The core artifact must remain free of runtime dependencies and publish no Maven dependencies. -Adapters must stay thin and explicit. Do not introduce auto-configuration, serialization frameworks, registries, hidden mapping rules, or unrelated abstractions as part of an adapter change. +`codes-spring` must remain a thin bridge. Its published dependency surface is limited to `codes`; applications provide Spring Web. + +Do not introduce auto-configuration, exception mapping, registries, serialization frameworks, or unrelated abstractions as part of a Spring integration change. ## Build @@ -38,7 +39,7 @@ Linux/macOS: ./gradlew clean verifyAll --stacktrace ``` -For publication or dependency changes, also verify all three artifacts from a clean isolated Maven repository. +For publication or dependency changes, also verify the artifacts from a clean isolated Maven repository. Windows: @@ -53,24 +54,15 @@ Linux/macOS: bash scripts/prepare-compatibility-repository.sh build/compatibility-maven ``` -The CI compatibility workflow covers the supported Java, Kotlin, Spring, gRPC, Gradle/Maven consumer, and operating-system matrix. +CI verifies the supported Java, Kotlin, Spring, Gradle/Maven consumer, and operating-system matrix. ## Pull requests -Keep pull requests small enough to review. - -Include tests for behavior changes. - -Explain any: - -- public API change -- semantic or wire change -- dependency change -- compatibility change +Keep changes small enough to review and include tests for behavior changes. -Do not mix unrelated cleanup with a behavioral change. +Do not mix unrelated cleanup with behavioral changes. -Do not weaken a compatibility, coverage, dependency, or publication check just to make CI green. +Do not weaken a compatibility, coverage, dependency, publication, or security check just to make CI green. ## AI-assisted contributions diff --git a/SECURITY.md b/SECURITY.md index 38b9d92..7d068c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,10 +13,10 @@ Do not use a public issue as a fallback. Please include, when applicable: - affected Codes version -- affected artifact: `codes`, `codes-spring`, or `codes-grpc-java` -- affected surface: core API, Spring boundary, gRPC boundary, publication, or build tooling +- affected artifact: `codes` or `codes-spring` +- affected surface: core API, Spring bridge, publication, or build tooling - Java and Kotlin versions -- Spring or gRPC version +- Spring version when `codes-spring` is involved - Gradle or Maven version - operating system - minimal reproduction steps @@ -32,16 +32,16 @@ Please avoid public disclosure until a fix or coordinated disclosure date is ava Security fixes target the latest stable release line. -Pre-release versions may receive a fix through a newer release candidate or the final release rather than a patch to the affected pre-release. +Older release lines and pre-release versions are not guaranteed to receive security fixes. -Older release lines are not guaranteed to receive security fixes. +## Application-controlled problem data -## Boundary data +Codes does not sanitize or redact application-provided problem data. -Codes can carry application-controlled occurrence `detail` and structured `Issue` content. +`ProblemType` stores the type URI, status, and title supplied by the application. `ProblemDetails.forTypeAndDetail(...)` copies the supplied detail into Spring's `ProblemDetail`. -The Spring and gRPC adapters use conservative defaults and do not expose protected occurrence detail unless the application explicitly opts in. +Treat titles and occurrence details as client-visible data. Do not expose exception messages, credentials, tokens, internal identifiers, stack traces, or other sensitive implementation details unless they are explicitly safe for the public API. -A case where safe defaults expose protected data is security-relevant and should be reported privately. +A case where Codes changes, corrupts, or unexpectedly exposes data beyond the values supplied through its public API is security-relevant and should be reported privately. -Suspected compromise of a published artifact, signature, or release process should also be reported privately. +Suspected compromise of a published artifact, signature, dependency metadata, or release process should also be reported privately. diff --git a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java index f95114b..46bc7ff 100644 --- a/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java +++ b/codes-spring/src/main/java/io/github/aalsanie/codes/spring/ProblemDetails.java @@ -4,10 +4,20 @@ import java.util.Objects; import org.springframework.http.ProblemDetail; +/** + * Creates Spring {@link ProblemDetail} instances from reusable {@link ProblemType} definitions. + */ public final class ProblemDetails { private ProblemDetails() { } + /** + * Creates a problem occurrence from the stable type, status, and title. + * + * @param problemType reusable problem type definition + * @return a new Spring problem detail + * @throws NullPointerException if {@code problemType} is {@code null} + */ public static ProblemDetail forType(ProblemType problemType) { Objects.requireNonNull(problemType, "problemType"); ProblemDetail problemDetail = ProblemDetail.forStatus(problemType.getStatus()); @@ -16,6 +26,14 @@ public static ProblemDetail forType(ProblemType problemType) { return problemDetail; } + /** + * Creates a problem occurrence with occurrence-specific detail. + * + * @param problemType reusable problem type definition + * @param detail client-visible detail for this occurrence + * @return a new Spring problem detail + * @throws NullPointerException if either argument is {@code null} + */ public static ProblemDetail forTypeAndDetail(ProblemType problemType, String detail) { Objects.requireNonNull(detail, "detail"); ProblemDetail problemDetail = forType(problemType); diff --git a/src/main/java/io/github/aalsanie/codes/ProblemType.java b/src/main/java/io/github/aalsanie/codes/ProblemType.java index 7d9a61e..48130c2 100644 --- a/src/main/java/io/github/aalsanie/codes/ProblemType.java +++ b/src/main/java/io/github/aalsanie/codes/ProblemType.java @@ -3,6 +3,9 @@ import java.net.URI; import java.util.Objects; +/** + * Immutable definition of a reusable RFC 9457 problem type. + */ public final class ProblemType { private static final String ABOUT_SCHEME = "about"; private static final String BLANK_SCHEME_SPECIFIC_PART = "blank"; @@ -17,6 +20,16 @@ private ProblemType(URI type, int status, String title) { this.title = title; } + /** + * Creates a problem type. + * + * @param type absolute problem type URI; must not be {@code about:blank} + * @param status HTTP status from 100 through 599 + * @param title non-blank human-readable title + * @return the problem type + * @throws NullPointerException if {@code type} or {@code title} is {@code null} + * @throws IllegalArgumentException if an argument violates the problem type contract + */ public static ProblemType of(URI type, int status, String title) { Objects.requireNonNull(type, "type"); Objects.requireNonNull(title, "title"); @@ -37,14 +50,23 @@ public static ProblemType of(URI type, int status, String title) { return new ProblemType(type, status, title); } + /** + * Returns the stable machine-readable problem type URI. + */ public URI getType() { return type; } + /** + * Returns the HTTP status associated with this problem type. + */ public int getStatus() { return status; } + /** + * Returns the human-readable title for this problem type. + */ public String getTitle() { return title; }