mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
WIP searchable decorator
This commit is contained in:
@@ -6,6 +6,7 @@ import { InitializerKey } from "../../../platform/services/cryptography/initiali
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { CipherType, LinkedIdType } from "../../enums";
|
||||
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
import { searchable } from "../../search/searchable.decorator";
|
||||
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
|
||||
import { LocalData } from "../data/local.data";
|
||||
import { Cipher } from "../domain/cipher";
|
||||
@@ -22,12 +23,17 @@ import { SshKeyView } from "./ssh-key.view";
|
||||
export class CipherView implements View, InitializerMetadata {
|
||||
readonly initializerKey = InitializerKey.CipherView;
|
||||
|
||||
@searchable()
|
||||
id: string = null;
|
||||
organizationId: string = null;
|
||||
folderId: string = null;
|
||||
@searchable()
|
||||
name: string = null;
|
||||
@searchable()
|
||||
notes: string = null;
|
||||
@searchable({ key: "type", strategy: { enum: CipherType } })
|
||||
type: CipherType = null;
|
||||
@searchable({ key: "favorite", strategy: { boolean: true } })
|
||||
favorite = false;
|
||||
organizationUseTotp = false;
|
||||
permissions: CipherPermissionsApi = new CipherPermissionsApi();
|
||||
@@ -46,6 +52,7 @@ export class CipherView implements View, InitializerMetadata {
|
||||
revisionDate: Date = null;
|
||||
creationDate: Date = null;
|
||||
deletedDate: Date = null;
|
||||
@searchable({ key: "reprompt", strategy: { enum: CipherRepromptType } })
|
||||
reprompt: CipherRepromptType = CipherRepromptType.None;
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,9 +6,11 @@ import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domai
|
||||
import { View } from "../../../models/view/view";
|
||||
import { SafeUrls } from "../../../platform/misc/safe-urls";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { searchable } from "../../search/searchable.decorator";
|
||||
import { LoginUri } from "../domain/login-uri";
|
||||
|
||||
export class LoginUriView implements View {
|
||||
@searchable({ key: "match detection", strategy: { enum: UriMatchStrategy } })
|
||||
match: UriMatchStrategySetting = null;
|
||||
|
||||
private _uri: string = null;
|
||||
@@ -25,6 +27,7 @@ export class LoginUriView implements View {
|
||||
this.match = u.match;
|
||||
}
|
||||
|
||||
@searchable({ key: "website", strategy: "string" })
|
||||
get uri(): string {
|
||||
return this._uri;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Utils } from "../../../platform/misc/utils";
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { LoginLinkedId as LinkedId } from "../../enums";
|
||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
import { searchable } from "../../search/searchable.decorator";
|
||||
import { Login } from "../domain/login";
|
||||
|
||||
import { Fido2CredentialView } from "./fido2-credential.view";
|
||||
@@ -12,6 +13,7 @@ import { ItemView } from "./item.view";
|
||||
import { LoginUriView } from "./login-uri.view";
|
||||
|
||||
export class LoginView extends ItemView {
|
||||
@searchable()
|
||||
@linkedFieldOption(LinkedId.Username, { sortPosition: 0 })
|
||||
username: string = null;
|
||||
@linkedFieldOption(LinkedId.Password, { sortPosition: 1 })
|
||||
|
||||
158
libs/common/src/vault/search/searchable.decorator.spec.ts
Normal file
158
libs/common/src/vault/search/searchable.decorator.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { SearchableValueType, getSearchableFields, searchable } from "./searchable.decorator";
|
||||
|
||||
describe("Searchable Decorator", () => {
|
||||
it("adds a searchable string", () => {
|
||||
class TestClass {
|
||||
@searchable() property = "test";
|
||||
}
|
||||
const instance = new TestClass();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "property", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("test");
|
||||
});
|
||||
|
||||
it("adds a searchable enum", () => {
|
||||
enum TestEnum {
|
||||
Option1,
|
||||
Option2,
|
||||
}
|
||||
class TestClass {
|
||||
@searchable({ strategy: { enum: TestEnum } }) property = TestEnum.Option1;
|
||||
}
|
||||
const instance = new TestClass();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "property", type: SearchableValueType.Enum, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toEqual({
|
||||
type: SearchableValueType.Enum,
|
||||
enum: TestEnum,
|
||||
value: TestEnum.Option1,
|
||||
});
|
||||
});
|
||||
|
||||
it("add a searchable getter", () => {
|
||||
class TestClass {
|
||||
@searchable() get property() {
|
||||
return "test";
|
||||
}
|
||||
}
|
||||
const instance = new TestClass();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "property", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("test");
|
||||
});
|
||||
|
||||
it("overrides the name of the searchable field", () => {
|
||||
class TestClass {
|
||||
@searchable({ key: "customName", strategy: "string" }) property = "test";
|
||||
}
|
||||
const instance = new TestClass();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "customName", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("test");
|
||||
});
|
||||
|
||||
it("adds all fields to the searchable fields", () => {
|
||||
class TestClass {
|
||||
@searchable() property1 = "test";
|
||||
@searchable({ key: "customName", strategy: "string" }) property2 = "test";
|
||||
@searchable({ strategy: { enum: { Option1: 1, Option2: 2 } } }) property3 = 1;
|
||||
@searchable() get property4() {
|
||||
return "test";
|
||||
}
|
||||
}
|
||||
const instance = new TestClass();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "property1", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
{ fieldName: "customName", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
{ fieldName: "property3", type: SearchableValueType.Enum, getter: expect.any(Function) },
|
||||
{ fieldName: "property4", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("test");
|
||||
expect(searchableFields[1].getter(instance)).toBe("test");
|
||||
expect(searchableFields[2].getter(instance)).toEqual({
|
||||
type: SearchableValueType.Enum,
|
||||
enum: { Option1: 1, Option2: 2 },
|
||||
value: 1,
|
||||
});
|
||||
expect(searchableFields[3].getter(instance)).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSearchableFields", () => {
|
||||
it("returns the searchable fields", () => {
|
||||
class TestClass {
|
||||
@searchable() property1 = "test";
|
||||
@searchable() get property2() {
|
||||
return "test";
|
||||
}
|
||||
}
|
||||
const instance = new TestClass();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "property1", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
{ fieldName: "property2", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("test");
|
||||
expect(searchableFields[1].getter(instance)).toBe("test");
|
||||
});
|
||||
|
||||
it("throws when a target class is not searchable", () => {
|
||||
class TestClass {
|
||||
property1 = "test";
|
||||
}
|
||||
const instance = new TestClass();
|
||||
expect(() => getSearchableFields(instance)).toThrow("Target is not searchable");
|
||||
});
|
||||
|
||||
it("recurses to get searchable fields from properties", () => {
|
||||
class Inner {
|
||||
@searchable() innerString = "innerVal";
|
||||
}
|
||||
class Outer {
|
||||
@searchable() outerString = "outerVal";
|
||||
inner = new Inner();
|
||||
}
|
||||
|
||||
const instance = new Outer();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "outerString", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
{ fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("outerVal");
|
||||
expect(searchableFields[1].getter(instance)).toBe("innerVal");
|
||||
});
|
||||
|
||||
it("recurses to get searchable fields from arrays", () => {
|
||||
class Inner {
|
||||
@searchable() innerString;
|
||||
constructor(innerVal?: string) {
|
||||
this.innerString = innerVal ?? "innerVal";
|
||||
}
|
||||
}
|
||||
class Outer {
|
||||
@searchable() outerString = "outerVal";
|
||||
innerArray: Inner[] = [new Inner(), new Inner("secondInnerVal")];
|
||||
}
|
||||
|
||||
const instance = new Outer();
|
||||
const searchableFields = getSearchableFields(instance);
|
||||
expect(searchableFields).toEqual([
|
||||
{ fieldName: "outerString", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
{ fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
{ fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) },
|
||||
]);
|
||||
expect(searchableFields[0].getter(instance)).toBe("outerVal");
|
||||
expect(searchableFields[1].getter(instance)).toBe("innerVal");
|
||||
expect(searchableFields[2].getter(instance)).toBe("secondInnerVal");
|
||||
});
|
||||
});
|
||||
193
libs/common/src/vault/search/searchable.decorator.ts
Normal file
193
libs/common/src/vault/search/searchable.decorator.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
const searchableFields = Symbol("searchable");
|
||||
|
||||
/** The types of values that are searchable */
|
||||
export enum SearchableValueType {
|
||||
String,
|
||||
Enum,
|
||||
Boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* The union type representing expected return data for each kind of searchable value.
|
||||
* This is more complicated than a simple value because matchers for different types of values need
|
||||
* different data to perform the search.
|
||||
*/
|
||||
export type SearchableData = string | EnumSearchableData | BooleanSearchableData;
|
||||
|
||||
/** The packet of data used by the search logic to perform matching */
|
||||
type SearchableGetter = {
|
||||
/** The search field */
|
||||
fieldName: string;
|
||||
/** The type of search to perform */
|
||||
type: SearchableValueType;
|
||||
/** A getter for the current value of this search field from a given instance */
|
||||
getter: (instance: unknown) => SearchableData;
|
||||
};
|
||||
|
||||
type SearchableTypes = "string" | EnumMetadata | BooleanMetadata;
|
||||
|
||||
export interface Searchable {
|
||||
[searchableFields]: SearchableGetter[];
|
||||
}
|
||||
|
||||
/** Guard function determining if an object contains Searchable fields */
|
||||
export function isSearchable(x: unknown): x is Searchable {
|
||||
return typeof x === "object" && x !== null && searchableFields in x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract {@link SearchableGetter} array from a searchable object. This method recurses all properties to find any
|
||||
* searchable field in the tree.
|
||||
* @param target
|
||||
* @returns
|
||||
*/
|
||||
export function getSearchableFields(target: unknown): SearchableGetter[] {
|
||||
if (!isSearchable(target)) {
|
||||
throw new Error("Target is not searchable");
|
||||
}
|
||||
|
||||
return recurseSearchableFields(target, (ancestor) => ancestor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses an object and returns all searchable fields. Getters are updated to traverse the object in the same way.
|
||||
*
|
||||
* Arrays containing searchable objects are also traversed.
|
||||
*
|
||||
* @param target The target searchable object
|
||||
* @param traversal The breadcrumb traversal function to use to traverse an ancestor object to the current level.
|
||||
* @returns
|
||||
*/
|
||||
function recurseSearchableFields(
|
||||
target: Searchable,
|
||||
traversal: (ancestor: unknown) => unknown,
|
||||
): SearchableGetter[] {
|
||||
const result = target[searchableFields].map((getterData) => ({
|
||||
...getterData,
|
||||
getter: (ancestor: unknown) => getterData.getter(traversal(ancestor)),
|
||||
}));
|
||||
|
||||
for (const [key, value] of Object.entries(target)) {
|
||||
const newTraversal = (ancestor: unknown) => (traversal(ancestor) as any)[key];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => {
|
||||
const arrayTraversal = (ancestor: unknown) => (newTraversal(ancestor) as any)[i];
|
||||
if (isSearchable(item)) {
|
||||
result.push(...recurseSearchableFields(item, arrayTraversal));
|
||||
}
|
||||
});
|
||||
} else if (isSearchable(value)) {
|
||||
result.push(...recurseSearchableFields(value, newTraversal));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data needed to set up enum searching
|
||||
*/
|
||||
type EnumMetadata = {
|
||||
enum: { [name: string]: any };
|
||||
};
|
||||
|
||||
function isEnumMetadata(type: unknown): type is EnumMetadata {
|
||||
return typeof type === "object" && type !== null && "enum" in type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data needed to perform enum searching for a given field
|
||||
*/
|
||||
type EnumSearchableData = {
|
||||
type: SearchableValueType.Enum;
|
||||
enum: { [name: string]: any };
|
||||
value: any;
|
||||
};
|
||||
|
||||
type BooleanMetadata = {
|
||||
boolean: boolean;
|
||||
};
|
||||
|
||||
function isBooleanMetadata(type: unknown): type is BooleanMetadata {
|
||||
return typeof type === "object" && type !== null && "boolean" in type;
|
||||
}
|
||||
|
||||
type BooleanSearchableData = {
|
||||
type: SearchableValueType.Boolean;
|
||||
boolean: boolean;
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
function getSearchableTypeValue(type: SearchableTypes): SearchableValueType {
|
||||
if (type === "string") {
|
||||
return SearchableValueType.String;
|
||||
}
|
||||
|
||||
if (isEnumMetadata(type)) {
|
||||
return SearchableValueType.Enum;
|
||||
} else if (isBooleanMetadata(type)) {
|
||||
return SearchableValueType.Boolean;
|
||||
}
|
||||
|
||||
throw new Error("Invalid searchable type");
|
||||
}
|
||||
|
||||
export type SearchableOptions = {
|
||||
key?: string;
|
||||
strategy: SearchableTypes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorator indicating that a the decorated field or getter should be included in search results.
|
||||
*
|
||||
* Note: It is important that the strategy is set to the correct type. There is _no_ type validation to ensure this.
|
||||
* It is undefined behavior to set the strategy to a type that does not match the field or to use this decorator on a
|
||||
* type that it cannot process.
|
||||
*
|
||||
* @see {@link SearchableValueType} for the types of values that can be searched
|
||||
*
|
||||
* @param options.key Optional name of the searchable field. Defaults to the property name
|
||||
* @param options.strategy The type of searchable value. Defaults to string if no options are given
|
||||
*/
|
||||
export function searchable(options: SearchableOptions = { strategy: "string" }) {
|
||||
return (prototype: unknown, propertyKey: string) => {
|
||||
if (options.key == null) {
|
||||
options.key = propertyKey;
|
||||
}
|
||||
|
||||
const target = prototype as Searchable & Record<string, any>;
|
||||
target[searchableFields] ??= [];
|
||||
|
||||
const type = getSearchableTypeValue(options.strategy);
|
||||
|
||||
const getter = (instance: unknown) => {
|
||||
const i = instance as Record<string, any>;
|
||||
switch (type) {
|
||||
case SearchableValueType.Enum: {
|
||||
return {
|
||||
type,
|
||||
enum: (options.strategy as EnumMetadata).enum,
|
||||
value: i[propertyKey],
|
||||
} as EnumSearchableData;
|
||||
}
|
||||
case SearchableValueType.String:
|
||||
return i[propertyKey];
|
||||
case SearchableValueType.Boolean: {
|
||||
return {
|
||||
type,
|
||||
boolean: (options.strategy as BooleanMetadata).boolean,
|
||||
value: i[propertyKey],
|
||||
} as BooleanSearchableData;
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid searchable type");
|
||||
}
|
||||
};
|
||||
|
||||
target[searchableFields].push({
|
||||
fieldName: options.key,
|
||||
type,
|
||||
getter,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -139,7 +139,6 @@ describe("Cipher Service", () => {
|
||||
domainSettingsService,
|
||||
apiService,
|
||||
i18nService,
|
||||
searchService,
|
||||
stateService,
|
||||
autofillSettingsService,
|
||||
encryptService,
|
||||
|
||||
Reference in New Issue
Block a user