diff --git a/src/.editorconfig b/src/.editorconfig index 9250576..481018b 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -12,6 +12,9 @@ trim_trailing_whitespace = true insert_final_newline = true [*.cs] +# Require braces on all control flow blocks +csharp_prefer_braces = true : error + # Meziantou.Analyzer rules - downgrade to suggestions for existing code # These can be gradually fixed over time diff --git a/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs b/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs index aec52d5..3ccccab 100644 --- a/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs +++ b/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs @@ -96,7 +96,9 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde ); if (useOtlpExporter) + { builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) diff --git a/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs index 96a89cf..b1a43cf 100644 --- a/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs +++ b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs @@ -68,6 +68,7 @@ private static async Task WaitForSimulatorToBeReady() var stopwatch = Stopwatch.StartNew(); while (stopwatch.ElapsedMilliseconds < MaxStartupWaitTimeMs) + { try { // Try to connect to the simulator's endpoint @@ -87,6 +88,7 @@ private static async Task WaitForSimulatorToBeReady() // Timeout, wait and retry await Task.Delay(PollingIntervalMs); } + } throw new InvalidOperationException( $"Simulator did not start within {MaxStartupWaitTimeMs}ms" @@ -96,7 +98,9 @@ private static async Task WaitForSimulatorToBeReady() private void KillExistingSimulators() { if (_simulatorExePath == null) + { return; + } try { @@ -115,7 +119,9 @@ private void KillExistingSimulators() .ToArray(); foreach (var process in simulatorProcesses) + { process.Kill(); + } } catch { diff --git a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs index f2180aa..474f8f3 100644 --- a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs +++ b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs @@ -44,7 +44,9 @@ public void Add_ExceedsMaxCapacity_EvictsOldestEvents() { // Add more than max capacity for (var i = 0; i < EventHistoryStore.MaxCapacityPerTopic + 10; i++) + { _store.Add(CreateTestRecord($"event-{i}")); + } _store.Count.ShouldBe(EventHistoryStore.MaxCapacityPerTopic); _store.TotalEventsReceived.ShouldBe(EventHistoryStore.MaxCapacityPerTopic + 10); @@ -54,7 +56,9 @@ public void Add_ExceedsMaxCapacity_EvictsOldestEvents() public void Add_ExceedsMaxCapacity_OldestEventsAreRemoved() { for (var i = 0; i < EventHistoryStore.MaxCapacityPerTopic + 5; i++) + { _store.Add(CreateTestRecord($"event-{i}")); + } // First 5 events should be evicted _store.Get("event-0").ShouldBeNull(); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs index 5265ef6..0f4abb6 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs @@ -407,16 +407,24 @@ private static DefaultHttpContext CreateBinaryModeContext( }; if (time != null) + { context.Request.Headers[Constants.CeTimeHeader] = time; + } if (subject != null) + { context.Request.Headers[Constants.CeSubjectHeader] = subject; + } if (dataContentType != null) + { context.Request.Headers[Constants.CeDataContentTypeHeader] = dataContentType; + } if (dataSchema != null) + { context.Request.Headers[Constants.CeDataSchemaHeader] = dataSchema; + } return context; } @@ -463,7 +471,9 @@ private static DefaultHttpContext CreateBinaryModeContextWithRawHeaders( }; if (subject != null) + { context.Request.Headers[Constants.CeSubjectHeader] = subject; + } return context; } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs index 9865086..4daf04d 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs @@ -66,16 +66,24 @@ public static HttpContext CreateCloudEventsBinaryModeContext( }; if (time != null) + { context.Request.Headers[Constants.CeTimeHeader] = time; + } if (subject != null) + { context.Request.Headers[Constants.CeSubjectHeader] = subject; + } if (dataContentType != null) + { context.Request.Headers[Constants.CeDataContentTypeHeader] = dataContentType; + } if (dataSchema != null) + { context.Request.Headers[Constants.CeDataSchemaHeader] = dataSchema; + } return context; } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/FilterSettingsValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/FilterSettingsValidationTests.cs index e564a5f..bb63c3b 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/FilterSettingsValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/FilterSettingsValidationTests.cs @@ -60,7 +60,9 @@ public void TestFilterSettingsValidationWithValidNumberOfAdvancedFilterSettings( AdvancedFilters = new List(), }; for (byte i = 0; i < n; i++) + { filterConfig.AdvancedFilters.Add(GetValidAdvancedFilter()); + } GetValidSimulatorSettings(filterConfig).Validate(); }); @@ -75,7 +77,9 @@ public void TestFilterSettingsValidationWithTooManyAdvancedFilters() }; // Azure Event Grid allows up to 25 filters, so 26 should fail for (var i = 0; i < 26; i++) + { filterConfig.AdvancedFilters.Add(GetValidAdvancedFilter()); + } var exception = Should.Throw(() => GetValidSimulatorSettings(filterConfig).Validate() diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs index 4069d12..771486a 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs @@ -27,7 +27,9 @@ public void Dispose() { // Clean up temp folder if (Directory.Exists(_tempFolder)) + { Directory.Delete(_tempFolder, true); + } } private static PendingDelivery CreatePendingDelivery( diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs index f467bc5..c95b3b9 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs @@ -32,7 +32,9 @@ public HttpEventDeliveryServiceTests() public void Dispose() { foreach (var client in _httpClients) + { client.Dispose(); + } _httpClients.Clear(); } @@ -158,7 +160,9 @@ public async Task GivenMultipleAttempts_WhenDelivering_ThenIncludesDeliveryCount var httpClientFactory = CreateMockHttpClientFactory(captureHeaders: headers => { if (headers.TryGetValues(Constants.AegDeliveryCountHeader, out var values)) + { capturedDeliveryCount = values.FirstOrDefault(); + } }); var service = new HttpEventDeliveryService(httpClientFactory, _formatterFactory, _logger); var delivery = CreatePendingDelivery(); @@ -298,7 +302,9 @@ CancellationToken cancellationToken _responseAction?.Invoke(); if (_exception != null) + { throw _exception; + } var response = new HttpResponseMessage(_statusCode) { @@ -315,7 +321,9 @@ protected override void Dispose(bool disposing) if (disposing) { foreach (var response in _responses) + { response.Dispose(); + } _responses.Clear(); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs index fa24096..15b112b 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs @@ -21,12 +21,14 @@ private static PendingDelivery CreatePendingDelivery( RetryPolicySettings? retryPolicy = null; if (retryEnabled.HasValue || ttlMinutes.HasValue || maxAttempts.HasValue) + { retryPolicy = new RetryPolicySettings { Enabled = retryEnabled ?? true, EventTimeToLiveInMinutes = ttlMinutes ?? 1440, MaxDeliveryAttempts = maxAttempts ?? 30, }; + } var subscriber = new HttpSubscriberSettings { diff --git a/src/AzureEventGridSimulator/Controllers/NotificationController.cs b/src/AzureEventGridSimulator/Controllers/NotificationController.cs index 8e4c373..34281e0 100644 --- a/src/AzureEventGridSimulator/Controllers/NotificationController.cs +++ b/src/AzureEventGridSimulator/Controllers/NotificationController.cs @@ -24,14 +24,18 @@ public async Task Post() // Events are parsed and validated by EventParsingMiddleware if (HttpContext.Items["ParsedEvents"] is not SimulatorEvent[] events) + { throw new InvalidOperationException( "ParsedEvents not found in HttpContext. Ensure EventParsingMiddleware is configured." ); + } if (HttpContext.Items["DetectedSchema"] is not EventSchema detectedSchema) + { throw new InvalidOperationException( "DetectedSchema not found in HttpContext. Ensure EventParsingMiddleware is configured." ); + } await mediator.Send( new SendNotificationEventsToSubscriberCommand( diff --git a/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs b/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs index bd602f4..0aea45b 100644 --- a/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs +++ b/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs @@ -28,6 +28,7 @@ public async Task Get(Guid id) ); if (!isValid) + { return BadRequest( new ErrorMessage( HttpStatusCode.BadRequest, @@ -36,6 +37,7 @@ public async Task Get(Guid id) ErrorDetailCodes.InputJsonInvalid ) ); + } return Ok("Webhook successfully validated as a subscription endpoint"); } diff --git a/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs index 3c24c6e..1f901d8 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs @@ -33,7 +33,9 @@ CancellationToken cancellationToken // Record events for dashboard foreach (var evt in request.Events) + { eventHistoryService.RecordEventReceived(evt, request.Topic, request.InputSchema); + } // Enrich events with topic information EnrichEvents(request.Events, request.Topic.Name); @@ -75,11 +77,13 @@ CancellationToken cancellationToken .ToArray(); foreach (var filteredEvent in eventsFilteredOutByAllSubscribers) + { logger.LogWarning( "All subscribers of topic '{TopicName}' filtered out event {EventId}", request.Topic.Name, filteredEvent.Id ); + } // Enqueue events for each subscriber var enqueuedCount = 0; @@ -142,11 +146,13 @@ subscriber is HttpSubscriberSettings httpSubscriber } if (enqueuedCount > 0) + { logger.LogDebug( "Enqueued {Count} event delivery(ies) for topic '{TopicName}'", enqueuedCount, request.Topic.Name ); + } return Task.CompletedTask; } @@ -157,6 +163,7 @@ private static void EnrichEvents(SimulatorEvent[] events, string topicName) $"/subscriptions/{Guid.Empty:D}/resourceGroups/eventGridSimulator/providers/Microsoft.EventGrid/topics/{topicName}"; foreach (var evt in events) + { if (evt.Schema == EventSchema.EventGridSchema && evt.EventGridEvent != null) { evt.EventGridEvent.SetTopic(topicPath); @@ -167,7 +174,10 @@ private static void EnrichEvents(SimulatorEvent[] events, string topicName) // CloudEvents use 'source' which is already set // Optionally set it to the topic path if not already set if (string.IsNullOrEmpty(evt.CloudEvent.Source)) + { evt.CloudEvent.Source = topicPath; + } } + } } } diff --git a/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs index e78aed1..5f087f9 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs @@ -26,13 +26,17 @@ CancellationToken cancellationToken ) { foreach (var enabledTopic in simulatorSettings.Topics.Where(o => !o.Disabled)) - // Only HTTP subscribers need validation (Service Bus subscribers don't use webhook validation) - foreach ( - var subscriber in enabledTopic.Subscribers.HttpSubscribers.Where(o => - !o.DisableValidation && !o.Disabled + { + // Only HTTP subscribers need validation (Service Bus subscribers don't use webhook validation) + foreach ( + var subscriber in enabledTopic.Subscribers.HttpSubscribers.Where(o => + !o.DisableValidation && !o.Disabled + ) ) - ) - await ValidateSubscription(enabledTopic, subscriber); + { + await ValidateSubscription(enabledTopic, subscriber); + } + } } private async Task ValidateSubscription( diff --git a/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs index cabbf99..78ab80b 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs @@ -122,30 +122,40 @@ public void Validate() // Validate required fields are non-empty if (string.IsNullOrWhiteSpace(Id)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'id' property to be set." ); + } if (string.IsNullOrWhiteSpace(Type)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'eventType' property to be set." ); + } // Azure does NOT enforce field length limits for type or subject // Optional: time - if present, must be valid date/time // Note: Azure is lenient and accepts time without timezone (though CloudEvents spec recommends RFC 3339 with timezone) if (!string.IsNullOrEmpty(Time) && !TimeIsValid) + { throw new InvalidOperationException( "The event time property 'time' was not a valid date/time." ); + } // Optional: dataschema - if present, must be a valid URI if (!string.IsNullOrEmpty(DataSchema)) + { if (!Uri.TryCreate(DataSchema, UriKind.RelativeOrAbsolute, out _)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'dataschema' property to be a valid URI." ); + } + } // Note: Azure Event Grid is lenient and accepts both data and data_base64 // The spec says they are mutually exclusive, but Azure doesn't enforce this diff --git a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs index 0fd42fd..51cc5d9 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs @@ -117,7 +117,9 @@ public void AddOrUpdateDelivery(DeliveryRecord delivery) d.SubscriberName == delivery.SubscriberName ); if (existing != null) + { Deliveries.Remove(existing); + } Deliveries.Add(delivery); } diff --git a/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs index ba0597c..a274e21 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs @@ -106,48 +106,62 @@ internal void SetTopic(string topic) public void Validate() { if (string.IsNullOrWhiteSpace(Id)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'id' property to be set." ); + } if (string.IsNullOrWhiteSpace(Subject)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'subject' property to be set." ); + } // Azure does NOT enforce subject length limits if (string.IsNullOrWhiteSpace(EventType)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'eventType' property to be set." ); + } // Azure does NOT enforce eventType length limits // DataVersion is optional, but if provided it must be non-empty if (DataVersion != null && string.IsNullOrWhiteSpace(DataVersion)) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'dataVersion' property to be set." ); + } if (!EventTimeIsValid) + { throw new InvalidOperationException( $"This resource is configured for '{SchemaName}' schema and requires 'eventTime' property to be a valid RFC 3339 timestamp." ); + } // Note: Azure is lenient and accepts eventTime without timezone (though best practice is to include it) if (MetadataVersion != null && MetadataVersion != "1") + { throw new InvalidOperationException( $"Property 'metadataVersion' was found to be set to {MetadataVersion}, but was expected to either be null or be set to 1." ); + } // Topic must NOT be set by the publisher - Event Grid sets this automatically // Skip this check if the simulator has already set the topic via SetTopic() // Azure returns 401 when the topic field doesn't match the actual endpoint topic if (!TopicHasBeenSet && !string.IsNullOrEmpty(Topic)) + { throw new TopicAuthorizationException( $"This resource is configured for '{SchemaName}' schema. The 'topic' property must not be set by the publisher." ); + } } } diff --git a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs index 1974df9..05cfb7a 100644 --- a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs +++ b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs @@ -63,10 +63,14 @@ public Dictionary GetHeaders(SimulatorEvent evt) private CloudEvent ConvertToCloudEvent(SimulatorEvent evt) { if (evt.Schema == EventSchema.CloudEventV1_0 && evt.CloudEvent != null) + { return evt.CloudEvent; + } if (evt.Schema == EventSchema.EventGridSchema && evt.EventGridEvent != null) + { return ConvertEventGridToCloudEvent(evt.EventGridEvent); + } throw new InvalidOperationException( $"Cannot convert event with schema {evt.Schema} to CloudEvents format." @@ -98,11 +102,15 @@ private CloudEvent ConvertEventGridToCloudEvent(EventGridEvent eventGridEvent) private string? ConvertDataVersionToSchema(string? dataVersion) { if (string.IsNullOrEmpty(dataVersion)) + { return null; + } // If it's already a URI, return as-is if (Uri.TryCreate(dataVersion, UriKind.Absolute, out _)) + { return dataVersion; + } // Otherwise, create a simple schema URI return $"#/schema/{dataVersion}"; diff --git a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs index e6124aa..26baf9f 100644 --- a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs +++ b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs @@ -28,10 +28,14 @@ public partial class CloudEventSchemaParser(EventSchemaDetector schemaDetector) public SimulatorEvent[] Parse(HttpContext context, string requestBody) { if (schemaDetector.IsBinaryMode(context)) + { return ParseBinaryMode(context, requestBody); + } if (schemaDetector.IsBatchMode(context)) + { return ParseBatchStructuredMode(context, requestBody); + } // Check if using application/json (strict single-event mode) var contentType = context.Request.ContentType; @@ -47,7 +51,9 @@ public SimulatorEvent[] Parse(HttpContext context, string requestBody) public void Validate(SimulatorEvent[] events) { foreach (var evt in events) + { evt.Validate(); + } } /// @@ -74,6 +80,7 @@ private SimulatorEvent[] ParseBinaryMode(HttpContext context, string requestBody // Parse the body as data if (!string.IsNullOrWhiteSpace(requestBody)) + { // Try to parse as JSON, otherwise treat as string try { @@ -84,6 +91,7 @@ private SimulatorEvent[] ParseBinaryMode(HttpContext context, string requestBody { cloudEvent.Data = requestBody; } + } return [SimulatorEvent.FromCloudEvent(cloudEvent)]; } @@ -105,7 +113,9 @@ bool strictSingleEvent ) { if (string.IsNullOrWhiteSpace(requestBody)) + { throw new InvalidOperationException("Unexpected end when reading JSON."); + } CloudEvent? cloudEvent; @@ -118,17 +128,21 @@ bool strictSingleEvent { // Azure behavior: application/json expects a single object, not an array if (strictSingleEvent) + { throw new InvalidOperationException( $"This resource is configured to receive event in '{SchemaName}' schema. " + "The JSON received does not conform to the expected schema. " + $"Token Expected: StartObject, Actual Token Received: StartArray.{context.GenerateReportSuffix()}" ); + } if (document.RootElement.GetArrayLength() == 0) + { throw new InvalidOperationException( $"This resource is configured to receive event in '{SchemaName}' schema. " + "The JSON received does not conform to the expected schema." ); + } // Handle single event in array format (for application/cloudevents+json) var events = JsonSerializer.Deserialize( @@ -149,7 +163,9 @@ bool strictSingleEvent } if (cloudEvent == null) + { throw new InvalidOperationException("Failed to parse CloudEvent from request body."); + } return [SimulatorEvent.FromCloudEvent(cloudEvent)]; } @@ -160,7 +176,9 @@ bool strictSingleEvent private SimulatorEvent[] ParseBatchStructuredMode(HttpContext context, string requestBody) { if (string.IsNullOrWhiteSpace(requestBody)) + { throw new InvalidOperationException("Unexpected end when reading JSON."); + } CloudEvent[]? events; @@ -177,10 +195,12 @@ private SimulatorEvent[] ParseBatchStructuredMode(HttpContext context, string re } if (events == null || events.Length == 0) + { throw new InvalidOperationException( $"This resource is configured to receive event in '{SchemaName}' schema. " + "The JSON received does not conform to the expected schema." ); + } return events.Select(SimulatorEvent.FromCloudEvent).ToArray(); } @@ -191,11 +211,15 @@ private SimulatorEvent[] ParseBatchStructuredMode(HttpContext context, string re private static string? GetHeaderValue(IHeaderDictionary headers, string headerName) { if (!headers.TryGetValue(headerName, out var values)) + { return null; + } var value = values.FirstOrDefault(); if (string.IsNullOrEmpty(value)) + { return value; + } return DecodeHeaderValue(value); } @@ -207,17 +231,21 @@ private SimulatorEvent[] ParseBatchStructuredMode(HttpContext context, string re private static string GetRequiredHeaderValue(IHeaderDictionary headers, string headerName) { if (!headers.TryGetValue(headerName, out var values)) + { throw new InvalidOperationException( $"{headerName} header is missing for the cloud event. " + "Please check required attributes at https://github.com/cloudevents/spec/blob/v1.0/spec.md#required-attributes" ); + } var value = values.FirstOrDefault(); if (string.IsNullOrEmpty(value)) + { throw new InvalidOperationException( $"{headerName} header is empty for the cloud event. " + "Please check required attributes at https://github.com/cloudevents/spec/blob/v1.0/spec.md#required-attributes" ); + } return DecodeHeaderValue(value); } diff --git a/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs b/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs index 3de339e..b21675c 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs @@ -102,8 +102,12 @@ public void Add(EventHistoryRecord record) && topicCount > MaxCapacityPerTopic && topicOrder.TryDequeue(out var oldestId) ) + { if (_records.TryRemove(oldestId, out _)) + { _topicCounts.AddOrUpdate(record.TopicName, 0, (_, count) => Math.Max(0, count - 1)); + } + } } /// @@ -112,7 +116,9 @@ public void Add(EventHistoryRecord record) public void UpdateDelivery(string? eventId, DeliveryRecord delivery) { if (eventId != null && _records.TryGetValue(eventId, out var record)) + { record.AddOrUpdateDelivery(delivery); + } } /// @@ -223,7 +229,9 @@ public void AddRejection(RejectedEventRecord rejection) while ( _rejections.Count > MaxRejectedCapacity && _rejectionOrder.TryDequeue(out var oldestId) ) + { _rejections.TryRemove(oldestId, out _); + } } /// diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs index 067ece7..b527047 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs @@ -30,13 +30,17 @@ SimulatorEvent evt var result = new Dictionary(); if (properties == null) + { return result; + } foreach (var (name, setting) in properties) { var value = ResolveProperty(setting, evt); if (value != null) + { result[name] = value; + } } return result; @@ -57,13 +61,19 @@ SimulatorEvent evt public object? ResolveProperty(DeliveryPropertySettings setting, SimulatorEvent evt) { if (setting == null) + { return null; + } if (setting.IsStatic) + { return setting.Value; + } if (setting.IsDynamic) + { return GetValueFromEvent(evt, setting.Value); + } return null; } @@ -75,7 +85,9 @@ SimulatorEvent evt private static object? GetValueFromEvent(SimulatorEvent evt, string? path) { if (string.IsNullOrWhiteSpace(path)) + { return null; + } // Handle top-level properties switch (path) @@ -118,7 +130,9 @@ SimulatorEvent evt && evt.Data != null && split.Length > 1 ) + { return GetNestedValue(evt.Data, split, 1); + } return null; } @@ -138,23 +152,31 @@ SimulatorEvent evt for (var i = startIndex; i < pathParts.Length; i++) { if (current.ValueKind == JsonValueKind.Null) + { return null; + } if (current.ValueKind != JsonValueKind.Object) + { return null; + } // Try case-insensitive property lookup var found = false; foreach (var prop in current.EnumerateObject()) + { if (prop.Name.Equals(pathParts[i], StringComparison.OrdinalIgnoreCase)) { current = prop.Value; found = true; break; } + } if (!found) + { return null; + } } // Convert the final JsonElement to an appropriate .NET type @@ -189,7 +211,9 @@ private static bool TryParseDateTime(string? value, out DateTimeOffset result) { result = default; if (string.IsNullOrEmpty(value)) + { return false; + } return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, out result); } @@ -198,7 +222,9 @@ private static bool TryParseGuid(string? value, out Guid result) { result = Guid.Empty; if (string.IsNullOrEmpty(value)) + { return false; + } return Guid.TryParse(value, out result); } diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs index 86974af..147da3f 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs @@ -22,7 +22,9 @@ DeliveryPropertyResolver propertyResolver public async ValueTask DisposeAsync() { foreach (var producer in _producers.Values) + { await producer.DisposeAsync(); + } _producers.Clear(); } @@ -40,11 +42,13 @@ CancellationToken cancellationToken ); if (delivery.Subscriber is not EventHubSubscriberSettings subscription) + { return new DeliveryResult( false, DeliveryOutcome.EventHubError, ErrorMessage: "Invalid subscriber type for Event Hub delivery" ); + } try { @@ -85,7 +89,9 @@ CancellationToken cancellationToken delivery.Event ); foreach (var (name, value) in properties) + { eventData.Properties[name] = value; + } // Add standard Event Grid headers as properties eventData.Properties["aeg-event-type"] = "Notification"; @@ -194,7 +200,9 @@ EventSchema inputSchema // Add delivery properties var properties = propertyResolver.ResolveProperties(subscription.Properties, evt); foreach (var (name, value) in properties) + { eventData.Properties[name] = value; + } // Add standard Event Grid headers as properties eventData.Properties["aeg-event-type"] = "Notification"; @@ -250,8 +258,10 @@ private EventHubProducerClient GetOrCreateProducer(EventHubSubscriberSettings su StringComparison.OrdinalIgnoreCase ); if (keyIndex > 0) + { connectionForLogging = connectionForLogging[..(keyIndex + 16)] + "***REDACTED***"; + } } logger.LogInformation( diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/HttpEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/HttpEventDeliveryService.cs index 9b9aa26..f9e0310 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/HttpEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/HttpEventDeliveryService.cs @@ -21,11 +21,13 @@ CancellationToken cancellationToken ) { if (delivery.Subscriber is not HttpSubscriberSettings httpSubscriber) + { return new DeliveryResult( false, DeliveryOutcome.NetworkError, ErrorMessage: "Invalid subscriber type for HTTP delivery" ); + } try { @@ -69,7 +71,9 @@ CancellationToken cancellationToken // Add any additional headers from the formatter foreach (var header in formatter.GetHeaders(delivery.Event)) + { httpClient.DefaultRequestHeaders.Add(header.Key, header.Value); + } httpClient.Timeout = TimeSpan.FromSeconds(60); diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs index 15e81f6..a31ba59 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs @@ -22,10 +22,14 @@ DeliveryPropertyResolver propertyResolver public async ValueTask DisposeAsync() { foreach (var sender in _senders.Values) + { await sender.DisposeAsync(); + } foreach (var client in _clients.Values) + { await client.DisposeAsync(); + } _senders.Clear(); _clients.Clear(); @@ -38,20 +42,24 @@ CancellationToken cancellationToken ) { if (delivery.Subscriber is not ServiceBusSubscriberSettings subscription) + { return new DeliveryResult( false, DeliveryOutcome.ServiceBusError, ErrorMessage: "Invalid subscriber type for Service Bus delivery" ); + } try { if (subscription.Disabled) + { return new DeliveryResult( false, DeliveryOutcome.ServiceBusError, ErrorMessage: "Subscription is disabled" ); + } // Determine the delivery schema var deliverySchema = @@ -77,7 +85,9 @@ CancellationToken cancellationToken delivery.Event ); foreach (var (name, value) in properties) + { message.ApplicationProperties[name] = value; + } // Add standard Event Grid headers as application properties message.ApplicationProperties["aeg-event-type"] = "Notification"; @@ -187,7 +197,9 @@ EventSchema inputSchema // Add delivery properties var properties = propertyResolver.ResolveProperties(subscription.Properties, evt); foreach (var (name, value) in properties) + { message.ApplicationProperties[name] = value; + } // Add standard Event Grid headers as application properties message.ApplicationProperties["aeg-event-type"] = "Notification"; diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/StorageQueueEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/StorageQueueEventDeliveryService.cs index 6873487..f4cba57 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/StorageQueueEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/StorageQueueEventDeliveryService.cs @@ -32,20 +32,24 @@ CancellationToken cancellationToken ) { if (delivery.Subscriber is not StorageQueueSubscriberSettings subscription) + { return new DeliveryResult( false, DeliveryOutcome.StorageQueueError, ErrorMessage: "Invalid subscriber type for Storage Queue delivery" ); + } try { if (subscription.Disabled) + { return new DeliveryResult( false, DeliveryOutcome.StorageQueueError, ErrorMessage: "Subscription is disabled" ); + } // Determine the delivery schema var deliverySchema = @@ -178,7 +182,9 @@ StorageQueueSubscriberSettings subscription var key = $"{subscription.EffectiveConnectionString}:{subscription.QueueName}"; if (_clients.TryGetValue(key, out var existingClient)) + { return existingClient; + } logger.LogDebug( "Creating Storage Queue client for subscription '{SubscriberName}' (Queue: '{QueueName}')", diff --git a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs index 8ac4272..76efa53 100644 --- a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs +++ b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs @@ -50,10 +50,14 @@ public Dictionary GetHeaders(SimulatorEvent evt) private EventGridEvent ConvertToEventGridEvent(SimulatorEvent evt) { if (evt.Schema == EventSchema.EventGridSchema && evt.EventGridEvent != null) + { return evt.EventGridEvent; + } if (evt.Schema == EventSchema.CloudEventV1_0 && evt.CloudEvent != null) + { return ConvertCloudEventToEventGrid(evt.CloudEvent); + } throw new InvalidOperationException( $"Cannot convert event with schema {evt.Schema} to Event Grid format." @@ -87,7 +91,9 @@ private EventGridEvent ConvertCloudEventToEventGrid(CloudEvent cloudEvent) private string ExtractDataVersion(string? dataSchema) { if (string.IsNullOrEmpty(dataSchema)) + { return ""; + } // Try to extract version from URI (e.g., "/schema/v1" -> "v1") if (Uri.TryCreate(dataSchema, UriKind.RelativeOrAbsolute, out var uri)) @@ -97,7 +103,9 @@ private string ExtractDataVersion(string? dataSchema) { var lastSegment = segments.Last().TrimEnd('/'); if (lastSegment.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { return lastSegment; + } } } diff --git a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs index fb16113..19f3fd7 100644 --- a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs +++ b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs @@ -27,7 +27,9 @@ public partial class EventGridSchemaParser : IEventSchemaParser public SimulatorEvent[] Parse(HttpContext context, string requestBody) { if (string.IsNullOrWhiteSpace(requestBody)) + { throw new InvalidOperationException("Unexpected end when reading JSON."); + } EventGridEvent[]? events; @@ -58,10 +60,12 @@ public SimulatorEvent[] Parse(HttpContext context, string requestBody) } if (events == null || events.Length == 0) + { throw new InvalidOperationException( $"This resource is configured to receive event in '{SchemaName}' schema. " + "The JSON received does not conform to the expected schema." ); + } return events.Select(SimulatorEvent.FromEventGridEvent).ToArray(); } @@ -70,7 +74,9 @@ public SimulatorEvent[] Parse(HttpContext context, string requestBody) public void Validate(SimulatorEvent[] events) { foreach (var evt in events) + { evt.Validate(); + } } // Azure validates fields in this order (observed from real Azure responses) diff --git a/src/AzureEventGridSimulator/Domain/Services/EventSchemaDetector.cs b/src/AzureEventGridSimulator/Domain/Services/EventSchemaDetector.cs index 0588293..b3060f1 100644 --- a/src/AzureEventGridSimulator/Domain/Services/EventSchemaDetector.cs +++ b/src/AzureEventGridSimulator/Domain/Services/EventSchemaDetector.cs @@ -20,11 +20,15 @@ public EventSchema DetectSchema(HttpContext context) { // Check for CloudEvents structured mode (content-type based) if (IsCloudEventStructuredMode(context)) + { return EventSchema.CloudEventV1_0; + } // Check for CloudEvents binary mode (header based) if (IsCloudEventBinaryMode(context)) + { return EventSchema.CloudEventV1_0; + } // Default to EventGrid schema return EventSchema.EventGridSchema; @@ -38,7 +42,9 @@ private bool IsCloudEventStructuredMode(HttpContext context) { var contentType = context.Request.ContentType; if (string.IsNullOrEmpty(contentType)) + { return false; + } // Check for CloudEvents JSON content type (use base types for detection) return contentType.Contains(Constants.CloudEventsContentTypeBase) diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs index d9284ef..908bf8a 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs @@ -17,17 +17,21 @@ public class InMemoryDeliveryQueue(TimeProvider timeProvider, ILogger @@ -76,7 +80,9 @@ public async IAsyncEnumerable GetDueDeliveriesAsync( foreach (var delivery in dueDeliveries) { if (cancellationToken.IsCancellationRequested) + { yield break; + } yield return delivery; diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs index 33e3f3c..a1c9600 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs @@ -86,10 +86,14 @@ private static TimeSpan GetStandardDelay(int attemptNumber) var index = attemptNumber - 1; if (index < 0) + { return TimeSpan.Zero; + } if (index < StandardSchedule.Length) + { return StandardSchedule[index]; + } // After schedule exhausted, retry every 12 hours return TimeSpan.FromHours(12); diff --git a/src/AzureEventGridSimulator/Domain/Services/Routing/RequestRouter.cs b/src/AzureEventGridSimulator/Domain/Services/Routing/RequestRouter.cs index 1086e16..bfe4e14 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Routing/RequestRouter.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Routing/RequestRouter.cs @@ -82,19 +82,27 @@ public RequestRouteResult RouteRequest(HttpContext context) // Check for subscription validation request (GET /validate?id=...) if (IsValidationRequest(context)) + { return new RequestRouteResult(RequestType.SubscriptionValidation); + } // Check for health check request (GET /api/health) if (IsHealthRequest(context)) + { return new RequestRouteResult(RequestType.Health); + } // Check for dashboard request (/dashboard/*) if (IsDashboardRequest(context)) + { return new RequestRouteResult(RequestType.Dashboard); + } // Favicon requests (browsers request this automatically) if (context.Request.Path.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase)) + { return new RequestRouteResult(RequestType.FaviconIgnore); + } // OPTIONS preflight request (CORS support) if ( @@ -113,14 +121,18 @@ public RequestRouteResult RouteRequest(HttpContext context) string.Equals(context.Request.Path, "/api/events", StringComparison.OrdinalIgnoreCase) && context.Request.Method == HttpMethods.Head ) + { return new RequestRouteResult(RequestType.HeadApiEvents); + } // Non-POST method to /api/events (Azure returns 405) if ( string.Equals(context.Request.Path, "/api/events", StringComparison.Ordinal) && context.Request.Method != HttpMethods.Post ) + { return new RequestRouteResult(RequestType.MethodNotAllowed); + } // Unknown path (returns 404) return new RequestRouteResult(RequestType.NotFound); @@ -134,11 +146,15 @@ private static bool IsNotificationRequest(HttpContext context) context.Request.Method != HttpMethods.Post || !string.Equals(path, "/api/events", StringComparison.OrdinalIgnoreCase) ) + { return false; + } // Check for CloudEvents binary mode (indicated by ce-* headers) if (IsCloudEventsBinaryMode(context)) + { return true; + } var contentType = context.Request.Headers.ContentType.FirstOrDefault(); @@ -146,8 +162,10 @@ private static bool IsNotificationRequest(HttpContext context) // For CloudEvents, we'll validate the content-type later and return 415 if invalid // Accept all POST /api/events requests and let the schema detection/validation handle it if (string.IsNullOrWhiteSpace(contentType)) + { // Accept requests without Content-Type - EventGrid schema is lenient return true; + } // Accept EventGrid format (application/json) or CloudEvents format (use base types for detection) // Also accept text/plain and other content types - Azure is lenient for EventGrid schema diff --git a/src/AzureEventGridSimulator/Domain/Services/Validation/ContentTypeValidator.cs b/src/AzureEventGridSimulator/Domain/Services/Validation/ContentTypeValidator.cs index 0fbd5e9..fd2bad3 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Validation/ContentTypeValidator.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Validation/ContentTypeValidator.cs @@ -39,7 +39,9 @@ EventSchema detectedSchema // Only validate content-type for CloudEvents schema if (detectedSchema != EventSchema.CloudEventV1_0) + { return new ContentTypeValidationResult(IsValid: true); + } if (IsCloudEventsBinaryMode(context)) { @@ -117,7 +119,9 @@ private static bool IsCloudEventsBinaryMode(HttpContext context) private static bool IsValidCloudEventsContentType(string? contentType) { if (string.IsNullOrWhiteSpace(contentType)) + { return false; + } return contentType.Contains( Constants.CloudEventsContentTypeBase, @@ -132,7 +136,9 @@ private static bool IsValidCloudEventsContentType(string? contentType) private static bool IsApplicationJson(string? contentType) { if (string.IsNullOrWhiteSpace(contentType)) + { return false; + } // Azure accepts application/json for CloudEvents and treats it as single event mode return contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase); @@ -141,7 +147,9 @@ private static bool IsApplicationJson(string? contentType) private static bool IsValidBinaryModeContentType(string? contentType) { if (string.IsNullOrWhiteSpace(contentType)) + { return false; + } // In binary mode, Content-Type represents the data's content type // Azure only accepts application/json for binary mode CloudEvents diff --git a/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs b/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs index b00bde2..fa82b86 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs @@ -41,7 +41,9 @@ private static IResult GetEventById(string id, IEventHistoryService eventHistory { var evt = eventHistoryService.GetEvent(id); if (evt == null) + { return Results.NotFound(); + } return Results.Ok(MapToEventDetails(evt)); } diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationBuilderExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationBuilderExtensions.cs index c925c81..5027044 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationBuilderExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationBuilderExtensions.cs @@ -15,10 +15,12 @@ IConfiguration configuration if (!string.IsNullOrWhiteSpace(configFileOverridden)) { if (!File.Exists(configFileOverridden)) + { throw new FileNotFoundException( "The specified ConfigFile could not be found.", configFileOverridden ); + } builder.AddJsonFile( Path.Combine(Directory.GetCurrentDirectory(), configFileOverridden), diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/StringExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/StringExtensions.cs index df90138..685ee0b 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/StringExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/StringExtensions.cs @@ -5,7 +5,9 @@ public static class StringExtensions public static string Otherwise(this string input, string otherwise) { if (string.IsNullOrWhiteSpace(input)) + { return otherwise; + } return input; } diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs index 91b80a9..9f4944e 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs @@ -205,7 +205,9 @@ out object? value value = null; if (string.IsNullOrWhiteSpace(key)) + { return false; + } // Map common property names to SimulatorEvent accessors switch (key) @@ -248,11 +250,13 @@ out object? value && simulatorEvent.Data != null && split.Length > 1 ) + { if (TryGetNestedValue(simulatorEvent.Data, split, 1, out var nestedValue)) { value = nestedValue; return true; } + } return false; } @@ -275,22 +279,28 @@ out object? value for (var i = startIndex; i < pathParts.Length; i++) { if (current.ValueKind != JsonValueKind.Object) + { return false; + } if (!current.TryGetProperty(pathParts[i], out var property)) { // Try case-insensitive match var found = false; foreach (var prop in current.EnumerateObject()) + { if (prop.Name.Equals(pathParts[i], StringComparison.OrdinalIgnoreCase)) { current = prop.Value; found = true; break; } + } if (!found) + { return false; + } } else { @@ -326,7 +336,9 @@ JsonValueKind.Number when element.TryGetInt64(out var l) => l, { var result = new List(); foreach (var item in arrayElement.EnumerateArray()) + { result.Add(ConvertJsonElement(item)); + } return result; } @@ -357,17 +369,23 @@ bool enableArrayFiltering ) { if (!enableArrayFiltering) + { return EvaluateAdvancedFilter(filter, value); + } // Check if the value is an array var arrayElements = TryGetArrayElements(value); if (arrayElements == null) + { // Not an array, evaluate normally return EvaluateAdvancedFilter(filter, value); + } // For negation operators on arrays, ALL elements must satisfy the condition if (IsNegationOperator(filter.OperatorType)) + { return arrayElements.All(element => EvaluateAdvancedFilter(filter, element)); + } // For positive operators on arrays, ANY element must satisfy the condition return arrayElements.Any(element => EvaluateAdvancedFilter(filter, element)); @@ -376,10 +394,12 @@ bool enableArrayFiltering private static double ToNumber(this object? value) { if (value == null) + { throw new ArgumentNullException( nameof(value), "null is not convertible to a number in this implementation" ); + } return Convert.ToDouble(value); } @@ -403,7 +423,9 @@ private static bool Try(Func function, bool valueOnException = false) private static bool IsNumberInRanges(double value, ICollection? ranges) { if (ranges == null || ranges.Count == 0) + { return false; + } foreach (var range in ranges) { @@ -444,7 +466,9 @@ private static bool IsNumberInRanges(double value, ICollection? ranges) // Check if value is within this range (inclusive) if (value >= min && value <= max) + { return true; + } } return false; @@ -456,7 +480,9 @@ private static bool TryGetValue(this EventGridEvent gridEvent, string? key, out value = null; if (string.IsNullOrWhiteSpace(key)) + { return retval; + } switch (key) { @@ -491,7 +517,9 @@ private static bool TryGetValue(this EventGridEvent gridEvent, string? key, out || gridEvent.Data == null || split.Length <= 1 ) + { break; + } if (TryGetNestedValue(gridEvent.Data, split, 1, out var nestedValue)) { @@ -528,7 +556,9 @@ bool enableArrayFiltering // For "Not" operators, return true when key doesn't exist (per Azure docs) if (!keyExists) + { return IsNegationOperator(filter.OperatorType); + } return EvaluateWithArraySupport(filter, value, enableArrayFiltering); } @@ -556,7 +586,9 @@ bool enableArrayFiltering // For "Not" operators, return true when key doesn't exist (per Azure docs) if (!keyExists) + { return IsNegationOperator(filter.OperatorType); + } return EvaluateWithArraySupport(filter, value, enableArrayFiltering); } @@ -569,7 +601,9 @@ bool enableArrayFiltering public bool AcceptsEvent(SimulatorEvent simulatorEvent) { if (filter == null) + { return true; + } var subject = simulatorEvent.Subject; @@ -625,7 +659,9 @@ public bool AcceptsEvent(SimulatorEvent simulatorEvent) public bool AcceptsEvent(EventGridEvent gridEvent) { if (filter == null) + { return true; + } // we have a filter to parse var retVal = diff --git a/src/AzureEventGridSimulator/Infrastructure/Mediator/ServiceCollectionExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Mediator/ServiceCollectionExtensions.cs index 689c5d7..3c4b39d 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Mediator/ServiceCollectionExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Mediator/ServiceCollectionExtensions.cs @@ -55,7 +55,9 @@ Assembly assembly ); foreach (var handlerInterface in handlerInterfaces) + { services.AddSingleton(handlerInterface, handlerType); + } } return services; diff --git a/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs b/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs index 9c6b6aa..b40b3eb 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs @@ -108,7 +108,9 @@ public SasValidationResult Validate(IHeaderDictionary requestHeaders, string top { var token = requestHeaders[Constants.AegSasTokenHeader].FirstOrDefault(); if (token == null) + { return new SasValidationResult(false, SasValidationFailureReason.MissingKey); + } var tokenResult = ValidateToken(token, topicKey); if (!tokenResult.IsValid) @@ -180,14 +182,18 @@ private SasValidationResult ValidateToken(string token, string key) || string.IsNullOrEmpty(expiration) || string.IsNullOrEmpty(signature) ) + { return new SasValidationResult(false, SasValidationFailureReason.InvalidTokenFormat); + } // Parse expiration as Unix epoch seconds if ( !long.TryParse(expiration, out var expiryEpoch) || DateTimeOffset.FromUnixTimeSeconds(expiryEpoch) <= timeProvider.GetUtcNow() ) + { return new SasValidationResult(false, SasValidationFailureReason.TokenExpired); + } // The string to sign is: {resource}\n{expiryEpoch} // This matches Azure Event Grid's SAS token format @@ -204,7 +210,9 @@ private SasValidationResult ValidateToken(string token, string key) // ParseQueryString already decodes URL-encoded values, so signature is ready to compare // Note: Don't call UrlDecode again as it would convert '+' to space if (string.Equals(signature, computedSignature, StringComparison.Ordinal)) + { return new SasValidationResult(true); + } // Sanitize signature to prevent log forging by escaping all control characters var sanitizedSignature = SanitizeForLogging(signature); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs index 9063ff6..d8aad17 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs @@ -53,7 +53,9 @@ public enum AdvancedFilterOperatorType internal void Validate() { if (string.IsNullOrWhiteSpace(Key)) + { throw new ArgumentException("A filter key must be provided", nameof(Key)); + } // IsNullOrUndefined and IsNotNull don't require values var nullCheckOperators = new[] @@ -62,25 +64,35 @@ internal void Validate() AdvancedFilterOperatorType.IsNotNull, }; - if (!nullCheckOperators.Contains(OperatorType) && Value == null && !Values.HasItems()) + if ( + !nullCheckOperators.Contains(OperatorType) + && Value == null + && (Values == null || !Values.HasItems()) + ) + { throw new ArgumentException( "Either a Value or a set of Values must be provided", nameof(Value) ); + } const short maxStringLength = 512; if ((Value as string)?.Length > maxStringLength) + { throw new ArgumentOutOfRangeException( nameof(Value), $"Advanced filtering limits strings to {maxStringLength} characters per string value" ); + } if (Values?.Any(o => (o as string)?.Length > maxStringLength) == true) + { throw new ArgumentOutOfRangeException( nameof(Values), $"Advanced filtering limits strings to {maxStringLength} characters per string value" ); + } // In/NotIn operators are limited to 5 values if ( @@ -93,10 +105,12 @@ internal void Validate() }.Contains(OperatorType) && Values?.Count > 5 ) + { throw new ArgumentOutOfRangeException( nameof(OperatorType), "Advanced filtering limits filters to five values for in and not in operators" ); + } // Range operators require values to be provided in pairs (min, max) if ( @@ -106,11 +120,15 @@ internal void Validate() AdvancedFilterOperatorType.NumberNotInRange, }.Contains(OperatorType) ) + { if (Values == null || Values.Count == 0) + { throw new ArgumentException( "NumberInRange and NumberNotInRange operators require at least one range specified as [min, max] pairs in Values", nameof(Values) ); + } + } } public override string ToString() diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs index e6f9b24..b4ccd84 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs @@ -31,12 +31,16 @@ internal void Validate() { // Azure Event Grid allows up to 25 advanced filters per subscription if (AdvancedFilters?.Count > 25) + { throw new ArgumentOutOfRangeException( nameof(AdvancedFilters), "Advanced filtering is limited to 25 advanced filters per event grid subscription." ); + } foreach (var advancedFilter in AdvancedFilters ?? []) + { advancedFilter.Validate(); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs index 6a74a3f..4910d77 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs @@ -28,15 +28,21 @@ public class SimulatorSettings public void Validate() { if (Topics.GroupBy(o => o.Port).Count() != Topics.Length) + { throw new InvalidOperationException("Each topic must use a unique port."); + } if (Topics.GroupBy(o => o.Name).Count() != Topics.Length) + { throw new InvalidOperationException("Each topic must have a unique name."); + } var allSubscribers = Topics.SelectMany(o => o.Subscribers.All).ToList(); if (allSubscribers.GroupBy(o => o.Name).Count() != allSubscribers.Count) + { throw new InvalidOperationException("Each subscriber must have a unique name."); + } if ( Topics @@ -46,9 +52,11 @@ public void Validate() || name.ToArray().Any(c => !(char.IsLetterOrDigit(c) || c == '-')) ) ) + { throw new InvalidOperationException( "A topic name can only contain letters, numbers, and dashes." ); + } if ( allSubscribers @@ -58,35 +66,49 @@ public void Validate() || name.ToArray().Any(c => !(char.IsLetterOrDigit(c) || c == '-')) ) ) + { throw new InvalidOperationException( "A subscriber name can only contain letters, numbers, and dashes." ); + } // Wire up topic references for connection string inheritance foreach (var topic in Topics) { foreach (var subscriber in topic.Subscribers.ServiceBusSubscribers) + { subscriber.ParentTopic = topic; + } foreach (var subscriber in topic.Subscribers.StorageQueueSubscribers) + { subscriber.ParentTopic = topic; + } foreach (var subscriber in topic.Subscribers.EventHubSubscribers) + { subscriber.ParentTopic = topic; + } } // Validate each subscriber foreach (var subscriber in allSubscribers) + { subscriber.Validate(); + } // Validate filters foreach (var filter in allSubscribers.Where(s => s.Filter != null).Select(s => s.Filter!)) + { filter.Validate(); + } // Validate dashboard port is determinable if dashboard is enabled if (DashboardEnabled && DashboardPort is null && !Topics.Any(t => !t.Disabled)) + { throw new InvalidOperationException( "Dashboard is enabled but no port is available. Either set 'dashboardPort' or enable at least one topic." ); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeadLetterSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeadLetterSettings.cs index 3649972..5c255b1 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeadLetterSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeadLetterSettings.cs @@ -28,6 +28,8 @@ public void Validate() // Path validation is performed at runtime when writing files // Empty/null path will use the default if (string.IsNullOrWhiteSpace(FolderPath)) + { FolderPath = "./dead-letters"; + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs index 7fc9dd3..25fc738 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs @@ -37,21 +37,27 @@ public class DeliveryPropertySettings public void Validate(string propertyName) { if (string.IsNullOrWhiteSpace(Type)) + { throw new ArgumentException( $"Property '{propertyName}' must have a type.", nameof(Type) ); + } if (!IsStatic && !IsDynamic) + { throw new ArgumentException( $"Property '{propertyName}' type must be 'static' or 'dynamic', got '{Type}'.", nameof(Type) ); + } if (string.IsNullOrWhiteSpace(Value)) + { throw new ArgumentException( $"Property '{propertyName}' must have a value.", nameof(Value) ); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs index b194ad9..c25a9a5 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs @@ -65,26 +65,34 @@ public string? EffectiveConnectionString { // Subscriber-level connection string (direct) if (!string.IsNullOrWhiteSpace(ConnectionString)) + { return ConnectionString; + } // Subscriber-level namespace components if (HasSubscriberNamespaceCredentials()) + { return BuildConnectionString(Namespace!, SharedAccessKeyName!, SharedAccessKey!); + } // Fall back to topic-level connection string if ( ParentTopic != null && !string.IsNullOrWhiteSpace(ParentTopic.EventHubConnectionString) ) + { return ParentTopic.EventHubConnectionString; + } // Fall back to topic-level namespace components if (HasTopicNamespaceCredentials() && ParentTopic != null) + { return BuildConnectionString( ParentTopic.EventHubNamespace!, ParentTopic.EventHubSharedAccessKeyName!, ParentTopic.EventHubSharedAccessKey! ); + } // No connection string available - will fail validation return null; @@ -128,13 +136,17 @@ public string? EffectiveConnectionString public void Validate() { if (string.IsNullOrWhiteSpace(Name)) + { throw new ArgumentException("Subscriber name is required.", nameof(Name)); + } // Validate Event Hub name if (string.IsNullOrWhiteSpace(EventHubName)) + { throw new ArgumentException( $"Event Hub subscriber '{Name}' must specify an eventHubName." ); + } // Validate authentication (considering topic-level defaults) var hasConnectionString = !string.IsNullOrWhiteSpace(ConnectionString); @@ -143,9 +155,11 @@ public void Validate() // Check if subscriber specifies both connection string and any namespace components if (hasConnectionString && HasAnySubscriberNamespaceCredential()) + { throw new ArgumentException( $"Event Hub subscriber '{Name}' should specify either connectionString or namespace credentials, not both." ); + } // Check if at least one authentication method is available (subscriber or topic level) if ( @@ -154,14 +168,20 @@ public void Validate() && !hasTopicConnectionString && !HasTopicNamespaceCredentials() ) + { throw new ArgumentException( $"Event Hub subscriber '{Name}' must have either a connectionString or namespace + sharedAccessKeyName + sharedAccessKey, either at subscriber or topic level." ); + } // Validate properties if (Properties != null) + { foreach (var (propertyName, propertySetting) in Properties) + { propertySetting.Validate(propertyName); + } + } Filter?.Validate(); RetryPolicy?.Validate(); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs index e52d55f..b09ef9c 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs @@ -60,22 +60,28 @@ public class HttpSubscriberSettings : ISubscriberSettings public void Validate() { if (string.IsNullOrWhiteSpace(Name)) + { throw new ArgumentException("Subscriber name is required.", nameof(Name)); + } if (string.IsNullOrWhiteSpace(Endpoint)) + { throw new ArgumentException( "Endpoint is required for HTTP subscribers.", nameof(Endpoint) ); + } if ( !Uri.TryCreate(Endpoint, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) ) + { throw new ArgumentException( "Endpoint must be a valid HTTP or HTTPS URL.", nameof(Endpoint) ); + } Filter?.Validate(); RetryPolicy?.Validate(); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/RetryPolicySettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/RetryPolicySettings.cs index d8912f7..3076e6f 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/RetryPolicySettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/RetryPolicySettings.cs @@ -31,15 +31,19 @@ public class RetryPolicySettings public void Validate() { if (MaxDeliveryAttempts < 1 || MaxDeliveryAttempts > 30) + { throw new ArgumentException( "MaxDeliveryAttempts must be between 1 and 30.", nameof(MaxDeliveryAttempts) ); + } if (EventTimeToLiveInMinutes < 1 || EventTimeToLiveInMinutes > 1440) + { throw new ArgumentException( "EventTimeToLiveInMinutes must be between 1 and 1440.", nameof(EventTimeToLiveInMinutes) ); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs index 776a195..07bf675 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs @@ -86,27 +86,37 @@ public string? EffectiveConnectionString { // Subscriber-level connection string (direct) if (!string.IsNullOrWhiteSpace(ConnectionString)) + { return ConnectionString; + } // Subscriber-level namespace components if (HasSubscriberNamespaceCredentials()) + { return BuildConnectionString(Namespace!, SharedAccessKeyName!, SharedAccessKey!); + } // Fall back to topic-level connection string if ( ParentTopic != null && !string.IsNullOrWhiteSpace(ParentTopic.ServiceBusConnectionString) ) + { return ParentTopic.ServiceBusConnectionString; + } // Fall back to topic-level namespace components if (HasTopicNamespaceCredentials()) + { if (ParentTopic != null) + { return BuildConnectionString( ParentTopic.ServiceBusNamespace!, ParentTopic.ServiceBusSharedAccessKeyName!, ParentTopic.ServiceBusSharedAccessKey! ); + } + } // No connection string available - will fail validation return null; @@ -150,7 +160,9 @@ public string? EffectiveConnectionString public void Validate() { if (string.IsNullOrWhiteSpace(Name)) + { throw new ArgumentException("Subscriber name is required.", nameof(Name)); + } // Validate authentication (considering topic-level defaults) var hasConnectionString = !string.IsNullOrWhiteSpace(ConnectionString); @@ -160,9 +172,11 @@ public void Validate() // Check if subscriber specifies both connection string and any namespace components if (hasConnectionString && HasAnySubscriberNamespaceCredential()) + { throw new ArgumentException( $"Service Bus subscriber '{Name}' should specify either connectionString or namespace credentials, not both." ); + } // Check if at least one authentication method is available (subscriber or topic level) if ( @@ -171,28 +185,38 @@ public void Validate() && !hasTopicConnectionString && !HasTopicNamespaceCredentials() ) + { throw new ArgumentException( $"Service Bus subscriber '{Name}' must have either a connectionString or namespace + sharedAccessKeyName + sharedAccessKey, either at subscriber or topic level." ); + } // Validate destination var hasTopic = !string.IsNullOrWhiteSpace(Topic); var hasQueue = !string.IsNullOrWhiteSpace(Queue); if (!hasTopic && !hasQueue) + { throw new ArgumentException( $"Service Bus subscriber '{Name}' must specify either a topic or queue." ); + } if (hasTopic && hasQueue) + { throw new ArgumentException( $"Service Bus subscriber '{Name}' must specify either a topic or queue, not both." ); + } // Validate properties if (Properties != null) + { foreach (var (propertyName, propertySetting) in Properties) + { propertySetting.Validate(propertyName); + } + } Filter?.Validate(); RetryPolicy?.Validate(); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs index 50f1033..4eec0ca 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs @@ -73,18 +73,24 @@ public class StorageQueueSubscriberSettings : ISubscriberSettings public void Validate() { if (string.IsNullOrWhiteSpace(Name)) + { throw new ArgumentException("Subscriber name is required.", nameof(Name)); + } // Validate connection string (considering topic-level default) if (string.IsNullOrWhiteSpace(EffectiveConnectionString)) + { throw new ArgumentException( $"Storage Queue subscriber '{Name}' must have a connectionString, either at subscriber or topic level." ); + } if (string.IsNullOrWhiteSpace(QueueName)) + { throw new ArgumentException( $"Storage Queue subscriber '{Name}' must have a queueName." ); + } Filter?.Validate(); RetryPolicy?.Validate(); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs index 389243e..e95db5a 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs @@ -82,7 +82,9 @@ public class SubscribersSettings public void Validate() { foreach (var subscriber in All) + { subscriber.Validate(); + } // Check for duplicate names var names = All.Select(s => s.Name).ToList(); @@ -93,8 +95,10 @@ public void Validate() .ToList(); if (duplicates.Count != 0) + { throw new ArgumentException( $"Duplicate subscriber names found: {string.Join(", ", duplicates)}" ); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettingsConverter.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettingsConverter.cs index 17a7124..a7dd58d 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettingsConverter.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettingsConverter.cs @@ -16,7 +16,9 @@ JsonSerializerOptions options ) { if (reader.TokenType == JsonTokenType.Null) + { return new SubscribersSettings(); + } // Legacy format: array of HTTP subscribers if (reader.TokenType == JsonTokenType.StartArray) @@ -42,32 +44,40 @@ JsonSerializerOptions options var root = document.RootElement; if (root.TryGetProperty("http", out var httpElement)) + { result.Http = JsonSerializer.Deserialize( httpElement.GetRawText(), options ) ?? []; + } if (root.TryGetProperty("serviceBus", out var serviceBusElement)) + { result.ServiceBus = JsonSerializer.Deserialize( serviceBusElement.GetRawText(), options ) ?? []; + } if (root.TryGetProperty("storageQueue", out var storageQueueElement)) + { result.StorageQueue = JsonSerializer.Deserialize( storageQueueElement.GetRawText(), options ) ?? []; + } if (root.TryGetProperty("eventHub", out var eventHubElement)) + { result.EventHub = JsonSerializer.Deserialize( eventHubElement.GetRawText(), options ) ?? []; + } return result; } diff --git a/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs b/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs index 46f470f..0c9fa14 100644 --- a/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs +++ b/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs @@ -31,7 +31,9 @@ public override string ToString() lock (_lock) { if (string.IsNullOrWhiteSpace(_ipAddress)) + { _ipAddress = Create(); + } } return _ipAddress; diff --git a/src/AzureEventGridSimulator/Program.cs b/src/AzureEventGridSimulator/Program.cs index ac9ef0b..5331bed 100644 --- a/src/AzureEventGridSimulator/Program.cs +++ b/src/AzureEventGridSimulator/Program.cs @@ -41,7 +41,9 @@ public static async Task Main(string[] args) // Conditionally enable dashboard based on settings var simulatorSettings = app.Services.GetRequiredService(); if (simulatorSettings.DashboardEnabled) + { app.UseDashboard(); + } app.UseRouting(); app.MapControllers(); @@ -52,7 +54,9 @@ public static async Task Main(string[] args) #endif if (simulatorSettings.DashboardEnabled) + { app.MapDashboardEndpoints(); + } await StartSimulator(app); } @@ -125,12 +129,14 @@ IHostApplicationLifetime lifetime ); foreach (var sub in allSubscribers) + { Log.Information( " - {SubscriberName} ({SubscriberType}){Disabled}", sub.Name, sub.SubscriberType, sub.Disabled ? " [DISABLED]" : "" ); + } } // Log dashboard availability @@ -381,11 +387,13 @@ IConfiguration configuration options.ConfigureSimulatorCertificate(); foreach (var topics in options.ApplicationServices.EnabledTopics()) + { options.Listen( IPAddress.Any, topics.Port, listenOptions => listenOptions.UseHttps() ); + } }); return builder;