diff --git a/README.md b/README.md index d37cafe..ee3bf20 100644 --- a/README.md +++ b/README.md @@ -3,321 +3,49 @@ ## Status This SDK current supports the following versions of CloudEvents: + - v1.0 # sdk-csharp .NET Standard 2.0 (C#) SDK for CloudEvents -The `CloudNative.CloudEvents` package provides utility methods and classes for creating, encoding, -decoding, sending, and receiving [CNCF CloudEvents](https://github.com/cloudevents/spec). +The `CloudNative.CloudEvents` package provides support for creating, encoding, +decoding, sending, and receiving [CNCF +CloudEvents](https://github.com/cloudevents/spec). Most applications +will want to add dependencies on other `CloudNative.CloudEvents.*` +packages for specific event format and protocol binding support. See +the [user guide](docs/guide.md) for details of the packages available. ## A few gotchas highlighted for the impatient who don't usually read docs 1. The [CloudEvent](src/CloudNative.CloudEvents/CloudEvent.cs) class is not meant to be used with - object serializers like JSON.NET and does not have a default constructor to underline this. If you need to serialize or deserialize a CloudEvent directly, always use an [ICloudEventFormatter](src/CloudNative.CloudEvents/ICloudEventFormatter.cs) like the [JsonEventFormatter](src/CloudNative.CloudEvents/JsonEventFormatter.cs). -2. The transport integration is provided in the form of extensions and the objective of those extensions - is to map the CloudEvent to and from the respective protocol message, like an [HTTP request](src/CloudNative.CloudEvents/CloudEventContent.cs) or [response](src/CloudNative.CloudEvents/HttpClientExtension.cs#L249) - object, but the application is otherwise fully in control of the client. Therefore, the extensions do not + object serializers like JSON.NET and does not have a default constructor to underline this. + If you need to serialize or deserialize a CloudEvent directly, always use a + [CloudEventFormatter](src/CloudNative.CloudEvents/CloudEventFormatter.cs) + such as [JsonEventFormatter](src/CloudNative.CloudEvents.JsonNet/JsonEventFormatter.cs). +2. Protocol binding integration is provided in the form of extensions and the objective of those extensions + is to map the CloudEvent to and from the respective protocol message, such as an HTTP request or response. + The application is otherwise fully in control of the client. Therefore, the extensions do not add security headers or credentials or any other headers or properties that may be required to interact with a particular product or service. Adding this information is up to the application. -## CloudEvent +## User guide and other documentation -The `CloudEvent` class reflects the event envelope defined by the -[CNCF CloudEvents 1.0 specification](https://github.com/cloudevents/spec/blob/v1.0/spec.md). -It supports version 1.0 of CloudEvents by default. It can also handle the pre-release versions -0.1, 0.2, and 0.3 of the CloudEvents specification. +The [docs/](docs) directory contains more documentation, including +the [user guide](docs/guide.md). Feedback on what else to include in +the documentation is particularly welcome. -The strongly typed API reflects the 1.0 standard. +## Changes since 1.x -If required for compatibility with code that leans on one of the prerelease specifications, you can -override the specification version explicitly: `new CloudEvent(CloudEventsSpecVersion.V0_1)`. -The `SpecVersion` property also allows the version to be switched, meaning you can receive a 0.1 -event, switch the version number, and forward it as a 1.0 event, with all required mappings done -for you. +From version 2.0.0-beta.2, there are a number of breaking changes +compared with the 1.x series of releases. New code is +strongly encouraged to adopt the latest version rather than relying +on the 1.3.80 stable release. We are hoping to provide a stable +2.0.0 release within the summer of 2021 (May/June/July). -| **1.0** | Property name | CLR type | -| ------------------- | ------------------------ | ----------------------------- | -| **id** | `CloudEvent.Id` | `System.String` | -| **type** | `CloudEvent.Type` | `System.String` | -| **specversion** | `CloudEvent.SpecVersion` | `System.String` | -| **time** | `CloudEvent.Time` | `System.DateTime` | -| **source** | `CloudEvent.Source` | `System.Uri` | -| **subject** | `CloudEvent.Subject` | `System.String` | -| **dataschema** | `CloudEvent.DataSchema` | `System.Uri` | -| **datacontenttype** | `CloudEvent.ContentType` | `System.Net.Mime.ContentType` | -| **data** | `CloudEvent.Data` | `System.Object` | - -The `CloudEvent.Data` property is `object` typed, and may hold any valid serializable -CLR type. The following types have special handling: - -- `System.String`: In binary content mode, strings are copied into the transport - message payload body using UTF-8 encoding. -- `System.Byte[]`: In binary content mode, byte array content is copied into the - message paylaod body without further transformation. -- `System.Stream`: In binary content mode, stream content is copied into the - message paylaod body without further transformation. - -Any other data type is transformed using the given event formatter for the operation -or the JSON formatter by default before being added to the transport payload body. - -All extension attributes can be reached via the `CloudEvent.GetAttributes()` method, -which returns the internal attribute collection. The internal collection performs -all required validations. - -## Extensions - -CloudEvent extensions are represented by implementations of the `ICloudEventExtension` -interface. The SDK includes strongly-typed implementations for all offical CloudEvents -extensions: - -- `DistributedTracingExtension` for [distributed tracing](https://github.com/cloudevents/spec/blob/master/extensions/distributed-tracing.md) -- `SampledRateExtension` for [sampled rate](https://github.com/cloudevents/spec/blob/master/extensions/sampled-rate.md) -- `SequenceExtension` for [sequence](https://github.com/cloudevents/spec/blob/master/extensions/sequence.md) - -Extension classes provide type-safe access to the extension attributes as well as implement the -required validations and type mappings. An extension object is always created as an -independent entity and is then attached to a `CloudEvent` instance. Once attached, the -extension object's attributes are merged into the `CloudEvent` instance. - -This snippet shows how to create a `CloudEvent` with an extension: - -```C# - var cloudEvent = new CloudEvent( - "com.github.pull.create", - new Uri("https://github.com/cloudevents/spec/pull/123"), - new DistributedTracingExtension() - { - TraceParent = "value", - TraceState = "value" - }) -{ - ContentType = new ContentType("application/json"), - Data = "[]" -}; -``` - -The extension can later be accessed via the `Extension()` method: - -``` - var s = cloudEvent.Extension().TraceParent -``` - -All APIs where a `CloudEvent` is constructed from an incoming event (or request or -response) allow for extension instances to be added via their respective methods, and -the extensions are invoked in the mapping process (for instance, to extract information -from headers that deviate from the CloudEvents default mapping). - -For example, the server-side mapping for `HttpRequestMessage` allows adding -extensions like this: - -```C# -public async Task Run( HttpRequestMessage req, ILogger log) -{ - var cloudEvent = req.ToCloudEvent(new DistributedTracingExtension()); -} -``` - -## Transport Bindings - -This SDK helps with mapping CloudEvents to and from messages or transport frames of -popular .NET clients in such a way as to be agnostic of your application's choices of -how you want to send an event (be it via HTTP PUT or POST) or how you want to handle -settlement of transfers in AMQP or MQTT. The transport binding classes and extensions -therefore don't wrap the send and receive operations; you still use the native -API of the respective library. - -### HTTP - System.Net.Http.HttpClient - -The .NET [`HttpClient`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) uses -the [`HttpContent`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpcontent) -abstraction to wrap payloads for sending requests that carry entity bodies. - -This SDK provides a [`CloudEventContent`] class derived from `HttpContent` that can be -created from a `CloudEvent` instance, the desired `ContentMode`, and an event formatter. - -```C# - -var cloudEvent = new CloudEvent("com.example.myevent", new Uri("urn:example-com:mysource")) -{ - ContentType = new ContentType(MediaTypeNames.Application.Json), - Data = JsonConvert.SerializeObject("hey there!") -}; - -var content = new CloudEventContent( cloudEvent, - ContentMode.Structured, - new JsonEventFormatter()); - -var httpClient = new HttpClient(); -var result = (await httpClient.PostAsync(this.Url, content)); -``` - -For responses, `HttpClient` puts all custom headers onto the `HttpResponseMessage` rather -than on the carried `HttpContent` instance. Therefore, if an event is retrieved with -`HttpClient` (for instance, from a queue-like structure) the `CloudEvent` is created from -the response message object rather than the content object using the `ToCloudEvent()` -extension method on `HttpResponseMessage`: - -```C# -var httpClient = new HttpClient(); -// delete and receive message from top of the queue -var result = await httpClient.DeleteAsync(new Uri("https://example.com/queue/messages/top")); -if (HttpStatusCode.OK == result.StatusCode) { - var receivedCloudEvent = await result.ToCloudEvent(); -} -``` - -### HTTP - System.Net.HttpWebRequest - -If your application uses the `HttpWebRequest` client, you can copy a CloudEvent into -the request payload in structured or binary mode: - -```C# - -HttpWebRequest httpWebRequest = WebRequest.CreateHttp("https://example.com/target"); -httpWebRequest.Method = "POST"; -await httpWebRequest.CopyFromAsync(cloudEvent, ContentMode.Structured, new JsonEventFormatter()); -``` - -Bear in mind that the `Method` property must be set to an HTTP method that allows an entity body -to be sent, otherwise the copy operation will fail. - -### HTTP - System.Net.HttpListener (HttpRequestMessage) - -On the server-side, you can extract a CloudEvent from the server-side `HttpRequestMessage` -with the `ToCloudEventAsync()` extension. If your code handles `HttpRequestContext`, -you will use the `Request` property: - -```C# -var cloudEvent = await context.Request.ToCloudEventAsync(); -``` - -If you use a functions framework that lets you handle `HttpResponseMessage` and return -`HttpResponseMessage`, you will call the extension on the request object directly: - -```C# -public async Task Run( HttpRequestMessage req, ILogger log) -{ - var cloudEvent = await req.ToCloudEventAsync(); -} -``` - -The extension implementation will read the `ContentType` header of the incoming request and -automatically select the correct built-in event format decoder. Your code can always pass an -overriding format decoder instance as the first argument if needed. - -If your HTTP handler needs to return a CloudEvent, you copy the `CloudEvent` into the -response with the `CopyFromAsync()` extension method: - -```C# -var cloudEvent = new CloudEvent("com.example.myevent", new Uri("urn:example-com:mysource")) -{ - ContentType = new ContentType(MediaTypeNames.Application.Json), - Data = JsonConvert.SerializeObject("hey there!") -}; - -await context.Response.CopyFromAsync(cloudEvent, - ContentMode.Structured, - new JsonEventFormatter()); -context.Response.StatusCode = (int)HttpStatusCode.OK; -``` - -### HTTP - Microsoft.AspNetCore.Http.HttpRequest - -On the server-side, you can extract a CloudEvent from the server-side `HttpRequest` -with the `ReadCloudEventAsync()` extension. - -```C# -var cloudEvent = await HttpContext.Request.ReadCloudEventAsync(); -``` - -### HTTP - ASP.NET Core MVC - -If you would like to deserialize CloudEvents in actions directly, you can register the -`CloudEventJsonInputFormatter` in the MVC options: - -```C# -public void ConfigureServices(IServiceCollection services) -{ - services.AddMvc(opts => - { - opts.InputFormatters.Insert(0, new CloudEventJsonInputFormatter()); - }); -} -``` - -This formatter will only intercept parameters where CloudEvent is the expected type. - -You can then receive CloudEvent objects in controller actions: - -```C# -[HttpPost("resource")] -public IActionResult ReceiveCloudEvent([FromBody] CloudEvent cloudEvent) -{ - return Ok(); -} -``` - -### AMQP - -The SDK provides extensions for the [AMQPNetLite](https://github.com/Azure/amqpnetlite) package. - -For AMQP support, you must reference the `CloudNative.CloudEvents.Amqp` assembly and -reference the namespace in your code with `using CloudNative.CloudEvents.Amqp`. - -The `AmqpCloudEventMessage` extends the `AMQPNetLite.Message` class. The constructor -allows creating a new AMQP message that holds a CloudEvent in either structured or binary -content mode. - -```C# - -var cloudEvent = new CloudEvent("com.example.myevent", new Uri("urn:example-com:mysource")) -{ - ContentType = new ContentType(MediaTypeNames.Application.Json), - Data = JsonConvert.SerializeObject("hey there!") -}; - -var message = new AmqpCloudEventMessage( cloudEvent, - ContentMode.Structured, - new JsonEventFormatter()); - -``` - -For mapping a received `Message` to a CloudEvent, you can use the `ToCloudEvent()` method: - -```C# - var receivedCloudEvent = await message.ToCloudEvent(); -``` - -## MQTT - -The SDK provides extensions for the [MQTTnet](https://github.com/chkr1011/MQTTnet) package. -For MQTT support, you must reference the `CloudNative.CloudEvents.Mqtt` assembly and -reference the namespace in your code with `using CloudNative.CloudEvents.Mqtt`. - -The `MqttCloudEventMessage` extends the `MqttApplicationMessage` class. The constructor -allows creating a new MQTT message that holds a CloudEvent in structured content mode. - -```C# - -var cloudEvent = new CloudEvent("com.example.myevent", new Uri("urn:example-com:mysource")) -{ - ContentType = new ContentType(MediaTypeNames.Application.Json), - Data = JsonConvert.SerializeObject("hey there!") -}; - -var message = new MqttCloudEventMessage( cloudEvent, - new JsonEventFormatter()); - -``` - -For mapping a received `MqttApplicationMessage` to a CloudEvent, you can use the -`ToCloudEvent()` method: - -```C# - var receivedCloudEvent = await message.ToCloudEvent(); -``` +A [more details list of changes](docs/changes-since-1x.md) is +provided within the documentation. ## Community diff --git a/docs/README.md b/docs/README.md index de9a525..382fdf8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,13 @@ # SDK documentation +**Note: all of this documentation is specific to versions 2.0-beta.2 and onwards** + This directory contains documentation on: -- Using the SDK as a consumer -- Implementing new event formats and protocol bindings +- [Usage guide](guide.md) (this is the most appropriate starting point for most + developers if they simply plan on *using* the CloudEvents SDK) +- [Changes since version 1.x of the CloudNative.CloudEvents packages](changes-since-1x.md) +- Implementing new [event formats](formatters.md) and [protocol bindings](bindings.md) ## Implementation utility classes diff --git a/docs/changes-since-1x.md b/docs/changes-since-1x.md new file mode 100644 index 0000000..4925636 --- /dev/null +++ b/docs/changes-since-1x.md @@ -0,0 +1,79 @@ +# Changes since version 1.x + +Many aspects of the SDK have changed since the 1.x versions. Users +adopting 2.x should expect to rewrite some code and retest +thoroughly when migrating from 1.x. + +The following sections are not exhaustive, but describe the most +important changes. + +## Core package + +The `CloudEvent` type constructor now only accepts the spec version +and initial extension attributes (with no values). Everything else +(type, ID, timestamp etc) must be set via properties or indexers. +(In particular, the timestamp and ID are no longer populated +automatically.) The spec version for an event is immutable once +constructed; everything else can be modified after construction. + +The types used to specify attribute values must match the +corresponding attribute type exactly; there is no implicit +conversion available. For example, `cloudEvent["source"] = +"https://cloudevents.io";` will fail because the `source` attribute +is expected to be a URI. + +The following are now fully-abstracted concepts, rather than +implicitly using more primitive types: + +- Spec version (`CloudEventSpecVersion`) +- Attributes (`CloudEventAttribute`) +- Attribute types (`CloudEventAttributeType`) + +The 1.x `CloudEventAttributes` class has now been removed, however - +the attributes are contained directly in a map within `CloudEvent`. + +Timestamp attributes are now represented by `DateTimeOffset` instead +of `DateTime`, as this provides a more specific "timestamp" concept +with fewer ambiguities. + +Extension attributes are no longer expected to implement an +interface (the old `ICloudEventExtension`). Instead, +`CloudEventAttribute` is used to represent all kinds of attribute, +and extensions are encouraged to be provided using C# extension +methods and static properties. See the [user +guide](guide.md#extension-attributes) for more details. + +## Event formatters + +`CloudEventFormatter` is now an abstract base class (compared with +the 1.x interface `ICloudEventFormatter`). Attribute encoding is no +longer part of the responsibility of a `CloudEventFormatter`, but +binary data encoding (and batch encoding where supported) *are* part +of the event formatter. + +The core package no longer contains any event formatters; the +Json.NET-based event formatter is now in a separate package +(`CloudNative.CloudEvents.NewtonsoftJson`) to avoid an unnecessary +dependency. An alternative implementation based on System.Text.Json +is now available in the `CloudNative.CloudEvents.SystemTextJson` +package. + +Event formatters no longer supports streams for data, but are +expected to handle strings and byte arrays, as well as supporting +any formatter-specific types (e.g. JSON objects for JSON +formatters). While each event formatter is still able to determine +its own approach to serialization (meaning that formatters aren't +really interchangable), the data responsiblities are more clearly +documented, and each formatter should provide details of its +serialization and deserialization algorithm. + +## Protocol bindings + +Protocol bindings now typically require a `CloudEventFormatter` for +all serialization and deserialization operations, as there's no +built-in formatter to use by default. (This sounds inconvenient, but +does make the dependency on a specific event format explicit.) + +The method names have been made consistent as far as possible. See +[the protocol bindings implementation guide](bindings.md) for +details. diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..83529e9 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,355 @@ +# Usage guide + +This guide provides a whistle-stop tour of .NET CloudEvents SDK. It +is not exhaustive by any means; please [file an +issue](https://github.com/cloudevents/sdk-csharp/issues) if you +would like to suggest a specific area for further documentation. + +## NuGet packages + +The CloudEvents SDK consists of a number of NuGet packages, to avoid +unnecessary dependencies. These packages are: + +|NuGet package|Description| +|-|-| +|[CloudNative.CloudEvents](https://www.nuget.org/packages/CloudNative.CloudEvents)|core SDK +|[CloudNative.CloudEvents.Amqp](https://www.nuget.org/packages/CloudNative.CloudEvents.Amqp)|AMQP protocol binding using [AMQPNetLite](https://www.nuget.org/packages/AMQPNetLite) +|[CloudNative.CloudEvents.AspNetCore](https://www.nuget.org/packages/CloudNative.CloudEvents.AspNetCore)|ASP.NET Core support for CloudEvents +|[CloudNative.CloudEvents.Avro](https://www.nuget.org/packages/CloudNative.CloudEvents.Avro)|Avro event formatter using [Apache.Avro](https://www.nuget.org/packages/Apache.Avro) +|[CloudNative.CloudEvents.Kafka](https://www.nuget.org/packages/CloudNative.CloudEvents.Kafka)|Kafka protocol binding using [Confluent.Kafka](https://www.nuget.org/packages/Confluent.Kafka) +|[CloudNative.CloudEvents.Mqtt](https://www.nuget.org/packages/CloudNative.CloudEvents.Mqtt)|MQTT protocol binding using [MQTTnet](https://www.nuget.org/packages/MQTTnet) +|[CloudNative.CloudEvents.NewtonsoftJson](https://www.nuget.org/packages/CloudNative.CloudEvents.NewtonsoftJson)|JSON event formatter using [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) +|[CloudNative.CloudEvents.System.Text.Json](https://www.nuget.org/packages/CloudNative.CloudEvents.SystemTextJson)|JSON event formatter using [System.Text.Json](https://www.nuget.org/packages/System.Text.Json) + +Note that protocol bindings for HTTP using `HttpRequestMessage`, +`HttpResponseMessage`, `HttpContent`, `HttpListenerRequest`, +`HttpListenerResponse` and `HttpWebRequest` are part of the core SDK. + +## In-memory CloudEvent representation + +The most important type in the CloudEvents SDK is the `CloudEvent` +type. This contains all the information about a CloudEvent, +including its *attributes* and *data*. + +Attributes are effectively metadata about the CloudEvent. Each +attribute is represented by a `CloudEventAttribute` which is aware +of the attribute name, its kind (see below), its data type (as a +`CloudEventAttributeType`) and any constraints (such as whether it +can be present but empty). + +There are three kinds of attributes: + +- Required: these attributes are part of the CloudEvents + specification, and are required on all valid CloudEvents. +- Optional: these attributes are part of the CloudEvents + specification, but are not required to be present in order for + a CloudEvent to be considered valid. +- Extension: these attributes are not formalized as part of the + CloudEvents specification. The CloudEvents specification repository + [includes descriptions of some extension + attributes](https://github.com/cloudevents/spec/tree/v1.0.1/extensions) + that may become standardized over time, but they are not + considered part of the specification. + +One attribute is handled differently to all others within the +.NET CloudEvents SDK: the `specversion` attribute. Once a +`CloudEvent` object has been created, its `specversion` cannot be +changed. Currently, only the 1.0 specification is supported anyway; +when new versions arise, we expect to provide a method to create a +new `CloudEvent` object from an existing one, but with a new version +(and with modified properties where appropriate). The specification +version can be specified explicitly in the CloudEvent constructor, +but otherwise defaults to 1.0. + +The optional and required attributes can be accessed in three ways: + +- Via specific properties, e.g. `cloudEvent.Id` or `cloudEvent.Time` +- Via the string-based indexer, e.g. `cloudEvent["id"]` +- Via the CloudEventAttribute-based indexer, e.g. + `cloudEvent[myAttribute]` + +Extension attributes do not have specific properties, so can only be +accessed via one of the indexers. + +The value returned by the indexer (or accepted when calling the +setter) depends on the attribute type: + +|CloudEvent attribute type|.NET type| +|-|-| +|String|System.String| +|Integer|System.Int32| +|Boolean|System.Boolean| +|Binary|System.Byte\[\]| +|URI|System.Uri| +|URI-Reference|System.Uri| +|Timestamp|System.DateTimeOffset| + +When a value is set by the string-based indexer and the CloudEvent +isn't already aware of the attribute, it is assumed to be a +string-based extension attribute with no constraints. + +The `CloudEvent.Data` property deserves special consideration, but +is best understood after reading about [protocol +bindings](#protocol-bindings) and [CloudEvent +formatters](#cloudevent-formatters). If you're already familiar with +those topics, [jump straight to data +considerations](#data-considerations). + +## Extension attributes + +Extension attributes can be specified without any values when a +CloudEvent is created. This is typically the case when using a +protocol binding to parse a transport message: if you're aware of +any extensions you *might* see in the CloudEvent, and want to use +them later, pass those extensions into the relevant method and the +CloudEvent will be created with them. This allows any extension +attribute values to be validated while the CloudEvent is being +parsed. + +The CloudEvents SDK contains some predefined extension attributes in +the `CloudNative.CloudEvents.Extensions` namespace. The SDK exposes +these with the following pattern, which you are encouraged to follow +if you write your own extensions: + +- Create a static class for all related extension attributes (e.g. the + `sequence` and `sequencetype` extension attributes are both exposed + via the `CloudNative.CloudEvents.Extension.Sequence` class) +- Create a static read-only property of type `CloudEventAttribute` + for each extension attribute +- Create a static read-only property of type + `IEnumerable` called `AllAttributes`, typically + implemented via a `ReadOnlyCollection`. This makes it easy to + pass "all the related extensions" into the CloudEvent constructor + or protocol binding methods accepting + `IEnumerable`. It also makes it easy to combine + multiple extension attributes using the LINQ `Concat` method +- Create extension methods to interact with CloudEvents, such as the + `SetSequence(this CloudEvent cloudEvent, object value)` method + in `Sequence`. + +When fetching extension attribute values from a CloudEvent, if the +attribute type is not String, you *may* wish fetch the value by +attribute name rather than by the attribute. This allows you to +handle the case where the attribute value has been populated without +prior knowledge of the attribute, and defaulted to a String type. If +you know that the CloudEvent will always have been populated using +the correct extension attribute, this is unnecessary complexity - +but if you need to work with arbitrary CloudEvent instances, it can +be more flexible. + +## Protocol bindings + +*Protocol bindings* are used to transport CloudEvents on specific +protocols (e.g. HTTP or Kafka). Each protocol binding has its own +methods, typically extracting a CloudEvent from an existing +transport message, or creating/populating a transport message with +an existing CloudEvent. + +Protocol bindings work with [CloudEvent formatters](#event-formatters) to +determine exactly how the CloudEvent is represented within any +given transport message. + +Due to differences between protocols, there's no abstract base class +or interface for protocol bindings. However, protocol bindings are +encouraged to follow certain conventions to provide a reasonably +consistent experience across protocols. See the +[bindings.md](protocol bindings implementation guide) for more +details of these conventions. + +The following table summarizes the protocol bindings available: + +|Protocol binding|Namespace|Types| +|-|-|-| +|HTTP (built-in)|CloudNative.CloudEvents.Http|HttpClientExtensions, HttpContentExtensions, HttpListenerExtensions, HttpWebExtensions| +|HTTP (ASP.NET Core)|CloudNative.CloudEvents.AspNetCore|HttpRequestExtensions, CloudEventJsonInputFormatter| +|AMQP|CloudNative.CloudEvents.Amqp|AmqpClientExtensions| +|Kafka|CloudNative.CloudEvents.Kafka|KafkaClientExtensions| +|MQTT|CloudNative.CloudEvents.Mqtt|MqttClientExtensions| + +### Content modes and batches + +Most protocol bindings support two *content modes*: + +- In *structured mode*, all the CloudEvent information is placed in the protocol message body, + with the exact format governed by the [CloudEvent format](#cloudevent-formatters) in use. The + content type of the message indicates that the message represents a CloudEvent. +- In *binary mode*, the CloudEvent data is placed in the protocol message body, + but the attributes of the CloudEvent are placed in the protocol metadata (e.g. HTTP headers). + In this case, the content type of the message is the content type of the data of the CloudEvent. + +Protocol bindings typically expose this option via a parameter of type `ContentMode` when serializing +a CloudEvent into a protocol message. Deserialization is typically transparent, using the appropriate +content mode based on the content type of the message being read. + +Some protocol bindings (e.g. HTTP) also support a *batch mode*. This +is like structured mode, in that all the CloudEvent information is +placed in the message body, but the message body can contain any +number of CloudEvents (including none). Where a protocol binding +supports batch mode, batch-specific methods are typically provided. + +## CloudEvent formatters + +For structured mode (and batch mode) messages, the way in which the +CloudEvent (or batch of CloudEvents) is represented is determined by +the *CloudEvent format* being used. In the .NET SDK, a CloudEvent +format is represented by concrete types derived from the +`CloudEventFormatter` abstract base class. Two formats are supported: + +- JSON, via the `JsonEventFormatter` types in the `CloudNative.CloudEvents.SystemTextJson` and + `CloudNative.CloudEvents.NewtonsoftJson` packages +- Avro, via the `AvroEventFormatter` type in the `CloudNative.CloudEvents.Avro` package + +Note that a `CloudEventFormatter` in the .NET SDK has more +responsibility than a CloudEvent format in the specification, in +that it is *also* responsible for serializing the data of the event +in both structured and binary modes. For example, the +`JsonEventFormatter` implementations will serialize objects as JSON +objects. See the [Data considerations](#data-considerations) section for more details. + +There are two different JSON implementations as they use different +JSON APIs for implementation purposes. This can affect the +serialized data, as each underlying JSON API has its own set of +attributes and settings governing the serialization and +deserialization. Both are provided separately from the core +CloudNative.CloudEvents package to avoid unnecessary dependencies. +We would recommend using a single JSON implementation across an +application where possible, for simplicity and consistency. + +## Sample code for protocol bindings and event formatters + +Sample code for creating a CloudEvent and using it to populate an +`HttpRequestMessage` (typically for sending with `HttpClient`): + + +```csharp +CloudEvent cloudEvent = new CloudEvent +{ + Id = "event-id", + Type = "event-type", + Source = new Uri("https://cloudevents.io/"), + Time = DateTimeOffset.UtcNow, + DataContentType = "text/plain", + Data = "This is CloudEvent data" +}; + +CloudEventFormatter formatter = new JsonEventFormatter(); +HttpRequestMessage request = new HttpRequestMessage +{ + Method = HttpMethod.Post, + Content = cloudEvent.ToHttpContent(ContentMode.Structured, formatter) +}; +``` + +`ToHttpContent` is an extension method requiring a `using` directive of + +```csharp +using CloudNative.CloudEvents.Http; +``` + +Sample code for consuming a CloudEvent within an ASP.NET Core `HttpRequest`: + + +```csharp +CloudEventFormatter formatter = new JsonEventFormatter(); +CloudEvent cloudEvent = await request.ToCloudEventAsync(formatter); +``` + +`ToCloudEventAsync` is an extension method requiring a `using` directive of + +```csharp +using CloudNative.CloudEvents.AspNetCore; +``` + +## Data considerations + +The `CloudEvent.Data` property is of type `System.Object` and can +hold any value. However, outside unit testing, CloudEvents are +almost always serialized using a protocol binding and event +formatter, and then deserialized later. When creating a CloudEvent +you need to consider the representation you want the CloudEvent data +to take when "on the wire". Likewise when you parse a CloudEvent +from a transport message, you need to be aware of the limitations of +the protocol binding and event formatter you're using, in terms of +how data is deserialized. + +As a concrete example, suppose you have a class `GameResult` +representing the result of a single game, and you wish to create a +CloudEvent for this result, using a JSON representation of the data +in an HTTP request. The class might look like this: + + + +```csharp +public class GameResult +{ + [JsonProperty("playerId")] + public string PlayerId { get; set; } + + [JsonProperty("gameId")] + public string GameId { get; set; } + + [JsonProperty("score")] + public int Score { get; set; } +} +``` + +Using the `JsonEventFormatter` from the +`CloudNative.CloudEvents.NewtonsoftJson` package, including an +instance of `GameResult` as the data of a CloudEvent and then using +that as the content of an `HttpRequestMessage` is simple: + + + +```csharp +var result = new GameResult +{ + PlayerId = "player1", + GameId = "game1", + Score = 200 +}; +var cloudEvent = new CloudEvent +{ + Id = "result-1", + Type = "game.played.v1", + Source = new Uri("https://cloudevents.io/"), + Time = DateTimeOffset.UtcNow, + DataContentType = "application/json", + Data = result +}; +var formatter = new JsonEventFormatter(); +var request = new HttpRequestMessage +{ + Method = HttpMethod.Post, + Content = cloudEvent.ToHttpContent(ContentMode.Binary, formatter) +}; +``` + +The `GameResult` object is automatically serialized as JSON in the +HTTP request. + +When the CloudEvent is deserialized at the receiving side, however, +it's a little more complex. The event formatter can use the content +type of "application/json" to detect that this is JSON, but it +doesn't know to deserialize it as a `GameResult`. Instead, it +deserializes it as a `JToken` (in this case a `JObject`, as the +content represents a JSON object). The calling code then has to use +normal Json.NET deserialization to convert the `JObject` stored in +`CloudEvent.Data` into a `GameResult`: + + + +```csharp +CloudEventFormatter formatter = new JsonEventFormatter(); +CloudEvent cloudEvent = await request.ToCloudEventAsync(formatter); +JObject dataAsJObject = (JObject) cloudEvent.Data; +GameResult result = dataAsJObject.ToObject(); +``` + +A future CloudEvent formatter could be written to know what type of +data to expect and deserialize it directly; that formatter could +even be a generic class derived from the existing +`JsonEventFormatter`. The `JObject` behavior is particular to +`JsonEventFormatter` - but the important point is that you need to +be aware of what the event formatter you're using is capable of. +Every event formatter should carefully document how it handles data, +both for serialization and deserialization purposes. diff --git a/samples/CloudNative.CloudEvents.AspNetCoreSample/Startup.cs b/samples/CloudNative.CloudEvents.AspNetCoreSample/Startup.cs index 719a30d..82cb86e 100644 --- a/samples/CloudNative.CloudEvents.AspNetCoreSample/Startup.cs +++ b/samples/CloudNative.CloudEvents.AspNetCoreSample/Startup.cs @@ -2,6 +2,7 @@ // 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.NewtonsoftJson; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; diff --git a/src/CloudNative.CloudEvents.AspNetCore/CloudEventJsonInputFormatter.cs b/src/CloudNative.CloudEvents.AspNetCore/CloudEventJsonInputFormatter.cs index cdf43be..ce784f8 100644 --- a/src/CloudNative.CloudEvents.AspNetCore/CloudEventJsonInputFormatter.cs +++ b/src/CloudNative.CloudEvents.AspNetCore/CloudEventJsonInputFormatter.cs @@ -9,7 +9,7 @@ using System; using System.Text; using System.Threading.Tasks; -namespace CloudNative.CloudEvents +namespace CloudNative.CloudEvents.AspNetCore { // 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. diff --git a/src/CloudNative.CloudEvents.AspNetCore/HttpRequestExtensions.cs b/src/CloudNative.CloudEvents.AspNetCore/HttpRequestExtensions.cs index 0892747..670ef2e 100644 --- a/src/CloudNative.CloudEvents.AspNetCore/HttpRequestExtensions.cs +++ b/src/CloudNative.CloudEvents.AspNetCore/HttpRequestExtensions.cs @@ -11,7 +11,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -namespace CloudNative.CloudEvents +namespace CloudNative.CloudEvents.AspNetCore { /// /// Extension methods to convert between HTTP requests and CloudEvents. @@ -58,7 +58,7 @@ namespace CloudNative.CloudEvents { var headers = httpRequest.Headers; headers.TryGetValue(HttpUtilities.SpecVersionHttpHeader, out var versionId); - var version = CloudEventsSpecVersion.FromVersionId(versionId.First()) + var version = CloudEventsSpecVersion.FromVersionId(versionId.FirstOrDefault()) ?? throw new ArgumentException($"Unknown CloudEvents spec version '{versionId}'", nameof(httpRequest)); if (version is null) diff --git a/test/CloudNative.CloudEvents.UnitTests/DocumentationSamples.cs b/test/CloudNative.CloudEvents.UnitTests/DocumentationSamples.cs new file mode 100644 index 0000000..ae081b0 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/DocumentationSamples.cs @@ -0,0 +1,152 @@ +// Copyright 2021 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.Http; +using CloudNative.CloudEvents.NewtonsoftJson; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Internal; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace CloudNative.CloudEvents.UnitTests +{ + /// + /// Tests for the code in the docs/ directory. + /// The code itself is currently copy/pasted from this file, but at least we have some confidence + /// that it's working code. In the future we can write tooling to extract the code automatically. + /// + public class DocumentationSamples + { + [Fact] + public async Task HttpRequestMessageRoundtrip() + { + var requestMessage = CreateHttpRequestMessage(); + var request = await ConvertHttpRequestMessage(requestMessage); + + var cloudEvent = await ParseHttpRequestAsync(request); + Assert.Equal("event-id", cloudEvent.Id); + Assert.Equal("This is CloudEvent data", cloudEvent.Data); + } + + private static HttpRequestMessage CreateHttpRequestMessage() + { + // Sample: guide.md#PopulateHttpRequestMessage + CloudEvent cloudEvent = new CloudEvent + { + Id = "event-id", + Type = "event-type", + Source = new Uri("https://cloudevents.io/"), + Time = DateTimeOffset.UtcNow, + DataContentType = "text/plain", + Data = "This is CloudEvent data" + }; + + CloudEventFormatter formatter = new JsonEventFormatter(); + HttpRequestMessage request = new HttpRequestMessage + { + Method = HttpMethod.Post, + Content = cloudEvent.ToHttpContent(ContentMode.Structured, formatter) + }; + // End sample + return request; + } + + private static async Task ParseHttpRequestAsync(HttpRequest request) + { + // Sample: guide.md#ParseHttpRequestMessage + CloudEventFormatter formatter = new JsonEventFormatter(); + CloudEvent cloudEvent = await request.ToCloudEventAsync(formatter); + // End sample + return cloudEvent; + } + + [Fact] + public async Task GameResultRoundtrip() + { + var requestMessage = SerializeGameResult(); + var request = await ConvertHttpRequestMessage(requestMessage); + var result = await DeserializeGameResult(request); + Assert.Equal("player1", result.PlayerId); + Assert.Equal("game1", result.GameId); + Assert.Equal(200, result.Score); + } + + // Sample: guide.md#GameResult + public class GameResult + { + [JsonProperty("playerId")] + public string PlayerId { get; set; } + + [JsonProperty("gameId")] + public string GameId { get; set; } + + [JsonProperty("score")] + public int Score { get; set; } + } + // End sample + + private static HttpRequestMessage SerializeGameResult() + { + // Sample: guide.md#SerializeGameResult + var result = new GameResult + { + PlayerId = "player1", + GameId = "game1", + Score = 200 + }; + var cloudEvent = new CloudEvent + { + Id = "result-1", + Type = "game.played.v1", + Source = new Uri("https://cloudevents.io/"), + Time = DateTimeOffset.UtcNow, + DataContentType = "application/json", + Data = result + }; + var formatter = new JsonEventFormatter(); + var request = new HttpRequestMessage + { + Method = HttpMethod.Post, + Content = cloudEvent.ToHttpContent(ContentMode.Binary, formatter) + }; + // End sample + return request; + } + + private static async Task DeserializeGameResult(HttpRequest request) + { + // Sample: guide.md#DeserializeGameResult + CloudEventFormatter formatter = new JsonEventFormatter(); + CloudEvent cloudEvent = await request.ToCloudEventAsync(formatter); + JObject dataAsJObject = (JObject) cloudEvent.Data; + GameResult result = dataAsJObject.ToObject(); + // End sample + return result; + } + + private static async Task ConvertHttpRequestMessage(HttpRequestMessage message) + { + var request = new DefaultHttpRequest(new DefaultHttpContext()); + foreach (var header in message.Headers) + { + request.Headers[header.Key] = header.Value.Single(); + } + foreach (var header in message.Content.Headers) + { + request.Headers[header.Key] = header.Value.Single(); + } + + var contentBytes = await message.Content.ReadAsByteArrayAsync(); + request.Body = new MemoryStream(contentBytes); + return request; + } + } +}