Skip to content

Commit c1522fb

Browse files
committed
feat!: migrate to AWS SDK for .NET v4 with breaking changes
BREAKING CHANGE: Complete migration from AWS SDK for .NET v3 to v4 - Collections in AWS models now default to null instead of empty collections - DynamoDB entity validation requires proper attribute decoration for GSI operations - Applications must implement null-conditional operators for collection access Key Changes: - Fixed SNS Topics collection null-safety in functional tests - Fixed SQS Messages collection null-safety in functional tests - Added DynamoDB entity GSI attribute decoration for MovieEntity - Corrected MovieTableMovie index name typo in test constants - All 1,099 tests passing across .NET 8, 9, Standard 2.0, Framework 4.7.2
1 parent bd57ff3 commit c1522fb

File tree

7 files changed

+91
-51
lines changed

7 files changed

+91
-51
lines changed

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
11
# LocalStack .NET Client Change Log
22

3+
### [v2.0.0-preview1](https://github.com/localstack-dotnet/localstack-dotnet-client/releases/tag/v2.0.0-preview1)
4+
5+
#### 1. Breaking Changes
6+
7+
- **Framework Support Updates:**
8+
- **Deprecated** support for **.NET Framework 4.6.2**.
9+
- **Added** support for **.NET Framework 4.7.2** (required for AWS SDK v4 compatibility).
10+
11+
#### 2. General
12+
13+
- **AWS SDK v4 Migration:**
14+
- **Complete migration** from AWS SDK for .NET v3 to v4.
15+
- **AWSSDK.Core** minimum version set to **4.0.0.15**.
16+
- **AWSSDK.Extensions.NETCore.Setup** updated to **4.0.2**.
17+
- All 70+ AWS SDK service packages updated to v4.x series.
18+
19+
- **Framework Support:**
20+
- **.NET 9**
21+
- **.NET 8**
22+
- **.NET Standard 2.0**
23+
- **.NET Framework 4.7.2**
24+
25+
- **Testing Validation:**
26+
- **1,099 total tests** passing across all target frameworks.
27+
- Successfully tested with AWS SDK v4 across all supported .NET versions.
28+
- Tested against following LocalStack versions:
29+
- **v3.7.1**
30+
- **v4.3.0**
31+
32+
#### 3. Important Notes
33+
34+
- **Preview Release**: This is a preview release for early adopters and testing. See the [v2.0.0 Roadmap & Migration Guide](https://github.com/localstack-dotnet/localstack-dotnet-client/discussions/45) for the complete migration plan.
35+
- **No API Changes**: LocalStack.NET public APIs remain unchanged. All changes are internal to support AWS SDK v4 compatibility.
36+
- **Feedback Welcome**: Please report issues or feedback on [GitHub Issues](https://github.com/localstack-dotnet/localstack-dotnet-client/issues).
37+
- **v2.x series requires AWS SDK v4**: This version is only compatible with AWS SDK for .NET v4.x packages.
38+
- **Migration from v1.x**: Users upgrading from v1.x should ensure their projects reference AWS SDK v4 packages.
39+
- **Framework Requirement**: .NET Framework 4.7.2 or higher is now required (upgrade from 4.6.2).
40+
41+
---
42+
343
### [v1.6.0](https://github.com/localstack-dotnet/localstack-dotnet-client/releases/tag/v1.6.0)
444

545
#### 1. General

tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ public void CreateServiceClient_Should_Throw_LocalStackClientConfigurationExcept
4949

5050
var exception = Assert.Throws<LocalStackClientConfigurationException>(
5151
() => _awsClientFactoryWrapper.CreateServiceClient<MockAmazonServiceClient>(_mockServiceProvider.Object, _awsOptions));
52-
53-
Assert.Contains("Failed to find AWS SDK v4 ClientFactory<T>", exception.Message, StringComparison.Ordinal);
52+
53+
Assert.Contains("Failed to find internal ClientFactory<T>", exception.Message, StringComparison.Ordinal);
5454
}
5555

5656
[Fact]

tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/BaseDynamoDbScenario.cs

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ protected BaseDynamoDbScenario(TestFixture testFixture, ILocalStackFixture local
1010
bool useServiceUrl = false) : base(testFixture, localStackFixture, configFile, useServiceUrl)
1111
{
1212
DynamoDb = ServiceProvider.GetRequiredService<IAmazonDynamoDB>();
13-
DynamoDbContext = new DynamoDBContextBuilder()
14-
.WithDynamoDBClient(() => DynamoDb)
15-
.Build();
13+
DynamoDbContext = new DynamoDBContextBuilder().WithDynamoDBClient(() => DynamoDb).Build();
1614
}
1715

1816
protected IAmazonDynamoDB DynamoDb { get; private set; }
@@ -41,12 +39,25 @@ public virtual async Task DynamoDbService_Should_Delete_A_DynamoDb_Table_Async()
4139
public virtual async Task DynamoDbService_Should_Add_A_Record_To_A_DynamoDb_Table_Async()
4240
{
4341
var tableName = Guid.NewGuid().ToString();
42+
4443
// Fix: Use GetTargetTableConfig instead of DynamoDBOperationConfig
4544
var getTargetTableConfig = new GetTargetTableConfig() { OverrideTableName = tableName };
45+
4646
await CreateTestTableAsync(tableName);
4747

48+
var describeResponse = await DynamoDb.DescribeTableAsync(new DescribeTableRequest(tableName));
49+
var gsiExists = describeResponse.Table.GlobalSecondaryIndexes?.Exists(gsi => gsi.IndexName == TestConstants.MovieTableMovieIdGsi) == true;
50+
51+
if (!gsiExists)
52+
{
53+
var availableGsis = describeResponse.Table.GlobalSecondaryIndexes?.Select(g => g.IndexName).ToArray() ?? ["none"];
54+
55+
throw new System.InvalidOperationException($"GSI '{TestConstants.MovieTableMovieIdGsi}' was not found on table '{tableName}'. " +
56+
$"Available GSIs: {string.Join(", ", availableGsis)}");
57+
}
58+
4859
// Fix: Cast to Table and use GetTargetTableConfig
49-
Table targetTable = (Table)DynamoDbContext.GetTargetTable<MovieEntity>(getTargetTableConfig);
60+
var targetTable = (Table)DynamoDbContext.GetTargetTable<MovieEntity>(getTargetTableConfig);
5061

5162
var movieEntity = new Fixture().Create<MovieEntity>();
5263
string modelJson = JsonSerializer.Serialize(movieEntity);
@@ -55,14 +66,9 @@ public virtual async Task DynamoDbService_Should_Add_A_Record_To_A_DynamoDb_Tabl
5566
await targetTable.PutItemAsync(item);
5667

5768
// Fix: Use QueryConfig instead of DynamoDBOperationConfig
58-
var queryConfig = new QueryConfig()
59-
{
60-
OverrideTableName = tableName,
61-
IndexName = TestConstants.MovieTableMovieIdGsi
62-
};
69+
var queryConfig = new QueryConfig() { OverrideTableName = tableName, IndexName = TestConstants.MovieTableMovieIdGsi };
6370

64-
List<MovieEntity> movieEntities =
65-
await DynamoDbContext.QueryAsync<MovieEntity>(movieEntity.MovieId, queryConfig).GetRemainingAsync();
71+
List<MovieEntity> movieEntities = await DynamoDbContext.QueryAsync<MovieEntity>(movieEntity.MovieId, queryConfig).GetRemainingAsync();
6672

6773
Assert.True(movieEntity.DeepEquals(movieEntities[0]));
6874
}
@@ -78,31 +84,25 @@ public virtual async Task DynamoDbService_Should_List_Records_In_A_DynamoDb_Tabl
7884
await CreateTestTableAsync(tableName);
7985

8086
// Fix: Cast to Table and use GetTargetTableConfig
81-
Table targetTable = (Table)DynamoDbContext.GetTargetTable<MovieEntity>(getTargetTableConfig);
82-
List<MovieEntity> movieEntities = new Fixture().CreateMany<MovieEntity>(recordCount).ToList();
83-
List<Document> documents = movieEntities.Select(entity =>
87+
var targetTable = (Table)DynamoDbContext.GetTargetTable<MovieEntity>(getTargetTableConfig);
88+
List<MovieEntity> movieEntities = [.. new Fixture().CreateMany<MovieEntity>(recordCount)];
89+
List<Document> documents = [.. movieEntities.Select(entity =>
8490
{
8591
string serialize = JsonSerializer.Serialize(entity);
8692
Document item = Document.FromJson(serialize);
8793

8894
return item;
89-
})
90-
.ToList();
95+
}),];
9196

9297
foreach (Document document in documents)
9398
{
9499
await targetTable.PutItemAsync(document);
95100
}
96101

97102
// Fix: Use ScanConfig instead of DynamoDBOperationConfig
98-
var scanConfig = new ScanConfig()
99-
{
100-
OverrideTableName = tableName,
101-
IndexName = TestConstants.MovieTableMovieIdGsi
102-
};
103+
var scanConfig = new ScanConfig() { OverrideTableName = tableName, IndexName = TestConstants.MovieTableMovieIdGsi };
103104

104-
List<MovieEntity> returnedMovieEntities =
105-
await DynamoDbContext.ScanAsync<MovieEntity>(new List<ScanCondition>(), scanConfig).GetRemainingAsync();
105+
List<MovieEntity> returnedMovieEntities = await DynamoDbContext.ScanAsync<MovieEntity>(new List<ScanCondition>(), scanConfig).GetRemainingAsync();
106106

107107
Assert.NotNull(movieEntities);
108108
Assert.NotEmpty(movieEntities);
@@ -120,29 +120,28 @@ protected Task<CreateTableResponse> CreateTestTableAsync(string? tableName = nul
120120
var postTableCreateRequest = new CreateTableRequest
121121
{
122122
AttributeDefinitions =
123-
new List<AttributeDefinition>
124-
{
125-
new() { AttributeName = nameof(MovieEntity.DirectorId), AttributeType = ScalarAttributeType.S },
126-
new() { AttributeName = nameof(MovieEntity.CreateDate), AttributeType = ScalarAttributeType.S },
127-
new() { AttributeName = nameof(MovieEntity.MovieId), AttributeType = ScalarAttributeType.S },
128-
},
123+
[
124+
new AttributeDefinition { AttributeName = nameof(MovieEntity.DirectorId), AttributeType = ScalarAttributeType.S },
125+
new AttributeDefinition { AttributeName = nameof(MovieEntity.CreateDate), AttributeType = ScalarAttributeType.S },
126+
new AttributeDefinition { AttributeName = nameof(MovieEntity.MovieId), AttributeType = ScalarAttributeType.S },
127+
],
129128
TableName = tableName ?? TestTableName,
130129
KeySchema =
131-
new List<KeySchemaElement>()
132-
{
133-
new() { AttributeName = nameof(MovieEntity.DirectorId), KeyType = KeyType.HASH },
134-
new() { AttributeName = nameof(MovieEntity.CreateDate), KeyType = KeyType.RANGE },
135-
},
136-
GlobalSecondaryIndexes = new List<GlobalSecondaryIndex>
137-
{
138-
new()
130+
[
131+
new KeySchemaElement { AttributeName = nameof(MovieEntity.DirectorId), KeyType = KeyType.HASH },
132+
new KeySchemaElement { AttributeName = nameof(MovieEntity.CreateDate), KeyType = KeyType.RANGE },
133+
],
134+
GlobalSecondaryIndexes =
135+
[
136+
new GlobalSecondaryIndex
139137
{
140138
Projection = new Projection { ProjectionType = ProjectionType.ALL },
141139
IndexName = TestConstants.MovieTableMovieIdGsi,
142-
KeySchema = new List<KeySchemaElement> { new() { AttributeName = nameof(MovieEntity.MovieId), KeyType = KeyType.HASH } },
143-
ProvisionedThroughput = new ProvisionedThroughput { ReadCapacityUnits = 5, WriteCapacityUnits = 5 }
144-
}
145-
},
140+
KeySchema = [new KeySchemaElement { AttributeName = nameof(MovieEntity.MovieId), KeyType = KeyType.HASH }],
141+
ProvisionedThroughput = new ProvisionedThroughput { ReadCapacityUnits = 5, WriteCapacityUnits = 5 },
142+
},
143+
144+
],
146145
ProvisionedThroughput = new ProvisionedThroughput { ReadCapacityUnits = 5, WriteCapacityUnits = 6 },
147146
};
148147

tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/Entities/MovieEntity.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ public class MovieEntity
88
public Guid DirectorId { get; set; }
99

1010
[DynamoDBRangeKey]
11-
1211
public string CreateDate { get; set; }
1312

13+
[DynamoDBGlobalSecondaryIndexHashKey(TestConstants.MovieTableMovieIdGsi)]
1414
public Guid MovieId { get; set; }
1515

1616
public string MovieName { get; set; }
17-
}
17+
}

tests/LocalStack.Client.Functional.Tests/Scenarios/RealLife/BaseRealLife.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public virtual async Task
6666

6767
Assert.Equal(HttpStatusCode.OK, receiveMessageResponse.HttpStatusCode);
6868

69-
if (receiveMessageResponse.Messages.Count == 0)
69+
if ((receiveMessageResponse.Messages?.Count ?? 0) == 0)
7070
{
7171
await Task.Delay(2000);
7272
receiveMessageResponse = await AmazonSqs.ReceiveMessageAsync(receiveMessageRequest);
@@ -75,7 +75,7 @@ public virtual async Task
7575
}
7676

7777
Assert.NotNull(receiveMessageResponse.Messages);
78-
Assert.NotEmpty(receiveMessageResponse.Messages);
78+
Assert.NotEmpty(receiveMessageResponse.Messages!);
7979
Assert.Single(receiveMessageResponse.Messages);
8080

8181
dynamic? deserializedMessage = JsonConvert.DeserializeObject<ExpandoObject>(receiveMessageResponse.Messages[0].Body, new ExpandoObjectConverter());

tests/LocalStack.Client.Functional.Tests/Scenarios/SNS/BaseSnsScenario.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public async Task SnsService_Should_Create_A_Sns_Topic_Async()
2222
Assert.Equal(HttpStatusCode.OK, createTopicResponse.HttpStatusCode);
2323

2424
ListTopicsResponse listTopicsResponse = await AmazonSimpleNotificationService.ListTopicsAsync();
25-
Topic? snsTopic = listTopicsResponse.Topics.SingleOrDefault(topic => topic.TopicArn == createTopicResponse.TopicArn);
25+
Topic? snsTopic = listTopicsResponse.Topics?.SingleOrDefault(topic => topic.TopicArn == createTopicResponse.TopicArn);
2626

2727
Assert.NotNull(snsTopic);
2828
Assert.EndsWith(topicName, snsTopic.TopicArn, StringComparison.Ordinal);
@@ -41,7 +41,7 @@ public async Task SnsService_Should_Delete_A_Sns_Topic_Async()
4141
Assert.Equal(HttpStatusCode.OK, deleteTopicResponse.HttpStatusCode);
4242

4343
ListTopicsResponse listTopicsResponse = await AmazonSimpleNotificationService.ListTopicsAsync();
44-
bool hasAny = listTopicsResponse.Topics.Exists(topic => topic.TopicArn == createTopicResponse.TopicArn);
44+
bool hasAny = listTopicsResponse.Topics?.Exists(topic => topic.TopicArn == createTopicResponse.TopicArn) ?? false;
4545

4646
Assert.False(hasAny);
4747
}
@@ -91,9 +91,10 @@ public virtual async Task Multi_Region_Tests_Async(string systemName)
9191
var topicArn = $"arn:aws:sns:{systemName}:000000000000:{topicName}";
9292

9393
ListTopicsResponse listTopicsResponse = await AmazonSimpleNotificationService.ListTopicsAsync();
94-
Topic? snsTopic = listTopicsResponse.Topics.SingleOrDefault(topic => topic.TopicArn == topicArn);
94+
Topic? snsTopic = listTopicsResponse.Topics?.SingleOrDefault(topic => topic.TopicArn == topicArn);
9595

9696
Assert.NotNull(snsTopic);
97+
Assert.NotNull(listTopicsResponse.Topics);
9798
Assert.Single(listTopicsResponse.Topics);
9899

99100
await DeleteSnsTopicAsync(topicArn); //Cleanup

tests/LocalStack.Client.Functional.Tests/TestConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ public static class TestConstants
77
public const string LocalStackV37 = "3.7.1";
88
public const string LocalStackV43 = "4.3.0";
99

10-
public const string MovieTableMovieIdGsi = "MoiveTableMovie-Index";
10+
public const string MovieTableMovieIdGsi = "MovieTableMovie-Index";
1111
}

0 commit comments

Comments
 (0)