1 | // Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved.
|
2 | // Node module: @loopback/context
|
3 | // This file is licensed under the MIT License.
|
4 | // License text available at https://opensource.org/licenses/MIT
|
5 |
|
6 | import {Binding, BindingTag} from './binding';
|
7 | import {BindingAddress} from './binding-key';
|
8 | import {MapObject} from './value-promise';
|
9 |
|
10 | /**
|
11 | * A function that filters bindings. It returns `true` to select a given
|
12 | * binding.
|
13 | *
|
14 | * @remarks
|
15 | * Originally, we allowed filters to be tied with a single value type.
|
16 | * This actually does not make much sense - the filter function is typically
|
17 | * invoked on all bindings to find those ones matching the given criteria.
|
18 | * Filters must be prepared to handle bindings of any value type. We learned
|
19 | * about this problem after enabling TypeScript's `strictFunctionTypes` check.
|
20 | * This aspect is resolved by typing the input argument as `Binding<unknown>`.
|
21 | *
|
22 | * Ideally, `BindingFilter` should be declared as a type guard as follows:
|
23 | * ```ts
|
24 | * export type BindingFilterGuard<ValueType = unknown> = (
|
25 | * binding: Readonly<Binding<unknown>>,
|
26 | * ) => binding is Readonly<Binding<ValueType>>;
|
27 | * ```
|
28 | *
|
29 | * But TypeScript treats the following types as incompatible and does not accept
|
30 | * type 1 for type 2.
|
31 | *
|
32 | * 1. `(binding: Readonly<Binding<unknown>>) => boolean`
|
33 | * 2. `(binding: Readonly<Binding<unknown>>) => binding is Readonly<Binding<ValueType>>`
|
34 | *
|
35 | * If we described BindingFilter as a type-guard, then all filter implementations
|
36 | * would have to be explicitly typed as type-guards too, which would make it
|
37 | * tedious to write quick filter functions like `b => b.key.startsWith('services')`.
|
38 | *
|
39 | * To keep things simple and easy to use, we use `boolean` as the return type
|
40 | * of a binding filter function.
|
41 | */
|
42 | export interface BindingFilter {
|
43 | (binding: Readonly<Binding<unknown>>): boolean;
|
44 | }
|
45 |
|
46 | /**
|
47 | * Select binding(s) by key or a filter function
|
48 | */
|
49 | export type BindingSelector<ValueType = unknown> =
|
50 | | BindingAddress<ValueType>
|
51 | | BindingFilter;
|
52 |
|
53 | /**
|
54 | * Check if an object is a `BindingKey` by duck typing
|
55 | * @param selector Binding selector
|
56 | */
|
57 | function isBindingKey(selector: BindingSelector) {
|
58 | if (selector == null || typeof selector !== 'object') return false;
|
59 | return (
|
60 | typeof selector.key === 'string' &&
|
61 | typeof selector.deepProperty === 'function'
|
62 | );
|
63 | }
|
64 |
|
65 | /**
|
66 | * Type guard for binding address
|
67 | * @param bindingSelector - Binding key or filter function
|
68 | */
|
69 | export function isBindingAddress(
|
70 | bindingSelector: BindingSelector,
|
71 | ): bindingSelector is BindingAddress {
|
72 | return (
|
73 | typeof bindingSelector !== 'function' &&
|
74 | (typeof bindingSelector === 'string' ||
|
75 | // See https://github.com/loopbackio/loopback-next/issues/4570
|
76 | // `bindingSelector instanceof BindingKey` is not always reliable as the
|
77 | // `@loopback/context` module might be loaded from multiple locations if
|
78 | // `npm install` does not dedupe or there are mixed versions in the tree
|
79 | isBindingKey(bindingSelector))
|
80 | );
|
81 | }
|
82 |
|
83 | /**
|
84 | * Binding filter function that holds a binding tag pattern. `Context.find()`
|
85 | * uses the `bindingTagPattern` to optimize the matching of bindings by tag to
|
86 | * avoid expensive check for all bindings.
|
87 | */
|
88 | export interface BindingTagFilter extends BindingFilter {
|
89 | /**
|
90 | * A special property on the filter function to provide access to the binding
|
91 | * tag pattern which can be utilized to optimize the matching of bindings by
|
92 | * tag in a context.
|
93 | */
|
94 | bindingTagPattern: BindingTag | RegExp;
|
95 | }
|
96 |
|
97 | /**
|
98 | * Type guard for BindingTagFilter
|
99 | * @param filter - A BindingFilter function
|
100 | */
|
101 | export function isBindingTagFilter(
|
102 | filter?: BindingFilter,
|
103 | ): filter is BindingTagFilter {
|
104 | if (filter == null || !('bindingTagPattern' in filter)) return false;
|
105 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
106 | const tagPattern = (filter as any).bindingTagPattern;
|
107 | return (
|
108 | tagPattern instanceof RegExp ||
|
109 | typeof tagPattern === 'string' ||
|
110 | typeof tagPattern === 'object'
|
111 | );
|
112 | }
|
113 |
|
114 | /**
|
115 | * A function to check if a given tag value is matched for `filterByTag`
|
116 | */
|
117 | export interface TagValueMatcher {
|
118 | /**
|
119 | * Check if the given tag value matches the search criteria
|
120 | * @param tagValue - Tag value from the binding
|
121 | * @param tagName - Tag name
|
122 | * @param tagMap - Tag map from the binding
|
123 | */
|
124 | (tagValue: unknown, tagName: string, tagMap: MapObject<unknown>): boolean;
|
125 | }
|
126 |
|
127 | /**
|
128 | * A symbol that can be used to match binding tags by name regardless of the
|
129 | * value.
|
130 | *
|
131 | * @example
|
132 | *
|
133 | * The following code matches bindings with tag `{controller: 'A'}` or
|
134 | * `{controller: 'controller'}`. But if the tag name 'controller' does not
|
135 | * exist for a binding, the binding will NOT be included.
|
136 | *
|
137 | * ```ts
|
138 | * ctx.findByTag({controller: ANY_TAG_VALUE})
|
139 | * ```
|
140 | */
|
141 | export const ANY_TAG_VALUE: TagValueMatcher = (tagValue, tagName, tagMap) =>
|
142 | tagName in tagMap;
|
143 |
|
144 | /**
|
145 | * Create a tag value matcher function that returns `true` if the target tag
|
146 | * value equals to the item value or is an array that includes the item value.
|
147 | * @param itemValues - A list of tag item value
|
148 | */
|
149 | export function includesTagValue(...itemValues: unknown[]): TagValueMatcher {
|
150 | return tagValue => {
|
151 | return itemValues.some(
|
152 | itemValue =>
|
153 | // The tag value equals the item value
|
154 | tagValue === itemValue ||
|
155 | // The tag value contains the item value
|
156 | (Array.isArray(tagValue) && tagValue.includes(itemValue)),
|
157 | );
|
158 | };
|
159 | }
|
160 |
|
161 | /**
|
162 | * Create a binding filter for the tag pattern
|
163 | * @param tagPattern - Binding tag name, regexp, or object
|
164 | */
|
165 | export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter {
|
166 | let filter: BindingFilter;
|
167 | let regex: RegExp | undefined = undefined;
|
168 | if (tagPattern instanceof RegExp) {
|
169 | // RegExp for tag names
|
170 | regex = tagPattern;
|
171 | }
|
172 | if (
|
173 | typeof tagPattern === 'string' &&
|
174 | (tagPattern.includes('*') || tagPattern.includes('?'))
|
175 | ) {
|
176 | // Wildcard tag name
|
177 | regex = wildcardToRegExp(tagPattern);
|
178 | }
|
179 |
|
180 | if (regex != null) {
|
181 | // RegExp or wildcard match
|
182 | filter = b => b.tagNames.some(t => regex!.test(t));
|
183 | } else if (typeof tagPattern === 'string') {
|
184 | // Plain tag string match
|
185 | filter = b => b.tagNames.includes(tagPattern);
|
186 | } else {
|
187 | // Match tag name/value pairs
|
188 | const tagMap = tagPattern as MapObject<unknown>;
|
189 | filter = b => {
|
190 | for (const t in tagMap) {
|
191 | if (!matchTagValue(tagMap[t], t, b.tagMap)) return false;
|
192 | }
|
193 | // All tag name/value pairs match
|
194 | return true;
|
195 | };
|
196 | }
|
197 | // Set up binding tag for the filter
|
198 | const tagFilter = filter as BindingTagFilter;
|
199 | tagFilter.bindingTagPattern = regex ?? tagPattern;
|
200 | return tagFilter;
|
201 | }
|
202 |
|
203 | function matchTagValue(
|
204 | tagValueOrMatcher: unknown,
|
205 | tagName: string,
|
206 | tagMap: MapObject<unknown>,
|
207 | ) {
|
208 | const tagValue = tagMap[tagName];
|
209 | if (tagValue === tagValueOrMatcher) return true;
|
210 |
|
211 | if (typeof tagValueOrMatcher === 'function') {
|
212 | return (tagValueOrMatcher as TagValueMatcher)(tagValue, tagName, tagMap);
|
213 | }
|
214 | return false;
|
215 | }
|
216 |
|
217 | /**
|
218 | * Create a binding filter from key pattern
|
219 | * @param keyPattern - Binding key/wildcard, regexp, or a filter function
|
220 | */
|
221 | export function filterByKey(
|
222 | keyPattern?: string | RegExp | BindingFilter,
|
223 | ): BindingFilter {
|
224 | if (typeof keyPattern === 'string') {
|
225 | const regex = wildcardToRegExp(keyPattern);
|
226 | return binding => regex.test(binding.key);
|
227 | } else if (keyPattern instanceof RegExp) {
|
228 | return binding => keyPattern.test(binding.key);
|
229 | } else if (typeof keyPattern === 'function') {
|
230 | return keyPattern;
|
231 | }
|
232 | return () => true;
|
233 | }
|
234 |
|
235 | /**
|
236 | * Convert a wildcard pattern to RegExp
|
237 | * @param pattern - A wildcard string with `*` and `?` as special characters.
|
238 | * - `*` matches zero or more characters except `.` and `:`
|
239 | * - `?` matches exactly one character except `.` and `:`
|
240 | */
|
241 | function wildcardToRegExp(pattern: string): RegExp {
|
242 | // Escape reserved chars for RegExp:
|
243 | // `- \ ^ $ + . ( ) | { } [ ] :`
|
244 | let regexp = pattern.replace(/[\-\[\]\/\{\}\(\)\+\.\\\^\$\|\:]/g, '\\$&');
|
245 | // Replace wildcard chars `*` and `?`
|
246 | // `*` matches zero or more characters except `.` and `:`
|
247 | // `?` matches one character except `.` and `:`
|
248 | regexp = regexp.replace(/\*/g, '[^.:]*').replace(/\?/g, '[^.:]');
|
249 | return new RegExp(`^${regexp}$`);
|
250 | }
|