Skip to content

Commit

Permalink
add first version of divviup client
Browse files Browse the repository at this point in the history
  • Loading branch information
jbr committed Aug 8, 2023
1 parent 05c3923 commit 76dc395
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 1 deletion.
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"./packages/vdaf",
"./packages/prio3",
"./packages/dap",
"./packages/divviup",
"./packages/interop-test-client"
],
"scripts": {
Expand Down
9 changes: 8 additions & 1 deletion packages/dap/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export { DAPClient, DAPClient as default } from "./client";
export {
DAPClient,
DAPClient as default,
KnownVdafSpec,
VdafMeasurement,
} from "./client";
export { DAPError } from "./errors";
export type { ReportOptions } from "./client";
export { TaskId } from "./taskId";
export { HpkeConfig, HpkeConfigList } from "./hpkeConfig";
31 changes: 31 additions & 0 deletions packages/divviup/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "divviup",
"version": "0.1.0",
"description": "",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"source": "src/index.ts",
"module": "dist/module.js",
"browser": "dist/browser.js",
"type": "module",
"license": "MPL-2.0",
"scripts": {
"clean": "rm -rf dist/*",
"build:clean": "npm run clean && npm run build",
"build": "npm run build:web && npm run build:node",
"build:web": "esbuild browser=src/index.ts --bundle --loader:.wasm=binary --format=esm --outdir=dist --sourcemap --minify",
"build:node": "tsc -p ./tsconfig.json",
"docs": "typedoc src",
"test": "mocha \"src/**/*.spec.ts\"",
"lint": "eslint src --ext .ts && prettier -c src",
"format": "prettier -w src",
"check": "tsc --noEmit -p ./tsconfig.json",
"test:coverage": "c8 npm test"
},
"dependencies": {
"@divviup/dap": "^0.1.0"
},
"devDependencies": {
"hpke": "^0.5.0"
}
}
189 changes: 189 additions & 0 deletions packages/divviup/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import assert from "assert";
import { inspect } from "node:util";
import { DivviupClient, sendMeasurement } from ".";
import { HpkeConfigList, HpkeConfig, TaskId } from "@divviup/dap";
import * as hpke from "hpke";

describe("DivviupClient", () => {
it("fetches task from an id", async () => {
let taskId = TaskId.random().toString();

Check failure on line 9 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'taskId' is never reassigned. Use 'const' instead
let client = new DivviupClient(taskId);

Check failure on line 10 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'client' is never reassigned. Use 'const' instead
let fetch = mockFetch({

Check failure on line 11 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'fetch' is never reassigned. Use 'const' instead
...dapMocks(taskId),
[`https://api.staging.divviup.org/tasks/${taskId}`]: [
{
status: 200,
body: JSON.stringify(task(taskId)),
contentType: "application/json",
},
],
});
client.fetch = fetch;
await client.sendMeasurement(10);
assert.equal(fetch.calls.length, 4);
assert.deepEqual(fetch.callStrings(), [
`GET https://api.staging.divviup.org/tasks/${taskId}`,
`GET https://a.example.com/v1/hpke_config?task_id=${taskId}`,
`GET https://b.example.com/dap/hpke_config?task_id=${taskId}`,
`PUT https://a.example.com/v1/tasks/${taskId}/reports`,
]);
});

it("fetches task from a task url", async () => {
let taskId = TaskId.random().toString();

Check failure on line 33 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'taskId' is never reassigned. Use 'const' instead
let client = new DivviupClient(

Check failure on line 34 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'client' is never reassigned. Use 'const' instead
`https://production.divvi.up/v3/different-url/${taskId}.json`,
);
let fetch = mockFetch({

Check failure on line 37 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'fetch' is never reassigned. Use 'const' instead
...dapMocks(taskId),
[`https://production.divvi.up/v3/different-url/${taskId}.json`]: [
{
status: 200,
body: JSON.stringify(task(taskId)),
contentType: "application/json",
},
],
});
client.fetch = fetch;
await client.sendMeasurement(10);
assert.equal(fetch.calls.length, 4);
assert.deepEqual(fetch.callStrings(), [
`GET https://production.divvi.up/v3/different-url/${taskId}.json`,
`GET https://a.example.com/v1/hpke_config?task_id=${taskId}`,
`GET https://b.example.com/dap/hpke_config?task_id=${taskId}`,
`PUT https://a.example.com/v1/tasks/${taskId}/reports`,
]);
});
});

describe("sendMeasurement", () => {
it("fetches task from an id", async () => {
let taskId = TaskId.random().toString();

Check failure on line 61 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'taskId' is never reassigned. Use 'const' instead
let fetch = mockFetch({

Check failure on line 62 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

'fetch' is never reassigned. Use 'const' instead
...dapMocks(taskId),
[`https://api.staging.divviup.org/tasks/${taskId}`]: [
{
status: 200,
body: JSON.stringify(task(taskId)),
contentType: "application/json",
},
],
});

await sendMeasurement(taskId, 10, fetch);

assert.equal(fetch.calls.length, 4);
assert.deepEqual(fetch.callStrings(), [
`GET https://api.staging.divviup.org/tasks/${taskId}`,
`GET https://a.example.com/v1/hpke_config?task_id=${taskId}`,
`GET https://b.example.com/dap/hpke_config?task_id=${taskId}`,
`PUT https://a.example.com/v1/tasks/${taskId}/reports`,
]);
});
});

function dapMocks(taskId: string) {
return {
[`https://a.example.com/v1/hpke_config?task_id=${taskId}`]: [
hpkeConfigResponse(),
],

[`https://b.example.com/dap/hpke_config?task_id=${taskId}`]: [
hpkeConfigResponse(),
],

[`https://api.staging.divviup.org/tasks/${taskId}`]: [
{
status: 200,
body: JSON.stringify(task(taskId)),
contentType: "application/json",
},
],
[`https://a.example.com/v1/tasks/${taskId}/reports`]: [{ status: 201 }],
};
}

interface Fetch {
(input: RequestInfo, init?: RequestInit | undefined): Promise<Response>;
calls: [RequestInfo, RequestInit | undefined][];
callStrings(): string[];
}

interface ResponseSpec {
body?: Buffer | Uint8Array | number[] | string;
contentType?: string;
status?: number;
}

function mockFetch(mocks: { [url: string]: ResponseSpec[] }): Fetch {
function fakeFetch(
input: RequestInfo,
init?: RequestInit | undefined,
): Promise<Response> {
fakeFetch.calls.push([input, init]);
const responseSpec = mocks[input.toString()];
const response = responseSpec?.shift();

if (!response) {
throw new Error(
`received unhandled request.\n\nurl: ${input.toString()}.\n\nmocks: ${inspect(
mocks,
).slice(1, -1)}`,
);
}

return Promise.resolve(
new Response(Buffer.from(response.body || ""), {
status: response.status || 200,
headers: { "Content-Type": response.contentType || "text/plain" },
}),
);
}

fakeFetch.calls = [] as [RequestInfo, RequestInit | undefined][];
fakeFetch.callStrings = function () {
return this.calls.map((x) => `${x[1]?.method || "GET"} ${x[0]}`);

Check failure on line 145 in packages/divviup/src/index.spec.ts

View workflow job for this annotation

GitHub Actions / Test

Invalid type "RequestInfo" of template literal expression
};
return fakeFetch;
}

function task(taskId: string): {
vdaf: {
type: "sum";
bits: number;
};
helper: string;
leader: string;
id: string;
time_precision_seconds: number;
} {
return {
vdaf: {
type: "sum",
bits: 16,
},
leader: "https://a.example.com/v1",
helper: "https://b.example.com/dap/",
id: taskId,
time_precision_seconds: 1,
};
}

function hpkeConfigResponse(config = buildHpkeConfigList()): ResponseSpec {
return {
body: config.encode(),
contentType: "application/dap-hpke-config-list",
};
}

function buildHpkeConfigList(): HpkeConfigList {
return new HpkeConfigList([
new HpkeConfig(
Math.floor(Math.random() * 255),
hpke.Kem.DhP256HkdfSha256,
hpke.Kdf.Sha256,
hpke.Aead.AesGcm128,
Buffer.from(new hpke.Keypair(hpke.Kem.DhP256HkdfSha256).public_key),
),
]);
}
78 changes: 78 additions & 0 deletions packages/divviup/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { DAPClient } from "@divviup/dap";
import { KnownVdafSpec } from "@divviup/dap/dist/client";

type Fetch = (
input: RequestInfo,
init?: RequestInit | undefined,
) => Promise<Response>;

interface PublicTask {
id: string;
vdaf: KnownVdafSpec;
leader: string;
helper: string;
time_precision_seconds: number;
}

type AnyMeasurement = number | bigint | boolean;
type GenericDAPClient = DAPClient<KnownVdafSpec, AnyMeasurement>;

export class DivviupClient {
#baseUrl = new URL("https://api.staging.divviup.org/tasks");
#fetch: Fetch = globalThis.fetch.bind(globalThis);
#dapClient: null | GenericDAPClient = null;
#taskUrl: URL;

/** @internal */
set fetch(fetch: Fetch) {
this.#fetch = fetch;
if (this.#dapClient) this.#dapClient.fetch = fetch;
}

constructor(urlOrTaskId: string | URL) {
if (typeof urlOrTaskId === "string") {
try {
this.#taskUrl = new URL(urlOrTaskId);
} catch (e) {
this.#taskUrl = new URL(`${this.#baseUrl}/${urlOrTaskId}`);

Check failure on line 37 in packages/divviup/src/index.ts

View workflow job for this annotation

GitHub Actions / Test

Invalid type "URL" of template literal expression
}
} else {
this.#taskUrl = urlOrTaskId;
}
}

private async taskClient(): Promise<GenericDAPClient> {
if (this.#dapClient) return this.#dapClient;
let response = await this.#fetch(this.#taskUrl.toString());
let task = (await response.json()) as PublicTask;
let { leader, helper, vdaf, id, time_precision_seconds } = task;
let client = new DAPClient({
taskId: id,
leader,
helper,
id,
timePrecisionSeconds: time_precision_seconds,
...vdaf,
});
client.fetch = this.#fetch;
this.#dapClient = client;
return client;
}

async sendMeasurement(measurement: AnyMeasurement) {
const client = await this.taskClient();
return client.sendMeasurement(measurement);
}
}

export default DivviupClient;

export async function sendMeasurement(
urlOrTaskId: string | URL,
measurement: AnyMeasurement,
fetch?: Fetch,
) {
let client = new DivviupClient(urlOrTaskId);
if (fetch) client.fetch = fetch;
return client.sendMeasurement(measurement);
}
8 changes: 8 additions & 0 deletions packages/divviup/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["./src/*.ts"]
}
5 changes: 5 additions & 0 deletions packages/divviup/typedoc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": ["../../typedoc.base.json"],
"entryPointStrategy": "expand",
"entryPoints": ["src/index.ts"]
}

0 comments on commit 76dc395

Please sign in to comment.