UNPKG

8.32 kBPlain TextView Raw
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
6import {Binding, BindingTag} from './binding';
7import {BindingAddress} from './binding-key';
8import {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 */
42export interface BindingFilter {
43 (binding: Readonly<Binding<unknown>>): boolean;
44}
45
46/**
47 * Select binding(s) by key or a filter function
48 */
49export 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 */
57function 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 */
69export 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 */
88export 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 */
101export 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 */
117export 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 */
141export 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 */
149export 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 */
165export 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
203function 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 */
221export 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 */
241function 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}