feat: use zod to parse config

This commit is contained in:
Eli Bosley
2025-01-18 10:42:34 -05:00
parent 6f9977eea0
commit 02c197f244
7 changed files with 187 additions and 203 deletions

View File

@@ -10,72 +10,75 @@ test('it creates a FLASH config with NO OPTIONAL values', () => {
const basicConfig = initialState; const basicConfig = initialState;
const config = getWriteableConfig(basicConfig, 'flash'); const config = getWriteableConfig(basicConfig, 'flash');
expect(config).toMatchInlineSnapshot(` expect(config).toMatchInlineSnapshot(`
{ {
"api": { "api": {
"extraOrigins": "", "extraOrigins": "",
"version": "", "version": "",
}, },
"local": {}, "local": {},
"notifier": { "notifier": {
"apikey": "", "apikey": "",
}, },
"remote": { "remote": {
"accesstoken": "", "accesstoken": "",
"apikey": "", "apikey": "",
"avatar": "", "avatar": "",
"dynamicRemoteAccessType": "DISABLED", "dynamicRemoteAccessType": "DISABLED",
"email": "", "email": "",
"idtoken": "", "idtoken": "",
"localApiKey": "", "localApiKey": "",
"refreshtoken": "", "refreshtoken": "",
"regWizTime": "", "regWizTime": "",
"username": "", "upnpEnabled": "",
"wanaccess": "", "username": "",
"wanport": "", "wanaccess": "",
}, "wanport": "",
"upc": { },
"apikey": "", "upc": {
}, "apikey": "",
} },
`); }
`);
}); });
test('it creates a MEMORY config with NO OPTIONAL values', () => { test('it creates a MEMORY config with NO OPTIONAL values', () => {
const basicConfig = initialState; const basicConfig = initialState;
const config = getWriteableConfig(basicConfig, 'memory'); const config = getWriteableConfig(basicConfig, 'memory');
expect(config).toMatchInlineSnapshot(` expect(config).toMatchInlineSnapshot(`
{ {
"api": { "api": {
"extraOrigins": "", "extraOrigins": "",
"version": "", "version": "",
}, },
"connectionStatus": { "connectionStatus": {
"minigraph": "PRE_INIT", "minigraph": "PRE_INIT",
}, "upnpStatus": "",
"local": {}, },
"notifier": { "local": {},
"apikey": "", "notifier": {
}, "apikey": "",
"remote": { },
"accesstoken": "", "remote": {
"allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", "accesstoken": "",
"apikey": "", "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000",
"avatar": "", "apikey": "",
"dynamicRemoteAccessType": "DISABLED", "avatar": "",
"email": "", "dynamicRemoteAccessType": "DISABLED",
"idtoken": "", "email": "",
"localApiKey": "", "idtoken": "",
"refreshtoken": "", "localApiKey": "",
"regWizTime": "", "refreshtoken": "",
"username": "", "regWizTime": "",
"wanaccess": "", "upnpEnabled": "",
"wanport": "", "username": "",
}, "wanaccess": "",
"upc": { "wanport": "",
"apikey": "", },
}, "upc": {
} "apikey": "",
`); },
}
`);
}); });
test('it creates a FLASH config with OPTIONAL values', () => { test('it creates a FLASH config with OPTIONAL values', () => {

View File

@@ -68,6 +68,7 @@ export const JWKS_LOCAL_PAYLOAD: JSONWebKeySet = {
}, },
], ],
}; };
export const OAUTH_BASE_URL = export const OAUTH_BASE_URL =
'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk'; 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk';
export const OAUTH_CLIENT_ID = '53ci4o48gac8vq5jepubkjmo36'; export const OAUTH_CLIENT_ID = '53ci4o48gac8vq5jepubkjmo36';

View File

@@ -1,95 +1,64 @@
import { getAllowedOrigins } from '@app/common/allowed-origins';
import { DynamicRemoteAccessType } from '@app/graphql/generated/api/types';
import {
type SliceState as ConfigSliceState,
initialState,
} from '@app/store/modules/config';
import { type RecursivePartial } from '@app/types';
import type {
MyServersConfig,
MyServersConfigMemory,
} from '@app/types/my-servers-config';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
export type ConfigType = 'flash' | 'memory'; import { getAllowedOrigins } from '@app/common/allowed-origins';
type ConfigObject<T> = T extends 'flash' import { initialState } from '@app/store/modules/config';
? MyServersConfig import {
: T extends 'memory' MyServersConfig,
? MyServersConfigMemory MyServersConfigMemory,
: never; MyServersConfigMemorySchema,
/** MyServersConfigSchema,
* } from '@app/types/my-servers-config';
* @param config Config to read from to create a new formatted server config to write
* @param mode 'flash' or 'memory', changes what fields are included in the writeable payload
* @returns
*/
export const getWriteableConfig = <T extends ConfigType>(
config: ConfigSliceState,
mode: T
): ConfigObject<T> => {
// Get current state
const { api, local, notifier, remote, upc, connectionStatus } = config;
// Create new state // Define ConfigType and ConfigObject
export type ConfigType = 'flash' | 'memory';
const newState: ConfigObject<T> = {
api: { /**
version: api?.version ?? initialState.api.version, * Get a writeable configuration based on the mode ('flash' or 'memory').
extraOrigins: api?.extraOrigins ?? initialState.api.extraOrigins, */
}, export const getWriteableConfig = <T extends ConfigType>(
local: {}, config: T extends 'memory' ? MyServersConfigMemory : MyServersConfig,
notifier: { mode: T
apikey: notifier.apikey ?? initialState.notifier.apikey, ): T extends 'memory' ? MyServersConfigMemory : MyServersConfig => {
}, const schema = mode === 'memory' ? MyServersConfigMemorySchema : MyServersConfigSchema;
const defaultConfig = schema.parse(initialState);
// Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory`
const mergedConfig = {
...defaultConfig,
...config,
remote: { remote: {
wanaccess: remote.wanaccess ?? initialState.remote.wanaccess, ...defaultConfig.remote,
wanport: remote.wanport ?? initialState.remote.wanport, ...config.remote,
...(remote.upnpEnabled ? { upnpEnabled: remote.upnpEnabled } : {}),
apikey: remote.apikey ?? initialState.remote.apikey,
localApiKey: remote.localApiKey ?? initialState.remote.localApiKey,
email: remote.email ?? initialState.remote.email,
username: remote.username ?? initialState.remote.username,
avatar: remote.avatar ?? initialState.remote.avatar,
regWizTime: remote.regWizTime ?? initialState.remote.regWizTime,
idtoken: remote.idtoken ?? initialState.remote.idtoken,
accesstoken: remote.accesstoken ?? initialState.remote.accesstoken,
refreshtoken:
remote.refreshtoken ?? initialState.remote.refreshtoken,
...(mode === 'memory'
? {
allowedOrigins:
getAllowedOrigins().join(', ')
}
: {}),
dynamicRemoteAccessType: remote.dynamicRemoteAccessType ?? DynamicRemoteAccessType.DISABLED,
}, },
upc: { } as T extends 'memory' ? MyServersConfigMemory : MyServersConfig;
apikey: upc.apikey ?? initialState.upc.apikey,
}, if (mode === 'memory') {
...(mode === 'memory' (mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', ');
? { (mergedConfig as MyServersConfigMemory).connectionStatus = {
connectionStatus: { ...(defaultConfig as MyServersConfigMemory).connectionStatus,
minigraph: ...(config as MyServersConfigMemory).connectionStatus,
connectionStatus.minigraph ?? };
initialState.connectionStatus.minigraph, }
...(connectionStatus.upnpStatus
? { upnpStatus: connectionStatus.upnpStatus } return schema.parse(mergedConfig) as any; // Narrowing ensures correct typing
: {}),
},
}
: {}),
} as ConfigObject<T>;
return newState;
}; };
/** /**
* Helper function to convert an object into a normalized config file. * Check if two configurations are equivalent by normalizing them through the Zod schema.
* This is used for loading config files and ensure changes have been made before the state is merged.
*/ */
export const areConfigsEquivalent = ( export const areConfigsEquivalent = (
newConfigFile: RecursivePartial<MyServersConfig>, newConfigFile: Partial<MyServersConfigMemory>, // Use Partial here for flexibility
currentConfig: ConfigSliceState currentConfig: MyServersConfig
): boolean => ): boolean => {
// Enable to view config diffs: logger.debug(getDiff(getWriteableConfig(currentConfig, 'flash'), newConfigFile)); // Parse and validate the new config file using the schema (with default values applied)
isEqual(newConfigFile, getWriteableConfig(currentConfig, 'flash')); const normalizedNewConfig = MyServersConfigSchema.parse({
...currentConfig, // Use currentConfig as a baseline to fill missing fields
...newConfigFile,
});
// Get the writeable configuration for the current config
const normalizedCurrentConfig = getWriteableConfig(currentConfig, 'flash');
// Compare the normalized configurations
return isEqual(normalizedNewConfig, normalizedCurrentConfig);
};

View File

@@ -1,58 +0,0 @@
import { type DynamicRemoteAccessType, type MinigraphStatus } from '@app/graphql/generated/api/types';
interface MyServersConfig extends Record<string, unknown> {
api: {
version: string;
extraOrigins: string;
};
local: {};
notifier: {
apikey: string;
};
remote: {
wanaccess: string;
wanport: string;
upnpEnabled?: string;
apikey: string;
localApiKey?: string;
email: string;
username: string;
avatar: string;
regWizTime: string;
accesstoken: string;
idtoken: string;
refreshtoken: string;
allowedOrigins?: string;
dynamicRemoteAccessType?: DynamicRemoteAccessType;
};
upc: {
apikey: string;
};
}
export interface MyServersConfigWithMandatoryHiddenFields extends MyServersConfig {
api: {
extraOrigins: string;
};
remote: MyServersConfig['remote'] & {
upnpEnabled: string;
dynamicRemoteAccessType: DynamicRemoteAccessType;
};
}
export interface MyServersConfigMemory extends MyServersConfig {
connectionStatus: {
minigraph: MinigraphStatus;
upnpStatus?: null | string;
};
remote: MyServersConfig['remote'] & {
allowedOrigins: string;
};
}
export interface MyServersConfigMemoryWithMandatoryHiddenFields extends MyServersConfigMemory {
connectionStatus: {
minigraph: MinigraphStatus;
upnpStatus?: null | string;
};
}

View File

@@ -0,0 +1,67 @@
import { z } from 'zod';
import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types';
// Define Zod schemas
const ApiConfigSchema = z.object({
version: z.string(),
extraOrigins: z.string(),
});
const NotifierConfigSchema = z.object({
apikey: z.string(),
});
const RemoteConfigSchema = z.object({
wanaccess: z.string(),
wanport: z.string(),
upnpEnabled: z.string(),
apikey: z.string(),
localApiKey: z.string(),
email: z.string(),
username: z.string(),
avatar: z.string(),
regWizTime: z.string(),
accesstoken: z.string(),
idtoken: z.string(),
refreshtoken: z.string(),
dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType),
});
const UpcConfigSchema = z.object({
apikey: z.string(),
});
// Base config schema
export const MyServersConfigSchema = z.object({
api: ApiConfigSchema,
local: z.object({}), // Empty object
notifier: NotifierConfigSchema,
remote: RemoteConfigSchema,
upc: UpcConfigSchema,
});
// Memory config schema
export const ConnectionStatusSchema = z.object({
minigraph: z.nativeEnum(MinigraphStatus),
upnpStatus: z.string().nullable().optional(),
});
export const MyServersConfigMemorySchema = MyServersConfigSchema.extend({
connectionStatus: ConnectionStatusSchema,
remote: RemoteConfigSchema.extend({
allowedOrigins: z.string(),
}),
});
// Memory config with mandatory hidden fields schema
export const MyServersConfigMemoryWithMandatoryHiddenFieldsSchema = MyServersConfigMemorySchema.extend({
connectionStatus: ConnectionStatusSchema,
});
// Infer and export types from Zod schemas
export type MyServersConfig = z.infer<typeof MyServersConfigSchema>;
export type MyServersConfigMemory = z.infer<typeof MyServersConfigMemorySchema>;
export type MyServersConfigMemoryWithMandatoryHiddenFields = z.infer<
typeof MyServersConfigMemoryWithMandatoryHiddenFieldsSchema
>;

View File

@@ -28,6 +28,7 @@ export class StartCommand extends CommandRunner {
); );
if (stdout) { if (stdout) {
this.logger.log(stdout); this.logger.log(stdout);
process.exit(0);
} }
if (stderr) { if (stderr) {
this.logger.error(stderr); this.logger.error(stderr);

View File

@@ -29,6 +29,7 @@ export class ValidateTokenCommand extends CommandRunner {
async run(passedParams: string[]): Promise<void> { async run(passedParams: string[]): Promise<void> {
if (passedParams.length !== 1) { if (passedParams.length !== 1) {
this.logger.error('Please pass token argument only'); this.logger.error('Please pass token argument only');
process.exit(1);
} }
const token = passedParams[0]; const token = passedParams[0];