@lightbend/akkaserverless-javascript-sdk
Version:
Akka Serverless JavaScript SDK
317 lines (289 loc) • 9.72 kB
JavaScript
/*
* Copyright 2021 Lightbend Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const path = require('path');
const util = require('util');
const protobufHelper = require('./protobuf-helper');
const protobuf = require('protobufjs');
const Long = require('long');
const stableJsonStringify = require('json-stable-stringify');
const Any = protobufHelper.moduleRoot.google.protobuf.Any;
// To allow primitive types to be stored, Akka Serverless defines a number of primitive type URLs, based on protobuf types.
// The serialized values are valid protobuf messages that contain a value of that type as their single field at index
// 15.
const AkkaServerlessPrimitive = 'p.akkaserverless.com/';
// Chosen because it reduces the likelihood of clashing with something else.
const AkkaServerlessPrimitiveFieldNumber = 1;
const AkkaServerlessPrimitiveFieldNumberEncoded =
AkkaServerlessPrimitiveFieldNumber << 3; // 8
const AkkaServerlessSupportedPrimitiveTypes = new Set();
['string', 'bytes', 'int64', 'bool', 'double'].forEach(
AkkaServerlessSupportedPrimitiveTypes.add.bind(
AkkaServerlessSupportedPrimitiveTypes,
),
);
const EmptyArray = Object.freeze([]);
const AkkaServerlessJson = 'json.akkaserverless.com/';
/**
* This is any type that has been returned by the protobufjs Message.create method.
*
* It should have a encode() method on it.
*
* @typedef module:akkaserverless.SerializableProtobufMessage
* @type {Object}
*/
/**
* Any type that has a type property on it can be serialized as JSON, with the value of the type property describing
* the type of the value.
*
* @typedef module:akkaserverless.TypedJson
* @type {Object}
* @property {string} type The type of the object.
*/
/**
* A type that is serializable.
*
* @typedef module:akkaserverless.Serializable
* @type {module:akkaserverless.SerializableProtobufMessage|module:akkaserverless.TypedJson|Object|string|number|boolean|Long|Buffer}
*/
/**
* @private
*/
class AnySupport {
constructor(root) {
this.root = root;
}
static fullNameOf(descriptor) {
function namespace(desc) {
if (desc.name === '') {
return '';
} else {
return namespace(desc.parent) + desc.name + '.';
}
}
return namespace(descriptor.parent) + descriptor.name;
}
static stripHostName(url) {
const idx = url.indexOf('/');
if (url.indexOf('/') >= 0) {
return url.substr(idx + 1);
} else {
// fail?
return url;
}
}
static isPrimitiveDefaultValue(obj, type) {
if (Long.isLong(obj)) return obj.equals(Long.ZERO);
else if (Buffer.isBuffer(obj)) return !obj.length;
else return obj === protobuf.types.defaults[type];
}
static serializePrimitiveValue(obj, type) {
if (this.isPrimitiveDefaultValue(obj, type)) return EmptyArray;
const writer = new protobuf.Writer();
// First write the field key.
// Field index is always 15, which gets shifted left by 3 bits (ie, 120).
writer.uint32(
(AkkaServerlessPrimitiveFieldNumberEncoded |
protobuf.types.basic[type]) >>>
0,
);
// Now write the primitive
writer[type](obj);
return writer.finish();
}
static serializePrimitive(obj, type) {
return Any.create({
// I have *no* idea why it's type_url and not typeUrl, but it is.
type_url: AkkaServerlessPrimitive + type,
value: this.serializePrimitiveValue(obj, type),
});
}
/**
* Create a comparable version of obj for use in sets and maps.
*
* The returned value guarantees === equality (both positive and negative) for the following types:
*
* - strings
* - numbers
* - booleans
* - Buffers
* - Longs
* - any protobufjs types
* - objects (based on stable JSON serialization)
* @private
*/
static toComparable(obj) {
// When outputting strings, we prefix with a letter for the type, to guarantee uniqueness of different types.
if (typeof obj === 'string') {
return 's' + obj;
} else if (typeof obj === 'number') {
return obj;
} else if (Buffer.isBuffer(obj)) {
return 'b' + obj.toString('base64');
} else if (typeof obj === 'boolean') {
return obj;
} else if (Long.isLong(obj)) {
return 'l' + obj.toString();
} else if (
obj.constructor &&
typeof obj.constructor.encode === 'function' &&
obj.constructor.$type
) {
return 'p' + obj.constructor.encode(obj).finish().toString('base64');
} else if (typeof obj === 'object') {
return 'j' + stableJsonStringify(obj);
} else {
throw new Error(
util.format(
'Object %o is not a protobuf object, object or supported primitive type, and ' +
"hence can't be dynamically serialized.",
obj,
),
);
}
}
/**
* Serialize a protobuf object to a google.protobuf.Any.
*
* @param obj The object to serialize. It must be a protobufjs created object.
* @param allowPrimitives Whether primitives should be allowed to be serialized.
* @param fallbackToJson Whether serialization should fallback to JSON if the object
* is not a protobuf, but defines a type property.
* @param requireJsonType If fallbackToJson is true, then if this is true, a property
* called type is required.
* @private
*/
static serialize(
obj,
allowPrimitives,
fallbackToJson,
requireJsonType = false,
) {
if (allowPrimitives) {
if (typeof obj === 'string') {
return this.serializePrimitive(obj, 'string');
} else if (typeof obj === 'number') {
return this.serializePrimitive(obj, 'double');
} else if (Buffer.isBuffer(obj)) {
return this.serializePrimitive(obj, 'bytes');
} else if (typeof obj === 'boolean') {
return this.serializePrimitive(obj, 'bool');
} else if (Long.isLong(obj)) {
return this.serializePrimitive(obj, 'int64');
}
}
if (
obj.constructor &&
typeof obj.constructor.encode === 'function' &&
obj.constructor.$type
) {
return Any.create({
// I have *no* idea why it's type_url and not typeUrl, but it is.
type_url:
'type.googleapis.com/' + AnySupport.fullNameOf(obj.constructor.$type),
value: obj.constructor.encode(obj).finish(),
});
} else if (fallbackToJson && typeof obj === 'object') {
let type = obj.type;
if (type === undefined) {
if (requireJsonType) {
throw new Error(
util.format(
'Fallback to JSON serialization supported, but object does not define a type property: %o',
obj,
),
);
} else {
type = 'object';
}
}
return Any.create({
type_url: AkkaServerlessJson + type,
value: this.serializePrimitiveValue(stableJsonStringify(obj), 'string'),
});
} else {
throw new Error(
util.format(
"Object %o is not a protobuf object, and hence can't be dynamically " +
'serialized. Try passing the object to the protobuf classes create function.',
obj,
),
);
}
}
/**
* Deserialize an any using the given protobufjs root object.
*
* @param any The any.
* @private
*/
deserialize(any) {
const url = any.type_url;
const idx = url.indexOf('/');
let hostName = '';
let type = url;
if (url.indexOf('/') >= 0) {
hostName = url.substr(0, idx + 1);
type = url.substr(idx + 1);
}
let bytes = any.value || EmptyArray;
if (hostName === AkkaServerlessPrimitive) {
return AnySupport.deserializePrimitive(bytes, type);
}
if (hostName === AkkaServerlessJson) {
const json = AnySupport.deserializePrimitive(bytes, 'string');
return JSON.parse(json);
}
const desc = this.root.lookupType(type);
return desc.decode(bytes);
}
static primitiveDefaultValue(type) {
if (type === 'int64') return Long.ZERO;
else if (type === 'bytes') return Buffer.alloc(0);
else return protobuf.types.defaults[type];
}
static deserializePrimitive(bytes, type) {
if (!AkkaServerlessSupportedPrimitiveTypes.has(type)) {
throw new Error('Unsupported AkkaServerless primitive Any type: ' + type);
}
if (!bytes.length) return this.primitiveDefaultValue(type);
const reader = new protobuf.Reader(bytes);
let fieldNumber = 0;
let pType = 0;
while (reader.pos < reader.len) {
const key = reader.uint32();
pType = key & 7;
fieldNumber = key >>> 3;
if (fieldNumber !== AkkaServerlessPrimitiveFieldNumber) {
reader.skipType(pType);
} else {
if (pType !== protobuf.types.basic[type]) {
throw new Error(
'Unexpected protobuf type ' +
pType +
', was expecting ' +
protobuf.types.basic[type] +
' for decoding a ' +
type,
);
}
return reader[type]();
}
}
// We didn't find the field, just return the default.
return this.primitiveDefaultValue(type);
}
}
module.exports = AnySupport;