Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nasty-beers-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smithy/util-endpoints": patch
---

Reduce temporary object allocations
7 changes: 5 additions & 2 deletions packages/util-endpoints/src/types/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EndpointARN, EndpointPartition, Logger } from "@smithy/types";
import type { EndpointARN, EndpointPartition, EndpointURL, Logger } from "@smithy/types";

export type ReferenceObject = { ref: string };

Expand All @@ -10,8 +10,11 @@ export type FunctionReturn =
| number
| EndpointARN
| EndpointPartition
| EndpointURL
| { [key: string]: FunctionReturn }
| null;
| Array<FunctionReturn>
| null
| undefined;

export type ConditionObject = FunctionObject & { assign?: string };

Expand Down
3 changes: 2 additions & 1 deletion packages/util-endpoints/src/utils/endpointFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
substring,
uriEncode,
} from "../lib";
import type { EndpointFunctions } from "../types";

export const endpointFunctions = {
export const endpointFunctions: EndpointFunctions = {
booleanEquals,
coalesce,
getAttr,
Expand Down
18 changes: 11 additions & 7 deletions packages/util-endpoints/src/utils/evaluateCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import type { ConditionObject, EvaluateOptions } from "../types";
import { EndpointError } from "../types";
import { callFunction } from "./callFunction";

export const evaluateCondition = ({ assign, ...fnArgs }: ConditionObject, options: EvaluateOptions) => {
export const evaluateCondition = (condition: ConditionObject, options: EvaluateOptions) => {
const { assign } = condition;

if (assign && assign in options.referenceRecord) {
throw new EndpointError(`'${assign}' is already defined in Reference Record.`);
}
const value = callFunction(fnArgs, options);
const value = callFunction(condition, options);

options.logger?.debug?.(`${debugId} evaluateCondition: ${toDebugString(condition)} = ${toDebugString(value)}`);

options.logger?.debug?.(`${debugId} evaluateCondition: ${toDebugString(fnArgs)} = ${toDebugString(value)}`);
const result = value === "" ? true : !!value;

return {
result: value === "" ? true : !!value,
...(assign != null && { toAssign: { name: assign, value } }),
};
if (assign != null) {
return { result, toAssign: { name: assign, value } };
}
return { result };
};
37 changes: 24 additions & 13 deletions packages/util-endpoints/src/utils/evaluateConditions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,27 +50,38 @@ describe(evaluateConditions.name, () => {
const value1 = "value1";
const value2 = "value2";

vi.mocked(evaluateCondition).mockReturnValueOnce({
result: true,
toAssign: { name: mockCn1.assign!, value: value1 },
});
vi.mocked(evaluateCondition).mockReturnValueOnce({
result: true,
toAssign: { name: mockCn2.assign!, value: value2 },
});
const capturedReferenceRecords: Record<string, unknown>[] = [];

vi.mocked(evaluateCondition)
.mockImplementationOnce((_condition, options) => {
capturedReferenceRecords.push({ ...options.referenceRecord });
return { result: true, toAssign: { name: mockCn1.assign!, value: value1 } };
})
.mockImplementationOnce((_condition, options) => {
capturedReferenceRecords.push({ ...options.referenceRecord });
return { result: true, toAssign: { name: mockCn2.assign!, value: value2 } };
});

const { result, referenceRecord } = evaluateConditions([mockCn1, mockCn2], { ...mockOptions });
expect(result).toBe(true);
expect(referenceRecord).toEqual({
[mockCn1.assign!]: value1,
[mockCn2.assign!]: value2,
});
expect(evaluateCondition).toHaveBeenNthCalledWith(1, mockCn1, mockOptions);
expect(evaluateCondition).toHaveBeenNthCalledWith(2, mockCn2, {
...mockOptions,
referenceRecord: { [mockCn1.assign!]: value1 },
});
expect(capturedReferenceRecords[0]).toEqual({});
expect(capturedReferenceRecords[1]).toEqual({ [mockCn1.assign!]: value1 });
expect(mockLogger.debug).nthCalledWith(1, `${debugId} assign: ${mockCn1.assign} := ${toDebugString(value1)}`);
expect(mockLogger.debug).nthCalledWith(2, `${debugId} assign: ${mockCn2.assign} := ${toDebugString(value2)}`);
});

it("returns true without a referenceRecord if no conditions assign values", () => {
vi.mocked(evaluateCondition).mockReturnValueOnce({ result: true }).mockReturnValueOnce({ result: true });

const result = evaluateConditions([mockCn1, mockCn2], mockOptions);

expect(result).toEqual({ result: true });
expect(evaluateCondition).toHaveBeenNthCalledWith(1, mockCn1, mockOptions);
expect(evaluateCondition).toHaveBeenNthCalledWith(2, mockCn2, mockOptions);
expect(mockLogger.debug).not.toHaveBeenCalled();
});
});
17 changes: 10 additions & 7 deletions packages/util-endpoints/src/utils/evaluateConditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@ import { evaluateCondition } from "./evaluateCondition";

export const evaluateConditions = (conditions: ConditionObject[] = [], options: EvaluateOptions) => {
const conditionsReferenceRecord: Record<string, FunctionReturn> = {};
const conditionOptions: EvaluateOptions = {
...options,
referenceRecord: { ...options.referenceRecord },
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it safe to use options all the way through with no copying?

Why I think it might be safe: options object lifecycle is one endpoint resolution, and this is all synchronous code...


for (const condition of conditions) {
const { result, toAssign } = evaluateCondition(condition, {
...options,
referenceRecord: {
...options.referenceRecord,
...conditionsReferenceRecord,
},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can we confirm no edge case relied on this? the diff seems to result in a potentially different set of objects

if it can be proven this is all part of the same evaluation's lifecycle, maybe even the initial copy isn't necessary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one thing we could do is run the full AWS endpoint test suite

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not changing the behavior. The conditionOptions object was additive even before. We're just not creating new object for every call.

The test needs to check the first call received an empty referenceRecord at call time, but the spy only holds a reference that's since been mutated. That's why tests had to be updated.

const { result, toAssign } = evaluateCondition(condition, conditionOptions);

if (!result) {
return { result };
}

if (toAssign) {
conditionsReferenceRecord[toAssign.name] = toAssign.value;
conditionOptions.referenceRecord[toAssign.name] = toAssign.value;
options.logger?.debug?.(`${debugId} assign: ${toAssign.name} := ${toDebugString(toAssign.value)}`);
}
}

if (Object.keys(conditionsReferenceRecord).length === 0) {
return { result: true };
}

return { result: true, referenceRecord: conditionsReferenceRecord };
};
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ describe(evaluateEndpointRule.name, () => {
expect(getEndpointProperties).not.toHaveBeenCalled();
});

it("reuses the original options when conditions assign no references", () => {
vi.mocked(evaluateConditions).mockReturnValue({ result: true });
vi.mocked(getEndpointUrl).mockReturnValue(new URL(mockEndpoint.url));

evaluateEndpointRule(mockEndpointRule, mockOptions);

expect(getEndpointUrl).toHaveBeenCalledWith(mockEndpoint.url, mockOptions);
});

describe("returns endpoint if conditions are true", () => {
const mockReferenceRecord = { key: "value" };
const mockEndpointUrl = new URL(mockEndpoint.url);
Expand Down
28 changes: 15 additions & 13 deletions packages/util-endpoints/src/utils/evaluateEndpointRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,24 @@ export const evaluateEndpointRule = (
return;
}

const endpointRuleOptions = {
...options,
referenceRecord: { ...options.referenceRecord, ...referenceRecord },
};
const endpointRuleOptions = referenceRecord
? {
...options,
referenceRecord: { ...options.referenceRecord, ...referenceRecord },
}
: options;

const { url, properties, headers } = endpoint;

options.logger?.debug?.(`${debugId} Resolving endpoint from template: ${toDebugString(endpoint)}`);

return {
...(headers != undefined && {
headers: getEndpointHeaders(headers, endpointRuleOptions),
}),
...(properties != undefined && {
properties: getEndpointProperties(properties, endpointRuleOptions),
}),
url: getEndpointUrl(url, endpointRuleOptions),
};
const endpointToReturn: EndpointV2 = { url: getEndpointUrl(url, endpointRuleOptions) };
if (headers != undefined) {
endpointToReturn.headers = getEndpointHeaders(headers, endpointRuleOptions);
}
if (properties != undefined) {
endpointToReturn.properties = getEndpointProperties(properties, endpointRuleOptions);
}

return endpointToReturn;
};
10 changes: 10 additions & 0 deletions packages/util-endpoints/src/utils/evaluateErrorRule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ describe(evaluateErrorRule.name, () => {
expect(evaluateExpression).not.toHaveBeenCalled();
});

it("reuses the original options if conditions assign no references", () => {
const mockErrorMsg = "mockErrorMsg";

vi.mocked(evaluateConditions).mockReturnValue({ result: true });
vi.mocked(evaluateExpression).mockReturnValue(mockErrorMsg);

expect(() => evaluateErrorRule(mockErrorRule, mockOptions)).toThrowError(new EndpointError(`mockErrorMsg`));
expect(evaluateExpression).toHaveBeenCalledWith(mockError, "Error", mockOptions);
});

it("throws error if conditions evaluate to true", () => {
const mockErrorMsg = "mockErrorMsg";
const mockReferenceRecord = { key: "value" };
Expand Down
14 changes: 8 additions & 6 deletions packages/util-endpoints/src/utils/evaluateErrorRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export const evaluateErrorRule = (errorRule: ErrorRuleObject, options: EvaluateO
return;
}

throw new EndpointError(
evaluateExpression(error, "Error", {
...options,
referenceRecord: { ...options.referenceRecord, ...referenceRecord },
}) as string
);
const errorRuleOptions = referenceRecord
? {
...options,
referenceRecord: { ...options.referenceRecord, ...referenceRecord },
}
: options;

throw new EndpointError(evaluateExpression(error, "Error", errorRuleOptions) as string);
};
19 changes: 10 additions & 9 deletions packages/util-endpoints/src/utils/evaluateExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,21 @@ export const callFunction = ({ fn, argv }: FunctionObject, options: EvaluateOpti
}
}

// if-statement to avoid split array allocation.
if (fn.includes(".")) {
const fnSegments = fn.split(".");
if (fnSegments[0] in customEndpointFunctions && fnSegments[1] != null) {
return (customEndpointFunctions as any)[fnSegments[0]][fnSegments[1]](...evaluatedArgs);
const namespaceSeparatorIndex = fn.indexOf(".");
if (namespaceSeparatorIndex !== -1) {
const namespaceFunctions = customEndpointFunctions[fn.slice(0, namespaceSeparatorIndex)];
const customFunction = namespaceFunctions?.[fn.slice(namespaceSeparatorIndex + 1)];
if (typeof customFunction === "function") {
return customFunction(...evaluatedArgs);
}
}

if (typeof (endpointFunctions as Record<string, Function>)[fn] !== "function") {
throw new Error(`function ${fn} not loaded in endpointFunctions.`);
const callable = endpointFunctions[fn as keyof typeof endpointFunctions];
if (typeof callable === "function") {
return callable(...evaluatedArgs);
}

const callable = endpointFunctions[fn as keyof typeof endpointFunctions] as (...args: any[]) => any;
return callable(...evaluatedArgs);
throw new Error(`function ${fn} not loaded in endpointFunctions.`);
};

export const group = {
Expand Down
9 changes: 5 additions & 4 deletions packages/util-endpoints/src/utils/evaluateRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ export const evaluateTreeRule = (treeRule: TreeRuleObject, options: EvaluateOpti
return;
}

return group.evaluateRules(rules, {
...options,
referenceRecord: { ...options.referenceRecord, ...referenceRecord },
});
const treeRuleOptions = referenceRecord
? { ...options, referenceRecord: { ...options.referenceRecord, ...referenceRecord } }
: options;

return group.evaluateRules(rules, treeRuleOptions);
};

export const group = {
Expand Down
12 changes: 6 additions & 6 deletions packages/util-endpoints/src/utils/getEndpointHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { evaluateExpression } from "./evaluateExpression";

export const getEndpointHeaders = (headers: EndpointObjectHeaders, options: EvaluateOptions) =>
Object.entries(headers ?? {}).reduce(
(acc, [headerKey, headerVal]) => ({
...acc,
[headerKey]: headerVal.map((headerValEntry) => {
(acc, [headerKey, headerVal]) => {
acc[headerKey] = headerVal.map((headerValEntry) => {
const processedExpr = evaluateExpression(headerValEntry, "Header value entry", options);
if (typeof processedExpr !== "string") {
throw new EndpointError(`Header '${headerKey}' value '${processedExpr}' is not a string`);
}
return processedExpr;
}),
}),
{}
});
return acc;
},
{} as Record<string, string[]>
);
10 changes: 5 additions & 5 deletions packages/util-endpoints/src/utils/getEndpointProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { evaluateTemplate } from "./evaluateTemplate";

export const getEndpointProperties = (properties: EndpointObjectProperties, options: EvaluateOptions) =>
Object.entries(properties).reduce(
(acc, [propertyKey, propertyVal]) => ({
...acc,
[propertyKey]: group.getEndpointProperty(propertyVal, options),
}),
{}
(acc, [propertyKey, propertyVal]) => {
acc[propertyKey] = group.getEndpointProperty(propertyVal, options);
return acc;
},
{} as Record<string, EndpointObjectProperty>
);

export const getEndpointProperty = (
Expand Down
Loading