UNPKG

@baqhub/sdk

Version:

The official JavaScript SDK for the BAQ federated app platform.

353 lines (352 loc) 14.8 kB
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, };