From 76dc39570d61597cb58fbc902327338a24eef9ee Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 8 Aug 2023 13:40:33 -0700 Subject: [PATCH 1/2] add first version of divviup client --- package-lock.json | 15 +++ package.json | 1 + packages/dap/src/index.ts | 9 +- packages/divviup/package.json | 31 +++++ packages/divviup/src/index.spec.ts | 189 +++++++++++++++++++++++++++++ packages/divviup/src/index.ts | 78 ++++++++++++ packages/divviup/tsconfig.json | 8 ++ packages/divviup/typedoc.json | 5 + 8 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 packages/divviup/package.json create mode 100644 packages/divviup/src/index.spec.ts create mode 100644 packages/divviup/src/index.ts create mode 100644 packages/divviup/tsconfig.json create mode 100644 packages/divviup/typedoc.json diff --git a/package-lock.json b/package-lock.json index ace5459a4..debfe8b47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "./packages/vdaf", "./packages/prio3", "./packages/dap", + "./packages/divviup", "./packages/interop-test-client" ], "devDependencies": { @@ -2509,6 +2510,10 @@ "node": ">=8" } }, + "node_modules/divviup": { + "resolved": "packages/divviup", + "link": true + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -7523,6 +7528,16 @@ "hpke": "^0.5.0" } }, + "packages/divviup": { + "version": "0.1.0", + "license": "MPL-2.0", + "dependencies": { + "@divviup/dap": "^0.1.0" + }, + "devDependencies": { + "hpke": "^0.5.0" + } + }, "packages/field": { "name": "@divviup/field", "version": "0.1.0", diff --git a/package.json b/package.json index 386db48fc..ed52135a5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "./packages/vdaf", "./packages/prio3", "./packages/dap", + "./packages/divviup", "./packages/interop-test-client" ], "scripts": { diff --git a/packages/dap/src/index.ts b/packages/dap/src/index.ts index c316ec886..9834c02c5 100644 --- a/packages/dap/src/index.ts +++ b/packages/dap/src/index.ts @@ -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"; diff --git a/packages/divviup/package.json b/packages/divviup/package.json new file mode 100644 index 000000000..0e24802e0 --- /dev/null +++ b/packages/divviup/package.json @@ -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" + } +} diff --git a/packages/divviup/src/index.spec.ts b/packages/divviup/src/index.spec.ts new file mode 100644 index 000000000..f888df84d --- /dev/null +++ b/packages/divviup/src/index.spec.ts @@ -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(); + let client = new DivviupClient(taskId); + let fetch = mockFetch({ + ...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(); + let client = new DivviupClient( + `https://production.divvi.up/v3/different-url/${taskId}.json`, + ); + let fetch = mockFetch({ + ...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(); + let fetch = mockFetch({ + ...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; + 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 { + 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]}`); + }; + 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), + ), + ]); +} diff --git a/packages/divviup/src/index.ts b/packages/divviup/src/index.ts new file mode 100644 index 000000000..d86801a92 --- /dev/null +++ b/packages/divviup/src/index.ts @@ -0,0 +1,78 @@ +import { DAPClient } from "@divviup/dap"; +import { KnownVdafSpec } from "@divviup/dap/dist/client"; + +type Fetch = ( + input: RequestInfo, + init?: RequestInit | undefined, +) => Promise; + +interface PublicTask { + id: string; + vdaf: KnownVdafSpec; + leader: string; + helper: string; + time_precision_seconds: number; +} + +type AnyMeasurement = number | bigint | boolean; +type GenericDAPClient = DAPClient; + +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}`); + } + } else { + this.#taskUrl = urlOrTaskId; + } + } + + private async taskClient(): Promise { + 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); +} diff --git a/packages/divviup/tsconfig.json b/packages/divviup/tsconfig.json new file mode 100644 index 000000000..bad77ca65 --- /dev/null +++ b/packages/divviup/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["./src/*.ts"] +} diff --git a/packages/divviup/typedoc.json b/packages/divviup/typedoc.json new file mode 100644 index 000000000..0ba819b1c --- /dev/null +++ b/packages/divviup/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPointStrategy": "expand", + "entryPoints": ["src/index.ts"] +} From 3c06d01916ea3f94dce2498060bbcc02ae5eb8ac Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 8 Aug 2023 14:07:04 -0700 Subject: [PATCH 2/2] lint --- packages/divviup/src/index.spec.ts | 18 +++++++++--------- packages/divviup/src/index.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/divviup/src/index.spec.ts b/packages/divviup/src/index.spec.ts index f888df84d..69fcf3090 100644 --- a/packages/divviup/src/index.spec.ts +++ b/packages/divviup/src/index.spec.ts @@ -6,9 +6,9 @@ import * as hpke from "hpke"; describe("DivviupClient", () => { it("fetches task from an id", async () => { - let taskId = TaskId.random().toString(); - let client = new DivviupClient(taskId); - let fetch = mockFetch({ + const taskId = TaskId.random().toString(); + const client = new DivviupClient(taskId); + const fetch = mockFetch({ ...dapMocks(taskId), [`https://api.staging.divviup.org/tasks/${taskId}`]: [ { @@ -30,11 +30,11 @@ describe("DivviupClient", () => { }); it("fetches task from a task url", async () => { - let taskId = TaskId.random().toString(); - let client = new DivviupClient( + const taskId = TaskId.random().toString(); + const client = new DivviupClient( `https://production.divvi.up/v3/different-url/${taskId}.json`, ); - let fetch = mockFetch({ + const fetch = mockFetch({ ...dapMocks(taskId), [`https://production.divvi.up/v3/different-url/${taskId}.json`]: [ { @@ -58,8 +58,8 @@ describe("DivviupClient", () => { describe("sendMeasurement", () => { it("fetches task from an id", async () => { - let taskId = TaskId.random().toString(); - let fetch = mockFetch({ + const taskId = TaskId.random().toString(); + const fetch = mockFetch({ ...dapMocks(taskId), [`https://api.staging.divviup.org/tasks/${taskId}`]: [ { @@ -142,7 +142,7 @@ function mockFetch(mocks: { [url: string]: ResponseSpec[] }): Fetch { fakeFetch.calls = [] as [RequestInfo, RequestInit | undefined][]; fakeFetch.callStrings = function () { - return this.calls.map((x) => `${x[1]?.method || "GET"} ${x[0]}`); + return this.calls.map((x) => `${x[1]?.method || "GET"} ${x[0].toString()}`); }; return fakeFetch; } diff --git a/packages/divviup/src/index.ts b/packages/divviup/src/index.ts index d86801a92..9af453d85 100644 --- a/packages/divviup/src/index.ts +++ b/packages/divviup/src/index.ts @@ -34,7 +34,7 @@ export class DivviupClient { try { this.#taskUrl = new URL(urlOrTaskId); } catch (e) { - this.#taskUrl = new URL(`${this.#baseUrl}/${urlOrTaskId}`); + this.#taskUrl = new URL(`${this.#baseUrl.toString()}/${urlOrTaskId}`); } } else { this.#taskUrl = urlOrTaskId; @@ -43,10 +43,10 @@ export class DivviupClient { private async taskClient(): Promise { 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({ + const response = await this.#fetch(this.#taskUrl.toString()); + const task = (await response.json()) as PublicTask; + const { leader, helper, vdaf, id, time_precision_seconds } = task; + const client = new DAPClient({ taskId: id, leader, helper, @@ -72,7 +72,7 @@ export async function sendMeasurement( measurement: AnyMeasurement, fetch?: Fetch, ) { - let client = new DivviupClient(urlOrTaskId); + const client = new DivviupClient(urlOrTaskId); if (fetch) client.fetch = fetch; return client.sendMeasurement(measurement); }