feat: add ConfigModule and FakeBackendInterceptor

Closes #34, #37
This commit is contained in:
Sebastian Lange
2019-02-08 11:58:51 +01:00
parent 0d1d26cd5d
commit 406f400555
15 changed files with 2479 additions and 359 deletions

View File

@@ -1,3 +1,3 @@
<ion-app>
<stapps-navigation></stapps-navigation>
</ion-app>
</ion-app>

View File

@@ -21,6 +21,7 @@ import {Platform} from '@ionic/angular';
import {TranslateService} from '@ngx-translate/core';
import {AppComponent} from './app.component';
import {ConfigProvider} from './modules/config/config.provider';
import {SettingsProvider} from './modules/settings/settings.provider';
describe('AppComponent', () => {
@@ -31,6 +32,7 @@ describe('AppComponent', () => {
let platformSpy: jasmine.SpyObj<Platform>;
let translateServiceSpy: jasmine.SpyObj<TranslateService>;
let settingsProvider: jasmine.SpyObj<SettingsProvider>;
let configProvider: jasmine.SpyObj<ConfigProvider>;
beforeEach(async(() => {
statusBarSpy = jasmine.createSpyObj('StatusBar', ['styleDefault']);
@@ -40,6 +42,8 @@ describe('AppComponent', () => {
translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
settingsProvider = jasmine.createSpyObj('SettingsProvider',
['getSettingValue', 'provideSetting', 'setCategoriesOrder']);
configProvider = jasmine.createSpyObj('ConfigProvider',
['init']);
TestBed.configureTestingModule({
declarations: [AppComponent],
@@ -49,6 +53,7 @@ describe('AppComponent', () => {
{provide: Platform, useValue: platformSpy},
{provide: TranslateService, useValue: translateServiceSpy},
{provide: SettingsProvider, useValue: settingsProvider},
{provide: ConfigProvider, useValue: configProvider},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();

View File

@@ -20,8 +20,11 @@ import {TranslateService} from '@ngx-translate/core';
import {SCLanguageCode} from '@openstapps/core';
import {Logger} from '@openstapps/logger';
import {ConfigProvider} from './modules/config/config.provider';
import {SettingsProvider} from './modules/settings/settings.provider';
const logger: Logger = new Logger();
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
@@ -34,7 +37,8 @@ export class AppComponent {
private statusBar: StatusBar,
private splashScreen: SplashScreen,
private translateService: TranslateService,
private settingsProvider: SettingsProvider) {
private settingsProvider: SettingsProvider,
private configProvider: ConfigProvider) {
this.initializeApp();
// this language will be used as a fallback when a translation isn't found in the current language
@@ -48,6 +52,18 @@ export class AppComponent {
this.statusBar.styleDefault();
this.splashScreen.hide();
// initialise the configProvider
try {
await this.configProvider.init();
} catch (error) {
if (typeof error.name !== 'undefined') {
if (error.name === 'ConfigInitError') {
// @TODO: Issue #43 handle initialisation error and inform user
}
}
logger.error(error);
}
// set order of categories in settings
this.settingsProvider.setCategoriesOrder([
'profile',

View File

@@ -26,6 +26,7 @@ import {TranslateLoader, TranslateModule} from '@ngx-translate/core';
import {TranslateHttpLoader} from '@ngx-translate/http-loader';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {ConfigModule} from './modules/config/config.module';
import {DataModule} from './modules/data/data.module';
import {MenuModule} from './modules/menu/menu.module';
import {SettingsModule} from './modules/settings/settings.module';
@@ -40,6 +41,7 @@ export function createTranslateLoader(http: HttpClient) {
declarations: [AppComponent],
imports: [
BrowserModule,
ConfigModule,
DataModule,
MenuModule,
SettingsModule,

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* An error that can occur in the StApps app
*/
export class AppError extends Error {
/**
* Instantiate a new error
*/
constructor(name: string, message: string) {
super(message);
this.name = name;
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (C) 2018, 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {HttpEvent,
HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {SCIndexResponse} from '@openstapps/core';
import {Observable, of} from 'rxjs';
const sampleIndexResponse: SCIndexResponse = {
app: {
campusPolygon: {
coordinates: [
[
[
13.31916332244873,
52.50796756998264,
],
[
13.336544036865234,
52.50796756998264,
],
[
13.336544036865234,
52.51726547416385,
],
[
13.31916332244873,
52.51726547416385,
],
[
13.31916332244873,
52.50796756998264,
],
],
],
type: 'Polygon',
},
features: {
widgets: true,
},
menus: [],
name: 'StApps - Technische Universität Berlin',
privacyPolicyUrl: 'https://stappsbe01.innocampus.tu-berlin.de/_static/privacy.md',
settings: [],
},
backend: {
SCVersion: '1.0.0',
hiddenTypes: [
'date series',
'diff',
'floor',
],
name: 'Technische Universität Berlin',
namespace: '909a8cbc-8520-456c-b474-ef1525f14209',
sortableFields: [
{
fieldName: 'name',
sortTypes: ['ducet'],
},
{
fieldName: 'type',
sortTypes: ['ducet'],
},
{
fieldName: 'categories',
onlyOnTypes: [
'academic event',
'building',
'catalog',
'dish',
'point of interest',
'room',
],
sortTypes: ['ducet'],
},
{
fieldName: 'geo.point.coordinates',
onlyOnTypes: [
'building',
'point of interest',
'room',
],
sortTypes: ['distance'],
},
{
fieldName: 'geo.point.coordinates',
onlyOnTypes: [
'building',
'point of interest',
'room',
],
sortTypes: ['distance'],
},
{
fieldName: 'inPlace.geo.point.coordinates',
onlyOnTypes: [
'date series',
'dish',
'floor',
'organization',
'point of interest',
'room',
'ticket',
],
sortTypes: ['distance'],
},
{
fieldName: 'offers',
onlyOnTypes: [
'dish',
],
sortTypes: ['price'],
},
],
},
};
@Injectable()
export class FakeBackendInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.url.endsWith('/') && request.method === 'POST') {
// respond 200 OK
return of(new HttpResponse({status: 200, body: sampleIndexResponse}));
} else {
return next.handle(request);
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NgModule} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
@NgModule({
imports: [
StorageModule,
DataModule,
],
providers: [
ConfigProvider,
],
})
export class ConfigModule {}

View File

@@ -0,0 +1,298 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {TestBed} from '@angular/core/testing';
import {SCIndexResponse} from '@openstapps/core';
import {StAppsWebHttpClient} from '../data/data.provider';
import {StorageProvider} from '../storage/storage.provider';
import {ConfigProvider, STORAGE_KEY_CONFIG} from './config.provider';
import {
ConfigFetchError,
ConfigInitError,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
describe('ConfigProvider', () => {
let configProvider: ConfigProvider;
let storageProviderSpy: jasmine.SpyObj<StorageProvider>;
beforeEach(() => {
const storageProviderMethodSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put']);
const webHttpClientMethodSpy = jasmine.createSpyObj('StAppsWebHttpClient', ['request']);
TestBed.configureTestingModule({
imports: [],
providers: [
ConfigProvider,
{
provide: StorageProvider, useValue: storageProviderMethodSpy,
},
{
provide: StAppsWebHttpClient, useValue: webHttpClientMethodSpy,
},
],
});
configProvider = TestBed.get(ConfigProvider);
storageProviderSpy = TestBed.get(StorageProvider);
});
it('should fetch app configuration', async () => {
spyOn(configProvider.client, 'handshake').and.returnValue(sampleIndexResponse);
const result = await configProvider.fetch();
expect(result).toEqual(sampleIndexResponse);
});
it('should throw error on fetch with error response', async () => {
spyOn(configProvider.client, 'handshake').and.throwError('');
let error = new Error('');
try {
await configProvider.fetch();
} catch (err) {
error = err;
}
expect(error).toEqual(new ConfigFetchError());
});
it('should init from remote and saved config not available', async () => {
storageProviderSpy.has.and.returnValue(false);
spyOn(configProvider.client, 'handshake').and.returnValue(sampleIndexResponse);
try {
await configProvider.init();
} catch (error) {
expect(error).toEqual(new SavedConfigNotAvailable());
}
expect(storageProviderSpy.has).toHaveBeenCalled();
expect(storageProviderSpy.get).toHaveBeenCalledTimes(0);
expect(configProvider.client.handshake).toHaveBeenCalled();
expect(configProvider.initialised).toBe(true);
expect(await configProvider.getValue('name')).toEqual(sampleIndexResponse.app.name);
});
it('should init from storage with remote fails', async () => {
storageProviderSpy.has.and.returnValue(true);
storageProviderSpy.get.and.returnValue(sampleIndexResponse);
spyOn(configProvider.client, 'handshake').and.throwError('');
let error = new Error('');
try {
await configProvider.init();
} catch (err) {
error = err;
}
expect(error).toEqual(new ConfigFetchError());
expect(storageProviderSpy.has).toHaveBeenCalled();
expect(storageProviderSpy.get).toHaveBeenCalled();
expect(configProvider.initialised).toBe(true);
expect(await configProvider.getValue('name')).toEqual(sampleIndexResponse.app.name);
});
it('should throw error on failed initialisation', async () => {
storageProviderSpy.has.and.returnValue(false);
spyOn(configProvider.client, 'handshake').and.throwError('');
let error = null;
try {
await configProvider.init();
} catch (err) {
error = err;
}
expect(error).toEqual(new ConfigInitError());
});
it('should throw error on wrong config version in storage', async () => {
storageProviderSpy.has.and.returnValue(true);
const wrongConfig = JSON.parse(JSON.stringify(sampleIndexResponse));
wrongConfig.backend.SCVersion = '0.1.0';
storageProviderSpy.get.and.returnValue(wrongConfig);
spyOn(configProvider.client, 'handshake').and.returnValue(sampleIndexResponse);
let error = null;
try {
await configProvider.init();
} catch (err) {
error = err;
}
expect(error).toEqual(new WrongConfigVersionInStorage('1.0.0', '0.1.0'));
});
it('should throw error on saved app configuration not available', async () => {
storageProviderSpy.has.and.returnValue(false);
let error = new Error('');
try {
await configProvider.loadLocal();
} catch (err) {
error = err;
}
expect(error).toEqual(new SavedConfigNotAvailable());
});
it('should save app configuration', async () => {
await configProvider.save(sampleIndexResponse);
expect(storageProviderSpy.put).toHaveBeenCalledWith(STORAGE_KEY_CONFIG, sampleIndexResponse);
});
it('should set app configuration', async () => {
await configProvider.set(sampleIndexResponse);
expect(storageProviderSpy.put).toHaveBeenCalled();
});
it('should return app configuration value', async () => {
storageProviderSpy.has.and.returnValue(true);
storageProviderSpy.get.and.returnValue(sampleIndexResponse);
spyOn(configProvider.client, 'handshake').and.returnValue(sampleIndexResponse);
await configProvider.init();
expect(await configProvider.getValue('name')).toEqual(sampleIndexResponse.app.name);
});
});
const sampleIndexResponse: SCIndexResponse = {
app: {
campusPolygon: {
coordinates: [[[1, 2]], [[1, 2]]],
type: 'Polygon',
},
features: {
widgets: false,
},
menus: [
{
icon: 'icon',
id: 'main',
items: [
{
icon: 'icon',
route: '/index',
title: 'start',
translations: {
de: {
title: 'Start',
},
en: {
title: 'start',
},
},
},
],
name: 'main',
translations: {
de: {
name: 'Haupt',
},
en: {
name: 'main',
},
},
},
],
name: 'StApps',
privacyPolicyUrl: 'foo.bar',
settings: [
{
categories: ['credentials'],
input: {
defaultValue: '',
inputType: 'text',
},
name: 'username',
order: 0,
origin: {
indexed: '2018-09-11T12:30:00Z',
name: 'Dummy',
},
translations: {
de: {
categories: ['Anmeldedaten'],
name: 'Benutzername',
},
en: {
categories: ['Credentials'],
name: 'Username',
},
},
type: 'setting',
uid: '',
},
],
},
backend: {
SCVersion: '1.0.0',
hiddenTypes: [
'date series',
'diff',
'floor',
],
name: 'Technische Universität Berlin',
namespace: '909a8cbc-8520-456c-b474-ef1525f14209',
sortableFields: [
{
fieldName: 'name',
sortTypes: ['ducet'],
},
{
fieldName: 'type',
sortTypes: ['ducet'],
},
{
fieldName: 'categories',
onlyOnTypes: [
'academic event',
'building',
'catalog',
'dish',
'point of interest',
'room',
],
sortTypes: ['ducet'],
},
{
fieldName: 'geo.point.coordinates',
onlyOnTypes: [
'building',
'point of interest',
'room',
],
sortTypes: ['distance'],
},
{
fieldName: 'geo.point.coordinates',
onlyOnTypes: [
'building',
'point of interest',
'room',
],
sortTypes: ['distance'],
},
{
fieldName: 'inPlace.geo.point.coordinates',
onlyOnTypes: [
'date series',
'dish',
'floor',
'organization',
'point of interest',
'room',
'ticket',
],
sortTypes: ['distance'],
},
{
fieldName: 'offers',
onlyOnTypes: [
'dish',
],
sortTypes: ['price'],
},
],
},
};

View File

@@ -0,0 +1,151 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {Client} from '@openstapps/api/lib/client';
import {SCAppConfiguration, SCIndexResponse} from '@openstapps/core';
import {Logger} from '@openstapps/logger';
import {environment} from '../../../environments/environment';
import {StAppsWebHttpClient} from '../data/data.provider';
import {StorageProvider} from '../storage/storage.provider';
import {
ConfigFetchError,
ConfigInitError,
ConfigValueNotAvailable,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
/**
* Key to store config in storage module
*
* @TODO: Issue #41 centralise storage keys
*/
export const STORAGE_KEY_CONFIG = 'stapps.config';
/**
* Provides configuration
*/
@Injectable()
export class ConfigProvider {
client: Client;
config: SCIndexResponse;
initialised: boolean = false;
logger: Logger = new Logger();
constructor(private storageProvider: StorageProvider, swHttpClient: StAppsWebHttpClient) {
this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version);
}
/**
* Fetches configuration from backend
*/
async fetch(): Promise<SCIndexResponse> {
try {
return await this.client.handshake(environment.backend_version);
} catch (error) {
throw new ConfigFetchError();
}
}
/**
* Returns the value of an app configuration
*
* @param attribute requested attribute from app configuration
*/
public async getValue(attribute: keyof SCAppConfiguration) {
if (!this.initialised) {
await this.init();
}
if (typeof this.config.app[attribute] !== 'undefined') {
return this.config.app[attribute];
} else {
throw new ConfigValueNotAvailable(attribute);
}
}
/**
* Initialises the ConfigProvider
*
* @throws ConfigInitError if no configuration could be loaded.
* @throws WrongConfigVersionInStorage if fetch failed and saved config has wrong SCVersion
*/
async init(): Promise<void> {
let loadError;
let fetchError;
this.initialised = false;
// load saved configuration
try {
this.config = await this.loadLocal();
this.initialised = true;
this.logger.log(`initialised configuration from storage: ${JSON.stringify(this.config)}`);
if (this.config.backend.SCVersion !== environment.backend_version) {
loadError = new WrongConfigVersionInStorage(environment.backend_version, this.config.backend.SCVersion);
this.logger.warn(loadError);
}
} catch (error) {
loadError = error;
}
// fetch remote configuration from backend
try {
const fetchedConfig: SCIndexResponse = await this.fetch();
await this.set(fetchedConfig);
this.initialised = true;
this.logger.log(`initialised configuration from remote: ${JSON.stringify(this.config)}`);
} catch (error) {
fetchError = error;
}
// check for occurred errors and throw them
if (typeof loadError !== 'undefined' && typeof fetchError !== 'undefined') {
throw new ConfigInitError();
} else if (typeof loadError !== 'undefined') {
throw loadError;
} else if (typeof fetchError !== 'undefined') {
throw fetchError;
}
}
/**
* Returns saved configuration from StorageModule
*
* @throws SavedConfigNotAvailable if no configuration could be loaded
*/
async loadLocal(): Promise<SCIndexResponse> {
await this.storageProvider.init();
// get local configuration
if (await this.storageProvider.has(STORAGE_KEY_CONFIG)) {
return await this.storageProvider.get<SCIndexResponse>(STORAGE_KEY_CONFIG);
}
throw new SavedConfigNotAvailable();
}
/**
* Saves the configuration from the provider
*
* @param config configuration to save
*/
async save(config: SCIndexResponse): Promise<void> {
await this.storageProvider.put(STORAGE_KEY_CONFIG, config);
}
/**
* Sets the configuration in the module and writes it into app storage
*
* @param config SCIndexResponse to set
*/
async set(config: SCIndexResponse): Promise<void> {
this.config = config;
await this.save(this.config);
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppError} from '../_helpers/errors';
/**
* Error that is thrown when fetching from backend fails
*/
export class ConfigFetchError extends AppError {
constructor() {
super('ConfigFetchError', 'App configuration could not be fetched!');
}
}
/**
* Error that is thrown when the ConfigProvider could be initialised
*/
export class ConfigInitError extends AppError {
constructor() {
super('ConfigInitError', 'App configuration could not be initialised!');
}
}
/**
* Error that is thrown when the requested config value is not available
*/
export class ConfigValueNotAvailable extends AppError {
constructor(valueKey: string) {
super('ConfigValueNotAvailable', `No attribute "${valueKey}" in config available!`);
}
}
/**
* Error that is thrown when no saved config is available
*/
export class SavedConfigNotAvailable extends AppError {
constructor() {
super('SavedConfigNotAvailable', 'No saved app configuration available.');
}
}
/**
* Error that is thrown when the SCVersion of the saved config is not compatible with the app
*/
export class WrongConfigVersionInStorage extends AppError {
constructor(correctVersion: string, savedVersion: string) {
super('WrongConfigVersionInStorage', `The saved configs backend version ${savedVersion} ` +
`does not equal the configured backend version ${correctVersion} of the app.`);
}
}

View File

@@ -13,5 +13,7 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const environment = {
production: true
backend_url: 'http://localhost:3000',
backend_version: '1.0.0',
production: true,
};

View File

@@ -17,7 +17,9 @@
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `.angular-cli.json`.
export const environment = {
production: false
backend_url: 'http://localhost:3000',
backend_version: '1.0.0',
production: false,
};
/*