Skip to content

Commit b1babda

Browse files
committed
wsContext in other formats
1 parent 8f49017 commit b1babda

File tree

12 files changed

+301
-64
lines changed

12 files changed

+301
-64
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1919
- Clean websocket context from internal read-only attributes
2020
- Normalize client identifier on upgrade request `request.id`
2121
- Return 404 when url cannot be associated with a service in upgrade request
22+
- Parse string values according to operation parameter type in custom formats (e.g. `pcp`)
2223

2324
### Added
2425

2526
- `reset` all contexts via `wsContext` flag `reset: true`
2627
- Support multiple contexts in `wsContext` with array value for `context`
2728
- Document usage of `srv.tx(req).emit` for tenant and user propagation in WS broadcasting
29+
- Document usage of `wsContext` for format `pcp` and `cloudevents`
2830

2931
## Version 1.5.2 - 2025-01-10
3032

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,30 @@ To configure the PCP message format the following annotations are available:
10101010
- `@websocket.pcp.action, @ws.pcp.action: Boolean`: Expose the string value of the annotated event element as
10111011
`pcp-action` field in the PCP message. Default `MESSAGE`.
10121012
1013+
##### Manage Contexts
1014+
1015+
To manage contexts in format `pcp`, `wsContext` event can be emitted in the following way:
1016+
1017+
- Model `wsContext` CDS service operation as follows:
1018+
```
1019+
@ws.pcp.action: 'wsContext'
1020+
action wsContext(context: String, exit: Boolean, reset: Boolean);
1021+
```
1022+
- Call `wsContext` message in `pcp` format like this:
1023+
1024+
```
1025+
pcp-action:wsContext
1026+
pcp-body-type:text
1027+
context:context
1028+
exit:false
1029+
reset:true
1030+
1031+
wsContext
1032+
```
1033+
1034+
The PCP action needs to match an operation with annotation `@websocket.pcp.action` or `@ws.pcp.action`.
1035+
Modeled action parameters `context`, `exit` and `reset` are mapped from `pcp` message fields.
1036+
10131037
#### Cloud Events
10141038
10151039
CDS WebSocket module supports the [Cloud Events](https://cloudevents.io) specification out-of-the-box according to
@@ -1237,6 +1261,32 @@ action sendCloudEventMap(
12371261
Unmapped operation parameters are consumed as cloud event data section and can be skipped for cloud event data section
12381262
via `@ws.ignore`, if not necessary.
12391263
1264+
##### Manage Contexts
1265+
1266+
To manage contexts in format `cloudevent`, `wsContext` event can be emitted in the following way:
1267+
1268+
- Model `wsContext` CDS service operation as follows:
1269+
```
1270+
@ws.cloudevent.name: 'event.ws.context'
1271+
action wsContext(context: String, exit: Boolean, reset: Boolean);
1272+
```
1273+
- Call `wsContext` message in `cloudevents` format like this:
1274+
```
1275+
{
1276+
specversion: "1.0",
1277+
type: "event.ws.context",
1278+
source: "CloudEventService",
1279+
data: {
1280+
context: "context",
1281+
exit: false,
1282+
reset: true,
1283+
}
1284+
}
1285+
```
1286+
1287+
The cloud event type needs to match an operation with annotation `@websocket.cloudevent.name` or `@ws.cloudevent.name`.
1288+
Modeled action parameters `context`, `exit` and `reset` are mapped from `cloudevent` message data section.
1289+
12401290
##### Cloud Event Format Alternative
12411291
12421292
Alternatives for format `cloudevent` also allows to use the plural name `@websocket.format: 'cloudevents'` or `@ws.format: 'cloudevents'`,

src/format/generic.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class GenericFormat extends BaseFormat {
3636
data: result,
3737
};
3838
}
39-
this.LOG?.error(`Operation could not be determined`, data);
39+
this.LOG?.error(`Operation could not be determined from name`, data);
4040
return {
4141
event: undefined,
4242
data: {},
@@ -283,6 +283,52 @@ class GenericFormat extends BaseFormat {
283283
serialize(data) {
284284
return this.origin === "json" ? data : JSON.stringify(data);
285285
}
286+
287+
/**
288+
* Serialize value to string
289+
* @param value Value
290+
* @returns {string} String value
291+
*/
292+
stringValue(value) {
293+
if (value instanceof Date) {
294+
return value.toISOString();
295+
} else if (value instanceof Object) {
296+
return JSON.stringify(value);
297+
}
298+
return String(value);
299+
}
300+
301+
/**
302+
* Parse string value based on type
303+
* @param value Value
304+
* @param type Type
305+
* @returns {string|boolean|number|Date} Parsed value
306+
*/
307+
parseStringValue(value, type) {
308+
if (value === undefined || value === null) {
309+
return value;
310+
}
311+
if (type === "cds.Boolean" && ["false", "true"].includes(value)) {
312+
return value === "true";
313+
}
314+
if (
315+
(type.startsWith("cds.Int") ||
316+
type.startsWith("cds.UInt") ||
317+
type.startsWith("cds.Decimal") ||
318+
type.startsWith("cds.Double")) &&
319+
!isNaN(value)
320+
) {
321+
return parseFloat(value);
322+
}
323+
if (
324+
["cds.Date", "cds.DateTime", "cds.Timestamp"].includes(type) &&
325+
new Date(value) instanceof Date &&
326+
!isNaN(new Date(value))
327+
) {
328+
return new Date(value);
329+
}
330+
return value;
331+
}
286332
}
287333

288334
module.exports = GenericFormat;

src/format/pcp.js

Lines changed: 55 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class PCPFormat extends GenericFormat {
2424
if (splitPos !== -1) {
2525
const result = {};
2626
const message = data.substring(splitPos + SEPARATOR.length);
27-
const pcpFields = extractPcpFields(data.substring(0, splitPos));
27+
const pcpFields = this.extractPcpFields(data.substring(0, splitPos));
2828
const operation = Object.values(this.service.operations).find((operation) => {
2929
return (
3030
(operation["@websocket.pcp.action"] &&
@@ -41,14 +41,15 @@ class PCPFormat extends GenericFormat {
4141
if (param["@websocket.pcp.message"] || param["@ws.pcp.message"]) {
4242
result[param.name] = message;
4343
} else if (pcpFields[param.name] !== undefined) {
44-
result[param.name] = pcpFields[param.name];
44+
result[param.name] = this.parseStringValue(pcpFields[param.name], param.type);
4545
}
4646
}
4747
return {
4848
event: this.localName(operation.name),
4949
data: result,
5050
};
5151
}
52+
this.LOG?.error(`Operation could not be determined from action`, data);
5253
}
5354
LOG?.error("Error parsing pcp format", data);
5455
return {
@@ -75,79 +76,70 @@ class PCPFormat extends GenericFormat {
7576
});
7677
const pcpEvent =
7778
eventDefinition?.["@websocket.pcp.event"] || eventDefinition?.["@ws.pcp.event"] ? event : undefined;
78-
const pcpFields = serializePcpFields(data, typeof pcpMessage, pcpAction, pcpEvent, eventDefinition?.elements);
79+
const pcpFields = this.serializePcpFields(data, typeof pcpMessage, pcpAction, pcpEvent, eventDefinition?.elements);
7980
return pcpFields + pcpMessage;
8081
}
81-
}
8282

83-
const serializePcpFields = (pcpFields, messageType, pcpAction, pcpEvent, elements) => {
84-
let pcpBodyType = "";
85-
if (messageType === "string") {
86-
pcpBodyType = "text";
87-
} else if (messageType === "blob" || messageType === "arraybuffer") {
88-
pcpBodyType = "binary";
89-
}
90-
let serialized = "";
91-
if (pcpFields && typeof pcpFields === "object") {
92-
for (const fieldName in pcpFields) {
93-
const fieldValue = stringValue(pcpFields[fieldName]);
94-
const element = elements?.[fieldName];
95-
if (element) {
96-
if (element["@websocket.ignore"] || element["@ws.ignore"]) {
97-
continue;
98-
}
99-
if (fieldValue && fieldName.indexOf("pcp-") !== 0) {
100-
serialized += escape(fieldName) + ":" + escape(fieldValue) + "\n";
83+
serializePcpFields(pcpFields, messageType, pcpAction, pcpEvent, elements) {
84+
let pcpBodyType = "";
85+
if (messageType === "string") {
86+
pcpBodyType = "text";
87+
} else if (messageType === "blob" || messageType === "arraybuffer") {
88+
pcpBodyType = "binary";
89+
}
90+
let serialized = "";
91+
if (pcpFields && typeof pcpFields === "object") {
92+
for (const fieldName in pcpFields) {
93+
const fieldValue = this.stringValue(pcpFields[fieldName]);
94+
const element = elements?.[fieldName];
95+
if (element) {
96+
if (element["@websocket.ignore"] || element["@ws.ignore"]) {
97+
continue;
98+
}
99+
if (fieldValue && fieldName.indexOf("pcp-") !== 0) {
100+
serialized += this.escape(fieldName) + ":" + this.escape(fieldValue) + "\n";
101+
}
101102
}
102103
}
103104
}
105+
return (
106+
(pcpAction ? "pcp-action:" + pcpAction + "\n" : "") +
107+
(pcpEvent ? "pcp-event:" + pcpEvent + "\n" : "") +
108+
"pcp-body-type:" +
109+
pcpBodyType +
110+
"\n" +
111+
serialized +
112+
"\n"
113+
);
104114
}
105-
return (
106-
(pcpAction ? "pcp-action:" + pcpAction + "\n" : "") +
107-
(pcpEvent ? "pcp-event:" + pcpEvent + "\n" : "") +
108-
"pcp-body-type:" +
109-
pcpBodyType +
110-
"\n" +
111-
serialized +
112-
"\n"
113-
);
114-
};
115115

116-
const extractPcpFields = (header) => {
117-
const pcpFields = {};
118-
for (const field of header.split("\n")) {
119-
const lines = field.match(DESERIALIZE_REGEX);
120-
if (lines && lines.length === 3) {
121-
pcpFields[unescape(lines[1])] = unescape(lines[2]);
116+
extractPcpFields(header) {
117+
const pcpFields = {};
118+
for (const field of header.split("\n")) {
119+
const lines = field.match(DESERIALIZE_REGEX);
120+
if (lines && lines.length === 3) {
121+
pcpFields[this.unescape(lines[1])] = this.unescape(lines[2]);
122+
}
122123
}
124+
return pcpFields;
123125
}
124-
return pcpFields;
125-
};
126-
127-
const escape = (unescaped) => {
128-
return unescaped.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/\n/g, "\\n");
129-
};
130126

131-
const unescape = (escaped) => {
132-
return escaped
133-
.split("\u0008")
134-
.map((part) => {
135-
return part
136-
.replace(/\\\\/g, "\u0008")
137-
.replace(/\\:/g, ":")
138-
.replace(/\\n/g, "\n")
139-
.replace(/\u0008/g, "\\");
140-
})
141-
.join("\u0008");
142-
};
127+
escape(unescaped) {
128+
return unescaped.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/\n/g, "\\n");
129+
}
143130

144-
const stringValue = (value) => {
145-
if (value instanceof Date) {
146-
return value.toISOString();
147-
} else if (value instanceof Object) {
148-
return JSON.stringify(value);
131+
unescape(escaped) {
132+
return escaped
133+
.split("\u0008")
134+
.map((part) => {
135+
return part
136+
.replace(/\\\\/g, "\u0008")
137+
.replace(/\\:/g, ":")
138+
.replace(/\\n/g, "\n")
139+
.replace(/\u0008/g, "\\");
140+
})
141+
.join("\u0008");
149142
}
150-
return String(value);
151-
};
143+
}
152144

153145
module.exports = PCPFormat;

test/_env/srv/cloudevent.cds

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ service CloudEventService {
1111
appinfoD : String;
1212
};
1313

14+
@ws.cloudevent.name: 'com.example.ws.context'
15+
action wsContext(context: String, exit: Boolean, reset: Boolean);
16+
1417
@ws.cloudevent.name: 'com.example.someevent.model'
1518
action sendCloudEventModel( specversion : String, type : String, source : String, subject : String, id : String, time : String, comexampleextension1 : String, comexampleothervalue : Integer, datacontenttype : String, data: CloudEventDataType) returns Boolean;
1619

@@ -31,6 +34,9 @@ service CloudEventService {
3134
appinfoC : Boolean,
3235
@ws.ignore appinfoD : String) returns Boolean;
3336

37+
@ws.cloudevent.name: 'com.example.someevent.context'
38+
action sendCloudEventContext() returns Boolean;
39+
3440
event cloudEvent1 {
3541
appinfoA : String;
3642
appinfoB : Integer;

test/_env/srv/handlers/cloudevent.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,18 @@ module.exports = (srv) => {
7777
});
7878
return true;
7979
});
80+
81+
srv.on("sendCloudEventContext", async (req) => {
82+
await srv.emit(
83+
"cloudEvent1",
84+
{
85+
appinfoA: "abcd",
86+
appinfoB: 1234,
87+
appinfoC: false,
88+
},
89+
{
90+
context: "context",
91+
},
92+
);
93+
});
8094
};

test/_env/srv/handlers/pcp.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,18 @@ module.exports = (srv) => {
3535
);
3636
return true;
3737
});
38+
39+
srv.on("sendNotificationWithContext", async (req) => {
40+
await srv.emit(
41+
"notification1",
42+
{
43+
field1: req.data.field1 || "value1",
44+
field2: req.data.field2 || "value2",
45+
},
46+
{
47+
context: "context",
48+
},
49+
);
50+
return true;
51+
});
3852
};

test/_env/srv/pcp.cds

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ service PCPService {
66
@ws.pcp.action: 'MESSAGE'
77
action sendNotification(@ws.pcp.message message: String, field1: String, field2: String, @ws.ignore field3: String, ![pcp-action]: String) returns Boolean;
88

9+
@ws.pcp.action: 'MESSAGE_CONTEXT'
10+
action sendNotificationWithContext() returns Boolean;
11+
12+
@ws.pcp.action: 'wsContext'
13+
action wsContext(context: String, exit: Boolean, reset: Boolean);
14+
915
@ws.pcp.event
1016
@ws.pcp.message: 'this is the body!'
1117
event notification1 {

0 commit comments

Comments
 (0)