Replace `enforce_order(bool)` with `enum RuleMode` (#3502)

RuleMode describes how rules will be interpreted.
- In RuleMode::MatchAny, the first matching rule will be applied, and
the rules will remain unchanged.
- In RuleMode::Sequential, the first matching rule will be applied, and
that rule will be removed from the list of rules.

Also adds a `make_client!` macro produces a Client configured with a
number of Rules and appropriate test default configuration.

## Motivation and Context
Working through improvements on experimental mocks after implementing
them in the Cloudwatch Logs example.

## Testing
Unit tests, doctests, 

## Checklist
- [x] I have updated `CHANGELOG.next.toml` if I made changes to the
smithy-rs codegen or runtime crates
- [ ] I have updated `CHANGELOG.next.toml` if I made changes to the AWS
SDK, generated SDK code, or SDK runtime crates

----

_By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice._
This commit is contained in:
David Souther 2024-03-20 15:51:12 -04:00 committed by GitHub
parent 3e81645f1f
commit e858d3e262
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 113 additions and 13 deletions

View File

@ -11,6 +11,11 @@
# meta = { "breaking" = false, "tada" = false, "bug" = false, "target" = "client | server | all"}
# author = "rcoh"
[[aws-smithy-mocks-experimental]]
message = "Replace `enforce_order(bool)` with `enum RuleMode`"
references = ["smithy-rs#3502", "awsdocs/aws-doc-sdk-examples#6264"]
meta = { "breaking" = true, "tada" = false, "bug" = false }
[[smithy-rs]]
message = "Increased minimum version of wasi crate dependency in aws-smithy-wasm to 0.12.1."
references = ["smithy-rs#3476"]

View File

@ -1,6 +1,6 @@
[package]
name = "aws-smithy-mocks-experimental"
version = "0.1.1"
version = "0.2.0"
authors = ["AWS Rust SDK Team <aws-sdk-rust@amazon.com>"]
description = "Experimental testing utilities for smithy-rs generated clients"
edition = "2021"

View File

@ -75,6 +75,46 @@ macro_rules! mock {
};
}
// This could be obviated by a reasonable trait, since you can express it with SdkConfig if clients implement From<&SdkConfig>.
/// `mock_client!` macro produces a Client configured with a number of Rules and appropriate test default configuration.
///
/// # Examples
/// **Create a client that uses a mock failure and then a success**:
/// ```rust
/// use aws_sdk_s3::operation::get_object::{GetObjectOutput, GetObjectError};
/// use aws_sdk_s3::types::error::NoSuchKey;
/// use aws_sdk_s3::Client;
/// use aws_smithy_types::byte_stream::ByteStream;
/// use aws_smithy_mocks_experimental::{mock_client, mock, RuleMode};
/// let get_object_happy_path = mock!(Client::get_object)
/// .match_requests(|req|req.bucket() == Some("test-bucket") && req.key() == Some("test-key"))
/// .then_output(||GetObjectOutput::builder().body(ByteStream::from_static(b"12345-abcde")).build());
/// let get_object_error_path = mock!(Client::get_object)
/// .then_error(||GetObjectError::NoSuchKey(NoSuchKey::builder().build()));
/// let client = mock_client!(aws_sdk_s3, RuleMode::Sequential, &[&get_object_error_path, &get_object_happy_path]);
/// ```
#[macro_export]
macro_rules! mock_client {
($aws_crate: ident, $rules: expr) => {
mock_client!($aws_crate, $crate::RuleMode::Sequential, $rules)
};
($aws_crate: ident, $rule_mode: expr, $rules: expr) => {{
let mut mock_response_interceptor =
$crate::MockResponseInterceptor::new().rule_mode($rule_mode);
for rule in $rules {
mock_response_interceptor = mock_response_interceptor.with_rule(rule)
}
$aws_crate::client::Client::from_conf(
$aws_crate::config::Config::builder()
.with_test_defaults()
.region($aws_crate::config::Region::from_static("us-east-1"))
.interceptor(mock_response_interceptor)
.build(),
)
}};
}
type MatchFn = Arc<dyn Fn(&Input) -> bool + Send + Sync>;
type OutputFn = Arc<dyn Fn() -> Result<Output, OrchestratorError<Error>> + Send + Sync>;
@ -90,10 +130,19 @@ enum MockOutput {
ModeledResponse(OutputFn),
}
/// RuleMode describes how rules will be interpreted.
/// - In RuleMode::MatchAny, the first matching rule will be applied, and the rules will remain unchanged.
/// - In RuleMode::Sequential, the first matching rule will be applied, and that rule will be removed from the list of rules.
#[derive()]
pub enum RuleMode {
MatchAny,
Sequential,
}
/// Interceptor which produces mock responses based on a list of rules
pub struct MockResponseInterceptor {
rules: Arc<Mutex<VecDeque<Rule>>>,
enforce_order: bool,
rule_mode: RuleMode,
must_match: bool,
}
@ -213,7 +262,7 @@ impl MockResponseInterceptor {
pub fn new() -> Self {
Self {
rules: Default::default(),
enforce_order: false,
rule_mode: RuleMode::MatchAny,
must_match: true,
}
}
@ -225,11 +274,11 @@ impl MockResponseInterceptor {
self
}
/// Require that rules are matched in order.
/// Set the RuleMode to use when evaluating rules.
///
/// If a rule matches out of order, the interceptor will panic.
pub fn enforce_order(mut self) -> Self {
self.enforce_order = true;
/// See `RuleMode` enum for modes and how they are applied.
pub fn rule_mode(mut self, rule_mode: RuleMode) -> Self {
self.rule_mode = rule_mode;
self
}
@ -251,8 +300,8 @@ impl Intercept for MockResponseInterceptor {
cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
let mut rules = self.rules.lock().unwrap();
let rule = match self.enforce_order {
true => {
let rule = match self.rule_mode {
RuleMode::Sequential => {
let rule = rules
.pop_front()
.expect("no more rules but a new request was received");
@ -264,7 +313,7 @@ impl Intercept for MockResponseInterceptor {
}
Some(rule)
}
false => rules
RuleMode::MatchAny => rules
.iter()
.find(|rule| (rule.matcher)(context.input()))
.cloned(),

View File

@ -14,7 +14,7 @@ use aws_smithy_types::byte_stream::ByteStream;
use aws_smithy_types::error::metadata::ProvideErrorMetadata;
use aws_smithy_types::error::ErrorMetadata;
use aws_smithy_mocks_experimental::{mock, MockResponseInterceptor};
use aws_smithy_mocks_experimental::{mock, mock_client, MockResponseInterceptor, RuleMode};
const S3_NO_SUCH_KEY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Error>
@ -52,10 +52,10 @@ async fn create_mock_s3_get_object() {
});
let get_object_mocks = MockResponseInterceptor::new()
.rule_mode(RuleMode::Sequential)
.with_rule(&s3_404)
.with_rule(&s3_real_object)
.with_rule(&modeled_error)
.enforce_order();
.with_rule(&modeled_error);
let s3 = aws_sdk_s3::Client::from_conf(
Config::builder()
@ -96,3 +96,49 @@ async fn create_mock_s3_get_object() {
let err = s3.list_buckets().send().await.expect_err("bad access key");
assert_eq!(err.code(), Some("InvalidAccessKey"));
}
#[tokio::test]
async fn mock_client() {
let s3_404 = mock!(Client::get_object).then_http_response(|| {
HttpResponse::new(
StatusCode::try_from(400).unwrap(),
SdkBody::from(S3_NO_SUCH_KEY),
)
});
let s3_real_object = mock!(Client::get_object).then_output(|| {
GetObjectOutput::builder()
.body(ByteStream::from_static(b"test-test-test"))
.build()
});
let s3 = mock_client!(aws_sdk_s3, [&s3_404, &s3_real_object]);
let error = s3
.get_object()
.bucket("test-bucket")
.key("foo")
.send()
.await
.expect_err("404");
assert!(matches!(
error.into_service_error(),
GetObjectError::NoSuchKey(_)
));
assert_eq!(s3_404.num_calls(), 1);
let data = s3
.get_object()
.bucket("test-bucket")
.key("correct-key")
.send()
.await
.expect("success response")
.body
.collect()
.await
.expect("successful read")
.to_vec();
assert_eq!(data, b"test-test-test");
assert_eq!(s3_real_object.num_calls(), 1);
}