From 68c1f9459f6eb8aeba0b13d0db5afe92677cab7b Mon Sep 17 00:00:00 2001 From: Tim Raderschad <tim.raderschad@gmail.com> Date: Thu, 30 Jan 2025 08:58:17 +0100 Subject: [PATCH] feat(core): add user --- packages/core/package.json | 5 ++ packages/core/src/defineConfig.ts | 9 ++- packages/core/src/index.ts | 22 +++++- packages/core/src/shared/schemas.ts | 12 ++++ packages/core/src/validation/index.ts | 89 +++++++++++++++++++++++ packages/core/tests/defineConfig.test.ts | 11 +++ packages/core/tests/types.test.ts | 20 ++++++ packages/core/tests/validation.test.ts | 90 ++++++++++++++++++++++++ 8 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/validation/index.ts create mode 100644 packages/core/tests/validation.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index b6cec524..f3ae6946 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,11 @@ "require": "./dist/defineConfig/index.js", "import": "./dist/defineConfig/index.js", "types": "./dist/defineConfig/index.d.ts" + }, + "./validation": { + "require": "./dist/validation/index.js", + "import": "./dist/validation/index.js", + "types": "./dist/validation/index.d.ts" } }, "scripts": { diff --git a/packages/core/src/defineConfig.ts b/packages/core/src/defineConfig.ts index 30423adf..ab13cf4d 100644 --- a/packages/core/src/defineConfig.ts +++ b/packages/core/src/defineConfig.ts @@ -1,4 +1,5 @@ import type { ABConfig, AbbyConfig, RemoteConfigValueString } from "."; +import type { ValidatorType } from "./validation"; export const DYNAMIC_ABBY_CONFIG_KEYS = [ "projectId", @@ -15,13 +16,17 @@ export function defineConfig< const Tests extends Record<string, ABConfig>, const RemoteConfig extends Record<RemoteConfigName, RemoteConfigValueString>, const RemoteConfigName extends Extract<keyof RemoteConfig, string>, + const User extends Record<string, ValidatorType> = Record< + string, + ValidatorType + >, >( dynamicConfig: Pick< - AbbyConfig<FlagName, Tests, string[], RemoteConfigName, RemoteConfig>, + AbbyConfig<FlagName, Tests, string[], RemoteConfigName, RemoteConfig, User>, DynamicConfigKeys >, config: Omit< - AbbyConfig<FlagName, Tests, string[], RemoteConfigName, RemoteConfig>, + AbbyConfig<FlagName, Tests, string[], RemoteConfigName, RemoteConfig, User>, DynamicConfigKeys > ) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 036c761d..2f8487a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ import { remoteConfigStringToType, stringifyRemoteConfigValue, } from "./shared/"; +import type { ValidatorType, Infer } from "./validation"; export * from "./shared/index"; export { @@ -93,6 +94,7 @@ export type AbbyConfig< RemoteConfigName, RemoteConfigValueString > = Record<RemoteConfigName, RemoteConfigValueString>, + User extends Record<string, ValidatorType> = Record<string, ValidatorType>, > = { projectId: string; apiUrl?: string; @@ -109,6 +111,7 @@ export type AbbyConfig< expiresInDays?: number; }; __experimentalCdnUrl?: string; + user?: User; }; export class Abby< @@ -118,6 +121,10 @@ export class Abby< const RemoteConfig extends Record<RemoteConfigName, RemoteConfigValueString>, const RemoteConfigName extends Extract<keyof RemoteConfig, string>, const Environments extends Array<string> = Array<string>, + const User extends Record<string, ValidatorType> = Record< + string, + ValidatorType + >, > { private log = (...args: any[]) => this.config.debug ? console.log("core.Abby", ...args) : () => {}; @@ -144,6 +151,7 @@ export class Abby< private remoteConfigOverrides = new Map<string, RemoteConfigValue>(); private COOKIE_CONSENT_KEY = "$_abcc_$"; + private user = {} as User; constructor( private config: AbbyConfig< @@ -151,7 +159,8 @@ export class Abby< Tests, Environments, RemoteConfigName, - RemoteConfig + RemoteConfig, + User >, private persistantTestStorage?: PersistentStorage, private persistantFlagStorage?: PersistentStorage, @@ -725,4 +734,15 @@ export class Abby< ); }); } + + updateUser( + user: Partial<{ + -readonly [K in keyof User]: Infer<User[K]>; + }> + ) { + this.user = { + ...this.user, + ...user, + }; + } } diff --git a/packages/core/src/shared/schemas.ts b/packages/core/src/shared/schemas.ts index 26564932..0a60ec39 100644 --- a/packages/core/src/shared/schemas.ts +++ b/packages/core/src/shared/schemas.ts @@ -23,6 +23,17 @@ export const remoteConfigValueStringSchema = z.union([ z.literal("JSON"), ]); +const validatorType = z.object({ + type: z.union([ + z.literal("string"), + z.literal("number"), + z.literal("boolean"), + ]), + optional: z.boolean().optional(), +}); + +export type ValidatorType = z.infer<typeof validatorType>; + export const abbyConfigSchema = z.object({ projectId: z.string(), apiUrl: z.string().optional(), @@ -67,6 +78,7 @@ export const abbyConfigSchema = z.object({ }) .optional(), __experimentalCdnUrl: z.string().optional(), + user: z.record(z.string(), validatorType).optional(), }) satisfies z.ZodType<AbbyConfig>; export type AbbyConfigFile = z.infer<typeof abbyConfigSchema>; diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts new file mode 100644 index 00000000..bc5f3f5b --- /dev/null +++ b/packages/core/src/validation/index.ts @@ -0,0 +1,89 @@ +import type { ValidatorType } from "../shared/schemas"; +export type { ValidatorType }; + +type StringValidatorType = { type: "string" }; +type NumberValidatorType = { type: "number" }; +type BooleanValidatorType = { type: "boolean" }; + +export const string = () => ({ type: "string" }) as StringValidatorType; +export const number = () => ({ type: "number" }) as NumberValidatorType; +export const boolean = () => ({ type: "boolean" }) as BooleanValidatorType; + +export const optional = < + T extends StringValidatorType | NumberValidatorType | BooleanValidatorType, +>( + type: T +) => ({ ...type, optional: true }) as const; + +export type Infer<T> = T extends StringValidatorType + ? T extends { optional: true } + ? string | null | undefined + : string + : T extends NumberValidatorType + ? T extends { optional: true } + ? number | null | undefined + : number + : T extends BooleanValidatorType + ? T extends { optional: true } + ? boolean | null | undefined + : boolean + : never; + +type Errors = Array<{ + property: string; + message: string; +}>; + +export function validate<T extends Record<string, ValidatorType>>( + userValidator: T, + user: Record<string, unknown> +): + | { + errors: Errors; + value?: never; + } + | { + errors?: never; + value: { + [key in keyof T]: Infer<T[keyof T]>; + }; + } { + const returnObject: { + [key in keyof T]: Infer<T[keyof T]>; + } = {} as any; + const errors: Errors = []; + for (const key in userValidator) { + const validator = userValidator[key]; + const value = user[key]; + + if (value === undefined && "optional" in validator) { + continue; + } + + if (validator.type === "string" && typeof value !== "string") { + errors.push({ + property: key, + message: `Expected string but got ${typeof value}`, + }); + } + + if (validator.type === "number" && typeof value !== "number") { + errors.push({ + property: key, + message: `Expected number but got ${typeof value}`, + }); + } + + if (validator.type === "boolean" && typeof value !== "boolean") { + errors.push({ + property: key, + message: `Expected boolean but got ${typeof value}`, + }); + } + returnObject[key] = value as Infer<typeof validator>; + } + if (errors.length > 0) { + return { errors }; + } + return { value: returnObject }; +} diff --git a/packages/core/tests/defineConfig.test.ts b/packages/core/tests/defineConfig.test.ts index 2164d9ca..725542ab 100644 --- a/packages/core/tests/defineConfig.test.ts +++ b/packages/core/tests/defineConfig.test.ts @@ -1,5 +1,6 @@ import { Abby } from "../src"; import { defineConfig } from "../src/defineConfig"; +import * as validation from "../src/validation"; describe("defineConfig", () => { const cfg = defineConfig( @@ -17,6 +18,11 @@ describe("defineConfig", () => { variants: ["true", "false"], }, }, + user: { + name: validation.string(), + age: validation.number(), + isDeveloper: validation.boolean(), + }, } ); @@ -32,5 +38,10 @@ describe("defineConfig", () => { >(); expectTypeOf(abby.getVariants).parameter(0).toEqualTypeOf<"abTest">(); + expectTypeOf(abby.updateUser).parameter(0).toEqualTypeOf<{ + name?: string; + age?: number; + isDeveloper?: boolean; + }>(); }); }); diff --git a/packages/core/tests/types.test.ts b/packages/core/tests/types.test.ts index 8caecec9..f0378a9f 100644 --- a/packages/core/tests/types.test.ts +++ b/packages/core/tests/types.test.ts @@ -1,5 +1,6 @@ import type { RemoteConfigValueStringToType } from "../dist"; import { Abby, type AbbyConfig } from "../src/index"; +import * as validation from "../src/validation"; describe("types", () => { it("produces proper types", () => { @@ -45,6 +46,25 @@ describe("types", () => { }); }); +describe("user types", () => { + const abby = new Abby({ + environments: [""], + currentEnvironment: "", + projectId: "", + user: { + name: validation.string(), + age: validation.number(), + isDeveloper: validation.boolean(), + }, + }); + + expectTypeOf(abby.updateUser).parameter(0).toEqualTypeOf<{ + name?: string; + age?: number; + isDeveloper?: boolean; + }>(); +}); + describe("Type Helpers", () => { it("converts the strings properly", () => { expectTypeOf< diff --git a/packages/core/tests/validation.test.ts b/packages/core/tests/validation.test.ts new file mode 100644 index 00000000..135f2ab6 --- /dev/null +++ b/packages/core/tests/validation.test.ts @@ -0,0 +1,90 @@ +import * as validation from "../src/validation"; + +describe("validation", () => { + it("produces proper types", () => { + const booleanValidation = validation.boolean(); + expectTypeOf< + validation.Infer<typeof booleanValidation> + >().toEqualTypeOf<boolean>(); + + const stringValidation = validation.string(); + expectTypeOf< + validation.Infer<typeof stringValidation> + >().toEqualTypeOf<string>(); + + const numberValidation = validation.number(); + expectTypeOf< + validation.Infer<typeof numberValidation> + >().toEqualTypeOf<number>(); + + const optionalNumberValidation = validation.optional(validation.number()); + + expectTypeOf< + validation.Infer<typeof optionalNumberValidation> + >().toEqualTypeOf<number | undefined | null>(); + + const optionalStringValidation = validation.optional(validation.string()); + + expectTypeOf< + validation.Infer<typeof optionalStringValidation> + >().toEqualTypeOf<string | undefined | null>(); + + const optionalBooleanValidation = validation.optional(validation.boolean()); + + expectTypeOf< + validation.Infer<typeof optionalBooleanValidation> + >().toEqualTypeOf<boolean | undefined | null>(); + }); + + it("validates properly", () => { + const userValidator = { + name: validation.string(), + age: validation.number(), + isDeveloper: validation.boolean(), + }; + const user = { + name: "John", + age: 25, + isDeveloper: true, + }; + expect(validation.validate(userValidator, user)).toEqual({ + value: user, + }); + }); + + it("validates properly with optional fields", () => { + const userValidator = { + name: validation.string(), + age: validation.optional(validation.number()), + isDeveloper: validation.boolean(), + }; + const user = { + name: "John", + isDeveloper: true, + }; + expect(validation.validate(userValidator, user)).toEqual({ + value: user, + }); + }); + + it("shows errors properly", () => { + const userValidator = { + name: validation.string(), + age: validation.number(), + isDeveloper: validation.boolean(), + }; + const user = { + name: "John", + age: "25", + isDeveloper: true, + }; + expect(validation.validate(userValidator, user)).toEqual({ + errors: [ + { + property: "age", + message: "Expected number but got string", + }, + ], + }); + }); +});