Skip to content

Commit

Permalink
feat(app): Convert config to instance so that implementation of behav…
Browse files Browse the repository at this point in the history
…ior is abstracted (#2508)

* feat(app): Convert config to instance so that implementation of behavior is abstracted

- create manifest for config validation
- create class for transpiling of config
- update cli to use the VO type of config

NOTE: direct access of endpoints if now deprecated!

BREAKING CHANGES: `endpoints` will be deprecate in future, applications should use `getEndpoint`

* refactor(app): Refactor AppConfig class to improve endpoint handling
  • Loading branch information
odinr authored Oct 8, 2024
1 parent 536392d commit a0d1359
Show file tree
Hide file tree
Showing 21 changed files with 406 additions and 152 deletions.
3 changes: 2 additions & 1 deletion packages/app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@equinor/fusion-framework-module-app';

import type { IAppConfigurator } from './AppConfigurator';
import { ConfigEnvironment } from '@equinor/fusion-framework-module-app';

export type {
AppModules,
Expand All @@ -25,7 +26,7 @@ export type {
* @template TConfig config value type
* @template TProps [__not in use__] properties for application component
*/
export type AppEnv<TConfig = unknown, TProps = unknown> = {
export type AppEnv<TConfig extends ConfigEnvironment = ConfigEnvironment, TProps = unknown> = {
/** base routing path of the application */
basename?: string;
manifest: AppManifest;
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"types": "./dist/types/lib/index.d.ts",
"import": "./dist/lib/index.js"
},
"./plugin-app-assets":{
"./plugin-app-assets": {
"types": "./dist/types/bin/plugins/app-assets/index.d.ts",
"import": "./dist/lib/plugins/app-assets/index.js"
},
Expand Down Expand Up @@ -62,7 +62,8 @@
"vite": "^5.4.3",
"vite-plugin-environment": "^1.1.3",
"vite-plugin-restart": "^0.4.0",
"vite-tsconfig-paths": "^4.2.0"
"vite-tsconfig-paths": "^4.2.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@equinor/eds-core-react": "^0.41.2",
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/bin/utils/load-app-config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Spinner } from './spinner.js';
import { formatPath, chalk } from './format.js';

import { createAppConfig, createAppConfigFromPackage } from '../../lib/app-config.js';
import { createAppConfig } from '../../lib/app-config.js';
import { type ConfigExecuterEnv } from '../../lib/utils/config.js';
import { type ResolvedAppPackage } from '../../lib/app-package.js';
import { ApiAppConfig } from '@equinor/fusion-framework-module-app/schemas.js';

export const loadAppConfig = async (
env: ConfigExecuterEnv,
Expand All @@ -18,7 +19,7 @@ export const loadAppConfig = async (
spinner.info(
`generating config with ${chalk.red.dim(env.command)} command in ${chalk.green.dim(env.mode)} mode`,
);
const baseAppConfig = createAppConfigFromPackage(pkg);
const baseAppConfig: ApiAppConfig = {} as ApiAppConfig;
const appConfig = await createAppConfig(env, baseAppConfig, { file: options?.file });
spinner.succeed();
if (appConfig.path) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/bin/utils/publishAppConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AppConfig } from '@equinor/fusion-framework-module-app';
import { ApiAppConfig } from '@equinor/fusion-framework-module-app/schemas.js';

/**
* Publishes app config to the apps-service endpoint
Expand All @@ -7,7 +7,7 @@ import type { AppConfig } from '@equinor/fusion-framework-module-app';
* @param config Object with app config
* @returns HTTP response as json
*/
export const publishAppConfig = async (endpoint: string, appKey: string, config: AppConfig) => {
export const publishAppConfig = async (endpoint: string, appKey: string, config: ApiAppConfig) => {
const requestConfig = await fetch(endpoint, {
method: 'PUT',
body: JSON.stringify(config),
Expand Down
43 changes: 10 additions & 33 deletions packages/cli/src/lib/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,25 @@ import {
resolveConfig,
} from './utils/config.js';

import { AssertionError, assertObject } from './utils/assert.js';
import { ResolvedAppPackage } from './app-package.js';
import deepmerge from 'deepmerge/index.js';
import { AssertionError } from './utils/assert.js';

import type { AppConfig } from '@equinor/fusion-framework-module-app';
import { ApiAppConfig, ApiAppConfigSchema } from '@equinor/fusion-framework-module-app/schemas.js';

type FindAppConfigOptions = FindConfigOptions & {
file?: string;
};

export type AppConfigFn = (
env: ConfigExecuterEnv,
args: { base: AppConfig },
) => AppConfig | Promise<AppConfig | void> | void;
export type AppConfigExport = AppConfig | AppConfigFn;
args: { base: ApiAppConfig },
) => ApiAppConfig | Promise<ApiAppConfig | void> | void;
export type AppConfigExport = ApiAppConfig | AppConfigFn;

export const appConfigFilename = 'app.config';

export function assertAppConfig(value: AppConfig): asserts value {
// TODO
assertObject(value);
}

export const defineAppConfig = (fn: AppConfigFn) => fn;

export const mergeAppConfigs = (
base: Partial<AppConfig>,
overrides: Partial<AppConfig>,
): AppConfig => {
const manifest = deepmerge(base, overrides) as unknown as AppConfig;
assertAppConfig(manifest);
return manifest;
};

export const loadAppConfig = (filename?: string) =>
loadConfig<AppConfig>(filename ?? appConfigFilename);
loadConfig<ApiAppConfig>(filename ?? appConfigFilename);

export const resolveAppConfig = async (
options?: FindConfigOptions & {
Expand All @@ -59,21 +42,15 @@ export const resolveAppConfig = async (
return resolveConfig(appConfigFilename, { find: options });
};

export const createAppConfigFromPackage = (_pkg: ResolvedAppPackage): AppConfig => {
const appConfig = {};
assertAppConfig(appConfig);
return appConfig;
};

export const createAppConfig = async (
env: ConfigExecuterEnv,
base: AppConfig,
base: ApiAppConfig,
options?: FindAppConfigOptions,
): Promise<{ config: AppConfig; path?: string }> => {
): Promise<{ config: ApiAppConfig; path?: string }> => {
const resolved = await resolveAppConfig(options);
if (resolved) {
const config = (await initiateConfig(resolved.config, env, { base })) ?? {};
assertAppConfig(config);
const configValue = (await initiateConfig(resolved.config, env, { base })) ?? {};
const config = ApiAppConfigSchema.parse(configValue);
return { config, path: resolved.path };
} else if (options?.file) {
throw new AssertionError({
Expand Down
7 changes: 1 addition & 6 deletions packages/cli/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
export { mergeManifests, defineAppManifest, type AppManifestFn } from './app-manifest.js';

export {
mergeAppConfigs,
defineAppConfig,
type AppConfigFn,
type AppConfigExport,
} from './app-config.js';
export { defineAppConfig, type AppConfigFn, type AppConfigExport } from './app-config.js';

export {
defineAppPackage,
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Plugin } from 'vite';

import { AppConfig, AppManifest } from '@equinor/fusion-framework-app';
import { AppManifest } from '@equinor/fusion-framework-app';

import { ClientRequest, IncomingMessage, ServerResponse } from 'node:http';

import { ApiAppConfig } from '@equinor/fusion-framework-module-app/schemas.js';

/**
* Preserve token for executing proxy assets
*
Expand Down Expand Up @@ -43,7 +46,7 @@ export type AppProxyPluginOptions = {
/** application version */
version: string;
/** callback function for generating configuration for the application */
generateConfig: () => Promise<AppConfig>;
generateConfig: () => Promise<ApiAppConfig>;
/** callback function for generating manifest for the application */
generateManifest: () => Promise<AppManifest>;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
{
"path": "../modules/bookmark"
},
{
"path": "../modules/app"
},
{
"path": "../app"
},
Expand Down
11 changes: 8 additions & 3 deletions packages/modules/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "5.3.11",
"description": "",
"main": "dist/esm/index.js",
"type": "module",
"exports": {
".": {
"import": "./dist/esm/index.js",
Expand All @@ -12,15 +13,16 @@
"import": "./dist/esm/errors.js",
"types": "./dist/types/errors.d.ts"
},
"./schemas.js": {
"import": "./dist/esm/schemas.js",
"types": "./dist/types/schemas.d.ts"
},
"./app": {
"import": "./dist/esm/app/index.js",
"types": "./dist/types/app/index.d.ts"
},
"./app/*.js": {
"import": "./dist/esm/app/*.js"
},
"./application.schema.js": {
"import": "./dist/esm/application.schema.js"
}
},
"types": "dist/types/index.d.ts",
Expand All @@ -29,6 +31,9 @@
"errors.js": [
"dist/types/errors.d.ts"
],
"schemas.js": [
"dist/types/schemas.d.ts"
],
"app": [
"dist/types/app/index.d.ts"
],
Expand Down
25 changes: 25 additions & 0 deletions packages/modules/app/src/AppClient.Selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
jsonSelector,
type ResponseSelector,
} from '@equinor/fusion-framework-module-http/selectors';

import { AppConfig } from './AppConfig';
import { ApiAppConfigSchema } from './schemas';

/**
* Asynchronously selects and parses the application configuration from the given response.
*
* @param response - The response object to select and parse the application configuration from.
* @returns A promise that resolves to an instance of `AppConfig` containing the parsed configuration data.
*
* @throws Will throw an error if the response cannot be parsed or does not conform to the expected schema.
*/
export const AppConfigSelector: ResponseSelector<AppConfig> = async (response) => {
// Select the JSON data from the response
const raw = await jsonSelector(response);

// Parse the JSON data using the API application configuration schema
const data = ApiAppConfigSchema.parse(raw);

return new AppConfig(data);
};
10 changes: 6 additions & 4 deletions packages/modules/app/src/AppClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { queryValue } from '@equinor/fusion-query/operators';
import { HttpResponseError, IHttpClient } from '@equinor/fusion-framework-module-http';
import { jsonSelector } from '@equinor/fusion-framework-module-http/selectors';

import { ApiApplicationSchema } from './application.schema';
import { ApiApplicationSchema } from './schemas';

import { AppConfig, AppManifest } from './types';
import type { AppConfig, AppManifest, ConfigEnvironment } from './types';
import { AppConfigError, AppManifestError } from './errors';
import { AppConfigSelector } from './AppClient.Selectors';

export interface IAppClient extends Disposable {
/**
Expand All @@ -25,7 +26,7 @@ export interface IAppClient extends Disposable {
/**
* Fetch app config by appKey and tag
*/
getAppConfig: <TType = unknown>(args: {
getAppConfig: <TType extends ConfigEnvironment = ConfigEnvironment>(args: {
appKey: string;
tag?: string;
}) => ObservableInput<AppConfig<TType>>;
Expand Down Expand Up @@ -103,6 +104,7 @@ export class AppClient implements IAppClient {
client: {
fn: ({ appKey, tag = 'latest' }) => {
return client.json(`/apps/${appKey}/builds/${tag}/config`, {
selector: AppConfigSelector,
headers: {
'Api-Version': '1.0',
},
Expand Down Expand Up @@ -136,7 +138,7 @@ export class AppClient implements IAppClient {
return this.#manifests.query(args).pipe(queryValue);
}

getAppConfig<TType = unknown>(args: {
getAppConfig<TType extends ConfigEnvironment = ConfigEnvironment>(args: {
appKey: string;
tag?: string;
}): Observable<AppConfig<TType>> {
Expand Down
95 changes: 95 additions & 0 deletions packages/modules/app/src/AppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// todo: move to utils
const deepFreeze = <T extends Record<string, unknown>>(obj: T): T => {
Object.keys(obj).forEach((property) => {
if (
typeof obj[property] === 'object' &&
obj[property] !== null &&
!Object.isFrozen(obj[property])
) {
deepFreeze(obj[property] as Record<string, unknown>);
}
});
return Object.freeze(obj);
};

export type ConfigEnvironment = Record<string, unknown>;

export type ConfigEndPoint = {
url: string;
scopes: string[];
};

/**
* Class representing the application configuration.
*
* @template TEnvironment - The type of the environment configuration, extending `ConfigEnvironment`.
*
* @remarks
* The `AppConfig` class provides a way to manage application configuration, including environment settings and endpoints.
*
* @example
* ```typescript
* const config = new AppConfig({
* environment: { ... },
* endpoints: {
* api: { url: 'https://api.example.com' }
* }
* });
*
* console.log(config.getEndpoint('api')); // { url: 'https://api.example.com' }
* ```
*
* @param {Object} config - The configuration object.
* @param {TEnvironment} [config.environment] - The environment configuration.
* @param {Record<string, ConfigEndPoint>} [config.endpoints] - The endpoints configuration.
*
* @property {TEnvironment} [environment] - The environment configuration.
* @property {Record<string, string | undefined>} endpoints - The endpoints configuration (deprecated).
*
* @method getEndpoint
* @param {string} key - The key of the endpoint to retrieve.
*/
export class AppConfig<TEnvironment extends ConfigEnvironment = ConfigEnvironment> {
#endpoints: Record<string, ConfigEndPoint>;

/**
* The environment configuration for the application.
* This property is read-only and is of type `TEnvironment`.
*/
public readonly environment: TEnvironment;

/**
* @deprecated Use `getEndpoint` instead.
*
* Retrieves the endpoints as a record of strings. This method returns a proxy
* that maps the endpoint names to their respective URLs.
*
* @returns {Record<string, string | undefined>} A record where the keys are endpoint names and the values are their URLs.
*/
public get endpoints(): Record<string, string | undefined> {
console.warn('endpoints is deprecated, use getEndpoint instead');
return new Proxy(this.#endpoints, {
get(target, prop): string | undefined {
return target[prop as string]?.url;
},
}) as unknown as Record<string, string>;
}

constructor(config: {
environment?: TEnvironment | null;
endpoints?: Record<string, ConfigEndPoint>;
}) {
this.environment = deepFreeze(config.environment ?? {}) as TEnvironment;
this.#endpoints = config.endpoints ?? {};
}

/**
* Retrieves the configuration endpoint associated with the given key.
*
* @param key - The key corresponding to the desired configuration endpoint.
* @returns The configuration endpoint if found, otherwise `undefined`.
*/
getEndpoint(key: string): ConfigEndPoint | undefined {
return this.#endpoints[key];
}
}
Loading

0 comments on commit a0d1359

Please sign in to comment.