From 26d7a9bafa60fa35a173db1e43c9b93f49e14e3f Mon Sep 17 00:00:00 2001 From: Erwin Date: Fri, 3 Apr 2026 14:59:28 +0200 Subject: [PATCH] Modernize ASP.NET Core samples Signed-off-by: Erwin --- .vscode/launch.json | 18 +++-- .../CloudEventBinding.cs | 56 ++++++++++++++++ .../CloudEventJsonInputFormatter.cs | 63 ----------------- .../CloudEventOperations.cs | 60 +++++++++++++++++ ...Native.CloudEvents.AspNetCoreSample.csproj | 2 +- ...udNative.CloudEvents.AspNetCoreSample.http | 44 +++++++++++- .../Controllers/CloudEventController.cs | 66 ------------------ .../Program.cs | 11 +-- samples/HttpSend/HttpSend.csproj | 2 +- samples/HttpSend/Program.cs | 67 ++++++++++--------- samples/README.md | 4 +- ...llerTests.cs => CloudEventBindingTests.cs} | 11 ++- ...Native.CloudEvents.IntegrationTests.csproj | 2 +- 13 files changed, 221 insertions(+), 185 deletions(-) create mode 100644 samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventBinding.cs delete mode 100644 samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventJsonInputFormatter.cs create mode 100644 samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventOperations.cs delete mode 100644 samples/CloudNative.CloudEvents.AspNetCoreSample/Controllers/CloudEventController.cs rename test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/{CloudEventControllerTests.cs => CloudEventBindingTests.cs} (77%) diff --git a/.vscode/launch.json b/.vscode/launch.json index e83dd3d3..715191f0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,9 +1,15 @@ { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md - "version": "0.2.0", - "configurations": [ + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ + { + "name": "ASP.NET Core Sample Launch", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.csproj" + }, { "name": ".NET Core Launch (console)", "type": "coreclr", @@ -24,5 +30,5 @@ "request": "attach", "processId": "${command:pickProcess}" } - ,] + ] } \ No newline at end of file diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventBinding.cs b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventBinding.cs new file mode 100644 index 00000000..a2a7e2c7 --- /dev/null +++ b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventBinding.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using CloudNative.CloudEvents.AspNetCore; +using CloudNative.CloudEvents.Core; +using CloudNative.CloudEvents.SystemTextJson; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; +using System.Threading.Tasks; + +namespace CloudNative.CloudEvents.AspNetCoreSample; + +public class CloudEventBinding : IBindableFromHttpContext +{ + public CloudEvent Value { get; init; } + + public ProblemHttpResult Error { get; init; } + + public static async ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + Validation.CheckNotNull(context, nameof(context)); + Validation.CheckNotNull(parameter, nameof(parameter)); + + var request = context.Request; + + // Even though we're not allowing non-JSON content in this binding, + // types such as "text/xml" could still be parsed with the current JsonEventFormatter, + // but it's just not making it strongly typed, or anything structured (XmlNode). + // Depending on your use-case, it may or may not be desirable to allow that. + if (request.ContentLength != 0 && !request.HasJsonContentType()) + { + return new CloudEventBinding + { + Error = TypedResults.Problem( + statusCode: StatusCodes.Status415UnsupportedMediaType, + title: "Unsupported media type", + detail: "Request content type is not JSON and not fully supported in this binding. " + + "Please note: the CloudEvents specification does allow for any data content, " + + "as long as it adheres to the provided datacontenttype." + ) + }; + } + + var formatter = context.RequestServices.GetRequiredService(); + + var cloudEvent = await request.ToCloudEventAsync(formatter); + + return new CloudEventBinding + { + Value = cloudEvent + }; + } +} \ No newline at end of file diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventJsonInputFormatter.cs b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventJsonInputFormatter.cs deleted file mode 100644 index 83cf74a3..00000000 --- a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventJsonInputFormatter.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Cloud Native Foundation. -// Licensed under the Apache 2.0 license. -// See LICENSE file in the project root for full license information. - -using CloudNative.CloudEvents.AspNetCore; -using CloudNative.CloudEvents.Core; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Net.Http.Headers; -using System; -using System.Text; -using System.Threading.Tasks; - -namespace CloudNative.CloudEvents.AspNetCoreSample -{ - // FIXME: This doesn't get called for binary CloudEvents without content, or with a different data content type. - // FIXME: This shouldn't really be tied to JSON. We need to work out how we actually want this to be used. - // See - - /// - /// A that parses HTTP requests into CloudEvents. - /// - public class CloudEventJsonInputFormatter : TextInputFormatter - { - private readonly CloudEventFormatter _formatter; - - /// - /// Constructs a new instance that uses the given formatter for deserialization. - /// - /// - public CloudEventJsonInputFormatter(CloudEventFormatter formatter) - { - _formatter = Validation.CheckNotNull(formatter, nameof(formatter)); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/cloudevents+json")); - - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } - - /// - public override async Task ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding) - { - Validation.CheckNotNull(context, nameof(context)); - Validation.CheckNotNull(encoding, nameof(encoding)); - - var request = context.HttpContext.Request; - - try - { - var cloudEvent = await request.ToCloudEventAsync(_formatter); - return await InputFormatterResult.SuccessAsync(cloudEvent); - } - catch (Exception) - { - return await InputFormatterResult.FailureAsync(); - } - } - - /// - protected override bool CanReadType(Type type) - => type == typeof(CloudEvent) && base.CanReadType(type); - } -} diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventOperations.cs b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventOperations.cs new file mode 100644 index 00000000..eca7196c --- /dev/null +++ b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudEventOperations.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using CloudNative.CloudEvents.AspNetCore; +using CloudNative.CloudEvents.SystemTextJson; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace CloudNative.CloudEvents.AspNetCoreSample; + +public static class CloudEventOperations +{ + public static async Task>> ReceiveCloudEvent(CloudEventBinding cloudEventBinding) + { + if (cloudEventBinding.Error is not null) + { + return cloudEventBinding.Error; + } + + var cloudEvent = cloudEventBinding.Value; + + var cloudEventAttributes = cloudEvent.GetPopulatedAttributes() + .ToDictionary(pair => pair.Key.Name, pair => pair.Key.Format(pair.Value)); + + return TypedResults.Json(new + { + note = "wow, such event, much disassembling, very skill", + cloudEvent.SpecVersion.VersionId, + cloudEventAttributes, + cloudEvent.Data + }); + } + + /// + /// Generates a CloudEvent. + /// + public static async Task GenerateCloudEvent(HttpResponse response, JsonEventFormatter formatter, ContentMode contentMode = ContentMode.Structured) + { + var evt = new CloudEvent + { + Type = "CloudNative.CloudEvents.AspNetCoreSample", + Source = new Uri("https://github.com/cloudevents/sdk-csharp"), + Time = DateTimeOffset.Now, + DataContentType = "application/json", + Id = Guid.NewGuid().ToString(), + Data = new + { + Language = "C#", + EnvironmentVersion = Environment.Version.ToString() + } + }; + + response.StatusCode = StatusCodes.Status200OK; + await evt.CopyToHttpResponseAsync(response, contentMode, formatter); + } +} diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.csproj b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.csproj index 62558207..a8a78125 100644 --- a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.csproj +++ b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.csproj @@ -7,7 +7,7 @@ - + diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.http b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.http index 9eec5937..f59e307b 100644 --- a/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.http +++ b/samples/CloudNative.CloudEvents.AspNetCoreSample/CloudNative.CloudEvents.AspNetCoreSample.http @@ -1,16 +1,56 @@ @HostAddress = https://localhost:5001 +### Send via Structured mode + +POST {{HostAddress}}/api/events/receive +Content-Type: application/cloudevents+json; charset=utf-8 + +{ + "specversion": "1.0", + "type": "com.example.myevent", + "source": "urn:example-com:mysource:abc", + "id": "{{$guid}}", + "data": { + "message": "Hello world!" + } +} + +### Send via Binary mode + POST {{HostAddress}}/api/events/receive Content-Type: application/json CE-SpecVersion: 1.0 CE-Type: "com.example.myevent" CE-Source: "urn:example-com:mysource:abc" -CE-Id: "c457b7c5-c038-4be9-98b9-938cb64a4fbf" +CE-Id: "{{$guid}}" { "message": "Hello world!" } -### +### Send content-less via Binary mode + +POST {{HostAddress}}/api/events/receive +CE-SpecVersion: 1.0 +CE-Type: "com.example.myevent" +CE-Source: "urn:example-com:mysource:abc" +CE-Id: "{{$guid}}" + +### Send unsupported media type (XML) via Binary mode + +POST {{HostAddress}}/api/events/receive +Content-Type: text/xml +CE-SpecVersion: 1.0 +CE-Type: "com.example.myevent" +CE-Source: "urn:example-com:mysource:abc" +CE-Id: "{{$guid}}" + +Hello world! + +### Generate via Structured mode GET {{HostAddress}}/api/events/generate + +### Generate via Binary mode + +GET {{HostAddress}}/api/events/generate?contentMode=Binary diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/Controllers/CloudEventController.cs b/samples/CloudNative.CloudEvents.AspNetCoreSample/Controllers/CloudEventController.cs deleted file mode 100644 index b2ed3714..00000000 --- a/samples/CloudNative.CloudEvents.AspNetCoreSample/Controllers/CloudEventController.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Cloud Native Foundation. -// Licensed under the Apache 2.0 license. -// See LICENSE file in the project root for full license information. - -using CloudNative.CloudEvents.NewtonsoftJson; -using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Text; - -namespace CloudNative.CloudEvents.AspNetCoreSample.Controllers -{ - [Route("api/events")] - [ApiController] - public class CloudEventController : ControllerBase - { - private static readonly CloudEventFormatter formatter = new JsonEventFormatter(); - - [HttpPost("receive")] - public ActionResult> ReceiveCloudEvent([FromBody] CloudEvent cloudEvent) - { - var attributeMap = new JObject(); - foreach (var (attribute, value) in cloudEvent.GetPopulatedAttributes()) - { - attributeMap[attribute.Name] = attribute.Format(value); - } - return Ok($"Received event with ID {cloudEvent.Id}, attributes: {attributeMap}"); - } - - /// - /// Generates a CloudEvent in "structured mode", where all CloudEvent information is - /// included within the body of the response. - /// - [HttpGet("generate")] - public ActionResult GenerateCloudEvent() - { - var evt = new CloudEvent - { - Type = "CloudNative.CloudEvents.AspNetCoreSample", - Source = new Uri("https://github.com/cloudevents/sdk-csharp"), - Time = DateTimeOffset.Now, - DataContentType = "application/json", - Id = Guid.NewGuid().ToString(), - Data = new - { - Language = "C#", - EnvironmentVersion = Environment.Version.ToString() - } - }; - // Format the event as the body of the response. This is UTF-8 JSON because of - // the CloudEventFormatter we're using, but EncodeStructuredModeMessage always - // returns binary data. We could return the data directly, but for debugging - // purposes it's useful to have the JSON string. - var bytes = formatter.EncodeStructuredModeMessage(evt, out var contentType); - string json = Encoding.UTF8.GetString(bytes.Span); - var result = Ok(json); - - // Specify the content type of the response: this is what makes it a CloudEvent. - // (In "binary mode", the content type is the content type of the data, and headers - // indicate that it's a CloudEvent.) - result.ContentTypes.Add(contentType.MediaType); - return result; - } - } -} diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/Program.cs b/samples/CloudNative.CloudEvents.AspNetCoreSample/Program.cs index c75e7419..f69cb0bb 100644 --- a/samples/CloudNative.CloudEvents.AspNetCoreSample/Program.cs +++ b/samples/CloudNative.CloudEvents.AspNetCoreSample/Program.cs @@ -3,18 +3,21 @@ // See LICENSE file in the project root for full license information. using CloudNative.CloudEvents.AspNetCoreSample; +using CloudNative.CloudEvents.SystemTextJson; using Microsoft.AspNetCore.Builder; -using CloudNative.CloudEvents.NewtonsoftJson; using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(opts => - opts.InputFormatters.Insert(0, new CloudEventJsonInputFormatter(new JsonEventFormatter()))); +JsonSerializerOptions jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; +builder.Services.AddSingleton(new JsonEventFormatter(jsonOptions, new JsonDocumentOptions())); var app = builder.Build(); -app.MapControllers(); +var apiEvents = app.MapGroup("/api/events"); +apiEvents.MapPost("/receive", CloudEventOperations.ReceiveCloudEvent); +apiEvents.MapGet("/generate", CloudEventOperations.GenerateCloudEvent); app.Run(); diff --git a/samples/HttpSend/HttpSend.csproj b/samples/HttpSend/HttpSend.csproj index e7200cc6..5cec7a68 100644 --- a/samples/HttpSend/HttpSend.csproj +++ b/samples/HttpSend/HttpSend.csproj @@ -8,7 +8,7 @@ - + diff --git a/samples/HttpSend/Program.cs b/samples/HttpSend/Program.cs index 7532aacf..d39aa99c 100644 --- a/samples/HttpSend/Program.cs +++ b/samples/HttpSend/Program.cs @@ -4,52 +4,53 @@ using CloudNative.CloudEvents; using CloudNative.CloudEvents.Http; -using CloudNative.CloudEvents.NewtonsoftJson; +using CloudNative.CloudEvents.SystemTextJson; using McMaster.Extensions.CommandLineUtils; -using Newtonsoft.Json; using System; using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Net.Mime; using System.Threading.Tasks; -namespace HttpSend +namespace HttpSend; + +// This application uses the McMaster.Extensions.CommandLineUtils library for parsing the command +// line and calling the application code. The [Option] attributes designate the parameters. +internal class Program { - // This application uses the McMaster.Extensions.CommandLineUtils library for parsing the command - // line and calling the application code. The [Option] attributes designate the parameters. - internal class Program - { - [Option(Description = "CloudEvents 'source' (default: urn:example-com:mysource:abc)", LongName = "source", ShortName = "s")] - private string Source { get; } = "urn:example-com:mysource:abc"; + [Option(Description = "CloudEvents 'source' (default: urn:example-com:mysource:abc)", LongName = "source", ShortName = "s")] + private string Source { get; } = "urn:example-com:mysource:abc"; - [Option(Description = "CloudEvents 'type' (default: com.example.myevent)", LongName = "type", ShortName = "t")] - private string Type { get; } = "com.example.myevent"; + [Option(Description = "CloudEvents 'type' (default: com.example.myevent)", LongName = "type", ShortName = "t")] + private string Type { get; } = "com.example.myevent"; - [Required, Option(Description = "HTTP(S) address to send the event to", LongName = "url", ShortName = "u"),] - private Uri Url { get; } + [Required, Option(Description = "HTTP(S) address to send the event to", LongName = "url", ShortName = "u"),] + private Uri Url { get; } - public static int Main(string[] args) => CommandLineApplication.Execute(args); + public static int Main(string[] args) => CommandLineApplication.Execute(args); - private async Task OnExecuteAsync() + private async Task OnExecuteAsync() + { + var cloudEvent = new CloudEvent { - var cloudEvent = new CloudEvent + Id = Guid.NewGuid().ToString(), + Type = Type, + Source = new Uri(Source), + DataContentType = MediaTypeNames.Application.Json, + Data = new { - Id = Guid.NewGuid().ToString(), - Type = Type, - Source = new Uri(Source), - DataContentType = MediaTypeNames.Application.Json, - Data = JsonConvert.SerializeObject("hey there!") - }; - - var content = cloudEvent.ToHttpContent(ContentMode.Structured, new JsonEventFormatter()); - - var httpClient = new HttpClient(); - // Your application remains in charge of adding any further headers or - // other information required to authenticate/authorize or otherwise - // dispatch the call at the server. - var result = await httpClient.PostAsync(Url, content); - - Console.WriteLine(result.StatusCode); - } + hey = "There" + } + }; + + var content = cloudEvent.ToHttpContent(ContentMode.Structured, new JsonEventFormatter()); + + var httpClient = new HttpClient(); + // Your application remains in charge of adding any further headers or + // other information required to authenticate/authorize or otherwise + // dispatch the call at the server. + var result = await httpClient.PostAsync(Url, content); + + Console.WriteLine(result.StatusCode); } } diff --git a/samples/README.md b/samples/README.md index 2e7c4408..6d24970e 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,7 +10,7 @@ This directory contains a sample ASP.NET Core application that exposes two endpo To run the sample, execute the `dotnet run` command in the `CloudNative.CloudEvents.AspNetCoreSample` directory. ```shell -dotnet run --framework net8.0 +dotnet run --framework net10.0 ``` After running the web service using the command above, there are three strategies for sending requests to the web service. @@ -20,7 +20,7 @@ After running the web service using the command above, there are three strategie The `HttpSend` project provides a CLI tool for sending requests to the `/api/events/receive` endpoint exposed by the service. To use the tool, navigate to the `HttpSend` directory and execute the following command: ```shell -dotnet run --framework net8.0 --url https://localhost:5001/api/events/receive +dotnet run --framework net10.0 --url https://localhost:5001/api/events/receive ``` ### Using the `.http` file diff --git a/test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/CloudEventControllerTests.cs b/test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/CloudEventBindingTests.cs similarity index 77% rename from test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/CloudEventControllerTests.cs rename to test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/CloudEventBindingTests.cs index b59ecf29..d1132aa6 100644 --- a/test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/CloudEventControllerTests.cs +++ b/test/CloudNative.CloudEvents.IntegrationTests/AspNetCore/CloudEventBindingTests.cs @@ -2,9 +2,8 @@ // Licensed under the Apache 2.0 license. // See LICENSE file in the project root for full license information. -using CloudNative.CloudEvents.AspNetCoreSample; using CloudNative.CloudEvents.Http; -using CloudNative.CloudEvents.NewtonsoftJson; +using CloudNative.CloudEvents.SystemTextJson; using Microsoft.AspNetCore.Mvc.Testing; using System; using System.Net; @@ -13,11 +12,11 @@ namespace CloudNative.CloudEvents.IntegrationTests.AspNetCore { - public class CloudEventControllerTests : IClassFixture> + public class CloudEventBindingTests : IClassFixture> { private readonly WebApplicationFactory _factory; - public CloudEventControllerTests(WebApplicationFactory factory) + public CloudEventBindingTests(WebApplicationFactory factory) { _factory = factory; } @@ -25,7 +24,7 @@ public CloudEventControllerTests(WebApplicationFactory factory) [Theory] [InlineData(ContentMode.Structured)] [InlineData(ContentMode.Binary)] - public async Task Controller_WithValidCloudEvent_NoContent_DeserializesUsingPipeline(ContentMode contentMode) + public async Task Binding_WithValidCloudEvent_NoContent_DeserializesUsingPipeline(ContentMode contentMode) { // Arrange var expectedExtensionKey = "comexampleextension1"; @@ -50,7 +49,7 @@ public async Task Controller_WithValidCloudEvent_NoContent_DeserializesUsingPipe Assert.Equal(HttpStatusCode.OK, result.StatusCode); Assert.Contains(cloudEvent.Id, resultContent); Assert.Contains(cloudEvent.Type, resultContent); - Assert.Contains($"\"{expectedExtensionKey}\": \"{expectedExtensionValue}\"", resultContent); + Assert.Contains($"\"{expectedExtensionKey}\":\"{expectedExtensionValue}\"", resultContent); } } } diff --git a/test/CloudNative.CloudEvents.IntegrationTests/CloudNative.CloudEvents.IntegrationTests.csproj b/test/CloudNative.CloudEvents.IntegrationTests/CloudNative.CloudEvents.IntegrationTests.csproj index 774ca9a5..2021be22 100644 --- a/test/CloudNative.CloudEvents.IntegrationTests/CloudNative.CloudEvents.IntegrationTests.csproj +++ b/test/CloudNative.CloudEvents.IntegrationTests/CloudNative.CloudEvents.IntegrationTests.csproj @@ -5,7 +5,7 @@ - +