mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[AC-1479][BEEEP] Refactor ConfigService to improve observable usage (#5602)
* refactor ConfigService to use observables * make environmentService.urls a ReplaySubject --------- Co-authored-by: Hinton <hinton@users.noreply.github.com>
This commit is contained in:
@@ -1,14 +1,26 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { Region } from "../environment.service";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export abstract class ConfigServiceAbstraction {
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
fetchServerConfig: () => Promise<ServerConfig>;
|
||||
getFeatureFlagBool: (key: FeatureFlag, defaultValue?: boolean) => Promise<boolean>;
|
||||
getFeatureFlagString: (key: FeatureFlag, defaultValue?: string) => Promise<string>;
|
||||
getFeatureFlagNumber: (key: FeatureFlag, defaultValue?: number) => Promise<number>;
|
||||
getCloudRegion: (defaultValue?: string) => Promise<string>;
|
||||
cloudRegion$: Observable<Region>;
|
||||
getFeatureFlag$: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T
|
||||
) => Observable<T>;
|
||||
getFeatureFlag: <T extends boolean | number | string>(
|
||||
key: FeatureFlag,
|
||||
defaultValue?: T
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Force ConfigService to fetch an updated config from the server and emit it from serverConfig$
|
||||
* @deprecated The service implementation should subscribe to an observable and use that to trigger a new fetch from
|
||||
* server instead
|
||||
*/
|
||||
triggerServerConfigFetch: () => void;
|
||||
}
|
||||
|
||||
174
libs/common/src/platform/services/config/config.service.spec.ts
Normal file
174
libs/common/src/platform/services/config/config.service.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { ReplaySubject, skip, take } from "rxjs";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
import {
|
||||
EnvironmentServerConfigResponse,
|
||||
ServerConfigResponse,
|
||||
ThirdPartyServerConfigResponse,
|
||||
} from "../../models/response/server-config.response";
|
||||
|
||||
import { ConfigService } from "./config.service";
|
||||
|
||||
describe("ConfigService", () => {
|
||||
let stateService: MockProxy<StateService>;
|
||||
let configApiService: MockProxy<ConfigApiServiceAbstraction>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
let serverResponseCount: number; // increments to track distinct responses received from server
|
||||
|
||||
// Observables will start emitting as soon as this is created, so only create it
|
||||
// after everything is mocked
|
||||
const configServiceFactory = () => {
|
||||
const configService = new ConfigService(
|
||||
stateService,
|
||||
configApiService,
|
||||
authService,
|
||||
environmentService
|
||||
);
|
||||
configService.init();
|
||||
return configService;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stateService = mock();
|
||||
configApiService = mock();
|
||||
authService = mock();
|
||||
environmentService = mock();
|
||||
environmentService.urls = new ReplaySubject<void>(1);
|
||||
|
||||
serverResponseCount = 1;
|
||||
configApiService.get.mockImplementation(() =>
|
||||
Promise.resolve(serverConfigResponseFactory("server" + serverResponseCount++))
|
||||
);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Loads config from storage", (done) => {
|
||||
const storedConfigData = serverConfigDataFactory("storedConfig");
|
||||
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
|
||||
|
||||
const configService = configServiceFactory();
|
||||
|
||||
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
|
||||
expect(config).toEqual(new ServerConfig(storedConfigData));
|
||||
expect(stateService.getServerConfig).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.setServerConfig).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fetches config from server", () => {
|
||||
beforeEach(() => {
|
||||
stateService.getServerConfig.mockResolvedValueOnce(null);
|
||||
});
|
||||
|
||||
it.each<number | jest.DoneCallback>([1, 2, 3])(
|
||||
"after %p hour/s",
|
||||
(hours: number, done: jest.DoneCallback) => {
|
||||
const configService = configServiceFactory();
|
||||
|
||||
// skip initial load from storage, plus previous hours (if any)
|
||||
configService.serverConfig$.pipe(skip(hours), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server" + hours);
|
||||
expect(configApiService.get).toHaveBeenCalledTimes(hours);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
const oneHourInMs = 1000 * 3600;
|
||||
jest.advanceTimersByTime(oneHourInMs * hours + 1);
|
||||
}
|
||||
);
|
||||
|
||||
it("when environment URLs change", (done) => {
|
||||
const configService = configServiceFactory();
|
||||
|
||||
// skip initial load from storage
|
||||
configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
(environmentService.urls as ReplaySubject<void>).next();
|
||||
});
|
||||
|
||||
it("when triggerServerConfigFetch() is called", (done) => {
|
||||
const configService = configServiceFactory();
|
||||
|
||||
// skip initial load from storage
|
||||
configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => {
|
||||
try {
|
||||
expect(config.gitHash).toEqual("server1");
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
});
|
||||
|
||||
it("Saves server config to storage when the user is logged in", (done) => {
|
||||
stateService.getServerConfig.mockResolvedValueOnce(null);
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
|
||||
const configService = configServiceFactory();
|
||||
|
||||
// skip initial load from storage
|
||||
configService.serverConfig$.pipe(skip(1), take(1)).subscribe(() => {
|
||||
try {
|
||||
expect(stateService.setServerConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitHash: "server1" })
|
||||
);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
configService.triggerServerConfigFetch();
|
||||
});
|
||||
});
|
||||
|
||||
function serverConfigDataFactory(gitHash: string) {
|
||||
return new ServerConfigData(serverConfigResponseFactory(gitHash));
|
||||
}
|
||||
|
||||
function serverConfigResponseFactory(gitHash: string) {
|
||||
return new ServerConfigResponse({
|
||||
version: "myConfigVersion",
|
||||
gitHash: gitHash,
|
||||
server: new ThirdPartyServerConfigResponse({
|
||||
name: "myThirdPartyServer",
|
||||
url: "www.example.com",
|
||||
}),
|
||||
environment: new EnvironmentServerConfigResponse({
|
||||
vault: "vault.example.com",
|
||||
}),
|
||||
featureStates: {
|
||||
feat1: "off",
|
||||
feat2: "on",
|
||||
feat3: "off",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,103 +1,106 @@
|
||||
import { BehaviorSubject, concatMap, from, timer } from "rxjs";
|
||||
import {
|
||||
ReplaySubject,
|
||||
Subject,
|
||||
concatMap,
|
||||
delayWhen,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { EnvironmentService, Region } from "../../abstractions/environment.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
|
||||
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
|
||||
|
||||
export class ConfigService implements ConfigServiceAbstraction {
|
||||
protected _serverConfig = new BehaviorSubject<ServerConfig | null>(null);
|
||||
protected _serverConfig = new ReplaySubject<ServerConfig | null>(1);
|
||||
serverConfig$ = this._serverConfig.asObservable();
|
||||
private _forceFetchConfig = new Subject<void>();
|
||||
private inited = false;
|
||||
|
||||
cloudRegion$ = this.serverConfig$.pipe(
|
||||
map((config) => config?.environment?.cloudRegion ?? Region.US)
|
||||
);
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private configApiService: ConfigApiServiceAbstraction,
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService
|
||||
) {
|
||||
// Re-fetch the server config every hour
|
||||
timer(0, 1000 * 3600)
|
||||
.pipe(concatMap(() => from(this.fetchServerConfig())))
|
||||
.subscribe((serverConfig) => {
|
||||
this._serverConfig.next(serverConfig);
|
||||
});
|
||||
private environmentService: EnvironmentService,
|
||||
|
||||
this.environmentService.urls.subscribe(() => {
|
||||
this.fetchServerConfig();
|
||||
});
|
||||
}
|
||||
// Used to avoid duplicate subscriptions, e.g. in browser between the background and popup
|
||||
private subscribe = true
|
||||
) {}
|
||||
|
||||
async fetchServerConfig(): Promise<ServerConfig> {
|
||||
try {
|
||||
const response = await this.configApiService.get();
|
||||
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new ServerConfigData(response);
|
||||
const serverConfig = new ServerConfig(data);
|
||||
this._serverConfig.next(serverConfig);
|
||||
|
||||
const userAuthStatus = await this.authService.getAuthStatus();
|
||||
if (userAuthStatus !== AuthenticationStatus.LoggedOut) {
|
||||
// Store the config for offline use if the user is logged in
|
||||
await this.stateService.setServerConfig(data);
|
||||
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
|
||||
}
|
||||
// Always return new server config from server to calling method
|
||||
// to ensure up to date information
|
||||
// This change is specifically for the getFeatureFlag > buildServerConfig flow
|
||||
// for locked or logged in users.
|
||||
return serverConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getFeatureFlagBool(key: FeatureFlag, defaultValue = false): Promise<boolean> {
|
||||
return await this.getFeatureFlag(key, defaultValue);
|
||||
}
|
||||
|
||||
async getFeatureFlagString(key: FeatureFlag, defaultValue = ""): Promise<string> {
|
||||
return await this.getFeatureFlag(key, defaultValue);
|
||||
}
|
||||
|
||||
async getFeatureFlagNumber(key: FeatureFlag, defaultValue = 0): Promise<number> {
|
||||
return await this.getFeatureFlag(key, defaultValue);
|
||||
}
|
||||
|
||||
async getCloudRegion(defaultValue = "US"): Promise<string> {
|
||||
const serverConfig = await this.buildServerConfig();
|
||||
return serverConfig.environment?.cloudRegion ?? defaultValue;
|
||||
}
|
||||
|
||||
private async getFeatureFlag<T>(key: FeatureFlag, defaultValue: T): Promise<T> {
|
||||
const serverConfig = await this.buildServerConfig();
|
||||
if (
|
||||
serverConfig == null ||
|
||||
serverConfig.featureStates == null ||
|
||||
serverConfig.featureStates[key] == null
|
||||
) {
|
||||
return defaultValue;
|
||||
}
|
||||
return serverConfig.featureStates[key] as T;
|
||||
}
|
||||
|
||||
private async buildServerConfig(): Promise<ServerConfig> {
|
||||
const data = await this.stateService.getServerConfig();
|
||||
const domain = data ? new ServerConfig(data) : this._serverConfig.getValue();
|
||||
|
||||
if (domain == null || !domain.isValid() || domain.expiresSoon()) {
|
||||
const value = await this.fetchServerConfig();
|
||||
return value ?? domain;
|
||||
init() {
|
||||
if (!this.subscribe || this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
return domain;
|
||||
// Get config from storage on initial load
|
||||
const fromStorage = from(this.stateService.getServerConfig()).pipe(
|
||||
map((data) => (data == null ? null : new ServerConfig(data)))
|
||||
);
|
||||
|
||||
fromStorage.subscribe((config) => this._serverConfig.next(config));
|
||||
|
||||
// Fetch config from server
|
||||
// If you need to fetch a new config when an event occurs, add an observable that emits on that event here
|
||||
merge(
|
||||
timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS), // after 1 hour, then every hour
|
||||
this.environmentService.urls, // when environment URLs change (including when app is started)
|
||||
this._forceFetchConfig // manual
|
||||
)
|
||||
.pipe(
|
||||
delayWhen(() => fromStorage), // wait until storage has emitted first to avoid a race condition
|
||||
concatMap(() => this.configApiService.get()),
|
||||
filter((response) => response != null),
|
||||
map((response) => new ServerConfigData(response)),
|
||||
delayWhen((data) => this.saveConfig(data)),
|
||||
map((data) => new ServerConfig(data))
|
||||
)
|
||||
.subscribe((config) => this._serverConfig.next(config));
|
||||
|
||||
this.inited = true;
|
||||
}
|
||||
|
||||
getFeatureFlag$<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[key] as T;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getFeatureFlag<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
|
||||
return await firstValueFrom(this.getFeatureFlag$(key, defaultValue));
|
||||
}
|
||||
|
||||
triggerServerConfigFetch() {
|
||||
this._forceFetchConfig.next();
|
||||
}
|
||||
|
||||
private async saveConfig(data: ServerConfigData) {
|
||||
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateService.setServerConfig(data);
|
||||
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { concatMap, Observable, Subject } from "rxjs";
|
||||
import { concatMap, Observable, ReplaySubject } from "rxjs";
|
||||
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
|
||||
export class EnvironmentService implements EnvironmentServiceAbstraction {
|
||||
private readonly urlsSubject = new Subject<void>();
|
||||
private readonly urlsSubject = new ReplaySubject<void>(1);
|
||||
urls: Observable<void> = this.urlsSubject.asObservable();
|
||||
selectedRegion?: Region;
|
||||
initialized = false;
|
||||
|
||||
Reference in New Issue
Block a user