diff --git a/src/Arcus.EventGrid.Proxy.Api/Arcus.EventGrid.Proxy.Api.csproj b/src/Arcus.EventGrid.Proxy.Api/Arcus.EventGrid.Proxy.Api.csproj index ab3b9b7..3150bee 100644 --- a/src/Arcus.EventGrid.Proxy.Api/Arcus.EventGrid.Proxy.Api.csproj +++ b/src/Arcus.EventGrid.Proxy.Api/Arcus.EventGrid.Proxy.Api.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Arcus.EventGrid.Proxy.Api/Controllers/v1/EventsController.cs b/src/Arcus.EventGrid.Proxy.Api/Controllers/v1/EventsController.cs index 037de21..be9dda7 100644 --- a/src/Arcus.EventGrid.Proxy.Api/Controllers/v1/EventsController.cs +++ b/src/Arcus.EventGrid.Proxy.Api/Controllers/v1/EventsController.cs @@ -1,6 +1,11 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.Net; +using System.Threading.Tasks; +using Arcus.EventGrid.Publishing.Interfaces; +using GuardNet; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; using Swashbuckle.AspNetCore.Annotations; namespace Arcus.EventGrid.Proxy.Api.Controllers.v1 @@ -9,24 +14,50 @@ namespace Arcus.EventGrid.Proxy.Api.Controllers.v1 [ApiController] public class EventsController : ControllerBase { + private readonly IEventGridPublisher _eventGridPublisher; + + public EventsController(IEventGridPublisher eventGridPublisher) + { + Guard.NotNull(eventGridPublisher, nameof(eventGridPublisher)); + _eventGridPublisher = eventGridPublisher; + } + /// /// Emit Event /// - /// Sends an event to Azure Event Grid Topic + /// Sends an event to an Azure Event Grid Topic /// Type of the event /// Event payload /// Id of the event /// Subject of the event - /// Timestamp of the event - /// Data version + /// Timestamp of the event. Example of preferred format is '2019-01-21T10:25:09.2292418+01:00' + /// Version of the data payload schema [Route("{eventType}")] [HttpPost] [SwaggerOperation(OperationId = "Events_Emit")] - [SwaggerResponse((int)HttpStatusCode.NoContent, Description = "Event was successfully emitted")] - [SwaggerResponse((int)HttpStatusCode.InternalServerError, Description = "Unable to emit event due to internal error")] - public IActionResult Emit(string eventType, [FromBody, Required] object eventPayload, string eventId, string eventSubject, string eventTimestamp, string dataVersion) + [SwaggerResponse((int) HttpStatusCode.NoContent, Description = "Event was successfully emitted")] + [SwaggerResponse((int) HttpStatusCode.BadRequest, Description = "The request to emit an event was not valid")] + [SwaggerResponse((int) HttpStatusCode.InternalServerError, Description = "Unable to emit event due to internal error")] + public async Task Emit(string eventType, [FromBody, Required] object eventPayload, string eventId, string eventTimestamp, string eventSubject = "/", string dataVersion = "1.0") { - return StatusCode((int)HttpStatusCode.NotImplemented); + eventId = string.IsNullOrWhiteSpace(eventId) ? Guid.NewGuid().ToString() : eventId; + eventTimestamp = string.IsNullOrWhiteSpace(eventTimestamp) ? DateTimeOffset.UtcNow.ToString(format: "O") : eventTimestamp; + + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + + if (DateTimeOffset.TryParse(eventTimestamp, out var parsedEventTimeStamp) == false) + { + return BadRequest($"Unable to parse specified event timestamp '{eventTimestamp}'"); + } + + await _eventGridPublisher.PublishRaw(eventId, eventType, rawEventPayload, eventSubject, dataVersion, parsedEventTimeStamp); + + Response.Headers.Add(Headers.Response.Events.Id, eventId); + Response.Headers.Add(Headers.Response.Events.Subject, eventSubject); + Response.Headers.Add(Headers.Response.Events.Timestamp, eventTimestamp); + Response.Headers.Add(Headers.Response.Events.DataVersion, dataVersion); + + return NoContent(); } } -} +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/EnvironmentVariables.cs b/src/Arcus.EventGrid.Proxy.Api/EnvironmentVariables.cs index 1316f7b..d816203 100644 --- a/src/Arcus.EventGrid.Proxy.Api/EnvironmentVariables.cs +++ b/src/Arcus.EventGrid.Proxy.Api/EnvironmentVariables.cs @@ -8,6 +8,12 @@ public class Ports { public const string Http = "ARCUS_HTTP_PORT"; } + + public class EventGrid + { + public const string TopicEndpoint = "ARCUS_EVENTGRID_TOPICENDPOINT"; + public const string AuthKey = "ARCUS_EVENTGRID_AUTHKEY"; + } } } } \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Headers.cs b/src/Arcus.EventGrid.Proxy.Api/Headers.cs new file mode 100644 index 0000000..5e82611 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Api/Headers.cs @@ -0,0 +1,16 @@ +namespace Arcus.EventGrid.Proxy.Api +{ + public class Headers + { + public class Response + { + public class Events + { + public const string Id = "X-Event-Id"; + public const string Subject = "X-Event-Subject"; + public const string Timestamp = "X-Event-Timestamp"; + public const string DataVersion = "X-Event-Data-Version"; + } + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Open-Api-Docs.xml b/src/Arcus.EventGrid.Proxy.Api/Open-Api-Docs.xml index ebd7bcb..11ff937 100644 --- a/src/Arcus.EventGrid.Proxy.Api/Open-Api-Docs.xml +++ b/src/Arcus.EventGrid.Proxy.Api/Open-Api-Docs.xml @@ -8,13 +8,13 @@ Emit Event - Sends an event to Azure Event Grid Topic + Sends an event to an Azure Event Grid Topic Type of the event Event payload Id of the event Subject of the event - Timestamp of the event - Data version + Timestamp of the event. Example of preferred format is '2019-01-21T10:25:09.2292418+01:00' + Version of the data payload schema @@ -34,5 +34,42 @@ Collections of services in application + + + Name of the validation step + + + + + Executes the validation step + + + + + Name of the step + + + + + Indication whether or not validation was successful + + + + + Error message describing why validation failed + + + + + Validation was successful + + Name of the step that was validated + + + + Validation failed + + Name of the step that was validated + diff --git a/src/Arcus.EventGrid.Proxy.Api/Program.cs b/src/Arcus.EventGrid.Proxy.Api/Program.cs index 7db92b3..69d316a 100644 --- a/src/Arcus.EventGrid.Proxy.Api/Program.cs +++ b/src/Arcus.EventGrid.Proxy.Api/Program.cs @@ -33,10 +33,7 @@ public static IWebHost BuildWebHost(string[] args) var httpEndpointUrl = $"http://+:{httpPort}"; return WebHost.CreateDefaultBuilder(args) - .UseKestrel(kestrelServerOptions => - { - kestrelServerOptions.AddServerHeader = false; - }) + .UseKestrel(kestrelServerOptions => { kestrelServerOptions.AddServerHeader = false; }) .UseUrls(httpEndpointUrl) .UseStartup() .Build(); @@ -53,4 +50,4 @@ private static int DetermineHttpPort() return 80; } } -} +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Startup.cs b/src/Arcus.EventGrid.Proxy.Api/Startup.cs index f1a264d..d795595 100644 --- a/src/Arcus.EventGrid.Proxy.Api/Startup.cs +++ b/src/Arcus.EventGrid.Proxy.Api/Startup.cs @@ -1,9 +1,16 @@ using Arcus.EventGrid.Proxy.Api.Extensions; +using System; +using System.Linq; +using Arcus.EventGrid.Proxy.Api.Validation; +using Arcus.EventGrid.Publishing; +using Arcus.EventGrid.Publishing.Interfaces; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace Arcus.EventGrid.Proxy.Api { @@ -20,14 +27,27 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { services.AddMvc() - .AddJsonOptions(jsonOptions => - { - jsonOptions.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter()); - jsonOptions.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore; - }) - .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + .AddJsonOptions(jsonOptions => + { + jsonOptions.SerializerSettings.Converters.Add(new StringEnumConverter()); + jsonOptions.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + }) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); services.UseOpenApiSpecifications(); + services.AddSingleton(BuildEventGridPublisher); + + ValidateConfiguration(); + } + + private void ValidateConfiguration() + { + var validationOutcomes = RuntimeValidator.Run(Configuration); + + if (validationOutcomes.Any(validationOutcome => validationOutcome.Successful == false)) + { + throw new Exception("Unable to start up due to invalid configuration"); + } } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -41,5 +61,15 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) app.UseMvc(); app.UseOpenApiDocsWithExplorer(); } + + private IEventGridPublisher BuildEventGridPublisher(IServiceProvider serviceProvider) + { + var rawTopicEndpoint = Configuration[EnvironmentVariables.Runtime.EventGrid.TopicEndpoint]; + var authenticationKey = Configuration[EnvironmentVariables.Runtime.EventGrid.AuthKey]; + var topicUri = new Uri(rawTopicEndpoint); + return EventGridPublisherBuilder.ForTopic(topicUri) + .UsingAuthenticationKey(authenticationKey) + .Build(); + } } -} +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Validation/RuntimeValidator.cs b/src/Arcus.EventGrid.Proxy.Api/Validation/RuntimeValidator.cs new file mode 100644 index 0000000..2841762 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Api/Validation/RuntimeValidator.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Arcus.EventGrid.Proxy.Api.Validation.Steps; +using Arcus.EventGrid.Proxy.Api.Validation.Steps.Interfaces; +using Microsoft.Extensions.Configuration; + +namespace Arcus.EventGrid.Proxy.Api.Validation +{ + public class RuntimeValidator + { + public static List Run(IConfiguration configuration) + { + var validationSteps = new List + { + new EventGridAuthKeyValidationStep(configuration), + new EventGridTopicEndpointValidationStep(configuration) + }; + + Console.WriteLine("Starting validation of runtime configuration..."); + List validationOutcomes = new List(); + foreach (var validationStep in validationSteps) + { + var validationOutcome = validationStep.Execute(); + validationOutcomes.Add(validationOutcome); + } + + validationOutcomes.ForEach(LogValidationResults); + Console.WriteLine("Validation of runtime configuration completed."); + + return validationOutcomes; + } + + private static void LogValidationResults(ValidationResult validationResult) + { + var validationOutcomeMessage = validationResult.Successful ? "successful" : "failed"; + var validationInfoMessage = $"Validation step '{validationResult.StepName}' {validationOutcomeMessage}."; + if (validationResult.Successful == false) + { + validationInfoMessage += $" Error: {validationResult.ErrorMessage}"; + } + + Console.WriteLine(validationInfoMessage); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/EventGridAuthKeyValidationStep.cs b/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/EventGridAuthKeyValidationStep.cs new file mode 100644 index 0000000..664a931 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/EventGridAuthKeyValidationStep.cs @@ -0,0 +1,28 @@ +using Arcus.EventGrid.Proxy.Api.Validation.Steps.Interfaces; +using Microsoft.Extensions.Configuration; + +namespace Arcus.EventGrid.Proxy.Api.Validation.Steps +{ + public class EventGridAuthKeyValidationStep : IValidationStep + { + private readonly IConfiguration _configuration; + public string StepName { get; } = "Event Grid Authentication Key"; + + public EventGridAuthKeyValidationStep(IConfiguration configuration) + { + _configuration = configuration; + } + + public ValidationResult Execute() + { + var authKey = _configuration[EnvironmentVariables.Runtime.EventGrid.AuthKey]; + + if (string.IsNullOrWhiteSpace(authKey)) + { + return ValidationResult.Failure(StepName, "No authentication key was specified"); + } + + return ValidationResult.Success(StepName); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/EventGridTopicEndpointValidationStep.cs b/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/EventGridTopicEndpointValidationStep.cs new file mode 100644 index 0000000..a99260f --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/EventGridTopicEndpointValidationStep.cs @@ -0,0 +1,39 @@ +using System; +using Arcus.EventGrid.Proxy.Api.Validation.Steps.Interfaces; +using Microsoft.Extensions.Configuration; + +namespace Arcus.EventGrid.Proxy.Api.Validation.Steps +{ + public class EventGridTopicEndpointValidationStep : IValidationStep + { + private readonly IConfiguration _configuration; + + public EventGridTopicEndpointValidationStep(IConfiguration configuration) + { + _configuration = configuration; + } + + public string StepName { get; } = "Event Grid Topic Endpoint"; + + public ValidationResult Execute() + { + var rawTopicEndpoint = _configuration[EnvironmentVariables.Runtime.EventGrid.TopicEndpoint]; + + if (string.IsNullOrWhiteSpace(rawTopicEndpoint)) + { + return ValidationResult.Failure(StepName, "No topic endpoint was specified"); + } + + try + { + var uri = new Uri(rawTopicEndpoint); + + return ValidationResult.Success(StepName); + } + catch + { + return ValidationResult.Failure(StepName, "No valid topic endpoint was specified"); + } + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/Interfaces/IValidationStep.cs b/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/Interfaces/IValidationStep.cs new file mode 100644 index 0000000..dfb6af1 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Api/Validation/Steps/Interfaces/IValidationStep.cs @@ -0,0 +1,15 @@ +namespace Arcus.EventGrid.Proxy.Api.Validation.Steps.Interfaces +{ + public interface IValidationStep + { + /// + /// Name of the validation step + /// + string StepName { get; } + + /// + /// Executes the validation step + /// + ValidationResult Execute(); + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Api/Validation/ValidationResult.cs b/src/Arcus.EventGrid.Proxy.Api/Validation/ValidationResult.cs new file mode 100644 index 0000000..6a97379 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Api/Validation/ValidationResult.cs @@ -0,0 +1,54 @@ +using GuardNet; + +namespace Arcus.EventGrid.Proxy.Api.Validation +{ + public class ValidationResult + { + private ValidationResult(string stepName, bool isSuccessful) : this(stepName, isSuccessful, errorMessage: string.Empty) + { + } + + private ValidationResult(string stepName, bool isSuccessful, string errorMessage) + { + Guard.NotNullOrEmpty(stepName, nameof(stepName)); + + StepName = stepName; + Successful = isSuccessful; + ErrorMessage = errorMessage; + } + + /// + /// Name of the step + /// + public string StepName { get; } + + /// + /// Indication whether or not validation was successful + /// + public bool Successful { get; } + + /// + /// Error message describing why validation failed + /// + public string ErrorMessage { get; } + + /// + /// Validation was successful + /// + /// Name of the step that was validated + public static ValidationResult Success(string stepName) + { + return new ValidationResult(stepName, isSuccessful: true); + } + + + /// + /// Validation failed + /// + /// Name of the step that was validated + public static ValidationResult Failure(string stepName, string errorMessage) + { + return new ValidationResult(stepName, isSuccessful: false, errorMessage: errorMessage); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Arcus.EventGrid.Proxy.Tests.Integration.csproj b/src/Arcus.EventGrid.Proxy.Tests.Integration/Arcus.EventGrid.Proxy.Tests.Integration.csproj new file mode 100644 index 0000000..633eeda --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Arcus.EventGrid.Proxy.Tests.Integration.csproj @@ -0,0 +1,36 @@ + + + + netcoreapp2.2 + + false + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/EndpointTest.cs b/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/EndpointTest.cs new file mode 100644 index 0000000..2ccf944 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/EndpointTest.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; + +namespace Arcus.EventGrid.Proxy.Tests.Integration.Endpoints +{ + public class EndpointTest + { + protected IConfiguration Configuration { get; } + + public EndpointTest() + { + Configuration = new ConfigurationBuilder() + .AddJsonFile(path: "appsettings.json", optional: true) + .AddJsonFile(path: "appsettings.local.json", optional: true) + .AddEnvironmentVariables() + .Build(); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/v1/EventEndpointTests.cs b/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/v1/EventEndpointTests.cs new file mode 100644 index 0000000..d4f8007 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/v1/EventEndpointTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Arcus.EventGrid.Contracts; +using Arcus.EventGrid.Parsers; +using Arcus.EventGrid.Proxy.Api; +using Arcus.EventGrid.Proxy.Tests.Integration.Services; +using Arcus.EventGrid.Testing.Infrastructure.Hosts.ServiceBus; +using Arcus.EventGrid.Testing.Logging; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Arcus.EventGrid.Proxy.Tests.Integration.Endpoints.v1 +{ + [Collection("Integration")] + public class EventEndpointTests : EndpointTest, IAsyncLifetime + { + private ServiceBusEventConsumerHost _serviceBusEventConsumerHost; + private readonly EventService _eventService; + + public EventEndpointTests() + { + _eventService = new EventService(Configuration); + } + + public async Task InitializeAsync() + { + var connectionString = Configuration.GetValue("Arcus:ServiceBus:ConnectionString"); + var topicName = Configuration.GetValue("Arcus:ServiceBus:TopicName"); + + var serviceBusEventConsumerHostOptions = new ServiceBusEventConsumerHostOptions(topicName, connectionString); + _serviceBusEventConsumerHost = await ServiceBusEventConsumerHost.Start(serviceBusEventConsumerHostOptions, new NoOpLogger()); + } + + public async Task DisposeAsync() + { + await _serviceBusEventConsumerHost.Stop(); + } + + [Fact] + public async Task Emit_BasicCallWithDefaults_Succeeds() + { + // Arrange + const string eventType = "Codito.NewCarRegistered"; + var eventPayload = new { LicensePlate = Guid.NewGuid().ToString() }; + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + var expectedEventPayload = JToken.Parse(rawEventPayload); + + // Act + var response = await _eventService.EmitAsync(rawEventPayload, eventType); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var eventId = AssertHttpHeader(Headers.Response.Events.Id, response); + var eventSubject = AssertHttpHeader(Headers.Response.Events.Subject, response); + var eventTimestamp = AssertHttpHeader(Headers.Response.Events.Timestamp, response); + var eventDataVersion = AssertHttpHeader(Headers.Response.Events.DataVersion, response); + AssertReceivedEvent(eventId, eventType, eventTimestamp, eventSubject, eventDataVersion, expectedEventPayload); + } + + [Fact] + public async Task Emit_BasicCallWithEventId_Succeeds() + { + // Arrange + const string eventType = "Codito.NewCarRegistered"; + var expectedEventId = Guid.NewGuid().ToString(); + var eventPayload = new { LicensePlate = Guid.NewGuid().ToString() }; + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + + // Act + var response = await _eventService.EmitAsync(rawEventPayload, eventType, eventId: expectedEventId); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var eventId = AssertHttpHeader(Headers.Response.Events.Id, response); + var receivedEvent = GetReceivedEvent(eventId); + Assert.NotNull(receivedEvent); + Assert.Equal(expectedEventId, receivedEvent.Id); + } + + [Fact] + public async Task Emit_BasicCallWithEventSubject_Succeeds() + { + // Arrange + const string eventType = "Codito.NewCarRegistered"; + var licensePlate = Guid.NewGuid().ToString(); + var expectedEventSubject = $"/cars/{licensePlate}"; + var eventPayload = new { LicensePlate = licensePlate }; + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + + // Act + var response = await _eventService.EmitAsync(rawEventPayload, eventType, eventSubject: expectedEventSubject); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var eventId = AssertHttpHeader(Headers.Response.Events.Id, response); + var receivedEvent = GetReceivedEvent(eventId); + Assert.NotNull(receivedEvent); + Assert.Equal(expectedEventSubject, receivedEvent.Subject); + } + + [Fact] + public async Task Emit_BasicCallWithEventTime_Succeeds() + { + // Arrange + const string eventType = "Codito.NewCarRegistered"; + var expectedTimestamp = DateTimeOffset.UtcNow; + var eventPayload = new { LicensePlate = Guid.NewGuid().ToString() }; + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + + // Act + var response = await _eventService.EmitAsync(rawEventPayload, eventType, eventTimestamp: expectedTimestamp.ToString("O")); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var eventId = AssertHttpHeader(Headers.Response.Events.Id, response); + var receivedEvent = GetReceivedEvent(eventId); + Assert.NotNull(receivedEvent); + Assert.Equal(expectedTimestamp, receivedEvent.EventTime); + } + + [Fact] + public async Task Emit_BasicCallWithDataVersion_Succeeds() + { + // Arrange + const string eventType = "Codito.NewCarRegistered"; + var licensePlate = Guid.NewGuid().ToString(); + var expectedDataVersion = "1337"; + var eventPayload = new { LicensePlate = licensePlate }; + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + + // Act + var response = await _eventService.EmitAsync(rawEventPayload, eventType, dataVersion: expectedDataVersion); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var eventId = AssertHttpHeader(Headers.Response.Events.Id, response); + var receivedEvent = GetReceivedEvent(eventId); + Assert.NotNull(receivedEvent); + Assert.Equal(expectedDataVersion, receivedEvent.DataVersion); + } + + private void AssertReceivedEvent(string eventId, string eventType, string eventTimestamp, string eventSubject, string eventDataVersion, JToken expectedEventPayload) + { + var receivedEvent = GetReceivedEvent(eventId); + var parsedTime = DateTimeOffset.Parse(eventTimestamp); + Assert.Equal(parsedTime.UtcDateTime, receivedEvent.EventTime.UtcDateTime); + Assert.Equal(eventType, receivedEvent.EventType); + Assert.Equal(eventId, receivedEvent.Id); + Assert.Equal(eventSubject, receivedEvent.Subject); + Assert.Equal(expectedEventPayload, receivedEvent.Data); + Assert.Equal(eventDataVersion, receivedEvent.DataVersion); + } + + private RawEvent GetReceivedEvent(string eventId) + { + var receivedEvent = _serviceBusEventConsumerHost.GetReceivedEvent(eventId); + var rawEvents = EventGridParser.Parse(receivedEvent); + Assert.NotNull(rawEvents); + Assert.NotNull(rawEvents.Events); + Assert.Single(rawEvents.Events); + var firstRawEvent = rawEvents.Events.FirstOrDefault(); + Assert.NotNull(firstRawEvent); + + return firstRawEvent; + } + + private string AssertHttpHeader(string headerName, HttpResponseMessage response) + { + var isHeaderFound = response.Headers.TryGetValues(headerName, out var headerValues); + Assert.True(isHeaderFound); + var headerValue = headerValues.Single(); + Assert.NotEmpty(headerValue); + + return headerValue; + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/v1/HealthEndpointTests.cs b/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/v1/HealthEndpointTests.cs new file mode 100644 index 0000000..27afd2f --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Endpoints/v1/HealthEndpointTests.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Threading.Tasks; +using Arcus.EventGrid.Proxy.Tests.Integration.Services; +using Xunit; + +namespace Arcus.EventGrid.Proxy.Tests.Integration.Endpoints.v1 +{ + [Collection("Integration")] + public class HealthEndpointTests : EndpointTest + { + private readonly HealthService _healthService; + + public HealthEndpointTests() + { + _healthService = new HealthService(Configuration); + } + + [Fact] + public async Task Health_Get_Succeeds() + { + // Act + var response = await _healthService.GetAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/EventService.cs b/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/EventService.cs new file mode 100644 index 0000000..5f5100e --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/EventService.cs @@ -0,0 +1,50 @@ +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Flurl; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Arcus.EventGrid.Proxy.Tests.Integration.Services +{ + public class EventService : Service + { + public EventService(IConfiguration configuration) : base(configuration) + { + } + + public async Task EmitAsync(object eventPayload, string eventType, string eventId = "", string eventTimestamp = "", string eventSubject = "", string dataVersion = "") + { + var rawEventPayload = JsonConvert.SerializeObject(eventPayload); + return await EmitAsync(rawEventPayload, eventType, eventId); + } + + public async Task EmitAsync(string eventPayload, string eventType, string eventId = "", string eventTimestamp = "", string eventSubject = "", string dataVersion = "") + { + var url = BaseUrl.AppendPathSegments("events", eventType); + + if (string.IsNullOrWhiteSpace(eventId) == false) + { + url = url.SetQueryParam("eventId", eventId); + } + + if (string.IsNullOrWhiteSpace(eventTimestamp) == false) + { + url = url.SetQueryParam("eventTimestamp", eventTimestamp); + } + + if (string.IsNullOrWhiteSpace(eventSubject) == false) + { + url = url.SetQueryParam("eventSubject", eventSubject); + } + + if (string.IsNullOrWhiteSpace(dataVersion) == false) + { + url = url.SetQueryParam("dataVersion", dataVersion); + } + + var response = await HttpClient.PostAsync(url, new StringContent(eventPayload, Encoding.UTF8, "application/json")); + return response; + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/HealthService.cs b/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/HealthService.cs new file mode 100644 index 0000000..9183770 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/HealthService.cs @@ -0,0 +1,22 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Flurl; +using Microsoft.Extensions.Configuration; + +namespace Arcus.EventGrid.Proxy.Tests.Integration.Services +{ + public class HealthService : Service + { + public HealthService(IConfiguration configuration) : base(configuration) + { + } + + public async Task GetAsync() + { + var url = BaseUrl.AppendPathSegments("health"); + + var response = await HttpClient.GetAsync(url); + return response; + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/Service.cs b/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/Service.cs new file mode 100644 index 0000000..ca20a9e --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/Services/Service.cs @@ -0,0 +1,23 @@ +using System.Net.Http; +using GuardNet; +using Microsoft.Extensions.Configuration; + +namespace Arcus.EventGrid.Proxy.Tests.Integration.Services +{ + public class Service + { + private readonly IConfiguration _configuration; + + protected Service(IConfiguration configuration) + { + Guard.NotNull(configuration, nameof(configuration)); + + _configuration = configuration; + + BaseUrl = _configuration.GetValue("Arcus:Api:BaseUrl"); + } + + protected HttpClient HttpClient { get; } = new HttpClient(); + protected string BaseUrl { get; } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/appsettings.json b/src/Arcus.EventGrid.Proxy.Tests.Integration/appsettings.json new file mode 100644 index 0000000..4671f47 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/appsettings.json @@ -0,0 +1,11 @@ +{ + "Arcus": { + "Api": { + "BaseUrl": "#{Arcus_Api_BaseUrl}#" + }, + "ServiceBus": { + "TopicName": "#{Arcus_ServiceBus_TopicName}#", + "ConnectionString": "#{Arcus_ServiceBus_ConnectionString}#" + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Integration/appsettings.local.json b/src/Arcus.EventGrid.Proxy.Tests.Integration/appsettings.local.json new file mode 100644 index 0000000..037e360 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Integration/appsettings.local.json @@ -0,0 +1,7 @@ +{ + "Arcus": { + "Api": { + "BaseUrl": "http://localhost:888/api/v1" + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Unit/Arcus.EventGrid.Proxy.Tests.Unit.csproj b/src/Arcus.EventGrid.Proxy.Tests.Unit/Arcus.EventGrid.Proxy.Tests.Unit.csproj new file mode 100644 index 0000000..cd47c1c --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Unit/Arcus.EventGrid.Proxy.Tests.Unit.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.2 + + false + + + + + + + + + + + + + + + diff --git a/src/Arcus.EventGrid.Proxy.Tests.Unit/Stubs/ConfigurationStub.cs b/src/Arcus.EventGrid.Proxy.Tests.Unit/Stubs/ConfigurationStub.cs new file mode 100644 index 0000000..f836e54 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Unit/Stubs/ConfigurationStub.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Arcus.EventGrid.Proxy.Tests.Unit.Stubs +{ + public class ConfigurationStub : IConfiguration + { + private readonly string _key; + private readonly string _valueToReturn; + + public ConfigurationStub(string key, string valueToReturn) + { + _key = key; + _valueToReturn = valueToReturn; + } + + public ConfigurationStub() + { + } + + public IConfigurationSection GetSection(string key) + { + return null; + } + + public IEnumerable GetChildren() + { + yield break; + } + + public IChangeToken GetReloadToken() + { + return null; + } + + public string this[string key] + { + get => _key == key ? _valueToReturn : null; + set { } + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Unit/Validation/Steps/EventGridAuthKeyValidationStepTests.cs b/src/Arcus.EventGrid.Proxy.Tests.Unit/Validation/Steps/EventGridAuthKeyValidationStepTests.cs new file mode 100644 index 0000000..d8e2c84 --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Unit/Validation/Steps/EventGridAuthKeyValidationStepTests.cs @@ -0,0 +1,40 @@ +using Arcus.EventGrid.Proxy.Api; +using Arcus.EventGrid.Proxy.Api.Validation.Steps; +using Arcus.EventGrid.Proxy.Tests.Unit.Stubs; +using Xunit; + +namespace Arcus.EventGrid.Proxy.Tests.Unit.Validation.Steps +{ + [Collection("Unit")] + public class EventGridAuthKeyValidationStepTests + { + [Fact] + public void Execute_HasValidAuthKey_Succeeds() + { + // Arrange + const string configuredAuthKey = "ABC"; + var configurationStub = new ConfigurationStub(EnvironmentVariables.Runtime.EventGrid.AuthKey, configuredAuthKey); + var validationStep = new EventGridAuthKeyValidationStep(configurationStub); + + // Act + var validationResult = validationStep.Execute(); + + // Assert + Assert.True(validationResult.Successful); + } + + [Fact] + public void Execute_NoValidAuthKey_ValidationFails() + { + // Arrange + var configurationStub = new ConfigurationStub(); + var validationStep = new EventGridAuthKeyValidationStep(configurationStub); + + // Act + var validationResult = validationStep.Execute(); + + // Assert + Assert.False(validationResult.Successful); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.Tests.Unit/Validation/Steps/EventGridTopicEndpointValidationStepTests.cs b/src/Arcus.EventGrid.Proxy.Tests.Unit/Validation/Steps/EventGridTopicEndpointValidationStepTests.cs new file mode 100644 index 0000000..e01972c --- /dev/null +++ b/src/Arcus.EventGrid.Proxy.Tests.Unit/Validation/Steps/EventGridTopicEndpointValidationStepTests.cs @@ -0,0 +1,55 @@ +using Arcus.EventGrid.Proxy.Api; +using Arcus.EventGrid.Proxy.Api.Validation.Steps; +using Arcus.EventGrid.Proxy.Tests.Unit.Stubs; +using Xunit; + +namespace Arcus.EventGrid.Proxy.Tests.Unit.Validation.Steps +{ + [Collection("Unit")] + public class EventGridTopicEndpointValidationStepTests + { + [Fact] + public void Execute_HasValidTopicEndpoint_Succeeds() + { + // Arrange + const string configuredTopicEndpoint = "https://arcus.com"; + var configurationStub = new ConfigurationStub(EnvironmentVariables.Runtime.EventGrid.TopicEndpoint, configuredTopicEndpoint); + var validationStep = new EventGridTopicEndpointValidationStep(configurationStub); + + // Act + var validationResult = validationStep.Execute(); + + // Assert + Assert.True(validationResult.Successful); + } + + [Fact] + public void Execute_NoValidTopicEndpoint_ValidationFails() + { + // Arrange + var configurationStub = new ConfigurationStub(); + var validationStep = new EventGridTopicEndpointValidationStep(configurationStub); + + // Act + var validationResult = validationStep.Execute(); + + // Assert + Assert.False(validationResult.Successful); + } + + [Fact] + public void Execute_InvalidTopicEndpoint_ValidationFails() + { + // Arrange + const string configuredTopicEndpoint = "arcus"; + var configurationStub = new ConfigurationStub(EnvironmentVariables.Runtime.EventGrid.TopicEndpoint, configuredTopicEndpoint); + var validationStep = new EventGridTopicEndpointValidationStep(configurationStub); + + // Act + var validationResult = validationStep.Execute(); + + // Assert + Assert.False(validationResult.Successful); + } + } +} \ No newline at end of file diff --git a/src/Arcus.EventGrid.Proxy.sln b/src/Arcus.EventGrid.Proxy.sln index 54718d1..5c179a2 100644 --- a/src/Arcus.EventGrid.Proxy.sln +++ b/src/Arcus.EventGrid.Proxy.sln @@ -7,6 +7,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Arcus.EventGrid.Proxy.Api", EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "Arcus.EventGrid.Proxy.Orchestrator", "Arcus.EventGrid.Proxy.Orchestrator.dcproj", "{49D85A35-F341-47A3-887F-68DEC06CEBCD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0276AB5E-56E3-4CF3-950E-10D0848F62C1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Arcus.EventGrid.Proxy.Tests.Integration", "Arcus.EventGrid.Proxy.Tests.Integration\Arcus.EventGrid.Proxy.Tests.Integration.csproj", "{4C81FC25-0394-4A8D-80EB-3BD1E7FB5FA2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Arcus.EventGrid.Proxy.Tests.Unit", "Arcus.EventGrid.Proxy.Tests.Unit\Arcus.EventGrid.Proxy.Tests.Unit.csproj", "{A19FD310-6ADF-43EB-B684-D5F843A231F8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,10 +27,22 @@ Global {49D85A35-F341-47A3-887F-68DEC06CEBCD}.Debug|Any CPU.Build.0 = Debug|Any CPU {49D85A35-F341-47A3-887F-68DEC06CEBCD}.Release|Any CPU.ActiveCfg = Release|Any CPU {49D85A35-F341-47A3-887F-68DEC06CEBCD}.Release|Any CPU.Build.0 = Release|Any CPU + {4C81FC25-0394-4A8D-80EB-3BD1E7FB5FA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C81FC25-0394-4A8D-80EB-3BD1E7FB5FA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C81FC25-0394-4A8D-80EB-3BD1E7FB5FA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C81FC25-0394-4A8D-80EB-3BD1E7FB5FA2}.Release|Any CPU.Build.0 = Release|Any CPU + {A19FD310-6ADF-43EB-B684-D5F843A231F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A19FD310-6ADF-43EB-B684-D5F843A231F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A19FD310-6ADF-43EB-B684-D5F843A231F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A19FD310-6ADF-43EB-B684-D5F843A231F8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4C81FC25-0394-4A8D-80EB-3BD1E7FB5FA2} = {0276AB5E-56E3-4CF3-950E-10D0848F62C1} + {A19FD310-6ADF-43EB-B684-D5F843A231F8} = {0276AB5E-56E3-4CF3-950E-10D0848F62C1} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2EA9E7EB-A72E-4555-8BC9-15344F321341} EndGlobalSection diff --git a/src/docker-compose.override.yml b/src/docker-compose.override.yml index 7db5c8e..67923d5 100644 --- a/src/docker-compose.override.yml +++ b/src/docker-compose.override.yml @@ -5,5 +5,7 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ARCUS_HTTP_PORT=88 + - ARCUS_EVENTGRID_TOPICENDPOINT= + - ARCUS_EVENTGRID_AUTHKEY= ports: - "42063:88" \ No newline at end of file