Skip to content

Commit 4b78fb1

Browse files
authored
chore(util-endpoints): add dynamic endpoint resolver (#3884)
* chore(util-endpoints): add endpoint resolver from experiments * fix: imports in lib * test: evaluateTemplate.spec.ts * test: evaluateCondition.spec.ts * test: evaluateConditions.spec.ts * test: evaluateEndpointRule.spec.ts * test: evaluateEndpointUrl.spec.ts * test: evaluateErrorRule.spec.ts * test: evaluateExpression.spec.ts * test: evaluateRef.spec.ts * test: evaluateTreeRule.spec.ts * test: evaluateFn.spec.ts * test: evaluateRules.spec.ts * chore: rename evaluateEndpointUrl to getEndpointUrl * chore: rename evaluateFn to callFunction * chore: rename evaluteRef to getReferenceValue * test: getEndpointHeaders.spec.ts * test: getEndpointProperties.spec.ts * test: getEndpointProperty.spec.ts * test: resolveEndpoint.spec.ts * chore: move EndpointV2 interface to types
1 parent 6e1534b commit 4b78fb1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1294
-5
lines changed

packages/types/src/endpoint.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,15 @@ export interface EndpointURL {
2525
*/
2626
isIp: boolean;
2727
}
28+
29+
export type EndpointObjectProperty =
30+
| string
31+
| boolean
32+
| { [key: string]: EndpointObjectProperty }
33+
| EndpointObjectProperty[];
34+
35+
export interface EndpointV2 {
36+
url: URL;
37+
properties?: Record<string, EndpointObjectProperty>;
38+
headers?: Record<string, string[]>;
39+
}

packages/util-endpoints/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from "./EndpointError";
1+
export * from "./resolveEndpoint";
2+
export * from "./types";

packages/util-endpoints/src/lib/getAttr.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EndpointError } from "../EndpointError";
1+
import { EndpointError } from "../types";
22
import { getAttr } from "./getAttr";
33
import { getAttrPathList } from "./getAttrPathList";
44

packages/util-endpoints/src/lib/getAttr.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EndpointError } from "../EndpointError";
1+
import { EndpointError } from "../types";
22
import { getAttrPathList } from "./getAttrPathList";
33

44
export type GetAttrValue = string | boolean | { [key: string]: GetAttrValue } | Array<GetAttrValue>;

packages/util-endpoints/src/lib/getAttrPathList.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EndpointError } from "..";
1+
import { EndpointError } from "../types";
22
import { getAttrPathList } from "./getAttrPathList";
33

44
describe(getAttrPathList.name, () => {

packages/util-endpoints/src/lib/getAttrPathList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EndpointError } from "../";
1+
import { EndpointError } from "../types";
22

33
/**
44
* Parses path as a getAttr expression, returning a list of strings.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { resolveEndpoint } from "./resolveEndpoint";
2+
import { EndpointError, EndpointParams, ParameterObject, RuleSetObject } from "./types";
3+
import { evaluateRules } from "./utils";
4+
5+
jest.mock("./utils");
6+
7+
describe(resolveEndpoint.name, () => {
8+
const boolParamKey = "boolParamKey";
9+
const stringParamKey = "stringParamKey";
10+
const requiredParamKey = "requiredParamKey";
11+
const paramWithDefaultKey = "paramWithDefaultKey";
12+
13+
const mockEndpointParams: EndpointParams = {
14+
[boolParamKey]: true,
15+
[stringParamKey]: "stringParamValue",
16+
[requiredParamKey]: "requiredParamValue",
17+
[paramWithDefaultKey]: "defaultParamValue",
18+
};
19+
20+
const mockRules = [];
21+
const mockRuleSetParameters: Record<string, ParameterObject> = {
22+
[boolParamKey]: {
23+
type: "boolean",
24+
},
25+
[stringParamKey]: {
26+
type: "string",
27+
},
28+
[requiredParamKey]: {
29+
type: "string",
30+
required: true,
31+
},
32+
[paramWithDefaultKey]: {
33+
type: "string",
34+
default: "paramWithDefaultValue",
35+
},
36+
};
37+
38+
const mockRuleSetObject: RuleSetObject = {
39+
version: "1.0",
40+
serviceId: "serviceId",
41+
parameters: mockRuleSetParameters,
42+
rules: mockRules,
43+
};
44+
45+
const mockResolvedEndpoint = { url: new URL("http://example.com") };
46+
47+
beforeEach(() => {
48+
(evaluateRules as jest.Mock).mockReturnValue(mockResolvedEndpoint);
49+
});
50+
51+
afterEach(() => {
52+
jest.resetAllMocks();
53+
});
54+
55+
it("should throw an error if a required parameter is missing", () => {
56+
const { requiredParamKey: ignored, ...endpointParamsWithoutRequired } = mockEndpointParams;
57+
expect(() => resolveEndpoint(mockRuleSetObject, { endpointParams: endpointParamsWithoutRequired })).toThrow(
58+
new EndpointError(`Missing required parameter: '${requiredParamKey}'`)
59+
);
60+
expect(evaluateRules).not.toHaveBeenCalled();
61+
});
62+
63+
it("should use the default value if a parameter is not set", () => {
64+
const { paramWithDefaultKey: ignored, ...endpointParamsWithoutDefault } = mockEndpointParams;
65+
66+
const resolvedEndpoint = resolveEndpoint(mockRuleSetObject, { endpointParams: endpointParamsWithoutDefault });
67+
expect(resolvedEndpoint).toEqual(mockResolvedEndpoint);
68+
69+
expect(evaluateRules).toHaveBeenCalledWith(mockRules, {
70+
endpointParams: {
71+
...mockEndpointParams,
72+
[paramWithDefaultKey]: mockRuleSetParameters[paramWithDefaultKey].default,
73+
},
74+
referenceRecord: {},
75+
});
76+
});
77+
78+
it("should call evaluateRules", () => {
79+
const resolvedEndpoint = resolveEndpoint(mockRuleSetObject, { endpointParams: mockEndpointParams });
80+
expect(resolvedEndpoint).toEqual(mockResolvedEndpoint);
81+
expect(evaluateRules).toHaveBeenCalledWith(mockRules, {
82+
endpointParams: mockEndpointParams,
83+
referenceRecord: {},
84+
});
85+
});
86+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { EndpointError, EndpointResolverOptions, RuleSetObject } from "./types";
2+
import { evaluateRules } from "./utils";
3+
4+
/**
5+
* Resolves an endpoint URL by processing the endpoints ruleset and options.
6+
*/
7+
export const resolveEndpoint = (ruleSetObject: RuleSetObject, options: EndpointResolverOptions) => {
8+
const { endpointParams, logger } = options;
9+
const { parameters, rules } = ruleSetObject;
10+
11+
const requiredParams = Object.entries(parameters)
12+
.filter(([, v]) => v.required)
13+
.map(([k]) => k);
14+
15+
for (const requiredParam of requiredParams) {
16+
if (endpointParams[requiredParam] == null) {
17+
throw new EndpointError(`Missing required parameter: '${requiredParam}'`);
18+
}
19+
}
20+
21+
// @ts-ignore Type 'undefined' is not assignable to type 'string | boolean' (2322)
22+
const paramsWithDefault: [string, string | boolean][] = Object.entries(parameters)
23+
.filter(([, v]) => v.default != null)
24+
.map(([k, v]) => [k, v.default]);
25+
26+
if (paramsWithDefault.length > 0) {
27+
for (const [paramKey, paramDefaultValue] of paramsWithDefault) {
28+
endpointParams[paramKey] = endpointParams[paramKey] ?? paramDefaultValue;
29+
}
30+
}
31+
32+
return evaluateRules(rules, { endpointParams, logger, referenceRecord: {} });
33+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { EndpointObjectProperty } from "@aws-sdk/types";
2+
3+
import { ConditionObject, Expression } from "./shared";
4+
5+
export type EndpointObjectProperties = Record<string, EndpointObjectProperty>;
6+
export type EndpointObjectHeaders = Record<string, Expression[]>;
7+
export type EndpointObject = {
8+
url: Expression;
9+
properties?: EndpointObjectProperties;
10+
headers?: EndpointObjectHeaders;
11+
};
12+
13+
export type EndpointRuleObject = {
14+
type: "endpoint";
15+
conditions?: ConditionObject[];
16+
endpoint: EndpointObject;
17+
documentation?: string;
18+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ConditionObject, Expression } from "./shared";
2+
3+
export type ErrorRuleObject = {
4+
type: "error";
5+
conditions?: ConditionObject[];
6+
error: Expression;
7+
documentation?: string;
8+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { RuleSetRules } from "./TreeRuleObject";
2+
3+
export type DeprecatedObject = {
4+
message?: string;
5+
since?: string;
6+
};
7+
8+
export type ParameterObject = {
9+
type: "string" | "boolean";
10+
default?: string | boolean;
11+
required?: boolean;
12+
documentation?: string;
13+
deprecated?: DeprecatedObject;
14+
};
15+
16+
export type RuleSetObject = {
17+
version: string;
18+
serviceId: string;
19+
parameters: Record<string, ParameterObject>;
20+
rules: RuleSetRules;
21+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { EndpointRuleObject } from "./EndpointRuleObject";
2+
import { ErrorRuleObject } from "./ErrorRuleObject";
3+
import { ConditionObject } from "./shared";
4+
5+
export type RuleSetRules = Array<EndpointRuleObject | ErrorRuleObject | TreeRuleObject>;
6+
7+
export type TreeRuleObject = {
8+
type: "tree";
9+
conditions?: ConditionObject[];
10+
rules: RuleSetRules;
11+
documentation?: string;
12+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from "./EndpointError";
2+
export * from "./EndpointRuleObject";
3+
export * from "./ErrorRuleObject";
4+
export * from "./RuleSetObject";
5+
export * from "./TreeRuleObject";
6+
export * from "./shared";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Logger } from "@aws-sdk/types";
2+
3+
export type ReferenceObject = { ref: string };
4+
5+
export type FunctionObject = { fn: string; argv: FunctionArgv };
6+
export type FunctionArgv = Array<string | boolean | ReferenceObject | FunctionObject>;
7+
export type FunctionReturn = string | boolean | number | { [key: string]: FunctionReturn };
8+
9+
export type ConditionObject = FunctionObject & { assign?: string };
10+
11+
export type Expression = string | ReferenceObject | FunctionObject;
12+
13+
export type EndpointParams = Record<string, string | boolean>;
14+
export type EndpointResolverOptions = {
15+
endpointParams: EndpointParams;
16+
logger?: Logger;
17+
};
18+
19+
export type ReferenceRecord = Record<string, FunctionReturn>;
20+
export type EvaluateOptions = EndpointResolverOptions & {
21+
referenceRecord: ReferenceRecord;
22+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as lib from "../lib";
2+
import { callFunction } from "./callFunction";
3+
import { evaluateExpression } from "./evaluateExpression";
4+
5+
jest.mock("./evaluateExpression");
6+
7+
describe(callFunction.name, () => {
8+
const mockOptions = {
9+
endpointParams: {},
10+
referenceRecord: {},
11+
};
12+
const mockFunctionName = "mockFunctionName";
13+
const mockReturn = "mockReturn";
14+
15+
beforeEach(() => {
16+
lib[mockFunctionName] = jest.fn().mockReturnValue(mockReturn);
17+
});
18+
19+
afterEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
it("skips evaluateExpression for boolean arg", () => {
24+
const mockBooleanArg = true;
25+
const mockFn = { fn: mockFunctionName, argv: [mockBooleanArg] };
26+
const result = callFunction(mockFn, mockOptions);
27+
expect(result).toBe(mockReturn);
28+
expect(evaluateExpression).not.toHaveBeenCalled();
29+
expect(lib[mockFunctionName]).toHaveBeenCalledWith(mockBooleanArg);
30+
});
31+
32+
it.each(["string", { ref: "ref" }, { fn: "fn", argv: [] }])(
33+
"calls evaluateExpression for non-boolean arg: %s",
34+
(arg) => {
35+
const mockArgReturn = "mockArgReturn";
36+
const mockFn = { fn: mockFunctionName, argv: [arg] };
37+
38+
(evaluateExpression as jest.Mock).mockReturnValue(mockArgReturn);
39+
40+
const result = callFunction(mockFn, mockOptions);
41+
expect(result).toBe(mockReturn);
42+
expect(evaluateExpression).toHaveBeenCalledWith(arg, "arg", mockOptions);
43+
expect(lib[mockFunctionName]).toHaveBeenCalledWith(mockArgReturn);
44+
}
45+
);
46+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as lib from "../lib";
2+
import { EvaluateOptions, FunctionObject, FunctionReturn } from "../types";
3+
import { evaluateExpression } from "./evaluateExpression";
4+
5+
export const callFunction = ({ fn, argv }: FunctionObject, options: EvaluateOptions): FunctionReturn => {
6+
const argvArray = argv.map((arg) => (typeof arg === "boolean" ? arg : evaluateExpression(arg, "arg", options)));
7+
// @ts-ignore Element implicitly has an 'any' type
8+
return lib[fn](...argvArray);
9+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { EndpointError, EvaluateOptions } from "../types";
2+
import { callFunction } from "./callFunction";
3+
import { evaluateCondition } from "./evaluateCondition";
4+
5+
jest.mock("./callFunction");
6+
7+
describe(evaluateCondition.name, () => {
8+
const mockOptions: EvaluateOptions = {
9+
endpointParams: {},
10+
referenceRecord: {},
11+
};
12+
const mockAssign = "mockAssign";
13+
const mockFnArgs = { fn: "fn", argv: ["arg"] };
14+
15+
it("throws error if assign is already defined in Reference Record", () => {
16+
const mockOptionsWithAssign = {
17+
...mockOptions,
18+
referenceRecord: {
19+
[mockAssign]: true,
20+
},
21+
};
22+
expect(() => evaluateCondition({ assign: mockAssign, ...mockFnArgs }, mockOptionsWithAssign)).toThrow(
23+
new EndpointError(`'${mockAssign}' is already defined in Reference Record.`)
24+
);
25+
expect(callFunction).not.toHaveBeenCalled();
26+
});
27+
28+
describe("evaluates function", () => {
29+
describe.each([
30+
[true, "truthy", [true, 1, -1, "true", "false"]],
31+
[false, "falsy", [false, 0, -0, "", null, undefined, NaN]],
32+
])("returns %s for %s values", (result, boolStatus, testCases) => {
33+
it.each(testCases)(`${boolStatus} value: '%s'`, (mockReturn) => {
34+
(callFunction as jest.Mock).mockReturnValue(mockReturn);
35+
const { result, toAssign } = evaluateCondition(mockFnArgs, mockOptions);
36+
expect(result).toBe(result);
37+
expect(toAssign).toBeUndefined();
38+
});
39+
});
40+
});
41+
42+
it("returns assigned value if defined", () => {
43+
const mockAssignedValue = "mockAssignedValue";
44+
(callFunction as jest.Mock).mockReturnValue(mockAssignedValue);
45+
const { result, toAssign } = evaluateCondition({ assign: mockAssign, ...mockFnArgs }, mockOptions);
46+
expect(result).toBe(true);
47+
expect(toAssign).toEqual({ name: mockAssign, value: mockAssignedValue });
48+
});
49+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ConditionObject, EndpointError, EvaluateOptions } from "../types";
2+
import { callFunction } from "./callFunction";
3+
4+
export const evaluateCondition = ({ assign, ...fnArgs }: ConditionObject, options: EvaluateOptions) => {
5+
if (assign && options.referenceRecord[assign]) {
6+
throw new EndpointError(`'${assign}' is already defined in Reference Record.`);
7+
}
8+
const value = callFunction(fnArgs, options);
9+
return {
10+
result: !!value,
11+
...(assign != null && { toAssign: { name: assign, value } }),
12+
};
13+
};

0 commit comments

Comments
 (0)