mirror of
https://github.com/blyssprivacy/sdk.git
synced 2026-01-14 09:38:05 -05:00
* bucket check and async setup clients perform direct setup by default * (python) more consistent json for internal api all requests and response are JSON. all binary payloads are explicitly encoded as base64 within api.py, and decoded back to bytes before leaving api.py. User-facing code, e.g. bucket.py and bucket_service.py, should not see base64 wrangling. * Support async for all ops refactor api.py to be async-first use new asyncio loops to support non-async interface; cannot call non-async methods from async context * [js] update client to work with unified service bump both versions to 0.2.1 disable npm/pypi publish except on manual workflow run * disable request compression * fix workflow tests update standalone Spiral test server to use new JSON interface
457 lines
11 KiB
TypeScript
457 lines
11 KiB
TypeScript
import { KeyInfo } from '../bucket/bucket';
|
|
import { BLYSS_HINT_URL_PREFIX } from '../bucket/bucket_service';
|
|
import { gzip } from '../compression/pako';
|
|
import { BloomFilter, bloomFilterFromBytes } from '../data/bloom';
|
|
import { base64ToBytes, bytesToBase64 } from './seed';
|
|
|
|
const CREATE_PATH = '/create';
|
|
const MODIFY_PATH = '/modify';
|
|
const CLEAR_PATH = '/clear';
|
|
const DESTROY_PATH = '/destroy';
|
|
const CHECK_PATH = '/check';
|
|
const DELETE_PATH = '/delete';
|
|
const META_PATH = '/meta';
|
|
const BLOOM_PATH = '/bloom';
|
|
const LIST_KEYS_PATH = '/list-keys';
|
|
const SETUP_PATH = '/setup';
|
|
const HINT_PATH = '/hint';
|
|
const WRITE_PATH = '/write';
|
|
const READ_PATH = '/private-read';
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public path: string,
|
|
public body: string,
|
|
public msg: string
|
|
) {
|
|
super(msg);
|
|
Object.setPrototypeOf(this, ApiError.prototype);
|
|
}
|
|
}
|
|
|
|
export type BucketMetadata = any;
|
|
|
|
// HTTP utilities
|
|
|
|
async function getData(
|
|
apiKey: string | null,
|
|
url: string,
|
|
getJson: boolean
|
|
): Promise<any | Uint8Array> {
|
|
const headers = new Headers();
|
|
if (apiKey) headers.append('X-API-Key', apiKey);
|
|
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(
|
|
response.status,
|
|
url,
|
|
await response.text(),
|
|
response.statusText
|
|
);
|
|
}
|
|
|
|
if (getJson) {
|
|
return response.json();
|
|
} else {
|
|
const data = await response.arrayBuffer();
|
|
return new Uint8Array(data);
|
|
}
|
|
}
|
|
|
|
async function postData(
|
|
apiKey: string | null,
|
|
url: string,
|
|
data: Uint8Array | string,
|
|
getJson: boolean
|
|
): Promise<Uint8Array | any> {
|
|
const headers = new Headers();
|
|
if (apiKey) headers.append('X-API-Key', apiKey);
|
|
|
|
if (typeof data === 'string' || data instanceof String) {
|
|
headers.append('Content-Type', 'application/json');
|
|
} else {
|
|
headers.append('Accept-Encoding', 'gzip');
|
|
headers.append('Content-Encoding', 'gzip');
|
|
headers.append('Content-Type', 'application/octet-stream');
|
|
data = gzip(data);
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: data,
|
|
headers
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(
|
|
response.status,
|
|
url,
|
|
await response.text(),
|
|
response.statusText
|
|
);
|
|
}
|
|
|
|
if (getJson) {
|
|
return response.json();
|
|
} else {
|
|
const data = await response.arrayBuffer();
|
|
return new Uint8Array(data);
|
|
}
|
|
}
|
|
|
|
async function postDataJson(
|
|
apiKey: string | null,
|
|
url: string,
|
|
data: Uint8Array | string,
|
|
): Promise<any> {
|
|
const headers = new Headers(
|
|
{
|
|
'Content-Type': 'application/json',
|
|
'Accept-Encoding': 'gzip'
|
|
}
|
|
);
|
|
if (apiKey) headers.append('X-API-Key', apiKey);
|
|
|
|
// base64 encode bytes-like data
|
|
if (typeof data !== 'string' && !(data instanceof String)) {
|
|
data = JSON.stringify(bytesToBase64(data));
|
|
}
|
|
|
|
// // compress
|
|
// data = gzip(data);
|
|
// headers.append('Content-Encoding', 'gzip');
|
|
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
body: data,
|
|
headers
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(
|
|
response.status,
|
|
url,
|
|
await response.text(),
|
|
response.statusText
|
|
);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
|
|
async function postFormData(
|
|
url: string,
|
|
fields: any,
|
|
data: Uint8Array
|
|
): Promise<Uint8Array | any> {
|
|
const formData = new FormData();
|
|
for (const field in fields) {
|
|
formData.append(field, fields[field]);
|
|
}
|
|
formData.append('file', new Blob([data]));
|
|
|
|
const req = new Request(url, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const contentLength = (await req.clone().arrayBuffer()).byteLength;
|
|
req.headers.append('Content-Length', contentLength + '');
|
|
|
|
const response = await fetch(req);
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(
|
|
response.status,
|
|
url,
|
|
await response.text(),
|
|
response.statusText
|
|
);
|
|
}
|
|
}
|
|
|
|
// API client
|
|
|
|
class Api {
|
|
apiKey: string;
|
|
serviceEndpoint: string;
|
|
bucketEndpoint: string | undefined;
|
|
|
|
constructor(
|
|
apiKey: string,
|
|
serviceEndpoint: string,
|
|
bucketEndpoint?: string
|
|
) {
|
|
this.apiKey = apiKey;
|
|
this.serviceEndpoint = serviceEndpoint;
|
|
this.bucketEndpoint = bucketEndpoint;
|
|
}
|
|
|
|
/**
|
|
* Create an API client instance for a connection to a specific bucket.
|
|
*
|
|
* @param bucketEndpoint URL to a directly hosted bucket
|
|
*/
|
|
static fromBucketEndpoint(bucketEndpoint: string): Api {
|
|
return new this('', '', bucketEndpoint);
|
|
}
|
|
|
|
private serviceUrlFor(path: string): string {
|
|
if (this.bucketEndpoint) {
|
|
return this.bucketEndpoint + path;
|
|
} else {
|
|
return this.serviceEndpoint + path;
|
|
}
|
|
}
|
|
|
|
private urlFor(bucketName: string, path: string): string {
|
|
if (this.bucketEndpoint) {
|
|
return this.bucketEndpoint + path;
|
|
} else {
|
|
return this.serviceEndpoint + '/' + bucketName + path;
|
|
}
|
|
}
|
|
|
|
// Service methods
|
|
|
|
/**
|
|
* Create a new bucket, given the supplied data.
|
|
*
|
|
* @param dataJson A JSON-encoded string of the new bucket request.
|
|
*/
|
|
async create(dataJson: string) {
|
|
await postData(
|
|
this.apiKey,
|
|
this.serviceUrlFor(CREATE_PATH),
|
|
dataJson,
|
|
true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check that a UUID is still valid on the server.
|
|
*
|
|
* @param uuid The UUID to check.
|
|
*/
|
|
async check(uuid: string): Promise<any> {
|
|
return await getData(
|
|
this.apiKey,
|
|
this.serviceUrlFor('/' + uuid + CHECK_PATH),
|
|
true
|
|
);
|
|
}
|
|
|
|
// Bucket-specific methods
|
|
|
|
/**
|
|
* Get metadata about a bucket.
|
|
*
|
|
* @param bucketName The name of the bucket.
|
|
* @returns Metadata about the bucket.
|
|
*/
|
|
async meta(bucketName: string): Promise<BucketMetadata> {
|
|
return await getData(this.apiKey, this.urlFor(bucketName, META_PATH), true);
|
|
}
|
|
|
|
/**
|
|
* Modify a bucket's properties.
|
|
*
|
|
* @param bucketName The name of the bucket.
|
|
* @param dataJson A JSON-encoded string of the bucket metadata. Supports the same fields as `create()`.
|
|
* @returns Bucket metadata after update.
|
|
*/
|
|
async modify(bucketName: string, dataJson: string): Promise<BucketMetadata> {
|
|
return await postData(this.apiKey, this.urlFor(bucketName, MODIFY_PATH), dataJson, true);
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the Bloom filter for keys in this bucket. The Bloom filter contains all
|
|
* keys ever inserted into this bucket; it does not remove deleted keys.
|
|
*
|
|
* The false positive rate is determined by parameters chosen by the server.
|
|
*
|
|
* @param bucketName The name of the bucket.
|
|
* @returns The Bloom filter for the keys of this bucket.
|
|
*/
|
|
async bloom(bucketName: string): Promise<BloomFilter> {
|
|
const presignedResp = await getData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, BLOOM_PATH),
|
|
true
|
|
);
|
|
const data = await getData(null, presignedResp['url'], false);
|
|
const filter = bloomFilterFromBytes(data);
|
|
|
|
return filter;
|
|
}
|
|
|
|
/**
|
|
* Upload new setup data.
|
|
*
|
|
* @param bucketName The name of the bucket associated with this setup data.
|
|
* @param data The setup data.
|
|
* @returns The setup data upload response, containing a UUID.
|
|
*/
|
|
async setupS3(bucketName: string, data: Uint8Array): Promise<any> {
|
|
if (this.bucketEndpoint) {
|
|
return await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, SETUP_PATH),
|
|
data,
|
|
true
|
|
);
|
|
}
|
|
|
|
const prelim_result = await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, SETUP_PATH),
|
|
JSON.stringify({ length: data.length }),
|
|
true
|
|
);
|
|
|
|
// perform the long upload
|
|
await postFormData(prelim_result['url'], prelim_result['fields'], data);
|
|
|
|
return prelim_result;
|
|
}
|
|
|
|
async setup(bucketName: string, data: Uint8Array): Promise<any> {
|
|
return await postDataJson(this.apiKey, this.urlFor(bucketName, SETUP_PATH), data);
|
|
}
|
|
|
|
/**
|
|
* Download hint data.
|
|
*
|
|
* @param bucketName The name of the bucket to get the hint data for.
|
|
*/
|
|
async hint(bucketName: string): Promise<Uint8Array> {
|
|
let url = BLYSS_HINT_URL_PREFIX + bucketName + '.hint';
|
|
if (this.bucketEndpoint) {
|
|
url = this.urlFor(bucketName, HINT_PATH);
|
|
}
|
|
const result = await getData(null, url, false);
|
|
return result;
|
|
}
|
|
|
|
/** Destroy this bucket. */
|
|
async destroy(bucketName: string) {
|
|
await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, DESTROY_PATH),
|
|
'',
|
|
false
|
|
);
|
|
}
|
|
|
|
/** Clear contents of this bucket. */
|
|
async clear(bucketName: string) {
|
|
await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, CLEAR_PATH),
|
|
'',
|
|
false
|
|
);
|
|
}
|
|
|
|
/** Write to this bucket. */
|
|
async write(bucketName: string, kvPairs: { [key: string]: Uint8Array | null }) {
|
|
// replace non-null values with base64-encoded strings, and leave null values as is
|
|
const json_data = JSON.stringify(
|
|
Object.fromEntries(
|
|
Object.entries(kvPairs).map(([k, v]) => [k, v ? bytesToBase64(v) : null])
|
|
)
|
|
);
|
|
|
|
await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, WRITE_PATH),
|
|
json_data,
|
|
false
|
|
);
|
|
}
|
|
|
|
/** Delete a key in this bucket. */
|
|
async deleteKey(bucketName: string, key: string) {
|
|
const kvDelete: { [key: string]: string | null } = { [key]: null };
|
|
|
|
await postDataJson(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, WRITE_PATH),
|
|
JSON.stringify(kvDelete),
|
|
);
|
|
}
|
|
|
|
/** Privately read data from this bucket. */
|
|
async privateRead(bucketName: string, data: Uint8Array): Promise<Uint8Array> {
|
|
return await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, READ_PATH),
|
|
data,
|
|
false
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* Privately read data from this bucket, returning bytes or null if the item was not found.
|
|
*/
|
|
async privateReadJson(bucketName: string, queries: Uint8Array[]): Promise<(Uint8Array | null)[]> {
|
|
|
|
// base64 encode each query
|
|
const queryStrings = queries.map(q => btoa(String.fromCharCode.apply(null, q)));
|
|
|
|
// Send list of Base64-encoded queries to the server
|
|
const resultsB64: (string | null)[] = await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, READ_PATH),
|
|
JSON.stringify(queryStrings),
|
|
true
|
|
);
|
|
|
|
// Parse results from the server, which are each either a Base64-encoded string or null
|
|
const results: (Uint8Array | null)[] = resultsB64.map(r => r ? Uint8Array.from(atob(r), c => c.charCodeAt(0)) : null);
|
|
return results;
|
|
}
|
|
|
|
|
|
/** Privately read data from this bucket. */
|
|
async privateReadMultipart(
|
|
bucketName: string,
|
|
data: Uint8Array,
|
|
targetUrl?: string
|
|
): Promise<Uint8Array> {
|
|
if (!targetUrl) targetUrl = this.urlFor(bucketName, READ_PATH);
|
|
|
|
if (this.bucketEndpoint) {
|
|
return await postData(
|
|
this.apiKey,
|
|
this.urlFor(bucketName, SETUP_PATH),
|
|
data,
|
|
true
|
|
);
|
|
}
|
|
|
|
const prelim_result = await postData(this.apiKey, targetUrl, '', true);
|
|
|
|
// perform the long upload
|
|
await postFormData(prelim_result['url'], prelim_result['fields'], data);
|
|
|
|
return await postData(
|
|
this.apiKey,
|
|
targetUrl,
|
|
JSON.stringify({ uuid: prelim_result['uuid'] }),
|
|
false
|
|
);
|
|
}
|
|
}
|
|
|
|
export { Api };
|