Skip to content

Commit

Permalink
Cloud Events
Browse files Browse the repository at this point in the history
  • Loading branch information
oklemenz2 committed Sep 30, 2024
1 parent 9d053a1 commit c9ae55a
Show file tree
Hide file tree
Showing 17 changed files with 717 additions and 313 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Added

- Add support for cloud events
- Add support for Cloud Events
- Include or exclude defined list of users
- Support exclusion of event contexts
- Overrule path of websocket event via `@websocket.path` or `@ws.path` for non-websocket services
- Overrule format of websocket event via `@websocket.format` or `@ws.format` for non-websocket services
- Ignore event elements or operation parameters with `@websocket.ignore` or `@ws.ignore`
- Optimization of client determination for kind `ws`
- Allow empty PCP message in event definition
- Improve documentation and examples
Expand Down
121 changes: 117 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ Furthermore, also additional equivalent annotations alternatives are available:

**Examples:**

**Entity Level:**
**Event Level:**

```cds
@websocket.user: 'includeCurrent'
Expand Down Expand Up @@ -434,7 +434,7 @@ Valid annotation values are:

**Examples:**

**Entity Level:**
**Event Level:**

```cds
@websocket.user.exclude: 'ABC'
Expand Down Expand Up @@ -498,7 +498,7 @@ Valid annotation values are:

**Examples:**

**Entity Level:**
**Event Level:**

```cds
@websocket.context: 'ABC'
Expand Down Expand Up @@ -642,7 +642,7 @@ Valid annotation values are:
**Examples:**
**Entity Level:**
**Event Level:**
```cds
@websocket.identifier.include: 'ABC'
Expand Down Expand Up @@ -765,6 +765,10 @@ specification. All event annotation values (static or dynamic) and header values
emit according to their kind. Values of all headers and annotations of same semantic type are unified for
single and array values.
### Ignore Elements
To ignore elements during event emit, the annotation `@websocket.ignore` or `@ws.ignore` is available on event element level.
### WebSocket Format
Per default the CDS websocket format is `json`, as CDS internally works with JSON objects.
Expand Down Expand Up @@ -820,6 +824,109 @@ To configure the PCP message format the following annotations are available:
- `@websocket.pcp.action, @ws.pcp.action: Boolean`: Expose the string value of the annotated event element as
`pcp-action` field in the PCP message. Default `MESSAGE`.
#### Cloud Events
CDS WebSocket module supports the Cloud Events specification out-of-the-box according to
[WebSockets Protocol Binding for CloudEvents](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/websockets-protocol-binding.md).
A Cloud Event message has the following structure:
```json
{
"specversion": "1.0",
"type": "com.example.someevent",
"source": "/mycontext",
"subject": null,
"id": "C234-1234-1234",
"time": "2018-04-05T17:31:00Z",
"comexampleextension1": "value",
"comexampleothervalue": 5,
"datacontenttype": "application/json",
"data": {
"appinfoA": "abc",
"appinfoB": 123,
"appinfoC": true
}
}
```
##### Modeling Cloud Event
```cds
event cloudEvent {
specversion : String;
type : String;
source : String;
subject : String;
id : String;
time : String;
comexampleextension1 : String;
comexampleothervalue : String;
datacontenttype : String;
data: {
appinfoA : String;
appinfoB : Integer;
appinfoC : Boolean;
}
}
```
##### Mapping Cloud Event
**Examples:**
**Event Level:**
```cds
@ws.cloudevent.specversion : '1.1'
@ws.cloudevent.type : 'com.example.someevent'
@ws.cloudevent.source : '/mycontext'
@ws.cloudevent.subject : 'example'
@ws.cloudevent.id : 'C234-1234-1234'
@ws.cloudevent.time : '2018-04-05T17:31:00Z'
@ws.cloudevent.comexampleextension1: 'value'
@ws.cloudevent.comexampleothervalue: 5
@ws.cloudevent.datacontenttype : 'application/cloudevents+json'
event cloudEvent2 {
appinfoA : String;
appinfoB : Integer;
appinfoC : Boolean;
}
```
Event is published only via cloud event sub-protocol, with the specified static cloud event attributes.
**Event Element Level:**
```cds
event cloudEvent3 {
@ws.cloudevent.specversion
specversion : String
@ws.cloudevent.type
type : String
@ws.cloudevent.source
source : String
@ws.cloudevent.subject
subject : String
@ws.cloudevent.id
id : String
@ws.cloudevent.time
time : String
@ws.cloudevent.comexampleextension1
extension1 : String
@ws.cloudevent.comexampleothervalue
othervalue : String
@ws.cloudevent.datacontenttype
datacontenttype : String;
appinfoA : String;
appinfoB : Integer;
appinfoC : Boolean;
}
```
Event is published only via cloud event sub-protocol, with the specified dynamic cloud event attributes derived from
CDS event elements.
#### Custom Format
A custom websocket format implementation can be provided via a path relative to the project root
Expand All @@ -835,6 +942,12 @@ In addition, it can implement the following functions (optional):
- **constructor(service)**: Setup instance with service definition on creation
#### Generic Format
Additionally, a custom formatter can be based on the generic implementation `format/generic.js` providing a name.
CDS annotations and header values are then derived from format name based on wildcard annotations
`@websocket.<name>.<annotation>` or `@ws.<name>.<annotation>`.
### Connect & Disconnect
Every time a server socket is connected via websocket client, the CDS service is notified by calling the corresponding
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 90,
functions: 95,
lines: 90,
statements: 90,
},
Expand Down
61 changes: 1 addition & 60 deletions src/format/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class BaseFormat {
/**
* Parse the event data into internal data (JSON), i.e. `{ event, data }`
* @param {String|Object} data Event data
* @returns {event: String, data: Object} Parsed data
* @returns [{event: String, data: Object}] Parsed data
*/
parse(data) {}

Expand All @@ -25,65 +25,6 @@ class BaseFormat {
* @returns {String|Object} Formatted string or a JSON object (for kind `socket.io` only)
*/
compose(event, data, headers) {}

/**
* Derive value from event data via annotations
* @param {String} event Event definition
* @param {Object} data Event data
* @param {Object} headers Event headers
* @param {[String]} headersNames Header names
* @param {[String]} annotationNames Annotations names
* @param {*} [fallback] Fallback value
* @returns {*} Derived value
*/
deriveValue(event, data, headers, { headerNames, annotationNames, fallback }) {
const eventDefinition = this.service.events()[event];
if (eventDefinition) {
if (headers) {
for (const header of headerNames || []) {
if (headers[header] !== undefined) {
return headers[header];
}
}
}
for (const annotation of annotationNames || []) {
if (eventDefinition[annotation] !== undefined) {
return eventDefinition[annotation];
}
}
if (data) {
const eventElements = Object.values(eventDefinition?.elements || {});
if (eventElements.length > 0) {
for (const annotation of annotationNames) {
const eventElement = eventElements.find((element) => {
return element[annotation];
});
if (eventElement) {
const elementValue = data[eventElement.name];
if (elementValue !== undefined) {
delete data[eventElement.name];
return elementValue;
}
}
}
}
}
}
return fallback;
}

localName(name) {
return name.startsWith(`${this.service.name}.`) ? name.substring(this.service.name.length + 1) : name;
};

stringValue(value) {
if (value instanceof Date) {
return value.toISOString();
} else if (value instanceof Object) {
return JSON.stringify(value);
}
return String(value);
}
}

module.exports = BaseFormat;
100 changes: 27 additions & 73 deletions src/format/cloudevent.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,98 +2,52 @@

const cds = require("@sap/cds");

const BaseFormat = require("./base");
const GenericFormat = require("./generic");

const LOG = cds.log("/websocket/cloudevent");

class CloudEventFormat extends BaseFormat {
class CloudEventFormat extends GenericFormat {
constructor(service, origin) {
super(service, origin);
super(service, origin, "cloudevent", "type");
}

parse(data) {
try {
const cloudEvent = data?.constructor === Object ? data : JSON.parse(data);
const result = {};
const operation = Object.values(this.service.operations || {}).find((operation) => {
return (
operation["@websocket.cloudevent.type"] === cloudEvent.type ||
operation["@ws.cloudevent.type"] === cloudEvent.type ||
operation.name === cloudEvent.type
);
});
if (operation) {
for (const param of operation.params) {
// TODO: Parse into data
}
return {
event: this.localName(operation.name),
data: result,
};
}
} catch (err) {
LOG?.error(err);
data = this.deserialize(data);
const operation = this.determineOperation(data);
if (typeof data?.data === "object" && !operation?.params?.data) {
const ceData = data.data;
delete data.data;
data = {
...data,
...ceData,
};
}
LOG?.warn("Error parsing cloud-event format", data);
return {
event: undefined,
data: {},
};
return super.parse(data);
}

compose(event, data, headers) {
const cloudEvent = {
let cloudEvent = {
specversion: "1.0",
type: `${this.service.name}.${event}`,
source: this.service.name,
subject: null,
id: cds.utils.uuid(),
time: new Date().toISOString(),
datacontenttype: "application/json",
data,
data: {},
};
const annotations = this.collectAnnotations(event);
for (const annotation of annotations) {
const value = this.deriveValue(event, data, headers, {
headerValues: [
`cloudevent-${annotation}`,
`cloudevent_${annotation}`,
`cloudevent.${annotation}`,
`cloudevent${annotation}`,
annotation,
],
annotationValues: [`@websocket.cloudevent.${annotation}`, `@ws.cloudevent.${annotation}`],
});
if (value !== undefined) {
cloudEvent[annotation] = value;
}
}
return this.origin === "json" ? cloudEvent : JSON.stringify(cloudEvent);
}

collectAnnotations(event) {
const annotations = new Set();
const eventDefinition = this.service.events()[event];
for (const annotation in eventDefinition) {
if (annotation.startsWith("@websocket.cloudevent.")) {
annotations.add(annotation.substring("@websocket.cloudevent.".length));
}
if (annotation.startsWith("@ws.cloudevent.")) {
annotations.add(annotation.substring("@ws.cloudevent.".length));
}
if (eventDefinition?.elements?.data && data.data) {
cloudEvent = {
...data,
};
} else {
cloudEvent.data = data;
}
const eventElements = Object.values(eventDefinition?.elements || {});
for (const element of eventElements) {
for (const annotation in element) {
if (annotation.startsWith("@websocket.cloudevent.")) {
annotations.add(annotation.substring("@websocket.cloudevent.".length));
}
if (annotation.startsWith("@ws.cloudevent.")) {
annotations.add(annotation.substring("@ws.cloudevent.".length));
}
}
}
return annotations;
const result = super.compose(event, data, headers);
cloudEvent = {
...cloudEvent,
...result,
};
return this.serialize(cloudEvent);
}
}

Expand Down
Loading

0 comments on commit c9ae55a

Please sign in to comment.