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.
+
+
+
+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];
}