Skip to content

Commit

Permalink
feat(util-endpoint): add endpoint ruleset cache
Browse files Browse the repository at this point in the history
  • Loading branch information
kuhe committed Sep 3, 2024
1 parent a2bb933 commit 1f511f3
Show file tree
Hide file tree
Showing 7 changed files with 364 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-fishes-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smithy/util-endpoints": minor
---

add endpoint ruleset cache
53 changes: 53 additions & 0 deletions packages/util-endpoints/src/cache/EndpointCache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { EndpointCache } from "./EndpointCache";

describe(EndpointCache.name, () => {
const endpoint1: any = {};
const endpoint2: any = {};

it("should store and retrieve items", () => {
const cache = new EndpointCache({
size: 50,
});

expect(cache.get({ A: "b", B: "b" }, () => endpoint1)).toBe(endpoint1);
expect(cache.get({ A: "b", B: "b" }, () => endpoint1)).toBe(endpoint1);
expect(cache.get({ A: "b", B: "b", C: "c" }, () => endpoint1)).toBe(endpoint1);
expect(cache.get({ A: "b", B: "b", C: "c" }, () => endpoint1)).toBe(endpoint1);
expect(cache.get({ A: "b", B: "b", C: "cc" }, () => endpoint2)).toBe(endpoint2);
expect(cache.get({ A: "b", B: "b", C: "cc" }, () => endpoint2)).toBe(endpoint2);

expect(cache.size()).toEqual(3);
});

it("should accept a custom parameter list", () => {
const cache = new EndpointCache({
size: 50,
params: ["A", "B"],
});

expect(cache.get({ A: "b", B: "b" }, () => endpoint1)).toBe(endpoint1);
expect(cache.get({ A: "b", B: "b", C: "c" }, () => endpoint1)).toBe(endpoint1);
expect(cache.get({ A: "b", B: "b", C: "cc" }, () => endpoint2)).toBe(endpoint1);

expect(cache.size()).toEqual(1);
});

it("should be an LRU cache", () => {
const cache = new EndpointCache({
size: 5,
params: ["A", "B"],
});

for (let i = 0; i < 50; ++i) {
cache.get({ A: "b", B: "b" + i }, () => endpoint1);
}

const size = cache.size();
expect(size).toBeLessThan(16);
expect(cache.get({ A: "b", B: "b49" }, () => endpoint2)).toBe(endpoint1);
expect(cache.size()).toEqual(size);

expect(cache.get({ A: "b", B: "b1" }, () => endpoint2)).toBe(endpoint2);
expect(cache.size()).toEqual(size + 1);
});
});
62 changes: 62 additions & 0 deletions packages/util-endpoints/src/cache/EndpointCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { EndpointParams, EndpointV2 } from "@smithy/types";

/**
* @internal
*
* Cache for endpoint ruleSet resolution.
*/
export class EndpointCache {
private capacity: number = 50;
private data = new Map<string, EndpointV2>();
private parameters: string[] = [];

/**
* @param [params] - list of params to consider as part of the cache key.
*
* If the params list is not populated, all object keys will be considered.
* This may be out of order depending on how the object is created and arrives to this class.
*/
public constructor({ size, params }: { size?: number; params?: string[] }) {
this.capacity = size ?? 50;
if (params) {
this.parameters = params;
}
}

/**
* @param endpointParams - query for endpoint.
* @param resolver - provider of the value if not present.
* @returns endpoint corresponding to the query.
*/
public get(endpointParams: EndpointParams, resolver: () => EndpointV2): EndpointV2 {
const key = this.hash(endpointParams);
if (!this.data.has(key)) {
if (this.data.size > this.capacity + 10) {
const keys = this.data.keys();
let i = 0;
while (true) {
const { value, done } = keys.next();
this.data.delete(value);
if (done || ++i > 10) {
break;
}
}
}
this.data.set(key, resolver());
}
return this.data.get(key)!;
}

public size() {
return this.data.size;
}

private hash(endpointParams: EndpointParams): string {
let buffer = "";
const params = this.parameters.length ? this.parameters : Object.keys(endpointParams);
for (const param of params) {
buffer += endpointParams[param] ?? "" + "|";
}
return buffer;
}
}
1 change: 1 addition & 0 deletions packages/util-endpoints/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./cache/EndpointCache";
export * from "./lib/isIpAddress";
export * from "./lib/isValidHostLabel";
export * from "./utils/customEndpointFunctions";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import software.amazon.smithy.codegen.core.SymbolDependency;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.node.ObjectNode;
Expand Down Expand Up @@ -79,6 +80,7 @@ public List<SymbolDependency> getDependencies() {
private final EndpointRuleSetTrait endpointRuleSetTrait;
private final ServiceShape service;
private final TypeScriptSettings settings;
private final RuleSetParameterFinder ruleSetParameterFinder;

public EndpointsV2Generator(
TypeScriptDelegator delegator,
Expand All @@ -90,6 +92,7 @@ public EndpointsV2Generator(
this.settings = settings;
endpointRuleSetTrait = service.getTrait(EndpointRuleSetTrait.class)
.orElseThrow(() -> new RuntimeException("service missing EndpointRuleSetTrait"));
ruleSetParameterFinder = new RuleSetParameterFinder(service);
}

@Override
Expand All @@ -114,8 +117,6 @@ private void generateEndpointParameters() {
"export interface ClientInputEndpointParameters {",
"}",
() -> {
RuleSetParameterFinder ruleSetParameterFinder = new RuleSetParameterFinder(service);

Map<String, String> clientInputParams = ruleSetParameterFinder.getClientContextParams();
//Omit Endpoint params that should not be a part of the ClientInputEndpointParameters interface
Map<String, String> builtInParams = ruleSetParameterFinder.getBuiltInParams();
Expand Down Expand Up @@ -164,10 +165,9 @@ private void generateEndpointParameters() {
writer.openBlock(
"export const commonParams = {", "} as const",
() -> {
RuleSetParameterFinder parameterFinder = new RuleSetParameterFinder(service);
Set<String> paramNames = new HashSet<>();

parameterFinder.getClientContextParams().forEach((name, type) -> {
ruleSetParameterFinder.getClientContextParams().forEach((name, type) -> {
if (!paramNames.contains(name)) {
writer.write(
"$L: { type: \"clientContextParams\", name: \"$L\" },",
Expand All @@ -176,7 +176,7 @@ private void generateEndpointParameters() {
paramNames.add(name);
});

parameterFinder.getBuiltInParams().forEach((name, type) -> {
ruleSetParameterFinder.getBuiltInParams().forEach((name, type) -> {
if (!paramNames.contains(name)) {
writer.write(
"$L: { type: \"builtInParams\", name: \"$L\" },",
Expand Down Expand Up @@ -222,25 +222,29 @@ private void generateEndpointResolver() {
Paths.get(".", CodegenUtils.SOURCE_FOLDER, ENDPOINT_FOLDER,
ENDPOINT_RULESET_FILE.replace(".ts", "")));

writer.openBlock(
"export const defaultEndpointResolver = ",
"",
() -> {
writer.openBlock(
"(endpointParams: EndpointParameters, context: { logger?: Logger } = {}): EndpointV2 => {",
"};",
() -> {
writer.openBlock(
"return resolveEndpoint(ruleSet, {",
"});",
() -> {
writer.write("endpointParams: endpointParams as EndpointParams,");
writer.write("logger: context.logger,");
}
);
}
);
}
writer.addImport("EndpointCache", null, TypeScriptDependency.UTIL_ENDPOINTS);
writer.write("""
const cache = new EndpointCache({
size: 50,
params: [$L]
});
""",
ruleSetParameterFinder.getEffectiveParams()
.stream().collect(Collectors.joining("\",\n \"", "\"", "\""))
);

writer.write(
"""
export const defaultEndpointResolver = (
endpointParams: EndpointParameters,
context: { logger?: Logger } = {}
): EndpointV2 => {
return cache.get(endpointParams as EndpointParams, () => resolveEndpoint(ruleSet, {
endpointParams: endpointParams as EndpointParams,
logger: context.logger,
}));
};
"""
);
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@

package software.amazon.smithy.typescript.codegen.endpointsV2;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import software.amazon.smithy.model.node.ArrayNode;
import software.amazon.smithy.model.node.Node;
Expand All @@ -29,6 +37,14 @@
import software.amazon.smithy.model.shapes.ServiceShape;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeType;
import software.amazon.smithy.rulesengine.language.Endpoint;
import software.amazon.smithy.rulesengine.language.EndpointRuleSet;
import software.amazon.smithy.rulesengine.language.syntax.expressions.Expression;
import software.amazon.smithy.rulesengine.language.syntax.rule.Condition;
import software.amazon.smithy.rulesengine.language.syntax.rule.EndpointRule;
import software.amazon.smithy.rulesengine.language.syntax.rule.ErrorRule;
import software.amazon.smithy.rulesengine.language.syntax.rule.Rule;
import software.amazon.smithy.rulesengine.language.syntax.rule.TreeRule;
import software.amazon.smithy.rulesengine.traits.ClientContextParamsTrait;
import software.amazon.smithy.rulesengine.traits.ContextParamTrait;
import software.amazon.smithy.rulesengine.traits.EndpointRuleSetTrait;
Expand All @@ -38,6 +54,9 @@

@SmithyInternalApi
public class RuleSetParameterFinder {

public static final Pattern URL_PARAMETERS = Pattern.compile("\\{(\\w+)[}#]");

private final ServiceShape service;
private final EndpointRuleSetTrait ruleset;

Expand All @@ -48,6 +67,88 @@ public RuleSetParameterFinder(ServiceShape service) {
);
}

/**
* It's possible for a parameter to pass validation, i.e. exist in the modeled shapes
* and be used in endpoint tests, but have no actual effect on endpoint resolution.
*
* @return the list of endpoint parameters that are actually used in endpoint resolution.
*/
public List<String> getEffectiveParams() {
Set<String> effectiveParams = new TreeSet<>();
EndpointRuleSet endpointRuleSet = ruleset.getEndpointRuleSet();
Set<String> initialParams = new HashSet<>();

endpointRuleSet.getParameters().forEach(parameter -> {
initialParams.add(parameter.getName().getName().getValue());
});

Queue<Rule> ruleQueue = new LinkedList<>(endpointRuleSet.getRules());
Queue<Condition> conditionQueue = new LinkedList<>();
Queue<ObjectNode> argQueue = new LinkedList<>();

while (!ruleQueue.isEmpty() || !conditionQueue.isEmpty() || !argQueue.isEmpty()) {
while (!argQueue.isEmpty()) {
ObjectNode arg = argQueue.poll();
Optional<Node> ref = arg.getMember("ref");
if (ref.isPresent()) {
String refName = ref.get().expectStringNode().getValue();
if (initialParams.contains(refName)) {
effectiveParams.add(refName);
}
}
Optional<Node> argv = arg.getMember("argv");
if (argv.isPresent()) {
ArrayNode nestedArgv = argv.get().expectArrayNode();
for (Node nestedArg : nestedArgv) {
if (nestedArg.isObjectNode()) {
argQueue.add(nestedArg.expectObjectNode());
}
}
}
}

while (!conditionQueue.isEmpty()) {
Condition condition = conditionQueue.poll();
ArrayNode argv = condition.toNode()
.expectObjectNode()
.expectArrayMember("argv");
for (Node arg : argv) {
if (arg.isObjectNode()) {
argQueue.add(arg.expectObjectNode());
}
}
}

Rule rule = ruleQueue.poll();
if (null == rule) {
continue;
}
List<Condition> conditions = rule.getConditions();
conditionQueue.addAll(conditions);
if (rule instanceof TreeRule treeRule) {
ruleQueue.addAll(treeRule.getRules());
} else if (rule instanceof EndpointRule endpointRule) {
Endpoint endpoint = endpointRule.getEndpoint();
Expression url = endpoint.getUrl();
String urlString = url.toString();

URL_PARAMETERS
.matcher(urlString)
.results().forEach(matchResult -> {
if (matchResult.groupCount() >= 1) {
if (initialParams.contains(matchResult.group(1))) {
effectiveParams.add(matchResult.group(1));
}
}
});
} else if (rule instanceof ErrorRule errorRule) {
// no additional use of endpoint parameters in error rules.
}
}

return new ArrayList<>(effectiveParams);
}

/**
* TODO(endpointsv2) From definitions in EndpointRuleSet.parameters, or
* TODO(endpointsv2) are they from the closed set?
Expand Down
Loading

0 comments on commit 1f511f3

Please sign in to comment.