@deployport/specular-runtime
Version:
Runtime for Specular API clients with support for Node.js and Browser
175 lines (174 loc) • 6.9 kB
JavaScript
import { BuiltinMeta } from "../metadata/builtin.js";
import { TypeNotFoundError } from "../metadata/package.js";
import { StructPath } from "../metadata/struct.js";
import { HTTPRequest, StreamMultipartMixedChunks } from "./http.js";
import { UnknownRpcError } from "./error.js";
const builtinMeta = BuiltinMeta();
function newHttpErrorException(err) {
return new Error(err.message + " " + err.code);
}
const contentTypeFormat = "+json";
/**
* parseContentType splits a Content-Type into its media type (with the
* `+json` suffix stripped) and its lower-cased media-type parameters, e.g.
* `application/spec.ns.mod.type+json; kind=error` →
* `{ mediaType: "application/spec.ns.mod.type", params: { kind: "error" } }`.
* The envelope's `kind` param is authoritative for whether a response is an error.
*/
const parseContentType = (contentType) => {
const [base = "", ...rawParams] = contentType.split(";");
const idx = base.indexOf(contentTypeFormat);
const mediaType = (idx === -1 ? base : base.substring(0, idx)).trim();
const params = {};
for (const param of rawParams) {
const eq = param.indexOf("=");
if (eq === -1) {
continue;
}
const key = param.slice(0, eq).trim().toLowerCase();
params[key] = param.slice(eq + 1).trim().replace(/['"]/g, "").toLowerCase();
}
return { mediaType, params };
};
function multirequireBuildFromJSON(pk, mediaType, json) {
try {
return builtinMeta.Module.requireBuildFromJSON(mediaType, json);
}
catch (e) {
if (e instanceof TypeNotFoundError) {
return pk.requireBuildFromJSON(mediaType, json);
}
throw e;
}
}
export async function parseHTTPResult(pkg, contentType, parseBody) {
const { mediaType: cleanType, params } = parseContentType(contentType);
const isError = params.kind === "error";
const outputJSON = await parseBody();
const mediaType = StructPath.fromString(cleanType);
let responseStruct;
try {
responseStruct = multirequireBuildFromJSON(pkg, mediaType, outputJSON);
}
catch (e) {
// An error envelope whose type this client was not generated with must
// still surface as an error, never as a decode failure.
if (isError && e instanceof TypeNotFoundError) {
throw new UnknownRpcError(mediaType.mediaType, outputJSON);
}
throw e;
}
// The envelope is authoritative: a `kind=error` response is always thrown,
// even if the decoded struct's prototype does not extend Error.
if (isError || responseStruct instanceof Error) {
throw responseStruct;
}
return responseStruct;
}
/**
* Returns a copy of original with any overrides applied. If overrides is null, the original is returned.
* @param a is the original configuration
* @param more is the configuration to add more to the original.
* @returns
*/
export function MergeClientConfig(a, more) {
return {
...a,
...more,
};
}
export default class Client {
endpoint;
requestConfigurators = [];
constructor(config) {
if (!config.endpoint) {
throw new Error("endpoint is required");
}
this.endpoint = config.endpoint;
if (config.requestConfigurators) {
this.requestConfigurators.push(...config.requestConfigurators);
}
}
async configureRequest(sub) {
for (const configurator of this.requestConfigurators) {
await configurator(sub);
}
}
async postOperation(operation, inputProps, abortController) {
const uri = this.endpoint + "/" + operation.resource.packageUniqueName + "/" + operation.name;
const content = await operation.input.serialize(inputProps);
const body = JSON.stringify(content);
const req = new HTTPRequest();
req.method = "POST";
req.url = uri;
req.headers["Content-Type"] = "application/json";
req.body = body;
req.abortController = abortController;
await this.configureRequest({
operation,
request: req,
});
const res = await req.fetch();
// if (res.status != 200) {
// throw new Error(`failed to stream operation ${res.status} ${res.statusText}`);
// }
return res;
}
/**
* Executes an operation and returns its single result
* @param operation
* @param input
* @returns the output struct of the operation
*/
async execute(operation, inputProps) {
const res = await this.postOperation(operation, inputProps, new AbortController());
// check if content type is specular/struct
const contentType = res.headers.get("Content-Type");
if (!contentType) {
throw new Error("invalid response, missing content type");
}
const result = await parseHTTPResult(operation.resource.package, contentType, async () => await res.json());
if (builtinMeta.HeartbeatMeta.path === result.__structPath) {
throw new Error("unexpected heartbeat");
}
else if (builtinMeta.ErrMeta.path === result.__structPath) {
throw newHttpErrorException(result);
}
else if (result instanceof Error) {
throw result;
}
return result;
}
/**
* Execute an operation and stream the results to the provided awaited callback.
* The returned promise resolves when the stream is complete.
* @param operation
* @param input
* @param outputCallback returns a promise that resolves when the given output has been processed and the next output can be streamed
* @throws Error if the operation fails or the stream fails. The stream will be aborted if the callback throws an error which is the recommended way to stop the stream.
*/
async stream(operation, input, outputCallback) {
const abortController = new AbortController();
const res = await this.postOperation(operation, input, abortController);
const partCallback = async (chunk) => {
const result = await parseHTTPResult(operation.resource.package, chunk.headers['content-type'] || '', async () => JSON.parse(chunk.body));
if (builtinMeta.HeartbeatMeta.path === result.__structPath) {
return;
}
else if (builtinMeta.ErrMeta.path === result.__structPath) {
throw newHttpErrorException(result);
}
else if (result instanceof Error) {
throw result;
}
await outputCallback(result);
};
try {
await StreamMultipartMixedChunks(res, partCallback);
}
finally {
// make sure we abort the request when done
abortController.abort();
}
}
}