Clients: add modify and clear (#23)

* add modify and clear

* test blyss service via python client
This commit is contained in:
Neil Movva
2023-04-20 13:31:59 -07:00
committed by GitHub
parent 1f5c056c4a
commit 3b28c30d89
16 changed files with 377 additions and 105 deletions

View File

@@ -1,5 +1,9 @@
name: Build Python SDK
env:
BLYSS_STAGING_SERVER: https://dev2.api.blyss.dev
BLYSS_STAGING_API_KEY: Gh1pz1kEiNa1npEdDaRRvM1LsVypM1u2x1YbGb54
on:
push:
branches: [ "main" ]
@@ -26,6 +30,14 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python SDK
working-directory: python
shell: bash
run: pip install .
- name: Test Python SDK
working-directory: python
shell: bash
run: python tests/test_service.py
- name: Build wheels
uses: PyO3/maturin-action@v1
with:

View File

@@ -1,3 +1,4 @@
{
"python.analysis.typeCheckingMode": "basic"
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnSave": true
}

129
e2e-tests/api.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { Bucket, Client } from '@blyss/sdk';
const blyss = require('@blyss/sdk/node');
async function keyToValue(key: string, len: number): Promise<Uint8Array> {
const keyBytes = new TextEncoder().encode(key);
const value = new Uint8Array(len);
let i = 0;
// fill the value with the hash.
// if the hash is smaller than the value, we hash the hash again.
while (i < len) {
const hash = await crypto.subtle.digest('SHA-1', keyBytes);
const hashBytes = new Uint8Array(hash);
const toCopy = Math.min(hashBytes.length, len - i);
value.set(hashBytes.slice(0, toCopy), i);
i += toCopy;
}
return value;
}
async function verifyRead(key: string, value: Uint8Array): Promise<void> {
const expected = await keyToValue(key, value.length);
if (expected.toString() !== value.toString()) {
throw new Error('Incorrect value for key ' + key);
}
}
function generateKeys(n: number, seed: number = 0): string[] {
return new Array(n).fill(0).map(
(_, i) => seed.toString() + '-' + i.toString()
);
}
function generateBucketName(): string {
return 'api-tester-' + Math.random().toString(16).substring(2, 10);
}
async function testBlyssService(endpoint: string = 'https://dev2.api.blyss.dev') {
const apiKey = process.env.BLYSS_API_KEY;
if (!apiKey) {
throw new Error('BLYSS_API_KEY environment variable is not set');
}
console.log('Using key: ' + apiKey + ' to connect to ' + endpoint);
const client: Client = await new blyss.Client(
{
endpoint: endpoint,
apiKey: apiKey
}
);
// generate random string for bucket name
const bucketName = generateBucketName();
await client.create(bucketName);
const bucket: Bucket = await client.connect(bucketName);
console.log(bucket.metadata);
// generate N random keys
const N = 100;
const itemSize = 32;
let localKeys = generateKeys(N);
function getRandomKey(): string {
return localKeys[Math.floor(Math.random() * localKeys.length)];
}
// write all N keys
await bucket.write(
await Promise.all(localKeys.map(
async (k) => ({
k: await keyToValue(k, itemSize)
})
))
);
console.log(`Wrote ${N} keys`);
// read a random key
let testKey = getRandomKey();
let value = await bucket.privateRead(testKey);
await verifyRead(testKey, value);
console.log(`Read key ${testKey}`);
// delete testKey from the bucket, and localData.
await bucket.deleteKey(testKey);
localKeys.splice(localKeys.indexOf(testKey), 1);
console.log(`Deleted key ${testKey}`);
// write a new value
testKey = 'newKey0';
await bucket.write({ testKey: keyToValue(testKey, itemSize) });
localKeys.push(testKey);
console.log(`Wrote key ${testKey}`);
// clear all keys
await bucket.clearEntireBucket();
localKeys = [];
console.log('Cleared bucket');
// write a new set of N keys
localKeys = generateKeys(N, 1);
await bucket.write(
await Promise.all(localKeys.map(
async (k) => ({
k: await keyToValue(k, itemSize)
})
))
);
console.log(`Wrote ${N} keys`);
// rename the bucket
const newBucketName = bucketName + '-rn';
await bucket.rename(newBucketName);
console.log(`Renamed bucket`);
console.log(await bucket.info());
// random read
testKey = getRandomKey();
value = await bucket.privateRead(testKey);
await verifyRead(testKey, value);
console.log(`Read key ${testKey}`);
// destroy the bucket
await bucket.destroyEntireBucket();
console.log(`Destroyed bucket ${bucket.name}`);
}
async function main() {
const endpoint = "https://dev2.api.blyss.dev"
console.log('Testing Blyss service at URL ' + endpoint);
await testBlyssService(endpoint);
console.log('All tests completed successfully.');
}
main();

View File

@@ -3,7 +3,6 @@ import { base64ToBytes, getRandomSeed } from '../client/seed';
import { decompress } from '../compression/bz2_decompress';
import { bloomLookup } from '../data/bloom';
import {
DataWithMetadata,
concatBytes,
deserialize,
deserializeChunks,
@@ -40,7 +39,7 @@ export class Bucket {
readonly api: Api;
/** The name of this bucket. */
readonly name: string;
name: string;
/**
* The secret seed for this instance of the client, which can be saved and
@@ -110,7 +109,7 @@ export class Bucket {
try {
decompressedResult = decompress(decryptedResult);
} catch (e) {
console.error('decompress error', e);
console.error(`key ${key} not found (decompression failed)`);
}
if (decompressedResult === null) {
return null;
@@ -120,7 +119,7 @@ export class Bucket {
try {
extractedResult = this.lib.extractResult(key, decompressedResult);
} catch (e) {
console.error('extraction error', e);
console.error(`key ${key} not found (extraction failed)`);
}
if (extractedResult === null) {
return null;
@@ -151,7 +150,7 @@ export class Bucket {
private async performPrivateReads(
keys: string[]
): Promise<DataWithMetadata[]> {
): Promise<any[]> {
if (!this.uuid || !this.check(this.uuid)) {
await this.setup();
}
@@ -185,7 +184,7 @@ export class Bucket {
return endResults;
}
private async performPrivateRead(key: string): Promise<DataWithMetadata> {
private async performPrivateRead(key: string): Promise<any> {
return (await this.performPrivateReads([key]))[0];
}
@@ -320,6 +319,15 @@ export class Bucket {
return await this.api.meta(this.name);
}
/** Renames this bucket, leaving all data and other bucket settings intact. */
async rename(newBucketName: string): Promise<BucketMetadata> {
const bucketCreateReq = {
name: newBucketName
};
await this.api.modify(this.name, JSON.stringify(bucketCreateReq));
this.name = newBucketName;
}
/** Gets info on all keys in this bucket. */
async listKeys(): Promise<KeyInfo[]> {
this.ensureSpiral();
@@ -333,14 +341,9 @@ export class Bucket {
* key-value pairs to write. Keys must be strings, and values may be any
* JSON-serializable value or a Uint8Array. The maximum size of a key is
* 1024 UTF-8 bytes.
* @param {{ [key: string]: any }} [metadata] - An optional object containing
* metadata. Each key of this object should also be a key of
* `keyValuePairs`, and the value should be some metadata object to store
* with the values being written.
*/
async write(
keyValuePairs: { [key: string]: any },
metadata?: { [key: string]: any }
keyValuePairs: { [key: string]: any }
) {
this.ensureSpiral();
@@ -348,17 +351,18 @@ export class Bucket {
for (const key in keyValuePairs) {
if (Object.prototype.hasOwnProperty.call(keyValuePairs, key)) {
const value = keyValuePairs[key];
let valueMetadata = undefined;
if (metadata && Object.prototype.hasOwnProperty.call(metadata, key)) {
valueMetadata = metadata[key];
}
const valueBytes = serialize(value, valueMetadata);
const valueBytes = serialize(value);
const keyBytes = new TextEncoder().encode(key);
const serializedKeyValue = wrapKeyValue(keyBytes, valueBytes);
data.push(serializedKeyValue);
// const kv = {
// key: key,
// value: Buffer.from(valueBytes).toString('base64')
// }
}
}
const concatenatedData = concatBytes(data);
// const concatenatedData = serialize(data);
await this.api.write(this.name, concatenatedData);
}
@@ -385,6 +389,14 @@ export class Bucket {
await this.api.destroy(this.name);
}
/**
* Clears the contents of the entire bucket, and all data inside of it. This action is
* permanent and irreversible.
*/
async clearEntireBucket() {
await this.api.clear(this.name);
}
/**
* Privately reads the supplied key from the bucket, returning the value
* corresponding to the key.
@@ -398,28 +410,13 @@ export class Bucket {
this.ensureSpiral();
if (Array.isArray(key)) {
return (await this.performPrivateReads(key)).map(r => r.data);
return (await this.performPrivateReads(key));
} else {
const result = await this.performPrivateRead(key);
return result ? result.data : null;
return result ? result : null;
}
}
/**
* Privately reads the supplied key from the bucket, returning the value and
* metadata corresponding to the key.
*
* No entity, including the Blyss service, should be able to determine which
* key this method was called for.
*
* @param {string} key - The key to _privately_ retrieve the value of.
*/
async privateReadWithMetadata(key: string): Promise<DataWithMetadata> {
this.ensureSpiral();
return await this.performPrivateRead(key);
}
/**
* Privately intersects the given set of keys with the keys in this bucket,
* returning the keys that intersected and their values. This is generally
@@ -437,7 +434,7 @@ export class Bucket {
this.ensureSpiral();
if (keys.length < BLOOM_CUTOFF) {
return (await this.performPrivateReads(keys)).map(x => x.data);
return (await this.performPrivateReads(keys));
}
const bloomFilter = await this.api.bloom(this.name);
@@ -451,7 +448,7 @@ export class Bucket {
if (!retrieveValues) {
return matches;
}
return (await this.performPrivateReads(matches)).map(x => x.data);
return (await this.performPrivateReads(matches));
}
/**

View File

@@ -4,6 +4,8 @@ import { gzip } from '../compression/pako';
import { BloomFilter, bloomFilterFromBytes } from '../data/bloom';
const CREATE_PATH = '/create';
const MODIFY_PATH = '/modify';
const CLEAR_PATH = '/clear';
const DESTROY_PATH = '/destroy';
const CHECK_PATH = '/check';
const DELETE_PATH = '/delete';
@@ -215,6 +217,18 @@ class Api {
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.
@@ -304,6 +318,16 @@ class Api {
);
}
/** 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, data: Uint8Array) {
await postData(

View File

@@ -48,7 +48,12 @@ export function mergeUint8Arrays(
return mergedArray;
}
function getObjectAsBytes(obj: any): Uint8Array {
/**
* Safely serializes an object into bytes.
*
* @param obj - Object to serialize.
*/
export function serialize(obj: any): Uint8Array {
if (obj instanceof ArrayBuffer || obj instanceof Uint8Array) {
return obj instanceof ArrayBuffer ? new Uint8Array(obj) : obj;
}
@@ -59,67 +64,21 @@ function getObjectAsBytes(obj: any): Uint8Array {
return encoder.encode(objJson);
}
function getHeaderBytes(obj: any, metadata?: any): Uint8Array {
if (!metadata && (obj instanceof ArrayBuffer || obj instanceof Uint8Array)) {
return varint.encode(0);
} else {
const encoder = new TextEncoder();
const headerData = { contentType: 'application/json', ...metadata };
const header = JSON.stringify(headerData);
const headerVarInt = varint.encode(header.length);
return mergeUint8Arrays(headerVarInt, encoder.encode(header));
}
}
/**
* Safely serializes an object (and optional metadta) into bytes.
*
* @param obj - Object to serialize.
*/
export function serialize(obj: any, metadata?: any): Uint8Array {
const headerBytes = getHeaderBytes(obj, metadata);
const objAsBytes = getObjectAsBytes(obj);
return mergeUint8Arrays(headerBytes, objAsBytes);
}
export interface DataWithMetadata {
data: any;
metadata?: any;
}
/**
* Safely deserializes an object, and possibly any associated metadata, from the
* input bytes.
* Safely deserializes an object from input bytes.
* If the input bytes are valid JSON, the object will be deserialized as JSON.
* Otherwise, the input bytes will be returned as-is (Uint8Array).
*
* @param data - Bytes to deserialize.
*/
export function deserialize(data: Uint8Array): DataWithMetadata {
const { value, bytesProcessed } = varint.decode(data);
const headerLength = value;
let i = bytesProcessed;
if (headerLength === 0) {
return { data: data.slice(i) };
export function deserialize(data: Uint8Array): any {
try {
const decoder = new TextDecoder();
const obj = JSON.parse(decoder.decode(data));
return obj;
} catch (e) {
return data;
}
const decoder = new TextDecoder();
const headerBytes = data.slice(i, i + headerLength);
i += headerLength;
const header = JSON.parse(decoder.decode(headerBytes));
const dataBytes = data.slice(i);
let obj;
if (header.contentType === 'application/json') {
obj = JSON.parse(decoder.decode(dataBytes));
} else {
obj = dataBytes;
}
return {
data: obj,
metadata: header
};
}
/** Concatenate the input Uint8Arrays. */

View File

@@ -3,11 +3,10 @@ import type { KeyInfo } from './bucket/bucket';
import type { ApiConfig } from './bucket/bucket_service';
import { BucketService } from './bucket/bucket_service';
import type { ApiError } from './client/api';
import { DataWithMetadata } from './data/serializer';
export { BucketService as Client, Bucket };
export type { KeyInfo, BucketService, ApiError, ApiConfig, DataWithMetadata };
export type { KeyInfo, BucketService, ApiError, ApiConfig };
// External copyright notices:
/*! pako (C) 1995-2013 Jean-loup Gailly and Mark Adler */

View File

@@ -23,7 +23,7 @@ describe('serialization/deserialization routines', () => {
0,
null
])(`should be inverses for: %s`, val => {
expect(deserialize(serialize(val)).data).toEqual(val);
expect(deserialize(serialize(val))).toEqual(val);
});
});

View File

@@ -36,6 +36,7 @@
"start": "webpack-dev-server --open",
"test": "jest",
"e2e-tests": "npm link && cd lib/server && cargo build --release && cd ../../e2e-tests && npm link @blyss/sdk && npx ts-node main.ts ../lib/server/target/release/server params",
"api-tests": "npm run --silent build && npm link && cd e2e-tests && npm link @blyss/sdk && npx ts-node api.ts",
"lint": "eslint . --ext .ts"
},
"devDependencies": {
@@ -60,4 +61,4 @@
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1"
}
}
}

2
python/Cargo.lock generated
View File

@@ -25,7 +25,7 @@ dependencies = [
[[package]]
name = "blyss-client-python"
version = "0.1.7"
version = "0.1.8"
dependencies = [
"pyo3",
"spiral-rs",

View File

@@ -1,6 +1,6 @@
[package]
name = "blyss-client-python"
version = "0.1.7"
version = "0.1.8"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -18,7 +18,9 @@ from blyss.req_compression import get_session
from blyss.bloom import BloomFilter
CREATE_PATH = "/create"
MODIFY_PATH = "/modify"
DESTROY_PATH = "/destroy"
CLEAR_PATH = "/clear"
CHECK_PATH = "/check"
LIST_BUCKETS_PATH = "/list-buckets"
DELETE_PATH = "/delete"
@@ -208,6 +210,16 @@ class API:
def _url_for(self, bucket_name: str, path: str) -> str:
return self.service_endpoint + "/" + bucket_name + path
def modify(self, bucket_name: str, data_json: str) -> dict[Any, Any]:
"""Modify existing bucket.
Args:
data_json (str): same as create.
"""
return _post_data_json(
self.api_key, self._url_for(bucket_name, MODIFY_PATH), data_json
)
def meta(self, bucket_name: str) -> dict[Any, Any]:
"""Get metadata about a bucket.
@@ -258,6 +270,10 @@ class API:
"""Destroy this bucket."""
_post_data(self.api_key, self._url_for(bucket_name, DESTROY_PATH), "")
def clear(self, bucket_name: str):
"""Delete all keys in this bucket."""
_post_data(self.api_key, self._url_for(bucket_name, CLEAR_PATH), "")
def write(self, bucket_name: str, data: bytes):
"""Write some data to this bucket."""
_post_data(self.api_key, self._url_for(bucket_name, WRITE_PATH), data)

View File

@@ -7,7 +7,7 @@ the compiled Rust code.
"""
from typing import Any
from typing import Any, Optional
from . import blyss, seed # type: ignore
# NB: There are many "type: ignore"s on purpose. Type information
@@ -82,7 +82,7 @@ class BlyssLib:
"""
return bytes(blyss.decode_response(self.inner_client, response)) # type: ignore
def extract_result(self, key: str, data: bytes) -> bytes:
def extract_result(self, key: str, data: bytes) -> Optional[bytes]:
"""Extracts the value for a given key, given the plaintext data from a response.
Args:
@@ -92,7 +92,11 @@ class BlyssLib:
Returns:
bytes: The plaintext data corresponding to the given key.
"""
return bytes(blyss.extract_result(self.inner_client, key, data)) # type: ignore
r = blyss.extract_result(self.inner_client, key, data)
if r is None:
return None
else:
return bytes(r)
def __init__(self, params: str, secret_seed: str):
"""Initializes a new BlyssLib instance.

View File

@@ -118,7 +118,7 @@ class Bucket:
"content-type": "application/octet-stream",
}
row.append(fmt)
row_size += int(72 + len(key) + len(value_str))
row_size += int(24 + len(key) + len(value_str) + 48)
# if the new row doesn't fit into the current chunk, start a new one
if current_chunk_size + row_size > _MAX_PAYLOAD:
@@ -211,6 +211,14 @@ class Bucket:
def list_keys(self) -> dict[str, Any]:
"""Gets info on all keys in this bucket."""
return self.api.list_keys(self.name)
def rename(self, new_name: str):
"""Renames this bucket."""
bucket_create_req = {
"name": new_name,
}
self.api.modify(self.name, json.dumps(bucket_create_req))
self.name = new_name
def write(self, kv_pairs: dict[str, Union[tuple[Any, Optional[Any]], Any]]):
"""Writes the supplied key-value pair(s) into the bucket.
@@ -251,6 +259,14 @@ class Bucket:
"""Destroys the entire bucket. This action is permanent and irreversible."""
self.api.destroy(self.name)
def clear_entire_bucket(self):
"""Deletes all keys in this bucket. This action is permanent and irreversible.
Differs from destroy in that the bucket's metadata
(e.g. permissions, PIR scheme parameters, and clients' setup data) are preserved.
"""
self.api.clear(self.name)
def private_read(self, keys: Union[str, list[str]]) -> Union[bytes, list[bytes]]:
"""Privately reads the supplied key from the bucket,
returning the value corresponding to the key.

View File

@@ -3,7 +3,7 @@ from . import bucket, api, seed
import json
BLYSS_BUCKET_URL = "https://beta.api.blyss.dev"
DEFAULT_BUCKET_PARAMETERS = {"maxItemSize": 1000}
DEFAULT_BUCKET_PARAMETERS = {"maxItemSize": 1000, "keyStoragePolicy": "bloom"}
ApiConfig = dict[str, str]

View File

@@ -0,0 +1,114 @@
import os
import sys
import random
import hashlib
import traceback
import blyss
def key_to_gold_value(key: str, length: int = 512) -> bytes:
h = hashlib.md5()
h.update(key.encode("utf-8"))
value = h.digest()
while len(value) < length:
h.update(b"0")
value += h.digest()
return value[:length]
def verify_read(key: str, value: bytes):
try:
assert value == key_to_gold_value(key, len(value))
except:
print(traceback.format_exc())
raise
def generate_keys(n: int, seed: int = 0) -> list:
return [f"{seed}-{i}" for i in range(n)]
def generateBucketName() -> str:
tag = int(random.random() * 1e6)
return f"api-tester-{tag:#0{6}x}"
async def main(endpoint: str, api_key: str):
print("Testing Blyss server at " + endpoint)
client = blyss.Client({"endpoint": endpoint, "api_key": api_key})
# generate random string for bucket name
bucketName = generateBucketName()
client.create(bucketName)
bucket = client.connect_async(bucketName)
print(bucket.info())
# generate N random keys
N = 20000
itemSize = 32
localKeys = generate_keys(N, 0)
# write all N keys
await bucket.write({k: key_to_gold_value(k, itemSize) for k in localKeys})
print(f"Wrote {N} keys")
# read a random key
testKey = random.choice(localKeys)
value = await bucket.private_read([testKey])
verify_read(testKey, value[0])
print(f"Read key {testKey}")
# delete testKey from the bucket, and localData.
bucket.delete_key(testKey)
localKeys.remove(testKey)
value = await bucket.private_read([testKey])
# TODO: why aren't deletes reflected in the next read?
# assert value is None
print(f"Deleted key {testKey}")
# clear all keys
bucket.clear_entire_bucket()
localKeys = []
print("Cleared bucket")
# write a new set of N keys
localKeys = generate_keys(N, 2)
await bucket.write({k: key_to_gold_value(k, itemSize) for k in localKeys})
print(f"Wrote {N} keys")
# test if clear took AFTER the new write
value = await bucket.private_read([testKey])
if value is not None:
print(f"ERROR: {testKey} was not deleted or cleared!")
# rename the bucket
newBucketName = bucketName + "-rn"
bucket.rename(newBucketName)
print("Renamed bucket")
print(bucket.info())
# read a random key
testKey = random.choice(localKeys)
value = await bucket.private_read([testKey])
verify_read(testKey, value[0])
print(f"Read key {testKey}")
# destroy the bucket
bucket.destroy_entire_bucket()
print("Destroyed bucket")
if __name__ == "__main__":
import asyncio
api_key = os.environ.get("BLYSS_STAGING_API_KEY", None)
endpoint = os.environ.get("BLYSS_STAGING_SERVER", None)
if len(sys.argv) > 1:
print("Using endpoint from command line")
endpoint = sys.argv[1]
if len(sys.argv) > 2:
print("Using api_key from command line")
api_key = sys.argv[2]
print("DEBUG", api_key, endpoint)
assert endpoint is not None
assert api_key is not None
asyncio.run(main(endpoint, api_key))