/
JavaScriptMessageMapperRhino.java
390 lines (340 loc) · 20 KB
/
JavaScriptMessageMapperRhino.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
/*
* Copyright (c) 2017-2018 Bosch Software Innovations GmbH.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/org/documents/epl-2.0/index.php
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.ditto.services.connectivity.mapping.javascript;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import javax.script.Bindings;
import org.eclipse.ditto.json.JsonFactory;
import org.eclipse.ditto.json.JsonObject;
import org.eclipse.ditto.model.base.common.DittoConstants;
import org.eclipse.ditto.model.base.exceptions.DittoJsonException;
import org.eclipse.ditto.model.base.headers.DittoHeaders;
import org.eclipse.ditto.model.connectivity.MessageMapperConfigurationFailedException;
import org.eclipse.ditto.model.connectivity.MessageMappingFailedException;
import org.eclipse.ditto.protocoladapter.Adaptable;
import org.eclipse.ditto.protocoladapter.JsonifiableAdaptable;
import org.eclipse.ditto.protocoladapter.ProtocolFactory;
import org.eclipse.ditto.services.connectivity.mapping.MessageMapper;
import org.eclipse.ditto.services.connectivity.mapping.MessageMapperConfiguration;
import org.eclipse.ditto.services.models.connectivity.ExternalMessage;
import org.eclipse.ditto.services.models.connectivity.ExternalMessageBuilder;
import org.eclipse.ditto.services.models.connectivity.ExternalMessageFactory;
import org.mozilla.javascript.Callable;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ContextFactory;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.NativeJSON;
import org.mozilla.javascript.NativeObject;
import org.mozilla.javascript.RhinoException;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.Undefined;
import org.mozilla.javascript.typedarrays.NativeArrayBuffer;
import com.typesafe.config.Config;
/**
* This mapper executes its mapping methods on the <b>current thread</b>. The caller should be aware of that.
*/
final class JavaScriptMessageMapperRhino implements MessageMapper {
private static final String WEBJARS_PATH = "/META-INF/resources/webjars";
private static final String WEBJARS_BYTEBUFFER = WEBJARS_PATH + "/bytebuffer/5.0.1/dist/bytebuffer.js";
private static final String WEBJARS_LONG = WEBJARS_PATH + "/long/3.2.0/dist/long.min.js";
private static final String DITTO_SCOPE_SCRIPT = "/javascript/ditto-scope.js";
private static final String INCOMING_SCRIPT = "/javascript/incoming-mapping.js";
private static final String OUTGOING_SCRIPT = "/javascript/outgoing-mapping.js";
private static final String EXTERNAL_MESSAGE_HEADERS = "headers";
private static final String EXTERNAL_MESSAGE_CONTENT_TYPE = "contentType";
private static final String EXTERNAL_MESSAGE_TEXT_PAYLOAD = "textPayload";
private static final String EXTERNAL_MESSAGE_BYTE_PAYLOAD = "bytePayload";
private static final String INCOMING_FUNCTION_NAME = "mapToDittoProtocolMsgWrapper";
private static final String OUTGOING_FUNCTION_NAME = "mapFromDittoProtocolMsgWrapper";
private static final String CONFIG_JAVASCRIPT_MAX_SCRIPT_SIZE_BYTES = "javascript.maxScriptSizeBytes";
private static final String CONFIG_JAVASCRIPT_MAX_SCRIPT_EXECUTION_TIME = "javascript.maxScriptExecutionTime";
private static final String CONFIG_JAVASCRIPT_MAX_SCRIPT_STACK_DEPTH = "javascript.maxScriptStackDepth";
@Nullable
private ContextFactory contextFactory;
@Nullable
private Scriptable scope;
@Nullable private JavaScriptMessageMapperConfiguration configuration;
private boolean incomingScriptIsEmpty = false;
private boolean outgoingScriptIsEmpty = false;
JavaScriptMessageMapperRhino() {
// no-op
}
@Override
public void configure(final Config mappingConfig, final MessageMapperConfiguration options) {
this.configuration = new ImmutableJavaScriptMessageMapperConfiguration.Builder(options.getProperties()).build();
final int maxScriptSizeBytes = mappingConfig.getInt(CONFIG_JAVASCRIPT_MAX_SCRIPT_SIZE_BYTES);
final Integer incomingScriptSize = configuration.getIncomingScript().map(String::length).orElse(0);
final Integer outgoingScriptSize = configuration.getOutgoingScript().map(String::length).orElse(0);
if (incomingScriptSize > maxScriptSizeBytes || outgoingScriptSize > maxScriptSizeBytes) {
throw MessageMapperConfigurationFailedException
.newBuilder("The script size was bigger than the allowed <" + maxScriptSizeBytes + "> bytes: " +
"incoming script size was <" + incomingScriptSize + "> bytes, " +
"outgoing script size was <" + outgoingScriptSize + "> bytes")
.build();
}
contextFactory = new SandboxingContextFactory(
mappingConfig.getDuration(CONFIG_JAVASCRIPT_MAX_SCRIPT_EXECUTION_TIME),
mappingConfig.getInt(CONFIG_JAVASCRIPT_MAX_SCRIPT_STACK_DEPTH));
try {
// create scope once and load the required libraries in order to get best performance:
scope = (Scriptable) contextFactory.call(cx -> {
final Scriptable scope = cx.initSafeStandardObjects(); // that one disables "print, exit, quit", etc.
initLibraries(cx, scope);
return scope;
});
} catch (final RhinoException e) {
final boolean sourceExists = e.lineSource() != null && !e.lineSource().isEmpty();
final String lineSource = sourceExists ? (", source:\n" + e.lineSource()) : "";
final boolean stackExists = e.getScriptStackTrace() != null && !e.getScriptStackTrace().isEmpty();
final String scriptStackTrace = stackExists ? (", stack:\n" + e.getScriptStackTrace()) : "";
throw MessageMapperConfigurationFailedException.newBuilder(e.getMessage() +
" - in line/column #" + e.lineNumber() + "/" + e.columnNumber() + lineSource + scriptStackTrace)
.cause(e)
.build();
}
}
@Override
public Optional<Adaptable> map(final ExternalMessage message) {
if (incomingScriptIsEmpty) {
// shortcut: the user defined an empty incoming mapping script -> assume that the ExternalMessage is in DittoProtocol
return Optional.ofNullable(
message.getTextPayload()
.orElseGet(() -> message.getBytePayload()
.map(b -> StandardCharsets.UTF_8.decode(b).toString())
.orElse(null))
).map(plainString -> DittoJsonException.wrapJsonRuntimeException(() -> {
final JsonObject jsonObject = JsonFactory.readFrom(plainString).asObject();
return ProtocolFactory.jsonifiableAdaptableFromJson(jsonObject);
}));
} else {
try {
return Optional.ofNullable((Adaptable) contextFactory.call(cx -> {
final NativeObject headersObj = new NativeObject();
message.getHeaders().forEach((key, value) -> headersObj.put(key, headersObj, value));
final NativeArrayBuffer bytePayload;
if (message.getBytePayload().isPresent()) {
final ByteBuffer byteBuffer = message.getBytePayload().get();
final byte[] array = byteBuffer.array();
bytePayload = new NativeArrayBuffer(array.length);
for (int a = 0; a < array.length; a++) {
bytePayload.getBuffer()[a] = array[a];
}
} else {
bytePayload = null;
}
final String contentType = message.getHeaders().get(ExternalMessage.CONTENT_TYPE_HEADER);
final String textPayload = message.getTextPayload().orElse(null);
final NativeObject externalMessage = new NativeObject();
externalMessage.put(EXTERNAL_MESSAGE_HEADERS, externalMessage, headersObj);
externalMessage.put(EXTERNAL_MESSAGE_TEXT_PAYLOAD, externalMessage, textPayload);
externalMessage.put(EXTERNAL_MESSAGE_BYTE_PAYLOAD, externalMessage, bytePayload);
externalMessage.put(EXTERNAL_MESSAGE_CONTENT_TYPE, externalMessage, contentType);
final Function mapToDittoProtocolMsgWrapper = (Function) scope.get(INCOMING_FUNCTION_NAME, scope);
final Object result = mapToDittoProtocolMsgWrapper.call(cx, scope, scope, new Object[] {externalMessage});
if (result == null) {
// return null if result is null causing the wrapping Optional to be empty
return null;
}
final String dittoProtocolJsonStr = (String) NativeJSON.stringify(cx, scope, result, null, null);
return DittoJsonException.wrapJsonRuntimeException(() -> {
final JsonObject jsonObject = JsonFactory.readFrom(dittoProtocolJsonStr).asObject();
return ProtocolFactory.jsonifiableAdaptableFromJson(jsonObject);
});
}));
} catch (final RhinoException e) {
throw buildMessageMappingFailedException(e, message.findContentType().orElse(""),
DittoHeaders.of(message.getHeaders()));
} catch (final Throwable e) {
throw MessageMappingFailedException.newBuilder(message.findContentType().orElse(null))
.description(e.getMessage())
.dittoHeaders(DittoHeaders.of(message.getHeaders()))
.cause(e)
.build();
}
}
}
private MessageMappingFailedException buildMessageMappingFailedException(final RhinoException e,
final String contentType, final DittoHeaders dittoHeaders) {
final boolean sourceExists = e.lineSource() != null && !e.lineSource().isEmpty();
final String lineSource = sourceExists ? (", source:\n" + e.lineSource()) : "";
final boolean stackExists = e.getScriptStackTrace() != null && !e.getScriptStackTrace().isEmpty();
final String scriptStackTrace = stackExists ? (", stack:\n" + e.getScriptStackTrace()) : "";
return MessageMappingFailedException.newBuilder(contentType)
.description(e.getMessage() + " - in line/column #" + e.lineNumber() + "/" + e.columnNumber() +
lineSource + scriptStackTrace)
.dittoHeaders(dittoHeaders)
.cause(e)
.build();
}
@Override
public Optional<ExternalMessage> map(final Adaptable adaptable) {
final JsonifiableAdaptable jsonifiableAdaptable =
ProtocolFactory.wrapAsJsonifiableAdaptable(adaptable);
if (outgoingScriptIsEmpty) {
// shortcut: the user defined an empty outgoing mapping script -> send the Adaptable as DittoProtocol JSON
final ExternalMessageBuilder messageBuilder = ExternalMessageFactory.newExternalMessageBuilder(
adaptable.getHeaders().orElseGet(adaptable::getDittoHeaders));
messageBuilder.withAdditionalHeaders(ExternalMessage.CONTENT_TYPE_HEADER,
DittoConstants.DITTO_PROTOCOL_CONTENT_TYPE);
messageBuilder.withText(jsonifiableAdaptable.toJsonString());
return Optional.of(messageBuilder.build());
} else {
try {
return Optional.ofNullable((ExternalMessage) contextFactory.call(cx -> {
final Object dittoProtocolMessage =
NativeJSON.parse(cx, scope, jsonifiableAdaptable.toJsonString(), new NullCallable());
final Function mapFromDittoProtocolMsgWrapper = (Function) scope.get(OUTGOING_FUNCTION_NAME, scope);
final NativeObject result =
(NativeObject) mapFromDittoProtocolMsgWrapper.call(cx, scope, scope,
new Object[]{dittoProtocolMessage});
if (result == null) {
// return null if result is null causing the wrapping Optional to be empty
return null;
}
final Object contentType = result.get(EXTERNAL_MESSAGE_CONTENT_TYPE);
final Object textPayload = result.get(EXTERNAL_MESSAGE_TEXT_PAYLOAD);
final Object bytePayload = result.get(EXTERNAL_MESSAGE_BYTE_PAYLOAD);
final Object mappingHeaders = result.get(EXTERNAL_MESSAGE_HEADERS);
final Map<String, String> headers;
if (mappingHeaders != null && !(mappingHeaders instanceof Undefined)) {
headers = new HashMap<>();
final Map jsHeaders = (Map) mappingHeaders;
jsHeaders.forEach((key, value) -> headers.put((String) key, value.toString()));
} else {
headers = Collections.emptyMap();
}
final ExternalMessageBuilder messageBuilder =
ExternalMessageFactory.newExternalMessageBuilder(headers);
if (!(contentType instanceof Undefined)) {
messageBuilder.withAdditionalHeaders(ExternalMessage.CONTENT_TYPE_HEADER,
((CharSequence) contentType).toString());
}
final Optional<ByteBuffer> byteBuffer = convertToByteBuffer(bytePayload);
if (byteBuffer.isPresent()) {
messageBuilder.withBytes(byteBuffer.get());
} else if (!(textPayload instanceof Undefined)) {
messageBuilder.withText(((CharSequence) textPayload).toString());
} else {
throw MessageMappingFailedException.newBuilder("")
.description("Neither <bytePayload> nor <textPayload> were defined in the outgoing script")
.dittoHeaders(adaptable.getHeaders().orElse(DittoHeaders.empty()))
.build();
}
return messageBuilder.build();
}));
} catch (final RhinoException e) {
throw buildMessageMappingFailedException(e, MessageMapper.findContentType(adaptable).orElse(""),
adaptable.getHeaders().orElseGet(DittoHeaders::empty));
} catch (final Throwable e) {
throw MessageMappingFailedException.newBuilder(MessageMapper.findContentType(adaptable).orElse(""))
.description(e.getMessage())
.dittoHeaders(adaptable.getHeaders().orElseGet(DittoHeaders::empty))
.cause(e)
.build();
}
}
}
private void initLibraries(final Context cx, final Scriptable scope) {
if (getConfiguration().map(JavaScriptMessageMapperConfiguration::isLoadLongJS).orElse(false)) {
loadJavascriptLibrary(cx, scope, new InputStreamReader(getClass().getResourceAsStream(WEBJARS_LONG)),
WEBJARS_LONG);
}
if (getConfiguration().map(JavaScriptMessageMapperConfiguration::isLoadBytebufferJS).orElse(false)) {
loadJavascriptLibrary(cx, scope, new InputStreamReader(getClass().getResourceAsStream(WEBJARS_BYTEBUFFER)),
WEBJARS_BYTEBUFFER);
}
loadJavascriptLibrary(cx, scope, new InputStreamReader(getClass().getResourceAsStream(DITTO_SCOPE_SCRIPT)),
DITTO_SCOPE_SCRIPT);
loadJavascriptLibrary(cx, scope, new InputStreamReader(getClass().getResourceAsStream(INCOMING_SCRIPT)),
INCOMING_SCRIPT);
loadJavascriptLibrary(cx, scope, new InputStreamReader(getClass().getResourceAsStream(OUTGOING_SCRIPT)),
OUTGOING_SCRIPT);
final String userIncomingScript = getConfiguration()
.flatMap(JavaScriptMessageMapperConfiguration::getIncomingScript)
.orElse("");
incomingScriptIsEmpty = userIncomingScript.isEmpty();
if (!incomingScriptIsEmpty) {
cx.evaluateString(scope, userIncomingScript,
JavaScriptMessageMapperConfigurationProperties.INCOMING_SCRIPT, 1, null);
}
final String userOutgoingScript = getConfiguration()
.flatMap(JavaScriptMessageMapperConfiguration::getOutgoingScript)
.orElse("");
outgoingScriptIsEmpty = userOutgoingScript.isEmpty();
if (!outgoingScriptIsEmpty) {
cx.evaluateString(scope, userOutgoingScript,
JavaScriptMessageMapperConfigurationProperties.OUTGOING_SCRIPT, 1, null);
}
}
private Optional<JavaScriptMessageMapperConfiguration> getConfiguration() {
return Optional.ofNullable(configuration);
}
private void loadJavascriptLibrary(final Context cx, final Scriptable scope, final Reader reader,
final String libraryName) {
try {
cx.evaluateReader(scope, reader, libraryName, 1, null);
} catch (final IOException e) {
throw new IllegalStateException("Could not load script <" + libraryName + ">", e);
}
}
private static Optional<ByteBuffer> convertToByteBuffer(final Object obj) {
if (obj instanceof NativeArrayBuffer) {
return Optional.of(ByteBuffer.wrap(((NativeArrayBuffer) obj).getBuffer()));
} else if (obj instanceof Bindings) {
try {
final Class<?> cls = Class.forName("jdk.nashorn.api.scripting.ScriptObjectMirror");
if (cls.isAssignableFrom(obj.getClass())) {
final Method isArray = cls.getMethod("isArray");
final Object result = isArray.invoke(obj);
if (result != null && result.equals(true)) {
final Method values = cls.getMethod("values");
final Object vals = values.invoke(obj);
if (vals instanceof Collection) {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
final Collection coll = (Collection) vals;
coll.forEach(e -> baos.write(((Number) e).intValue()));
return Optional.of(ByteBuffer.wrap(baos.toByteArray()));
}
}
}
} catch (final ClassNotFoundException | NoSuchMethodException | SecurityException
| IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new IllegalStateException("Could not retrieve array values", e);
}
} else if (obj instanceof List<?>) {
final List<?> list = (List<?>) obj;
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
list.forEach(e -> baos.write(((Number) e).intValue()));
return Optional.of(ByteBuffer.wrap(baos.toByteArray()));
}
return Optional.empty();
}
private static class NullCallable implements Callable {
@Override
public Object call(final Context context, final Scriptable scope, final Scriptable holdable,
final Object[] objects) {
return objects[1];
}
}
}