Skip to content

Commit 708bc6d

Browse files
authored
[CPS] Relocate CrossProjectRoutingResolver (#136129)
Move CrossProjectRoutingResolver to the same search.crossproject package as the other cross-project index classes.
1 parent 75c1e10 commit 708bc6d

File tree

2 files changed

+465
-0
lines changed

2 files changed

+465
-0
lines changed
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.search.crossproject;
11+
12+
import org.elasticsearch.ElasticsearchStatusException;
13+
14+
import java.util.List;
15+
import java.util.Set;
16+
import java.util.function.Predicate;
17+
import java.util.stream.IntStream;
18+
import java.util.stream.Stream;
19+
20+
import static org.elasticsearch.rest.RestStatus.BAD_REQUEST;
21+
22+
/**
23+
* Tech Preview.
24+
* Resolves a single entry _alias for a cross-project request specifying a project_routing.
25+
* We currently only support a single entry routing containing either a specific name, a prefix, a suffix, or a match-all (*).
26+
*/
27+
public class CrossProjectRoutingResolver {
28+
private static final String ALIAS = "_alias:";
29+
private static final String ORIGIN = "_origin";
30+
private static final int ALIAS_LENGTH = ALIAS.length();
31+
private static final String ALIAS_MATCH_ALL = ALIAS + "*";
32+
private static final String ALIAS_MATCH_ORIGIN = ALIAS + ORIGIN;
33+
34+
/**
35+
* Initially, we only support the "*" wildcard.
36+
* https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-query-string-query
37+
*/
38+
private static final Set<Character> UNSUPPORTED_CHARACTERS = Set.of(
39+
'+',
40+
'-',
41+
'=',
42+
'&',
43+
'|',
44+
'>',
45+
'<',
46+
'!',
47+
'(',
48+
')',
49+
'{',
50+
'}',
51+
'[',
52+
']',
53+
'^',
54+
'"',
55+
'~',
56+
'?',
57+
':',
58+
'\\',
59+
'/'
60+
);
61+
62+
/**
63+
* @param projectRouting the project_routing specified in the request object.
64+
* @param originProject the project alias where this function is being called.
65+
* @param candidateProjects the list of project aliases for the active request. This list must *NOT* contain the originProject entry.
66+
* @return the filtered list of projects matching the projectRouting, or an empty list if none are found.
67+
* @throws ElasticsearchStatusException if the projectRouting is null, empty, does not start with "_alias:", contains more than one
68+
* entry, or contains an '*' in the middle of a string.
69+
*/
70+
public List<ProjectRoutingInfo> resolve(
71+
String projectRouting,
72+
ProjectRoutingInfo originProject,
73+
List<ProjectRoutingInfo> candidateProjects
74+
) {
75+
assert originProject.projectAlias().equalsIgnoreCase(ORIGIN) == false : "origin project alias must not be " + ORIGIN;
76+
77+
var candidateProjectStream = candidateProjects.stream().peek(candidateProject -> {
78+
assert candidateProject.projectAlias().equalsIgnoreCase(ORIGIN) == false : "project alias must not be " + ORIGIN;
79+
}).filter(candidateProject -> {
80+
assert candidateProject.equals(originProject) == false : "origin project must not be in the candidateProjects list";
81+
return candidateProject.equals(originProject) == false; // assertions are disabled in prod, instead we should filter this out
82+
});
83+
84+
if (ALIAS_MATCH_ORIGIN.equalsIgnoreCase(projectRouting)) {
85+
return List.of(originProject);
86+
}
87+
88+
if (projectRouting == null || projectRouting.isEmpty() || ALIAS_MATCH_ALL.equalsIgnoreCase(projectRouting)) {
89+
return Stream.concat(Stream.of(originProject), candidateProjectStream).toList();
90+
}
91+
92+
validateProjectRouting(projectRouting);
93+
94+
var matchesSpecifiedRoute = createRoutingEntryFilter(projectRouting);
95+
return Stream.concat(Stream.of(originProject), candidateProjectStream).filter(matchesSpecifiedRoute).toList();
96+
}
97+
98+
private static void validateProjectRouting(String projectRouting) {
99+
var startsWithAlias = startsWithIgnoreCase(ALIAS, projectRouting);
100+
if (startsWithAlias && projectRouting.length() == ALIAS_LENGTH) {
101+
throw new ElasticsearchStatusException("project_routing expression [{}] cannot be empty", BAD_REQUEST, projectRouting);
102+
}
103+
if ((startsWithAlias == false) && projectRouting.contains(":")) {
104+
throw new ElasticsearchStatusException(
105+
"Unsupported tag [{}] in project_routing expression [{}]. Supported tags [_alias].",
106+
BAD_REQUEST,
107+
projectRouting.substring(0, projectRouting.indexOf(":")),
108+
projectRouting
109+
);
110+
}
111+
if (startsWithAlias == false) {
112+
throw new ElasticsearchStatusException(
113+
"project_routing [{}] must start with the prefix [_alias:]",
114+
BAD_REQUEST,
115+
projectRouting
116+
);
117+
}
118+
}
119+
120+
private static Predicate<ProjectRoutingInfo> createRoutingEntryFilter(String projectRouting) {
121+
// we're using index pointers and directly accessing the internal character array rather than using higher abstraction
122+
// methods like String.split or creating multiple substrings. we don't expect a lot of linked projects or long project routing
123+
// expressions, but this is expected to run on every search request so we're opting to avoid creating multiple objects.
124+
// plus we plan to replace this all soon anyway...
125+
var matchPrefix = projectRouting.charAt(projectRouting.length() - 1) == '*';
126+
var matchSuffix = projectRouting.charAt(ALIAS_LENGTH) == '*';
127+
128+
int foundAsterix = -1;
129+
int startIndex = matchSuffix ? ALIAS_LENGTH + 1 : ALIAS_LENGTH;
130+
int endIndex = matchPrefix ? projectRouting.length() - 1 : projectRouting.length();
131+
132+
for (int i = startIndex; i < endIndex; ++i) {
133+
var nextChar = projectRouting.charAt(i);
134+
135+
// verify that there are no whitespaces, unsupported characters,
136+
// or more complex asterisk expressions (*pro*_2 is unsupported, pro*_2, pro*, and *project_2 are all supported)
137+
if (Character.isWhitespace(nextChar)
138+
|| UNSUPPORTED_CHARACTERS.contains(nextChar)
139+
|| (nextChar == '*' && (foundAsterix >= 0 || matchPrefix || matchSuffix))) {
140+
throw new ElasticsearchStatusException(
141+
"Unsupported project_routing expression [{}]. "
142+
+ "Tech Preview only supports project routing via a single project alias or wildcard alias expression",
143+
BAD_REQUEST,
144+
projectRouting.substring(ALIAS_LENGTH)
145+
);
146+
}
147+
148+
if (nextChar == '*') {
149+
foundAsterix = i;
150+
}
151+
}
152+
153+
if (foundAsterix >= 0) {
154+
var prefix = projectRouting.substring(startIndex, foundAsterix);
155+
var suffix = projectRouting.substring(foundAsterix + 1, endIndex);
156+
return possibleRoute -> startsWithIgnoreCase(prefix, possibleRoute.projectAlias())
157+
&& endsWithIgnoreCase(suffix, possibleRoute.projectAlias());
158+
}
159+
160+
var routingEntry = projectRouting.substring(startIndex, endIndex);
161+
if (matchPrefix && matchSuffix) {
162+
return possibleRoute -> containsIgnoreCase(routingEntry, possibleRoute.projectAlias());
163+
} else if (matchPrefix) {
164+
return possibleRoute -> startsWithIgnoreCase(routingEntry, possibleRoute.projectAlias());
165+
} else if (matchSuffix) {
166+
return possibleRoute -> endsWithIgnoreCase(routingEntry, possibleRoute.projectAlias());
167+
} else {
168+
return possibleRoute -> possibleRoute.projectAlias().equalsIgnoreCase(routingEntry);
169+
}
170+
}
171+
172+
private static boolean startsWithIgnoreCase(String prefix, String str) {
173+
if (prefix == null || str == null) {
174+
return false;
175+
}
176+
if (str.startsWith(prefix)) {
177+
return true;
178+
}
179+
if (str.length() < prefix.length()) {
180+
return false;
181+
}
182+
if (str.length() == prefix.length() && str.equalsIgnoreCase(prefix)) {
183+
return true;
184+
}
185+
return str.substring(0, prefix.length()).equalsIgnoreCase(prefix);
186+
}
187+
188+
private static boolean endsWithIgnoreCase(String suffix, String str) {
189+
if (suffix == null || str == null) {
190+
return false;
191+
}
192+
if (str.endsWith(suffix)) {
193+
return true;
194+
}
195+
if (str.length() < suffix.length()) {
196+
return false;
197+
}
198+
if (str.length() == suffix.length() && str.equalsIgnoreCase(suffix)) {
199+
return true;
200+
}
201+
return str.substring(str.length() - suffix.length()).equalsIgnoreCase(suffix);
202+
}
203+
204+
private static boolean containsIgnoreCase(String substring, String str) {
205+
if (substring == null || str == null) {
206+
return false;
207+
}
208+
if (str.contains(substring)) {
209+
return true;
210+
}
211+
if (str.length() < substring.length()) {
212+
return false;
213+
}
214+
if (str.length() == substring.length() && str.equalsIgnoreCase(substring)) {
215+
return true;
216+
}
217+
var substringLength = substring.length();
218+
return IntStream.range(0, str.length() - substringLength).anyMatch(i -> str.regionMatches(true, i, substring, 0, substringLength));
219+
}
220+
}

0 commit comments

Comments
 (0)