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",
+        },
+      ],
+    });
+  });
+});