@baqhub/sdk
Version:
The official JavaScript SDK for the BAQ federated app platform.
353 lines (352 loc) • 14.8 kB
JavaScript
import UriTemplate from "es6-url-template";
import compact from "lodash/compact.js";
import { Constants } from "../constants.js";
import { AbortedError, Async } from "../helpers/async.js";
import { ErrorWithData } from "../helpers/customError.js";
import { findLink } from "../helpers/headers.js";
import { Str } from "../helpers/string.js";
import { findStableTimestamp } from "../helpers/time.js";
import { noop } from "../helpers/type.js";
import { HttpCredentialsHeader } from "../model/core/httpCredentialsHeader.js";
import { HttpMethod } from "../model/core/httpMethod.js";
import { HttpBearerSignature } from "../model/httpSignature/httpBearerSignature.js";
import { HttpSignature } from "../model/httpSignature/httpSignature.js";
import { HttpSignatureInput } from "../model/httpSignature/httpSignatureInput.js";
import { Query } from "../model/query/query.js";
import { Q } from "../model/query/queryFilter.js";
import { AppRecord } from "../model/recordTypes/appRecord.js";
import { EntityRecord, } from "../model/recordTypes/entityRecord.js";
import { AnyRecord, RNoContentRecord, Record, } from "../model/records/record.js";
import { RBlobResponse } from "../model/response/blobResponse.js";
import { RecordResponse } from "../model/response/recordResponse.js";
import { recordsResponse } from "../model/response/recordsResponse.js";
import { Api } from "./api.js";
import { Http } from "./http.js";
function fixUrl(url) {
if (url.includes("/events")) {
return url.replace("https://localhost", "http://localhost:5254");
}
if (url.startsWith("https://localhost")) {
return url.replace("https://localhost", "http://localhost:5173");
}
return url;
}
function buildClientBase(clientOptions) {
const { getEntityRecord: ger, authorizationBuilder, bearerBuilder, } = clientOptions;
const getEntityRecord = Async.sharePromise(ger);
//
// Sync entity record.
//
let entityRecord;
getEntityRecord().then(e => (entityRecord = e), noop);
function getEntityRecordSync() {
if (!entityRecord) {
throw Error("Entity record not available");
}
return entityRecord;
}
//
// Template resolution.
//
function getEntityUrlTemplateForRecord(entityRecord, endpoint) {
const firstServer = entityRecord.content.servers[0];
if (!firstServer) {
throw new Error("No server found.");
}
return new UriTemplate(firstServer.endpoints[endpoint]);
}
async function getEntityUrlTemplate(endpoint, signal) {
const entityRecord = await getEntityRecord(signal);
return getEntityUrlTemplateForRecord(entityRecord, endpoint);
}
async function expandUrlTemplate(endpoint, values, signal) {
const urlTemplate = await getEntityUrlTemplate(endpoint, signal);
return fixUrl(urlTemplate.expand(values));
}
//
// Records.
//
async function getRecord(knownModel, model, entity, recordId, { query, signal } = {}) {
const url = await expandUrlTemplate("record", {
entity,
record_id: recordId,
}, signal);
const urlAndQuery = url + Query.singleToQueryString(query);
const httpOptions = { authorizationBuilder, signal };
const responseModel = RecordResponse.io(knownModel, model);
const [, response] = await Api.get(responseModel, urlAndQuery, httpOptions);
return response;
}
async function getRecordVersion(knownModel, model, entity, recordId, versionHash, { query, signal } = {}) {
const url = await expandUrlTemplate("recordVersion", {
entity,
record_id: recordId,
version_hash: versionHash,
}, signal);
const urlAndQuery = url + Query.singleToQueryString(query);
const httpOptions = { authorizationBuilder, signal };
const responseModel = RecordResponse.io(knownModel, model);
const [, response] = await Api.get(responseModel, urlAndQuery, httpOptions);
return response;
}
async function getOwnRecord(knownModel, model, recordId, options) {
const entityRecord = await getEntityRecord(options?.signal);
return getRecord(knownModel, model, entityRecord.author.entity, recordId, options);
}
async function getRecords(knownModel, model, query, signal) {
const url = await expandUrlTemplate("records", {}, signal);
const urlAndQuery = url + Query.toQueryString(query);
const httpOptions = { authorizationBuilder, signal };
const responseModel = recordsResponse(knownModel, model);
const [, response] = await Api.get(responseModel, urlAndQuery, httpOptions);
return response;
}
async function getMoreRecords(knownModel, model, query, signal) {
const url = await expandUrlTemplate("records", {}, signal);
const urlAndQuery = url + query;
const httpOptions = { authorizationBuilder, signal };
const responseModel = recordsResponse(knownModel, model);
const [, response] = await Api.get(responseModel, urlAndQuery, httpOptions);
return response;
}
async function recordEventSource(recordModel, onRecord, query, signal) {
try {
const url = await expandUrlTemplate("events", {}, signal);
const urlAndQuery = url + Query.toQueryString(query);
const httpOptions = { authorizationBuilder, signal };
Api.eventSource(recordModel, onRecord, "record", urlAndQuery, httpOptions);
}
catch (error) {
if (error instanceof AbortedError) {
return;
}
throw error;
}
}
async function postRecordBaseAsync(knownModel, recordModel, record, signal, options = {}) {
const url = await expandUrlTemplate("newRecord", {}, signal);
const responseModel = RecordResponse.io(knownModel, recordModel);
const httpOptions = { ...options, authorizationBuilder, signal };
return await Api.post(responseModel, recordModel, record, url, httpOptions);
}
async function postRecord(knownModel, recordModel, record, signal) {
const [, response] = await postRecordBaseAsync(knownModel, recordModel, record, signal);
return response;
}
async function postAppRecord(record, credentialsRecord, signal) {
const credentialsHeader = HttpCredentialsHeader.ofRecord(credentialsRecord);
const options = {
headers: {
[Constants.credentialsHeader]: HttpCredentialsHeader.toString(credentialsHeader),
},
};
const [headers, response] = await postRecordBaseAsync(AnyRecord, AppRecord, record, signal, options);
const responseCredentials = HttpCredentialsHeader.tryParseHeader(headers.get(Constants.credentialsHeader));
if (!responseCredentials) {
throw new Error("Server credentials not found.");
}
return [responseCredentials.publicKey, response.record];
}
async function putRecord(knownModel, recordModel, record, signal) {
const url = await expandUrlTemplate("record", {
entity: record.author.entity,
record_id: record.id,
}, signal);
const responseModel = RecordResponse.io(knownModel, recordModel);
const httpOptions = { authorizationBuilder, signal };
const [, response] = await Api.put(responseModel, recordModel, record, url, httpOptions);
return response;
}
async function deleteRecord(knownModel, record, signal) {
const url = await expandUrlTemplate("record", {
entity: record.author.entity,
record_id: record.id,
}, signal);
const responseModel = RecordResponse.io(knownModel, RNoContentRecord);
const httpOptions = { authorizationBuilder, signal };
const [_, response] = await Api.delete(responseModel, RNoContentRecord, record, url, httpOptions);
return response;
}
//
// Discovery.
//
async function discover(entity, signal) {
const query = Query.new({
pageSize: 1,
proxyTo: entity,
filter: Q.and(Q.author(entity), Q.type(EntityRecord)),
});
const { records } = await getRecords(AnyRecord, EntityRecord, query, signal);
const firstRecord = records[0];
if (!firstRecord) {
throw new ErrorWithData("Discovery failed", { records });
}
return firstRecord;
}
//
// Blobs.
//
async function uploadBlob(blob, signal) {
if (!blob.type) {
throw new ErrorWithData("Blob does not have a type.", { blob });
}
const url = await expandUrlTemplate("newBlob", {}, signal);
const headers = { "Content-Type": blob.type };
const httpOptions = { authorizationBuilder, headers, signal };
const [, r] = await Api.postBlob(RBlobResponse, blob, url, httpOptions);
return r;
}
async function downloadBlob(record, blob, signal) {
const url = await expandUrlTemplate("recordBlob", {
entity: record.author.entity,
record_id: record.id,
blob_hash: blob.hash,
file_name: blob.name,
}, signal);
const isProxyRecord = record.source === "proxy";
const query = isProxyRecord ? { proxyTo: record.author.entity } : undefined;
const options = {
authorizationBuilder,
signal,
query,
};
const [, result] = await Http.download(url, options);
return result;
}
function blobUrlBuilderFor(entityRecord) {
const urlTemplate = getEntityUrlTemplateForRecord(entityRecord, "recordBlob");
return (record, blob, expiresInSeconds) => {
const maybeAddBearer = (url) => {
const isProxyRecord = record.source === "proxy";
const bearer = (() => {
if (!bearerBuilder || (Record.isPublic(record) && !isProxyRecord)) {
return undefined;
}
const expiresAt = expiresInSeconds
? Date.now() + expiresInSeconds * 1000
: findStableTimestamp(blob.hash, 90);
return bearerBuilder(url, expiresAt);
})();
const proxyTo = (() => {
if (!isProxyRecord) {
return;
}
return record.author.entity;
})();
const query = compact([
bearer && ["bearer", bearer],
proxyTo && ["proxy_to", proxyTo],
]);
return url + Str.buildQuery(query);
};
const url = fixUrl(urlTemplate.expand({
entity: record.author.entity,
record_id: record.id,
blob_hash: blob.hash,
file_name: blob.name,
}));
return maybeAddBearer(url);
};
}
async function blobUrlBuilder() {
const entityRecord = await getEntityRecord();
return blobUrlBuilderFor(entityRecord);
}
return {
expandUrlTemplate,
getEntityRecord,
getEntityRecordSync,
getRecord,
getRecordVersion,
getOwnRecord,
getRecords,
getMoreRecords,
recordEventSource,
postRecord,
postAppRecord,
putRecord,
deleteRecord,
discover,
uploadBlob,
downloadBlob,
blobUrlBuilderFor,
blobUrlBuilder,
};
}
async function getEntityRecordFromEntityRecordUrl(entityRecordUrl, signal) {
const options = { signal };
const responseModel = RecordResponse.io(AnyRecord, EntityRecord);
const [, { record }] = await Api.get(responseModel, fixUrl(entityRecordUrl), options);
return record;
}
async function getEntityRecordFromEntity(entity, signal) {
// Perform discovery.
const headers = await Http.head(fixDiscoverUrl(`https://${entity}/`));
const entityRecordLink = findLink(headers, "https://baq.dev/rels/entity-record");
if (!entityRecordLink) {
throw new Error("Entity record link not found.");
}
// Fetch the record.
return getEntityRecordFromEntityRecordUrl(entityRecordLink, signal);
}
function buildClientFromUrl(entityRecordUrl) {
const getEntityRecord = (signal) => getEntityRecordFromEntityRecordUrl(entityRecordUrl, signal);
return buildClientBase({ getEntityRecord });
}
function buildClientFromEntity(entity) {
const getEntityRecord = (signal) => getEntityRecordFromEntity(entity, signal);
return buildClientBase({ getEntityRecord });
}
function buildClientFromRecord(entityRecord) {
const getEntityRecord = () => Promise.resolve(entityRecord);
return buildClientBase({ getEntityRecord });
}
function buildAuthenticatedClient(state) {
const { entityRecord, appRecord, credentialsRecord, authorizationId } = state;
const appRecordId = appRecord.id;
const privateKey = credentialsRecord.content.privateKey;
const authorizationBuilder = (m, url, headers) => {
const input = HttpSignatureInput.new(m, url, headers, authorizationId);
const signature = HttpSignature.request(appRecordId, privateKey, input);
return HttpSignature.toHeader(signature);
};
const bearerBuilder = (url, expiresAt) => {
const m = HttpMethod.GET;
const signatureInput = HttpSignatureInput.new(m, url, {}, authorizationId);
const signature = HttpBearerSignature.request(appRecordId, privateKey, signatureInput, expiresAt);
return HttpBearerSignature.toQuery(signature);
};
const getEntityRecord = () => Promise.resolve(entityRecord);
return buildClientBase({
getEntityRecord,
authorizationBuilder,
bearerBuilder,
});
}
//
// Static discovery.
//
function fixDiscoverUrl(url) {
switch (url) {
case "https://quentez.localhost/":
return "http://localhost:5254/api/quentez.localhost";
case "https://testaccount1.localhost/":
return "http://localhost:5254/api/testaccount1.localhost";
case "https://testaccount2.localhost/":
return "http://localhost:5254/api/testaccount2.localhost";
default:
return url;
}
}
async function discover(entity, signal) {
const client = buildClientFromEntity(entity);
await client.getEntityRecord(signal);
return client;
}
export const Client = {
ofUrl: buildClientFromUrl,
ofEntity: buildClientFromEntity,
ofRecord: buildClientFromRecord,
authenticated: buildAuthenticatedClient,
discover,
};