Skip to content

Commit 7ad0eeb

Browse files
Merge pull request #224 from SpineEventEngine/message-validator
Support custom message validators
2 parents b8c1583 + ed00afe commit 7ad0eeb

File tree

30 files changed

+5529
-642
lines changed

30 files changed

+5529
-642
lines changed

buildSrc/src/main/kotlin/io/spine/dependency/Dependency.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ abstract class Dependency {
5858
/**
5959
* The [modules] given with the [version].
6060
*/
61-
final val artifacts: Map<String, String> by lazy {
61+
val artifacts: Map<String, String> by lazy {
6262
modules.associateWith { "$it:$version" }
6363
}
6464

@@ -114,3 +114,19 @@ private fun ResolutionStrategy.forceWithLogging(
114114
force(artifact)
115115
project.log { "Forced the version of `$artifact` in " + configuration.diagSuffix(project) }
116116
}
117+
118+
/**
119+
* Obtains full Maven coordinates for the requested [module].
120+
*
121+
* This extension allows referencing properties of the [Dependency],
122+
* upon which it is invoked.
123+
*
124+
* An example usage:
125+
*
126+
* ```
127+
* // Supposing there is `Ksp.symbolProcessingApi: String` property declared.
128+
* Ksp.artifact { symbolProcessingApi }
129+
* ```
130+
*/
131+
fun <T : Dependency> T.artifact(module: T.() -> String): String =
132+
artifact(module())

buildSrc/src/main/kotlin/io/spine/dependency/test/KotlinCompileTesting.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ package io.spine.dependency.test
3333
*/
3434
@Suppress("unused", "ConstPropertyName")
3535
object KotlinCompileTesting {
36-
private const val version = "0.7.0"
36+
private const val version = "0.7.1"
3737
private const val group = "dev.zacsweers.kctfork"
3838
const val libCore = "$group:core:$version"
3939
const val libKsp = "$group:ksp:$version"

buildSrc/src/main/kotlin/module.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import io.spine.dependency.boms.BomsPlugin
2828
import io.spine.dependency.build.Dokka
2929
import io.spine.dependency.build.ErrorProne
3030
import io.spine.dependency.build.JSpecify
31+
import io.spine.dependency.build.Ksp
3132
import io.spine.dependency.lib.Grpc
3233
import io.spine.dependency.lib.Kotlin
3334
import io.spine.dependency.lib.Protobuf
@@ -144,8 +145,10 @@ fun Module.forceConfigurations() {
144145
all {
145146
resolutionStrategy {
146147
Grpc.forceArtifacts(project, this@all, this@resolutionStrategy)
148+
Ksp.forceArtifacts(project, this@all, this@resolutionStrategy)
147149
force(
148150
Kotlin.bom,
151+
Kotlin.Compiler.embeddable,
149152
Reflect.lib,
150153
Base.lib,
151154
Protobuf.compiler,

dependencies.md

Lines changed: 3502 additions & 593 deletions
Large diffs are not rendered by default.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2025, TeamDev. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Redistribution and use in source and/or binary forms, with or without
11+
* modification, must retain the above copyright notice and the following
12+
* disclaimer.
13+
*
14+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
package io.spine.validation.api
28+
29+
import io.spine.base.FieldPath
30+
import io.spine.validate.TemplateString
31+
32+
/**
33+
* Abstract base for violations detected by [MessageValidator]s.
34+
*
35+
* @param message The error message describing the violation.
36+
* @param fieldPath The path to the field where the violation occurred, if applicable.
37+
* @param fieldValue The field value that caused the violation, if any.
38+
*/
39+
public abstract class DetectedViolation(
40+
public val message: TemplateString,
41+
public val fieldPath: FieldPath?,
42+
public val fieldValue: Any?,
43+
)
44+
45+
/**
46+
* A violation tied to a specific field in a message.
47+
*
48+
* @param message The error message describing the violation.
49+
* @param fieldPath The path to the field where the violation occurred.
50+
* @param fieldValue The field value that caused the violation, if any.
51+
*/
52+
public class FieldViolation(
53+
message: TemplateString,
54+
fieldPath: FieldPath,
55+
fieldValue: Any? = null,
56+
) : DetectedViolation(message, fieldPath, fieldValue)
57+
58+
/**
59+
* A violation related to the message level (not tied to a specific field).
60+
*
61+
* @param message The error message describing the violation.
62+
*/
63+
public class MessageViolation(
64+
message: TemplateString
65+
) : DetectedViolation(message, fieldPath = null, fieldValue = null)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2025, TeamDev. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Redistribution and use in source and/or binary forms, with or without
11+
* modification, must retain the above copyright notice and the following
12+
* disclaimer.
13+
*
14+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
package io.spine.validation.api
28+
29+
import io.spine.annotation.Internal
30+
import java.io.File
31+
32+
/**
33+
* Holds a path to a file with the discovered validators.
34+
*
35+
* The KSP processor generates a resource file using this path.
36+
* Then, the Java codegen plugin picks up this file.
37+
*/
38+
@Internal
39+
public object DiscoveredValidators {
40+
41+
/**
42+
* The path to the file with the discovered message validators.
43+
*
44+
* The path is relative to the output directory of the KSP processor.
45+
*/
46+
public const val RESOURCES_LOCATION: String = "spine/validation/message-validators"
47+
48+
/**
49+
* Resolves the path to the file containing discovered message validators.
50+
*
51+
* @param kspOutputDirectory The path to the KSP output.
52+
*/
53+
public fun resolve(kspOutputDirectory: File): File = kspOutputDirectory
54+
.resolve("resources")
55+
.resolve(RESOURCES_LOCATION)
56+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright 2025, TeamDev. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Redistribution and use in source and/or binary forms, with or without
11+
* modification, must retain the above copyright notice and the following
12+
* disclaimer.
13+
*
14+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
package io.spine.validation.api
28+
29+
import com.google.protobuf.Message
30+
import io.spine.annotation.SPI
31+
32+
/**
33+
* A validator for an external Protobuf message of type [M].
34+
*
35+
* This interface enforces validation rules for `Message`s whose Java/Kotlin classes
36+
* are already generated by third parties, in case they are used in the Protobuf
37+
* codebase to which the Validation library is applied.
38+
*
39+
* ## Problem
40+
*
41+
* Java/Kotlin libraries that use Protobuf messages often distribute both the `.proto`
42+
* definitions and the compiled class files (`.class`) for these messages.
43+
* As these classes are pre-generated, consumers cannot modify their underlying
44+
* `.proto` files to define validation constraints and the Validation library
45+
* cannot use code generation to enforce the constraints.
46+
*
47+
* Thus, the library effectively deals with the two types of messages:
48+
*
49+
* 1. **Local messages** are message types for which end-users generate and control
50+
* the Java/Kotlin classes by compiling the corresponding `.proto` definitions
51+
* within their own codebase. For such messages, the Validation library allows
52+
* declaring validation constraints and enforces them with the generated code.
53+
*
54+
* 2. **External messages** are message types for which end-users do not generate or control
55+
* the Java/Kotlin classes. Because the classes are already generated, users cannot modify
56+
* the underlying `.proto` definitions and add validation options at compile time.
57+
*
58+
* ## Validation of external messages
59+
*
60+
* The Validation library provides a mechanism that allows validating of
61+
* the external messages, **which are used for fields within local messages**.
62+
* Implement this interface and annotate the implementing class with
63+
* the [@Validator][Validator] annotation, specifying the message type to validate.
64+
*
65+
* For each field of type [M] within any local message, the library will invoke
66+
* the [MessageValidator.validate] method when validating the local message.
67+
*
68+
* The following Protobuf field types are supported:
69+
*
70+
* 1. Singular fields of type [M].
71+
* 2. Repeated field of type [M].
72+
* 3. Map field with values of type [M].
73+
*
74+
* An example of the validator declaration for the `Earphones` message:
75+
*
76+
* ```kotlin
77+
* @Validator(Earphones::class)
78+
* public class EarphonesValidator : MessageValidator<Earphones> {
79+
* public override fun validate(message: Earphones): List<DetectedViolation> {
80+
* return emptyList() // Always valid.
81+
* }
82+
* }
83+
* ```
84+
*
85+
* Please note that standalone instances of [M] and fields of [M] type that occur in
86+
* other external messages **will not be validated**.
87+
*
88+
* Consider the following example:
89+
*
90+
* ```proto
91+
* // Brings the `Earphones` message from dependencies.
92+
* // Suppose we don't control the generated code of declarations from this file.
93+
* import "earphones.proto";
94+
*
95+
* // A locally-declared message.
96+
* message WorkingSetup {
97+
*
98+
* // The field of external message type.
99+
* Earphones earphones = 1;
100+
* }
101+
* ```
102+
*
103+
* Supposing that the Validation library applied to the module where both `WorkingSetup`
104+
* and `EarphonesValidator` classes are declared, then the generated code of `WorkingSetup`
105+
* will apple the validator to each instance passed to the `WorkingSetup.earphones` field.
106+
*
107+
* Please note that the following use cases are NOT supported and will lead to an error:
108+
*
109+
* 1) Declaring a validator for a local message is prohibited. Only external messages are
110+
* allowed to have a validator. Use built-in or custom validation options to declare
111+
* constraints for local messages.
112+
* 2) Declaring multiple validators for the same message type is prohibited. The library
113+
* scans the module’s classpath to discover validators, and expects exactly one validator
114+
* per message type.
115+
*
116+
* ## Implementation
117+
*
118+
* The interface offers flexible validation strategies. Implementations can choose to
119+
* validate a particular field, several fields, the whole message instance (for example,
120+
* checking the field relations), and perform a deep validation.
121+
*
122+
* It is a responsibility of the validator to provide the correct instances
123+
* of [DetectedViolation]. Before reporting to the user, the library converts
124+
* [DetectedViolation] to a [ConstraintViolation][io.spine.validate.ConstraintViolation].
125+
* Returning of an empty list of violations means that the given message is valid.
126+
*
127+
* Please keep in mind that for each invocation a new instance of [MessageValidator]
128+
* is created. Every implementation of [MessageValidator] must have a public,
129+
* no-args constructor.
130+
*
131+
* @param M the type of Protobuf [Message] being validated.
132+
*/
133+
@SPI
134+
public interface MessageValidator<M : Message> {
135+
136+
/**
137+
* Validates the given [message].
138+
*
139+
* @return the detected violations or empty list.
140+
*/
141+
public fun validate(message: M): List<DetectedViolation>
142+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2025, TeamDev. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Redistribution and use in source and/or binary forms, with or without
11+
* modification, must retain the above copyright notice and the following
12+
* disclaimer.
13+
*
14+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25+
*/
26+
27+
package io.spine.validation.api
28+
29+
import com.google.protobuf.Message
30+
import kotlin.annotation.AnnotationRetention.SOURCE
31+
import kotlin.annotation.AnnotationTarget.CLASS
32+
import kotlin.reflect.KClass
33+
34+
/**
35+
* Marks the class as a message validator.
36+
*
37+
* Applying this annotation to an implementation of [MessageValidator]
38+
* makes the class visible to the validation library.
39+
*
40+
* Please note that the following requirements are imposed to the marked class:
41+
*
42+
* 1. The class must implement the [MessageValidator] interface.
43+
* 2. The class must have a public, no-args constructor.
44+
* 3. The class cannot be `inner`, but nested classes are allowed.
45+
* 4. The message type of [Validator.value] and [MessageValidator] must match.
46+
*/
47+
@Target(CLASS)
48+
@Retention(SOURCE)
49+
public annotation class Validator(
50+
51+
/**
52+
* The class of the validated external message.
53+
*/
54+
val value: KClass<out Message>
55+
)

0 commit comments

Comments
 (0)