diff --git a/api.bs b/api.bs index 9f3c6eb..a5622b9 100644 --- a/api.bs +++ b/api.bs @@ -1527,6 +1527,14 @@ The measureConversion(|options|) method steps 1. Let |implicitInputs| be the result of [=obtaining the implicit API inputs=] from [=this=]. 1. [=Assert=]: |implicitInputs| is not null. +1. Return the result of running [=measure a conversion=] with |options| and |implicitInputs|. + + + +
+To measure a conversion given {{AttributionConversionOptions}} |options| +and [=implicit API inputs=] |implicitInputs|: + 1. Let |document| be |implicitInputs|'s [=implicit API inputs/associated document=]. 1. Let |realm| be |document|'s [=relevant realm=]. 1. If |document| is not [=allowed to use=] the [=policy-controlled feature=] named @@ -1917,6 +1925,8 @@ if the user has opted out of collection of diagnostic data. # HTTP API # {#http-api} +## Saving impressions ## {#http-api-impressions} + \`Save-Impression\` is a [=structured header/dictionary|Dictionary Structured Header=] set on a response requesting that the user agent invoke the @@ -1933,8 +1943,8 @@ The following keys are defined, corresponding to the members of the {{AttributionImpressionOptions}} dictionary passed to saveImpression(). Default values for omitted optional keys are treated the same way as the corresponding -{{AttributionImpressionOptions}} field. - +{{AttributionImpressionOptions}} field. Unknown dictionary keys are ignored, +as are unknown parameters.
conversion-sites
@@ -2016,6 +2026,207 @@ To parse a `Save-Impression` header given a [=header value=]
+## Measuring Conversions ## {#http-api-conversions} + +\`Measure-Conversion\` is a +[=structured header/dictionary|Dictionary Structured Header=] +set on a response requesting that the user agent invoke the +measureConversion() API. + +
+This is the HTTP equivalent of the JavaScript `measureConversion` example, +with the addition of a `report-url` to which the resulting report will be `POST`ed: + +Measure-Conversion: aggregation-service="https://aggregator.example/tee", histogram-size=20, epsilon=1.0, lookback-days=14, impression-sites=("publisher.example" "other.example"), impression-callers=("ad-tech.example"), match-values=(2), credit=(0.25 0.25 0.5), value=3, max-value=7, report-url="https://report-handler.example/foo" + +
+ +The following keys are defined, corresponding to the members of +the {{AttributionConversionOptions}} dictionary passed to +measureConversion(). Default values for omitted +optional keys are treated the same way as the corresponding +{{AttributionConversionOptions}} field. Unknown dictionary keys are ignored, as +are unknown parameters. + +
+
aggregation-service
+
+ Value of aggregationService, + a [=structured header/string=]. This key is required. +
+
epsilon
+
+ Value of epsilon, + a positive [=structured header/decimal=] or [=structured header/integer=]. This key is optional. +
+
histogram-size
+
+ Value of histogramSize, + a positive [=structured header/integer=]. This key is required. +
+
lookback-days
+
+ Value of lookbackDays, + a positive [=structured header/integer=]. This key is optional. +
+
match-values
+
+ Value of matchValues, + an [=structured header/inner list=] containing non-negative [=structured header/integer|integers=]. + This key is optional. +
+
impression-sites
+
+ Value of impressionSites, + an [=structured header/inner list=] containing [=structured header/string|strings=]. + Each string value includes a domain name using A-labels only; + [[RFC5890|Internationalized Domain Names]] therefore need to use [[RFC3492|punycode]]. + This key is optional. +
+
impression-callers
+
+ Value of impressionCallers, + an [=structured header/inner list=] containing [=structured header/string|strings=]. + Each string value includes a domain name using A-labels only; + [[RFC5890|Internationalized Domain Names]] therefore need to use [[RFC3492|punycode]]. + This key is optional. +
+
credit
+
+ Value of credit, + an [=structured header/inner list=] containing positive [=structured header/decimal|decimals=] + or positive [=structured header/integer|integers=]. This key is optional. +
+
value
+
+ Value of value, + a positive [=structured header/integer=]. This key is optional. +
+
max-value
+
+ Value of maxValue, + a positive [=structured header/integer=]. This key is optional. +
+
report-url
+
+ A [=structured header/string=] containing the [=potentially trustworthy URL=] + to which the resulting report, if any, will be `POST`ed. The URL may be + relative to the response URL. Its [=url/scheme=] must be "`https`". + This key is required. +
+
+ +
+To parse a `Measure-Conversion` header given a [=header value=] +|input| and [=URL=] |baseUrl|, run these steps: + +1. Let |dict| be the result of [=structured header/parsing structured fields=] + with input_bytes set to |input| and + field_type set to "`dictionary`". +1. If parsing failed, return an error. +1. Let |aggregationService| be |dict|["[=measure-conversion/aggregation-service=]"] [=map/with default=] `undefined`. +1. If |aggregationService| is not a [=structured header/string=], return an error. +1. Let |histogramSize| be |dict|["[=measure-conversion/histogram-size=]"] [=map/with default=] `undefined`. +1. If |histogramSize| is not a positive [=structured header/integer=] in the [=32-bit unsigned integer=] range, return an error. +1. Let |reportUrlString| be |dict|["[=measure-conversion/report-url=]"] [=map/with default=] `undefined`. +1. If |reportUrlString| is not a [=structured header/string=], return an error. +1. Let |reportUrl| be the result of applying the [=URL parser=] to |reportUrlString|, with |baseUrl|. +1. If |reportUrl| is failure, return an error. +1. If |reportUrl| is not a [=potentially trustworthy URL=], return an error. +1. If |reportUrl|'s [=url/scheme=] is not "`https`", return an error. +1. Let |opts| be a new {{AttributionConversionOptions}} with the following items: + : {{AttributionConversionOptions/aggregationService}} + :: |aggregationService| + : {{AttributionConversionOptions/histogramSize}} + :: |histogramSize| +1. If |dict|["[=measure-conversion/epsilon=]"] [=map/exists=]: + 1. Let |epsilon| be its [=map/value=]. + 1. If |epsilon| is not a [=structured header/decimal=] or [=structured header/integer=], return an error. + 1. Set |opts|.{{AttributionConversionOptions/epsilon}} to |epsilon|. +1. If |dict|["[=measure-conversion/lookback-days=]"] [=map/exists=]: + 1. Let |lookbackDays| be its [=map/value=]. + 1. If |lookbackDays| is not a positive [=structured header/integer=], return an error. + 1. Set |opts|.{{AttributionConversionOptions/lookbackDays}} to |lookbackDays|. +1. If |dict|["[=measure-conversion/match-values=]"] [=map/exists=]: + 1. Let |matchValues| be its [=map/value=]. + 1. If |matchValues| is not an [=structured header/inner list=], or if any of + |matchValues|' [=list/items=] is not an [=structured header/integer=] in + the [=32-bit unsigned integer=] range, return an error. + 1. Set |opts|.{{AttributionConversionOptions/matchValues}} to |matchValues|. +1. If |dict|["[=measure-conversion/impression-sites=]"] [=map/exists=]: + 1. Let |impressionSites| be its [=map/value=]. + 1. If |impressionSites| is not an [=structured header/inner list=], or if any of + |impressionSites|' [=list/items=] is not a [=structured header/string=], + return an error. + 1. Set |opts|.{{AttributionConversionOptions/impressionSites}} to |impressionSites|. +1. If |dict|["[=measure-conversion/impression-callers=]"] [=map/exists=]: + 1. Let |impressionCallers| be its [=map/value=]. + 1. If |impressionCallers| is not an [=structured header/inner list=], or if any of + |impressionCallers|' [=list/items=] is not a [=structured header/string=], + return an error. + 1. Set |opts|.{{AttributionConversionOptions/impressionCallers}} to |impressionCallers|. +1. If |dict|["[=measure-conversion/credit=]"] [=map/exists=]: + 1. Let |credit| be its [=map/value=]. + 1. If |credit| is not an [=structured header/inner list=], or if any of + |credit|'s [=list/items=] is not a [=structured header/decimal=] or + [=structured header/integer=], return an error. + 1. Set |opts|.{{AttributionConversionOptions/credit}} to |credit|. +1. If |dict|["[=measure-conversion/value=]"] [=map/exists=]: + 1. Let |value| be its [=map/value=]. + 1. If |value| is not a positive [=structured header/integer=] in the + [=32-bit unsigned integer=] range, return an error. + 1. Set |opts|.{{AttributionConversionOptions/value}} to |value|. +1. If |dict|["[=measure-conversion/max-value=]"] [=map/exists=]: + 1. Let |maxValue| be its [=map/value=]. + 1. If |maxValue| is not a positive [=structured header/integer=] in the + [=32-bit unsigned integer=] range, return an error. + 1. Set |opts|.{{AttributionConversionOptions/maxValue}} to |maxValue|. +1. Return (|opts|, |reportUrl|). + +Issue: Should we allow `http` for |reportUrl|'s [=url/scheme=]? +Related to issue 146. + +
+ +
+To send a report given a [=byte sequence=] |report|, +a [=URL=] |url|, and an [=environment settings object=] |settings|: + +1. [=Assert=]: |url| is a [=potentially trustworthy URL=]. +1. [=Assert=]: |url|'s [=url/scheme=] is "`https`". +1. Let |headers| be a new [=header list=] containing a [=header=] named + `"Content-Type"` whose value is + "[[DAP#name-application-dap-report-medi|application/dap-report]]". + + Note: This will need to be updated if {{AttributionAggregationProtocol}} + ever gains a value other than {{AttributionAggregationProtocol/dap-15-histogram}}. +1. Let |request| be a new [=request=] with the following properties: + : [=request/method=] + :: "`POST`" + : [=request/URL=] + :: |url| + : [=request/header list=] + :: |headers| + : [=request/body=] + :: |report| + : [=request/client=] + :: |settings| + : [=request/mode=] + :: "`cors`" + : [=request/cache mode=] + :: "`no-store`" + : [=request/keepalive=] + :: true + : [=request/credentials mode=] + :: "`omit`" + : [=request/referrer=] + :: |url| +1. [=Fetch=] |request|, optionally retrying in the event of an error. + +
+ +## Fetch monkey patches ## {#fetch-monkey-patches} +
To handle Attribution headers given a [=request=] |request| and [=response=] |response|, run these steps: @@ -2027,7 +2238,7 @@ and [=response=] |response|, run these steps: 1. If |response|'s [=response/URL=] is not a [=potentially trustworthy URL=], return. -1. If |response|'s [=response/URL=]'s [=url/scheme=] is not not "`http`" or "`https`", return. +1. If |response|'s [=response/URL=]'s [=url/scheme=] is not "`https`", return. 1. Let |implicitInputs| be the result of [=obtaining the implicit API inputs=] from |request|'s [=request/client=] with |response|'s [=response/URL=]'s [=url/origin=]. @@ -2036,17 +2247,35 @@ and [=response=] |response|, run these steps: 1. Let |saveImpressionHeader| be the result of [=header list/get|getting=] [:Save-Impression:] from |response|'s [=response/header list=]. -1. If |saveImpressionHeader| is null, return. +1. If |saveImpressionHeader| is not null: -1. Let |impressionOptions| be the result of [=parse a Save-Impression header|parsing=] |saveImpressionHeader|. + 1. Let |impressionOptions| be the result of [=parse a Save-Impression header|parsing=] |saveImpressionHeader|. -1. If |impressionOptions| is an error, return. + 1. If |impressionOptions| is not an error, [=save an impression|Save=] |impressionOptions| with |implicitInputs|. -1. [=save an impression|Save=] |impressionOptions| with |implicitInputs|. +1. Let |measureConversionHeader| be the result of [=header list/get|getting=] [:Measure-Conversion:] from |response|'s [=response/header list=]. -
+1. If |measureConversionHeader| is not null: -## Fetch monkey patches ## {#fetch-monkey-patches} + 1. Let |parseConversionResult| be the result of [=parse a Measure-Conversion header|parsing=] |measureConversionHeader|. + + 1. If |parseConversionResult| is not an error: + + 1. Let (|conversionOptions|, |reportUrl|) be |parseConversionResult|. + + 1. Let |reportPromise| be the result of running [=measure a conversion=] with |conversionOptions| and |implicitInputs|. + + 1. [=In parallel=]: + 1. [=Upon fulfillment=] of |reportPromise|, let |result| be the fulfilled value. + 1. [=Send a report=] with |result|.{{AttributionConversionResult/report}}, |reportUrl|, and + |request|'s [=request/client=]. + +Issue: Confirm the desired semantics of [:Save-Impression:] and [:Measure-Conversion:] in the same response. + +Issue: Should we allow `http` for |response|'s [=response/URL=]'s [=url/scheme=]? +Related to issue 146. + + Modify [=HTTP-network fetch=] as follows: @@ -3135,6 +3364,7 @@ urlPrefix:https://tc39.es/ecma262/#;type:dfn;spec:ecma-262 spec:structured header; type:dfn; urlPrefix: https://httpwg.org/specs/rfc9651; text: structured header; url: #name-introduction for: structured header + text: decimal; url: #decimal text: dictionary; url: #dictionary text: parse structured fields; url: #text-parse text: string; url: #string diff --git a/impl/src/http.test.ts b/impl/src/http.test.ts index 89ce598..1dd3321 100644 --- a/impl/src/http.test.ts +++ b/impl/src/http.test.ts @@ -1,34 +1,36 @@ import type { AttributionImpressionOptions } from "./index"; -import { parseSaveImpressionHeader } from "./http"; +import * as http from "./http"; import { strict as assert } from "assert"; import test from "node:test"; -interface TestCase { +interface TestCase { name: string; input: string; - expected?: AttributionImpressionOptions; + expected?: T; } -function runTests(cases: readonly TestCase[]): void { - void test("parseSaveImpression", async (t) => { - await Promise.all( - cases.map((tc) => - t.test(tc.name, () => { - if (tc.expected) { - const actual = parseSaveImpressionHeader(tc.input); - assert.deepEqual(actual, tc.expected); - } else { - assert.throws(() => parseSaveImpressionHeader(tc.input)); - } - }), - ), - ); - }); +async function runTests( + t: test.TestContext, + parse: (input: string) => T, + cases: readonly TestCase[], +): Promise { + await Promise.all( + cases.map((tc) => + t.test(tc.name, () => { + if (tc.expected) { + const actual = parse(tc.input); + assert.deepEqual(actual, tc.expected); + } else { + assert.throws(() => parse(tc.input)); + } + }), + ), + ); } -runTests([ +const impressionTests: TestCase[] = [ { name: "invalid-structured-header-syntax", input: "!" }, { name: "not-structured-header-dictionary", input: "histogram-index" }, { name: "a-different-type", input: "10" }, @@ -195,4 +197,387 @@ runTests([ name: "priority-lt-32-bit-min", input: "priority=-2147483649, histogram-index=1", }, -]); +]; + +void test("parseSaveImpressionHeader", (t) => + runTests(t, http.parseSaveImpressionHeader, impressionTests)); + +const baseURL = new URL("https://base.example/abc/"); + +const conversionTests: TestCase[] = [ + { name: "invalid-structured-header-syntax", input: "!" }, + { name: "not-structured-header-dictionary", input: "aggregation-service" }, + { name: "a-different-type", input: "10" }, + + { + name: "valid-minimal", + input: `aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + aggregationService: "", + histogramSize: 1, + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + matchValues: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + + { + name: "valid-maximal", + input: `aggregation-service="foo", histogram-size=1, report-url="https://r.example/bar?x=y", epsilon=2.1, value=4, max-value=3, lookback-days=5, match-values=(0 6), credit=(0 0.5 0.25), impression-callers=("a" "b"), impression-sites=("c")`, + expected: [ + { + aggregationService: "foo", + histogramSize: 1, + epsilon: 2.1, + value: 4, + maxValue: 3, + lookbackDays: 5, + matchValues: [0, 6], + credit: [0, 0.5, 0.25], + impressionCallers: ["a", "b"], + impressionSites: ["c"], + }, + new URL("https://r.example/bar?x=y"), + ], + }, + + { + name: "valid-empty-lists", + input: `credit=(), impression-sites=(), impression-callers=(), match-values=(), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + credit: [], + impressionSites: [], + impressionCallers: [], + matchValues: [], + aggregationService: "", + histogramSize: 1, + + epsilon: undefined, + lookbackDays: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + + { + name: "aggregation-service-missing", + input: `histogram-size=1, report-url="https://r.example/"`, + }, + { + name: "aggregation-service-wrong-type", + input: `aggregation-service=a, histogram-size=1, report-url="https://r.example"`, + }, + + { + name: "histogram-size-missing", + input: `aggregation-service="", report-url="https://r.example"`, + }, + { + name: "histogram-size-wrong-type", + input: `histogram-size=a, aggregation-service="", report-url="https://r.example"`, + }, + { + name: "histogram-size-zero", + input: `histogram-size=0, aggregation-service="", report-url="https://r.example"`, + }, + { + name: "histogram-size-negative", + input: `histogram-size=-1, aggregation-service="", report-url="https://r.example"`, + }, + { + name: "histogram-size-not-integer", + input: `histogram-size=1.2, aggregation-service="", report-url="https://r.example"`, + }, + { + name: "valid-histogram-size-eq-32-bit-max", + input: `histogram-size=4294967295, aggregation-service="", report-url="https://r.example"`, + expected: [ + { + histogramSize: 4294967295, + aggregationService: "", + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + matchValues: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + { + name: "histogram-size-gt-32-bit-max", + input: `histogram-size=4294967296, aggregation-service="", report-url="https://r.example"`, + }, + + { + name: "report-url-missing", + input: `aggregation-service="foo", histogram-size=1`, + }, + { + name: "report-url-wrong-type", + input: `report-url=a, aggregation-service="", histogram-size=1`, + }, + { + name: "report-url-bad-url-syntax", + input: `report-url="https://:", aggregation-service="", histogram-size=1`, + }, + { + name: "report-url-invalid-scheme", + input: `report-url="http://r.example", aggregation-service="", histogram-size=1`, + }, + { + name: "valid-report-url-relative", + input: `report-url="xyz", aggregation-service="", histogram-size=1`, + expected: [ + { + aggregationService: "", + histogramSize: 1, + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + matchValues: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://base.example/abc/xyz"), + ], + }, + + { + name: "epsilon-wrong-type", + input: `epsilon=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "valid-epsilon-integer", + input: `epsilon=2, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + epsilon: 2, + aggregationService: "", + histogramSize: 1, + + credit: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + matchValues: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + + { + name: "lookback-days-wrong-type", + input: `lookback-days=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "lookback-days-zero", + input: `lookback-days=0, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "lookback-days-negative", + input: `lookback-days=-1, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "lookback-days-not-integer", + input: `lookback-days=1.2, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "valid-lookback-days-maximal", + input: `lookback-days=999999999999999, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + lookbackDays: 999999999999999, + aggregationService: "", + histogramSize: 1, + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + matchValues: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + + { + name: "match-values-wrong-type", + input: `match-values=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "match-values-item-wrong-type", + input: `match-values=(a), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "match-values-item-negative", + input: `match-values=(-1), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "match-values-item-not-integer", + input: `match-values=(1.2), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "valid-value-item-eq-32-bit-max", + input: `match-values=(4294967295), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + matchValues: [4294967295], + aggregationService: "", + histogramSize: 1, + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + maxValue: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + { + name: "match-values-item-gt-32-bit-max", + input: `match-values=(4294967296), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + + { + name: "impression-sites-wrong-type", + input: `impression-sites=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "impression-sites-item-wrong-type", + input: `impression-sites=(a), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + + { + name: "impression-callers-wrong-type", + input: `impression-callers=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "impression-callers-item-wrong-type", + input: `impression-callers=(a), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + + { + name: "credit-wrong-type", + input: `credit=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "credit-item-wrong-type", + input: `credit=(a), aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + + { + name: "value-wrong-type", + input: `value=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "value-zero", + input: `value=0, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "value-negative", + input: `value=-1, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "value-not-integer", + input: `value=1.2, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "valid-value-eq-32-bit-max", + input: `value=4294967295, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + value: 4294967295, + aggregationService: "", + histogramSize: 1, + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + matchValues: undefined, + maxValue: undefined, + }, + new URL("https://r.example"), + ], + }, + { + name: "value-gt-32-bit-max", + input: `value=4294967296, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + + { + name: "max-value-wrong-type", + input: `max-value=a, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "max-value-zero", + input: `max-value=0, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "max-value-negative", + input: `max-value=-1, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "max-value-not-integer", + input: `max-value=1.2, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, + { + name: "valid-max-value-eq-32-bit-max", + input: `max-value=4294967295, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + expected: [ + { + maxValue: 4294967295, + aggregationService: "", + histogramSize: 1, + + credit: undefined, + epsilon: undefined, + impressionCallers: undefined, + impressionSites: undefined, + lookbackDays: undefined, + matchValues: undefined, + value: undefined, + }, + new URL("https://r.example"), + ], + }, + { + name: "max-value-gt-32-bit-max", + input: `max-value=4294967296, aggregation-service="", histogram-size=1, report-url="https://r.example"`, + }, +]; + +void test("parseMeasureConversionHeader", (t) => + runTests( + t, + (input) => http.parseMeasureConversionHeader(input, baseURL), + conversionTests, + )); diff --git a/impl/src/http.ts b/impl/src/http.ts index 629497d..bbfa7ad 100644 --- a/impl/src/http.ts +++ b/impl/src/http.ts @@ -1,4 +1,7 @@ -import type { AttributionImpressionOptions } from "./index"; +import type { + AttributionConversionOptions, + AttributionImpressionOptions, +} from "./index"; import type { BareItem, Dictionary, Item } from "structured-headers"; @@ -14,56 +17,110 @@ function get(dict: Dictionary, key: string): BareItem | Item[] | undefined { return value; } -function getInteger(dict: Dictionary, key: string): number | undefined { +function optional( + dict: Dictionary, + key: string, + f: (value: BareItem | Item[], errPrefix: string) => T, +): T | undefined { + const value = get(dict, key); + return value === undefined ? value : f(value, key); +} + +function required( + dict: Dictionary, + key: string, + f: (value: BareItem | Item[], errPrefix: string) => T, +): T { const value = get(dict, key); if (value === undefined) { - return value; + throw new TypeError(`${key} is required`); } + return f(value, key); +} +function asInteger(value: BareItem | Item[], errPrefix: string): number { if (typeof value !== "number" || !Number.isInteger(value)) { - throw new TypeError(`${key} must be an integer`); + throw new TypeError(`${errPrefix} be an integer`); } + return value; +} +function asDecimalOrInteger( + value: BareItem | Item[], + errPrefix: string, +): number { + if (typeof value !== "number") { + throw new TypeError(`${errPrefix} must be a decimal or an integer`); + } return value; } -function get32BitUnsignedInteger( - dict: Dictionary, - key: string, -): number | undefined { - const value = getInteger(dict, key); - if (value === undefined) { - return value; +function as32BitUnsignedInteger( + value: BareItem | Item[], + errPrefix: string, +): number { + const integer = asInteger(value, errPrefix); + if (integer < 0 || integer > MAX_UINT32) { + throw new RangeError(`${errPrefix} must be in the 32-bit unsigned range`); } + return integer; +} - if (value < 0 || value > MAX_UINT32) { - throw new RangeError(`${key} must be in the 32-bit unsigned range`); +function as32BitSignedInteger( + value: BareItem | Item[], + errPrefix: string, +): number { + const integer = asInteger(value, errPrefix); + if (integer < MIN_INT32 || integer > MAX_INT32) { + throw new RangeError(`${errPrefix} must be in the 32-bit signed range`); } + return integer; +} +function asPositive(value: number, errPrefix: string): number { + if (value <= 0) { + throw new TypeError(`${errPrefix} be positive`); + } return value; } -function parseInnerListOfSites( - dict: Dictionary, - key: string, -): string[] | undefined { - const values = get(dict, key); - if (values === undefined) { - return values; +function asPositiveInteger( + value: BareItem | Item[], + errPrefix: string, +): number { + return asPositive(asInteger(value, errPrefix), errPrefix); +} + +function asPositive32BitUnsignedInteger( + value: BareItem | Item[], + errPrefix: string, +): number { + return asPositive(as32BitUnsignedInteger(value, errPrefix), errPrefix); +} + +function asString(value: BareItem | Item[], errPrefix: string): string { + if (typeof value !== "string") { + throw new TypeError(`${errPrefix} must be a string`); } + return value; +} +function asInnerList( + values: BareItem | Item[], + errPrefix: string, + parseItem: (value: BareItem, errPrefix: string) => T, +): T[] { if (!Array.isArray(values)) { - throw new TypeError(`${key} must be an inner list`); + throw new TypeError(`${errPrefix} must be an inner list`); } + return values.map(([value], i) => parseItem(value, `${errPrefix}[${i}]`)); +} - const sites = []; - for (const [i, [value]] of values.entries()) { - if (typeof value !== "string") { - throw new TypeError(`${key}[${i}] must be a string`); - } - sites.push(value); - } - return sites; +function asInnerListOfStrings( + values: BareItem | Item[], + errPrefix: string, +): string[] { + return asInnerList(values, errPrefix, asString); } export function parseSaveImpressionHeader( @@ -71,30 +128,66 @@ export function parseSaveImpressionHeader( ): AttributionImpressionOptions { const dict = parseDictionary(input); - const histogramIndex = get32BitUnsignedInteger(dict, "histogram-index"); - if (histogramIndex === undefined) { - throw new TypeError("histogram-index is required"); - } - - const opts: AttributionImpressionOptions = { histogramIndex }; - - opts.conversionSites = parseInnerListOfSites(dict, "conversion-sites"); - opts.conversionCallers = parseInnerListOfSites(dict, "conversion-callers"); - - opts.matchValue = get32BitUnsignedInteger(dict, "match-value"); + return { + histogramIndex: required(dict, "histogram-index", as32BitUnsignedInteger), + conversionSites: optional(dict, "conversion-sites", asInnerListOfStrings), + conversionCallers: optional( + dict, + "conversion-callers", + asInnerListOfStrings, + ), + matchValue: optional(dict, "match-value", as32BitUnsignedInteger), + lifetimeDays: optional(dict, "lifetime-days", asPositiveInteger), + priority: optional(dict, "priority", as32BitSignedInteger), + }; +} - opts.lifetimeDays = getInteger(dict, "lifetime-days"); - if (opts.lifetimeDays !== undefined && opts.lifetimeDays <= 0) { - throw new RangeError("lifetime-days must be positive"); - } +export type ParsedMeasureConversionHeader = [ + opts: AttributionConversionOptions, + reportUrl: URL, +]; - opts.priority = getInteger(dict, "priority"); - if ( - opts.priority !== undefined && - (opts.priority < MIN_INT32 || opts.priority > MAX_INT32) - ) { - throw new RangeError("priority must be in the 32-bit signed range"); - } +export function parseMeasureConversionHeader( + input: string, + baseUrl: URL, +): ParsedMeasureConversionHeader { + const dict = parseDictionary(input); - return opts; + const reportUrl = required(dict, "report-url", (value, errPrefix) => { + const url = new URL(asString(value, errPrefix), baseUrl); + // The specification requires reportUrl to be potentially trustworthy, but + // there is no direct analogue of this in JS, so for now we let the protocol + // check below suffice. + if (url.protocol !== "https:") { + throw new TypeError(`${errPrefix}'s scheme must be https`); + } + return url; + }); + + const opts = { + aggregationService: required(dict, "aggregation-service", asString), + histogramSize: required( + dict, + "histogram-size", + asPositive32BitUnsignedInteger, + ), + epsilon: optional(dict, "epsilon", asDecimalOrInteger), + lookbackDays: optional(dict, "lookback-days", asPositiveInteger), + matchValues: optional(dict, "match-values", (values, errPrefix) => + asInnerList(values, errPrefix, as32BitUnsignedInteger), + ), + impressionSites: optional(dict, "impression-sites", asInnerListOfStrings), + impressionCallers: optional( + dict, + "impression-callers", + asInnerListOfStrings, + ), + credit: optional(dict, "credit", (values, errPrefix) => + asInnerList(values, errPrefix, asDecimalOrInteger), + ), + value: optional(dict, "value", asPositive32BitUnsignedInteger), + maxValue: optional(dict, "max-value", asPositive32BitUnsignedInteger), + }; + + return [opts, reportUrl]; }