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, const RemoteConfig extends Record, const RemoteConfigName extends Extract, + const User extends Record = Record< + string, + ValidatorType + >, >( dynamicConfig: Pick< - AbbyConfig, + AbbyConfig, DynamicConfigKeys >, config: Omit< - AbbyConfig, + AbbyConfig, 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, + User extends Record = Record, > = { 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, const RemoteConfigName extends Extract, const Environments extends Array = Array, + const User extends Record = 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(); 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; + }> + ) { + 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; + 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; export type AbbyConfigFile = z.infer; 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 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>( + userValidator: T, + user: Record +): + | { + errors: Errors; + value?: never; + } + | { + errors?: never; + value: { + [key in keyof T]: Infer; + }; + } { + const returnObject: { + [key in keyof T]: Infer; + } = {} 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; + } + 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 + >().toEqualTypeOf(); + + const stringValidation = validation.string(); + expectTypeOf< + validation.Infer + >().toEqualTypeOf(); + + const numberValidation = validation.number(); + expectTypeOf< + validation.Infer + >().toEqualTypeOf(); + + const optionalNumberValidation = validation.optional(validation.number()); + + expectTypeOf< + validation.Infer + >().toEqualTypeOf(); + + const optionalStringValidation = validation.optional(validation.string()); + + expectTypeOf< + validation.Infer + >().toEqualTypeOf(); + + const optionalBooleanValidation = validation.optional(validation.boolean()); + + expectTypeOf< + validation.Infer + >().toEqualTypeOf(); + }); + + 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", + }, + ], + }); + }); +});