@deployport/specular-runtime
Version:
Runtime for Specular API clients with support for Node.js and Browser
359 lines (341 loc) • 13.5 kB
JavaScript
import test from 'tape';
import { SpecularPackage, ResponseMeta, BodyMeta, BodyType, NotFoundProblem, NotFoundProblemMeta } from './generated-client/specular.js';
import {
Metadata,
} from '../lib/index.js';
import { StructPath } from '../lib/metadata/struct.js';
import { defaultZeroTime } from '../lib/metadata/builtin.js';
import { BlobToBase64 } from '../lib/runtime/builtin.js';
import { parseHTTPResult } from '../lib/runtime/client.js';
import { RpcError, UnknownRpcError } from '../lib/runtime/error.js';
import { TypeNotFoundError } from '../lib/metadata/package.js';
const _pkg = SpecularPackage();
test('StructPath', (t) => {
const sp = StructPath.fromString('application/spec.myns.mymod.mytype');
t.equal(sp.name, "mytype");
t.equal(sp.module.namespace, "myns");
t.equal(sp.module.name, "mymod");
t.end();
});
test('Deserialize with builtins props', (t) => {
t.test("missing", (t) => {
const obj = {
"body": {}
};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.notEqual(s.body, null);
t.equal(s.body.contentLengthInt32, 0);
t.equal(s.body.contentLengthInt64, 0);
t.equal(s.body.contentLengthFloat32, 0);
t.equal(s.body.contentLengthFloat64, 0);
t.equal(s.body.contentLengthFloat64Nullable, null);
t.equal(s.body.contentLengthUint32, 0);
t.equal(s.body.contentLengthUint64, 0);
t.equal(s.body.messageString, "");
t.equal(s.body.messageStringNullable, null);
t.equal(s.body.enabledBool, false, "default bool value when missing");
t.equal(s.body.enabledBoolNullable, null);
t.deepEqual(s.body.createdAt, defaultZeroTime());
t.deepEqual(s.body.createdAtNullable, null);
t.notEqual(s.body.fileData, null);
t.equal(typeof s.body.fileData, 'object');
t.equal(s.body.fileData.constructor, Blob);
t.equal(s.body.fileDataNullable, null);
t.equal(s.body.bodyType, BodyType.Normal, "default enum value when missing");
t.equal(s.body.bodyTypeNullable, null);
t.end();
});
t.test("value", (t) => {
const obj = {
"body": {
"contentLengthInt32": 32,
"contentLengthFloat64Nullable": 64,
"messageString": "msg",
"messageStringNullable": "msg nullable",
"enabledBool": true,
"enabledBoolNullable": true,
"createdAtNullable": "2024-06-18T02:02:07.602Z",
"fileData": "",
"bodyType": "special",
"bodyTypeNullable": "special"
}
};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.notEqual(s.body, null);
t.equal(s.body.contentLengthInt32, 32);
t.equal(s.body.contentLengthFloat64Nullable, 64);
t.equal(s.body.messageString, "msg");
t.equal(s.body.messageStringNullable, "msg nullable");
t.equal(s.body.enabledBool, true);
t.equal(s.body.enabledBoolNullable, true);
t.same(s.body.createdAtNullable, new Date('2024-06-18T02:02:07.602Z'));
t.same(s.body.fileData, new Blob([]));
t.equal(s.body.bodyType, BodyType.Special);
t.equal(s.body.bodyTypeNullable, BodyType.Special);
t.end();
});
t.test("value binary", async (t) => {
const obj = {
"body": {
"fileData": await BlobToBase64(new Blob(['hi'], { type: "text/plain" })),
}
};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.notEqual(s.body, null);
t.notEqual(s.body.fileData, null);
t.true(areArrayBuffersEqual(await s.body.fileData.arrayBuffer(), await new Blob(['hi'], { type: "text/plain" }).arrayBuffer()));
t.end();
});
t.test("nullable binary null", async (t) => {
const obj = {
"body": {
"fileDataNullable": null,
}
};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.notEqual(s.body, null);
t.equal(s.body.fileDataNullable, null);
t.end();
});
t.test("nullable binary value", async (t) => {
const obj = {
"body": {
"fileDataNullable": await BlobToBase64(new Blob(['hi'], { type: "text/plain" })),
}
};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.notEqual(s.body, null);
t.notEqual(s.body.fileDataNullable, null);
t.true(areArrayBuffersEqual(await s.body.fileDataNullable.arrayBuffer(), await new Blob(['hi'], { type: "text/plain" }).arrayBuffer()));
t.end();
});
});
function areArrayBuffersEqual(buffer1, buffer2) {
if (buffer1.byteLength !== buffer2.byteLength) return false;
const view1 = new Uint8Array(buffer1);
const view2 = new Uint8Array(buffer2);
for (let i = 0; i < view1.length; i++) {
if (view1[i] !== view2[i]) return false;
}
return true;
}
test('Deserialize with struct props', (t) => {
t.test("null field", (t) => {
const obj = {
"body": null
};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.equal(s.body, null);
t.end();
});
t.test("missing field", (t) => {
const obj = {};
const s = _pkg.requireBuildFromJSON(ResponseMeta.path, obj);
console.log("s", s)
t.equal(s.body, null);
t.end();
});
});
test('Serialize with struct props', (t) => {
t.test("missing", async (t) => {
const obj = await ResponseMeta.serialize({})
t.deepEqual(Object.keys(obj), [])
t.end()
});
t.test("struct prop null", async (t) => {
const obj = await ResponseMeta.serialize({
body: null
})
t.deepEqual(Object.keys(obj), [])
t.end()
});
t.test("struct prop empty", async (t) => {
const obj = await ResponseMeta.serialize({
body: {}
})
t.false(Object.keys(obj).indexOf('body'), -1)
t.end()
});
});
test('Serialize with builtin props', (t) => {
t.test("missing", async (t) => {
const obj = await BodyMeta.serialize({})
t.deepEqual(Object.keys(obj), [])
t.end()
});
t.test("custom value", async (t) => {
const obj = await BodyMeta.serialize({
contentLengthInt32: 32,
contentLengthUint64Nullable: 64,
messageString: 'msg',
enabledBool: true,
})
const props = Object.keys(obj);
t.true(props.includes('contentLengthInt32'))
t.false(props.includes('contentLengthFloat64Nullable'), 'should not include default nullable null')
t.equal(obj.contentLengthInt32, 32);
t.equal(obj.contentLengthUint64Nullable, 64);
t.equal(obj.messageString, 'msg');
t.true(props.includes('enabledBool'))
t.equal(obj.enabledBool, true);
t.end()
});
t.test("bool false is omitted", async (t) => {
const obj = await BodyMeta.serialize({
enabledBool: false,
enabledBoolNullable: false,
})
const props = Object.keys(obj);
t.false(props.includes('enabledBool'), 'false should be omitted (zero value)')
t.false(props.includes('enabledBoolNullable'), 'nullable false should be omitted too')
t.end()
});
t.test("nulls", async (t) => {
const obj = await BodyMeta.serialize({
messageStringNullable: null,
contentLengthFloat64Nullable: null,
fileDataNullable: null,
bodyTypeNullable: null,
})
const props = Object.keys(obj);
t.false(props.includes('messageStringNullable'))
t.false(props.includes('contentLengthFloat64Nullable'), 'should not include default nullable null')
t.false(props.includes('fileDataNullable'), 'should not include default nullable null')
t.false(props.includes('bodyTypeNullable'), 'should not include default nullable null')
t.end()
});
t.test("nullable set", async (t) => {
const obj = await BodyMeta.serialize({
messageStringNullable: 'val',
bodyTypeNullable: 'special',
})
const props = Object.keys(obj);
t.true(props.includes('messageStringNullable'))
t.equal(obj.messageStringNullable, 'val')
t.equal(obj.bodyTypeNullable, 'special')
t.end()
});
t.test("time default", async (t) => {
const obj = await BodyMeta.serialize({
createdAt: defaultZeroTime(),
})
const props = Object.keys(obj);
t.false(props.includes('createdAt'), 'should not include date since is default date zero value')
t.deepEqual(props, []);
t.end()
});
t.test("time custom", async (t) => {
const obj = await BodyMeta.serialize({
createdAt: new Date(Date.UTC(2024, 1, 1)),
})
const props = Object.keys(obj);
t.true(props.includes('createdAt'))
t.deepEqual(props, ['createdAt']);
t.equal(obj.createdAt, '2024-02-01T00:00:00.000Z');
t.end()
});
t.test("binary default", async (t) => {
const obj = await BodyMeta.serialize({
fileData: new Blob([''], { type: "text/plain" }),
})
const props = Object.keys(obj);
t.false(props.includes('fileData'), 'should not include date since is default binary is an empty string')
t.deepEqual(props, []);
t.end()
});
t.test("binary value", async (t) => {
const obj = await BodyMeta.serialize({
fileData: new Blob(['hi'], { type: "text/plain" }),
fileDataNullable: new Blob(['hi'], { type: "text/plain" }),
})
const props = Object.keys(obj);
t.true(props.includes('fileData'))
t.equal(obj.fileData, 'aGk=');
t.equal(obj.fileDataNullable, 'aGk=');
t.end()
});
t.test("enum default", async (t) => {
const obj = await BodyMeta.serialize({
bodyType: BodyType.Normal,
})
const props = Object.keys(obj);
t.false(props.includes('bodyType'), 'should not include since default value was set')
t.deepEqual(props, []);
t.end()
});
});
const importingPkg = new Metadata.Package(
'specularJS', 'importing',
);
importingPkg.importPackage(_pkg);
export const ImportingDoc = new Metadata.Struct(
importingPkg,
"Doc",
);
test('imported instantiation', (t) => {
const obj = {
"body": null
};
const s = importingPkg.requireBuildFromJSON(ResponseMeta.path, obj);
t.equal(s.body, null);
t.end();
});
test('unmatched case instantiation', (t) => {
const obj = {
"body": null
};
const sp = StructPath.fromString(ResponseMeta.path.mediaType.toUpperCase());
const s = _pkg.requireBuildFromJSON(sp, obj);
t.equal(s.body, null);
t.end();
});
test('unknown error type degrades to UnknownRpcError', async (t) => {
const ct = 'application/spec.myns.mymod.futureproblem+json; kind=error';
try {
await parseHTTPResult(_pkg, ct, async () => ({ message: 'quota exceeded', code: 'QUOTA', limit: 100 }));
t.fail('expected parseHTTPResult to throw');
} catch (e) {
t.ok(e instanceof UnknownRpcError, 'is UnknownRpcError');
t.ok(e instanceof RpcError, 'is RpcError');
t.ok(e instanceof Error, 'is Error');
t.equal(e.message, 'quota exceeded');
t.equal(e.code, 'QUOTA');
t.equal(e.typeName, 'application/spec.myns.mymod.futureproblem');
t.deepEqual(e.payload, { message: 'quota exceeded', code: 'QUOTA', limit: 100 });
}
t.end();
});
test('deserialized struct preserves its prototype (buildByFQTN does not flatten)', (t) => {
const r = _pkg.requireBuildFromJSON(NotFoundProblemMeta.path, { message: 'boom', status: 1 });
t.ok(r instanceof NotFoundProblem, 'instance keeps its NotFoundProblem prototype');
t.ok(r instanceof Error, 'instance is a real Error');
t.equal(r.message, 'boom', 'message preserved');
t.equal(r.status, 1);
t.equal(r.__structPath, NotFoundProblemMeta.path, '__structPath preserved');
t.end();
});
test('user-defined error is thrown (not returned) as a real Error', async (t) => {
const ct = NotFoundProblemMeta.path.mediaType + '+json; kind=error';
try {
await parseHTTPResult(_pkg, ct, async () => ({ message: 'not found', detail: 'x', status: 404, title: 'NF' }));
t.fail('expected parseHTTPResult to throw the user-defined error');
} catch (e) {
t.ok(e instanceof Error, 'thrown value is a real Error');
t.ok(e instanceof NotFoundProblem, 'thrown value is a NotFoundProblem instance');
t.equal(e.message, 'not found', 'message preserved');
t.equal(e.detail, 'x');
t.equal(e.status, 404);
}
t.end();
});
test('unknown type without kind=error stays a TypeNotFoundError', async (t) => {
const ct = 'application/spec.myns.mymod.futureoutput+json';
try {
await parseHTTPResult(_pkg, ct, async () => ({ id: '1' }));
t.fail('expected parseHTTPResult to throw');
} catch (e) {
t.notOk(e instanceof UnknownRpcError, 'not UnknownRpcError');
t.ok(e instanceof TypeNotFoundError, 'is TypeNotFoundError');
}
t.end();
});