Skip to content

Commit 5b66889

Browse files
authored
fix(rust): validate config on client construction (#748)
Description of changes: In the Rust codegen, constraint validation is performed on operation calls instead of within structure builders' build() function. (The reasoning for this choice is documented in the description of #582.) But the validation wasn't applied to the constructors of localService clients, so attempting to construct a client with an invalid config could panic during conversion (in particular, when a @required field was missing). This PR implements the missing validation in client constructors, and tests that both valid and invalid configs have the expected behavior when passed to the client constructor.
1 parent 52427df commit 5b66889

File tree

9 files changed

+155
-32
lines changed

9 files changed

+155
-32
lines changed

TestModels/Constraints/Model/Constraints.smithy

+18-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ service SimpleConstraints {
1515
errors: [ SimpleConstraintsException ],
1616
}
1717

18-
structure SimpleConstraintsConfig {}
18+
structure SimpleConstraintsConfig {
19+
@required
20+
RequiredString: String,
21+
}
1922

2023
// This is just a sanity check on the smokeTests support.
2124
// We need to actually convert all the tests in test/WrappedSimpleConstraintsImplTest.dfy
@@ -24,6 +27,9 @@ structure SimpleConstraintsConfig {}
2427
@smithy.test#smokeTests([
2528
{
2629
id: "GetConstraintsSuccess"
30+
vendorParams: {
31+
RequiredString: "foobar",
32+
}
2733
params: {
2834
OneToTen: 5,
2935
GreaterThanOne: 2,
@@ -40,6 +46,9 @@ structure SimpleConstraintsConfig {}
4046
},
4147
{
4248
id: "GetConstraintsFailure"
49+
vendorParams: {
50+
RequiredString: "foobar",
51+
}
4352
params: {
4453
// These two always have to be present because of https://github.com/smithy-lang/smithy-dafny/issues/278,
4554
// because otherwise they are interpreted as 0.
@@ -51,6 +60,14 @@ structure SimpleConstraintsConfig {}
5160
expect: {
5261
failure: {}
5362
}
63+
},
64+
{
65+
id: "GetConstraintsInvalidConfig"
66+
params: {}
67+
expect: {
68+
failure: {}
69+
},
70+
tags: ["INVALID_CONFIG"]
5471
}
5572
])
5673
operation GetConstraints {

TestModels/Constraints/runtimes/rust/tests/simple_constraints_test.rs

+17-1
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,26 @@ mod simple_constraints_test {
55
use simple_constraints::*;
66

77
fn client() -> Client {
8-
let config = SimpleConstraintsConfig::builder().build().expect("config");
8+
let config = SimpleConstraintsConfig::builder()
9+
.required_string("test string")
10+
.build()
11+
.expect("config");
912
client::Client::from_conf(config).expect("client")
1013
}
1114

15+
#[test]
16+
fn test_config_missing_field() {
17+
let config = SimpleConstraintsConfig::builder()
18+
.build()
19+
.expect("config");
20+
let error = client::Client::from_conf(config).err().expect("err");
21+
assert!(matches!(
22+
error,
23+
simple_constraints::types::error::Error::ValidationError(..)
24+
));
25+
assert!(error.to_string().contains("required_string"));
26+
}
27+
1228
#[tokio::test]
1329
async fn test_empty_input() {
1430
let result = client().get_constraints().send().await;

TestModels/Constraints/src/Index.dfy

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module {:extern "simple.constraints.internaldafny" } SimpleConstraints refines A
66
import Operations = SimpleConstraintsImpl
77

88
function method DefaultSimpleConstraintsConfig(): SimpleConstraintsConfig {
9-
SimpleConstraintsConfig
9+
SimpleConstraintsConfig(RequiredString := "default")
1010
}
1111

1212
method SimpleConstraints(config: SimpleConstraintsConfig)

TestModels/Constraints/src/WrappedSimpleConstraintsImpl.dfy

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ include "../Model/SimpleConstraintsTypesWrapped.dfy"
55
module {:extern "simple.constraints.internaldafny.wrapped"} WrappedSimpleConstraintsService refines WrappedAbstractSimpleConstraintsService {
66
import WrappedService = SimpleConstraints
77
function method WrappedDefaultSimpleConstraintsConfig(): SimpleConstraintsConfig {
8-
SimpleConstraintsConfig
8+
SimpleConstraintsConfig(RequiredString := "default")
99
}
1010
}

TestModels/dafny-dependencies/StandardLibrary/Makefile

+2-2
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,13 @@ transpile_implementation:
114114
# Override SharedMakefile's build_java to not install
115115
# StandardLibrary as a dependency
116116
build_java: transpile_java
117-
gradle -p runtimes/java build
117+
$(GRADLEW) -p runtimes/java build
118118

119119
# Override SharedMakefile's mvn_local_deploy to
120120
# issue warning
121121
mvn_local_deploy:
122122
@echo "${RED}Warning!!${YELLOW} Installing TestModel's STD to Maven Local replaces ESDK's STD!\n$(RESET)" >&2
123-
gradle -p runtimes/java publishToMavenLocal
123+
$(GRADLEW) -p runtimes/java publishToMavenLocal
124124

125125
dafny_benerate: DAFNY_RUST_OUTPUT=\
126126
runtimes/rust/implementation_from_dafny

codegen/smithy-dafny-codegen/src/main/java/software/amazon/polymorph/smithyjava/generator/library/ModelTestCodegen.java

+65-20
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import software.amazon.polymorph.smithyjava.generator.Generator;
1919
import software.amazon.polymorph.smithyjava.modeled.ModeledShapeValue;
2020
import software.amazon.polymorph.traits.LocalServiceTrait;
21+
import software.amazon.smithy.model.node.ObjectNode;
2122
import software.amazon.smithy.model.shapes.OperationShape;
2223
import software.amazon.smithy.model.shapes.Shape;
2324
import software.amazon.smithy.model.shapes.ShapeId;
@@ -29,9 +30,32 @@
2930
* e.g. generating JUnit tests from traits like @smithy.test#smokeTests.
3031
* This is distinct from the Dafny testing code
3132
* and the testing wrapper to support it generated by TestJavaLibrary.
33+
*
34+
* <h1>Smoke test generation</h1>
35+
*
36+
* If the {@code vendorParams} property is present,
37+
* the generated test constructs the client config with its values.
38+
*
39+
* <h2>Assertions</h2>
40+
*
41+
* If {@code "INVALID_CONFIG"} is present in the test case's {@code tags},
42+
* the generated test asserts that an exception is thrown
43+
* during either client config construction or client construction,
44+
* and the test neither constructs the operation input nor performs the operation call.
45+
* <p>
46+
* Otherwise, if the {@code expect.success} property is present,
47+
* the generated test asserts that no exception is thrown
48+
* during the operation call and all setup steps
49+
* (construction of client config, client, and operation input).
50+
* <p>
51+
* Otherwise, if the {@code expect.failure} property is present,
52+
* the generated test asserts that an exception is thrown
53+
* during either operation input construction or the operation call.
3254
*/
3355
public class ModelTestCodegen extends Generator {
3456

57+
static final String INVALID_CONFIG_TAG = "INVALID_CONFIG";
58+
3559
final JavaLibrary subject;
3660

3761
public ModelTestCodegen(JavaLibrary subject) {
@@ -86,29 +110,42 @@ private MethodSpec smokeTest(
86110
configShapeId
87111
);
88112

89-
// SimpleConstraintsConfig config = SimpleConstraintsConfig.builder().build();
113+
// SimpleConstraintsConfig config = SimpleConstraintsConfig.builder()
114+
// ...
115+
// (multiple .foo(...) calls to populate builder)
116+
// ...
117+
// .build();
90118
// SimpleConstraints client = SimpleConstraints.builder()
91-
// .SimpleConstraintsConfig(config)
92-
// .build();
93-
method.addStatement(
94-
"$T config = $T.builder().build()",
95-
configType,
96-
configType
97-
);
98-
method.addStatement(
99-
"$T client = $T.builder().$N(config).build()",
100-
clientType,
101-
clientType,
102-
configShapeId.getName()
119+
// .SimpleConstraintsConfig(config)
120+
// .build();
121+
final ObjectNode configParams = testCase
122+
.getVendorParams()
123+
.orElseGet(() -> ObjectNode.builder().build());
124+
final CodeBlock configValue = ModeledShapeValue.shapeValue(
125+
subject,
126+
false,
127+
subject.model.expectShape(configShapeId),
128+
configParams
103129
);
130+
final CodeBlock configAndClientConstruction = CodeBlock
131+
.builder()
132+
.addStatement("$T config = $L", configType, configValue)
133+
.addStatement(
134+
"$T client = $T.builder().$N(config).build()",
135+
clientType,
136+
clientType,
137+
configShapeId.getName()
138+
)
139+
.build();
104140

105141
// GetConstraintsInput input = GetConstraintsInput.builder()
106-
// ...
107-
// (multiple .foo(...) calls to populate builder)
108-
// ...
109-
// .build();
142+
// ...
143+
// (multiple .foo(...) calls to populate builder)
144+
// ...
145+
// .build();
146+
// client.GetConstraints(input);
110147
final Shape inputShape = subject.model.expectShape(
111-
operationShape.getInput().get()
148+
operationShape.getInput().orElseThrow()
112149
);
113150
final TypeName inputType = subject.nativeNameResolver.typeForShape(
114151
inputShape.getId()
@@ -117,17 +154,25 @@ private MethodSpec smokeTest(
117154
subject,
118155
false,
119156
inputShape,
120-
testCase.getParams().get()
157+
testCase.getParams().orElseThrow()
121158
);
122159
final CodeBlock inputAndClientCall = CodeBlock
123160
.builder()
124161
.addStatement("$T input = $L", inputType, inputValue)
125162
.addStatement("client.$L(input)", operationName)
126163
.build();
127164

128-
if (testCase.getExpectation().isSuccess()) {
165+
if (testCase.hasTag(INVALID_CONFIG_TAG)) {
166+
method.addStatement(
167+
"$T.assertThrows(Exception.class, () -> {\n$L\n})",
168+
TESTNG_ASSERT,
169+
configAndClientConstruction.toString()
170+
);
171+
} else if (testCase.getExpectation().isSuccess()) {
172+
method.addCode(configAndClientConstruction);
129173
method.addCode(inputAndClientCall);
130174
} else {
175+
method.addCode(configAndClientConstruction);
131176
// We're not specific about what kind of exception for now.
132177
// If the smokeTests trait gets more specific we can be too.
133178
// The inputAndClientCall.toString() is necessary because otherwise we get nested

codegen/smithy-dafny-codegen/src/main/java/software/amazon/polymorph/smithyrust/generator/RustLibraryShimGenerator.java

+43-2
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,18 @@ private RustFile clientModule() {
236236
)
237237
.collect(Collectors.joining("\n\n"))
238238
);
239+
240+
final StructureShape configShape = ModelUtils.getConfigShape(
241+
model,
242+
service
243+
);
244+
variables.put(
245+
"inputValidations",
246+
new InputValidationGenerator()
247+
.generateValidations(model, configShape)
248+
.collect(Collectors.joining("\n"))
249+
);
250+
239251
final String content = evalTemplateResource(
240252
getClass(),
241253
"runtimes/rust/client.rs",
@@ -853,6 +865,9 @@ class InputValidationGenerator
853865

854866
private final Map<String, String> commonVariables;
855867

868+
/**
869+
* Generates validation expressions for operation input structures.
870+
*/
856871
InputValidationGenerator(
857872
final Shape bindingShape,
858873
final OperationShape operationShape
@@ -862,6 +877,21 @@ class InputValidationGenerator
862877
serviceVariables(),
863878
operationVariables(bindingShape, operationShape)
864879
);
880+
this.commonVariables.put(
881+
"inputStructureName",
882+
commonVariables.get("pascalCaseOperationInputName")
883+
);
884+
}
885+
886+
/**
887+
* Generates validation expressions for this service's client config structure.
888+
*/
889+
InputValidationGenerator() {
890+
this.commonVariables = serviceVariables();
891+
this.commonVariables.put(
892+
"inputStructureName",
893+
commonVariables.get("qualifiedRustConfigName")
894+
);
865895
}
866896

867897
@Override
@@ -871,7 +901,7 @@ protected String validateRequired(final MemberShape memberShape) {
871901
if input.$fieldName:L.is_none() {
872902
return ::std::result::Result::Err(::aws_smithy_types::error::operation::BuildError::missing_field(
873903
"$fieldName:L",
874-
"$fieldName:L was not specified but it is required when building $pascalCaseOperationInputName:L",
904+
"$fieldName:L was not specified but it is required when building $inputStructureName:L",
875905
)).map_err($qualifiedRustServiceErrorType:L::wrap_validation_err);
876906
}
877907
""",
@@ -1815,8 +1845,19 @@ protected HashMap<String, String> serviceVariables() {
18151845
service
18161846
);
18171847
final String configName = configShape.getId().getName(service);
1848+
final String snakeCaseConfigName = toSnakeCase(configName);
1849+
18181850
variables.put("configName", configName);
1819-
variables.put("snakeCaseConfigName", toSnakeCase(configName));
1851+
variables.put("snakeCaseConfigName", snakeCaseConfigName);
1852+
variables.put(
1853+
"qualifiedRustConfigName",
1854+
String.join(
1855+
"::",
1856+
getRustTypesModuleName(),
1857+
snakeCaseConfigName,
1858+
configName
1859+
)
1860+
);
18201861
variables.put("rustErrorModuleName", rustErrorModuleName());
18211862
variables.put(
18221863
"qualifiedRustServiceErrorType",

codegen/smithy-dafny-codegen/src/main/java/software/amazon/polymorph/utils/ConstrainTraitUtils.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ public Stream<V> generateValidations(
9696
return validateLength(memberShape, lengthTrait);
9797
}
9898
throw new UnsupportedOperationException(
99-
"Unsupported constraint trait %s on shape %s".formatted(trait)
99+
"Unsupported constraint trait %s on shape %s".formatted(
100+
trait,
101+
structureShape.getId()
102+
)
100103
);
101104
});
102105
}

codegen/smithy-dafny-codegen/src/main/resources/templates/runtimes/rust/client.rs

+4-3
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ impl Client {
1010
/// Creates a new client from the service [`Config`](crate::Config).
1111
#[track_caller]
1212
pub fn from_conf(
13-
conf: $rustTypesModuleName:L::$snakeCaseConfigName:L::$configName:L,
14-
) -> Result<Self, $rustTypesModuleName:L::error::Error> {
13+
input: $qualifiedRustConfigName:L,
14+
) -> Result<Self, $qualifiedRustServiceErrorType:L> {
15+
$inputValidations:L
1516
let inner =
1617
crate::$dafnyInternalModuleName:L::_default::$sdkId:L(
17-
&$rustConversionsModuleName:L::$snakeCaseConfigName:L::_$snakeCaseConfigName:L::to_dafny(conf),
18+
&$rustConversionsModuleName:L::$snakeCaseConfigName:L::_$snakeCaseConfigName:L::to_dafny(input),
1819
);
1920
if matches!(
2021
inner.as_ref(),

0 commit comments

Comments
 (0)