From f0d187543a4c19efbef1ec658c8ac8b6c0d1c0d8 Mon Sep 17 00:00:00 2001 From: rphlmr Date: Mon, 30 Sep 2024 11:52:48 +0200 Subject: [PATCH 1/2] make logical operators accept boolean values or predicates. --- src/index.test.ts | 207 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 14 +++- 2 files changed, 217 insertions(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 003081a..6f91271 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -620,3 +620,210 @@ describe("Inference", () => { } }); }); + +describe("Logical operators", () => { + type Context = { userId: string }; + type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; + + it("should [or] accept predicates", () => { + const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all published posts or mine", (post: Post) => + or( + () => check(myPostPolicy, post), + () => post.status === "published" + ) + ), + ]; + }); + + const guard = { + post: PostPolicies({ userId: "1" }), + }; + + expect( + check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) + ).toBe(true); + expect( + check(guard.post.policy("all published posts or mine"), { + userId: "2", + comments: [], + status: "published", + }) + ).toBe(true); + expect( + check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) + ).toBe(false); + + expect(() => + assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) + ).not.toThrowError(); + expect(() => + assert(guard.post.policy("all published posts or mine"), { + userId: "2", + comments: [], + status: "published", + }) + ).not.toThrowError(); + expect(() => + assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) + ).toThrowError(); + }); + + it("should [or] accept booleans", () => { + const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all published posts or mine", (post: Post) => + or(check(myPostPolicy, post), post.status === "published") + ), + ]; + }); + + const guard = { + post: PostPolicies({ userId: "1" }), + }; + + expect( + check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) + ).toBe(true); + expect( + check(guard.post.policy("all published posts or mine"), { + userId: "2", + comments: [], + status: "published", + }) + ).toBe(true); + expect( + check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) + ).toBe(false); + + expect(() => + assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) + ).not.toThrowError(); + expect(() => + assert(guard.post.policy("all published posts or mine"), { + userId: "2", + comments: [], + status: "published", + }) + ).not.toThrowError(); + expect(() => + assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) + ).toThrowError(); + }); + + it("should [and] accept predicates", () => { + const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all my published posts", (post: Post) => + and( + () => check(myPostPolicy, post), + () => post.status === "published" + ) + ), + ]; + }); + + const guard = { + post: PostPolicies({ userId: "1" }), + }; + + expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })).toBe( + true + ); + expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })).toBe( + false + ); + expect( + check(guard.post.policy("all my published posts"), { + userId: "2", + comments: [], + status: "published", + }) + ).toBe(false); + + expect(() => + assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" }) + ).not.toThrowError(); + expect(() => + assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" }) + ).toThrowError(); + expect(() => + assert(guard.post.policy("all my published posts"), { + userId: "2", + comments: [], + status: "published", + }) + ).toThrowError(); + }); + + it("should [and] accept booleans", () => { + const PostPolicies = definePolicies((context: Context) => { + const myPostPolicy = definePolicy( + "my post", + (post: Post) => post.userId === context.userId, + () => new Error("Not the author") + ); + + return [ + myPostPolicy, + definePolicy("all my published posts", (post: Post) => + and(check(myPostPolicy, post), post.status === "published") + ), + ]; + }); + + const guard = { + post: PostPolicies({ userId: "1" }), + }; + + expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })).toBe( + true + ); + expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })).toBe( + false + ); + expect( + check(guard.post.policy("all my published posts"), { + userId: "2", + comments: [], + status: "published", + }) + ).toBe(false); + + expect(() => + assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" }) + ).not.toThrowError(); + expect(() => + assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" }) + ).toThrowError(); + expect(() => + assert(guard.post.policy("all my published posts"), { + userId: "2", + comments: [], + status: "published", + }) + ).toThrowError(); + }); +}); diff --git a/src/index.ts b/src/index.ts index b4ae62e..24c3dd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -279,9 +279,12 @@ export function definePolicies(defineOrPol *``` */ export function or( - ...conditions: (() => Policy> | boolean)[] + ...conditions: ( + | (() => Policy> | boolean) + | boolean + )[] ) { - return conditions.some((predicate) => predicate()); + return conditions.some((predicate) => (typeof predicate === "function" ? predicate() : predicate)); } /** @@ -322,9 +325,12 @@ export function or( *``` */ export function and( - ...conditions: (() => Policy> | boolean)[] + ...conditions: ( + | (() => Policy> | boolean) + | boolean + )[] ) { - return conditions.every((predicate) => predicate()); + return conditions.every((predicate) => (typeof predicate === "function" ? predicate() : predicate)); } /* -------------------------------------------------------------------------- */ From d077bf88862c480e3a7146328ea385adfcff3104 Mon Sep 17 00:00:00 2001 From: rphlmr Date: Mon, 30 Sep 2024 12:36:42 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=A6=20NEW:=20extends=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 40 ++++++----- package-lock.json | 4 +- package.json | 2 +- src/index.test.ts | 172 +++++++++++++++++++++++++++++++--------------- src/index.ts | 42 +++++++---- 5 files changed, 170 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 0886ecc..f8abe91 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,20 @@ type MyContext = { userId: string; rolesByOrg: Record (orgId: string) => { const currentUserOrgRole = context.rolesByOrg[orgId]; return [ - definePolicy("can administrate", () => - or( - () => currentUserOrgRole === "admin", - () => currentUserOrgRole === "superadmin" - ) - ), - definePolicy("is superadmin", () => currentUserOrgRole === "superadmin"), + definePolicy("can administrate", or(currentUserOrgRole === "admin", currentUserOrgRole === "superadmin")), + definePolicy("is superadmin", currentUserOrgRole === "superadmin"), ]; }); const UserPolicies = definePolicies((context: MyContext) => (userId: string) => [ - definePolicy("can edit profile", () => context.userId === userId), + definePolicy("can edit profile", context.userId === userId), ]); // create and export a 'guard' that contains all your policies, scoped by domain @@ -90,9 +85,11 @@ If the condition requires a parameter, `assert` and `check` will require it. Finally, if the condition is a type guard, the parameter you pass will be inferred automatically. +Note: for convenience, the condition can be a boolean value but you will lose type inference. + ## Defining Policies To define policies, you create a policy set using the `definePolicies` function. -Each policy definition is created using the `definePolicy` function, which takes a policy name and a callback that defines the policy logic. +Each policy definition is created using the `definePolicy` function, which takes a policy name and a callback that defines the policy logic (or a boolean value). The callback logic can receive a unique parameter (scalar or object) and return a boolean value or a a type predicate. You can also provide an error factory to the policy (3rd argument) to customize the error message. @@ -108,9 +105,11 @@ _Primary use case_: simple policies that can be defined inline and are 'self-con ```typescript const policies = definePolicies([ + // built-in type guards definePolicy("is not null", notNull), + definePolicy('params are valid', matchSchema(z.object({ name: z.string() }))), + // type guard definePolicy("is a string", (v: unknown): v is string => typeof v === "string"), - definePolicy('params are valid', matchSchema(z.object({ name: z.string() }))) ]); ``` @@ -127,7 +126,7 @@ Here's a quick example: type Context = { userId: string; rolesByOrg: Record; appRole: "admin" | "user" }; const AdminPolicies = definePolicies((context: Context) => [ - definePolicy("has app admin role", () => context.appRole === "admin"), + definePolicy("has app admin role", context.appRole === "admin") ]); // 2️⃣ @@ -142,7 +141,7 @@ const OrgPolicies = definePolicies((context: MyContext) => (orgId: string) => { () => currentUserOrgRole === "superadmin", () => check(adminGuard.policy("has app admin role")) ), - definePolicy("is superadmin", () => currentUserOrgRole === "superadmin"), + definePolicy("is superadmin", currentUserOrgRole === "superadmin"), // lazy evaluation ]; }); @@ -233,10 +232,7 @@ type Context = { userId: string; rolesByOrg: Record }; const OrgPolicies = definePolicies((context: Context) => (orgId: string) => [ definePolicy("can administrate org", (stillOrgAdmin: boolean) => - and( - () => context.rolesByOrg[orgId] === "admin", - () => stillOrgAdmin - ) + and(context.rolesByOrg[orgId] === "admin", stillOrgAdmin) ), ]); @@ -277,7 +273,7 @@ type PolicyConditionTypeGuard = (arg: T) => arg is U; type PolicyConditionTypeGuardResult

= P extends PolicyConditionTypeGuard ? U : PolicyConditionArg

; -type PolicyConditionNoArg = () => boolean; +type PolicyConditionNoArg = (() => boolean) | boolean; type PolicyCondition = | PolicyConditionTypeGuard | PolicyConditionWithArg @@ -418,6 +414,9 @@ const PostPolicies = definePolicies((context: Context) => { () => post.status === "published" ) ), + definePolicy("[lazy] all published posts or mine", (post: Post) => + or(check(myPostPolicy, post), post.status === "published") + ), ]; }); ``` @@ -449,6 +448,9 @@ const PostPolicies = definePolicies((context: Context) => { () => post.status === "published" ) ), + definePolicy("[lazy] my published post", (post: Post) => + and(check(myPostPolicy, post), post.status === "published") + ), ]; }); ``` diff --git a/package-lock.json b/package-lock.json index d9efbbc..393e4b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "comply", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "comply", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "workspaces": [ ".", diff --git a/package.json b/package.json index 36d82e6..a1ef2d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "comply", - "version": "0.1.1", + "version": "0.2.0", "description": "Comply is a tiny library to help you define policies in your app", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/index.test.ts b/src/index.test.ts index 6f91271..df9c115 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -88,6 +88,14 @@ describe("Define policy", () => { expect(() => assert(truePolicy)).not.toThrowError(); }); + it("can define a policy with a boolean condition", () => { + const truePolicy = definePolicy("is true", true); + + expect(truePolicy.check()).toBe(true); + + expect(() => assert(truePolicy)).not.toThrowError(); + }); + it("should allow defining a policy on the fly", () => { const params = { id: "123" }; @@ -287,7 +295,31 @@ describe("Define policies", () => { ).toThrowError(); }); - it("should define a policy set that composes other policy sets", () => { + it("should define a policy set that comprises other policy sets only", () => { + type Context = { role: "admin" | "user" | "bot" }; + + const AdminPolicies = definePolicies((context: Context) => [ + definePolicy("has admin role", () => context.role === "admin"), + ]); + + const PostPolicies = definePolicies((context: Context) => { + const adminGuard = AdminPolicies(context); + + return [ + definePolicy("can moderate comments", or(context.role === "bot", check(adminGuard.policy("has admin role")))), + ]; + }); + + expect(check(PostPolicies({ role: "admin" }).policy("can moderate comments"))).toBe(true); + expect(check(PostPolicies({ role: "bot" }).policy("can moderate comments"))).toBe(true); + expect(check(PostPolicies({ role: "user" }).policy("can moderate comments"))).toBe(false); + + expect(() => assert(PostPolicies({ role: "admin" }).policy("can moderate comments"))).not.toThrowError(); + expect(() => assert(PostPolicies({ role: "bot" }).policy("can moderate comments"))).not.toThrowError(); + expect(() => assert(PostPolicies({ role: "user" }).policy("can moderate comments"))).toThrowError(); + }); + + it("should define a policy set that comprises other policy sets", () => { type Context = { userId: string; role: "admin" | "user" }; const AdminPolicies = definePolicies((context: Context) => [ @@ -398,16 +430,20 @@ describe("Inference", () => { const label: Label = "label"; - if (check(guard.input.policy("not null"), label)) { - expect(label).not.toBeNull(); - expectTypeOf(label).toEqualTypeOf(); + function test(label: Label) { + if (check(guard.input.policy("not null"), label)) { + expect(label).not.toBeNull(); + expectTypeOf(label).toEqualTypeOf(); + } + + expect(() => { + assert(guard.input.policy("not null"), label); + expect(label).not.toBeNull(); + expectTypeOf(label).toEqualTypeOf(); + }).not.toThrowError(); } - expect(() => { - assert(guard.input.policy("not null"), label); - expect(label).not.toBeNull(); - expectTypeOf(label).toEqualTypeOf(); - }).not.toThrowError(); + test(label); expect.assertions(3); }); @@ -416,16 +452,20 @@ describe("Inference", () => { type Label = string | null; const label: Label = "label"; - if (check("not null", notNull, label)) { - expect(label).not.toBeNull(); - expectTypeOf(label).toEqualTypeOf(); + function test(label: Label) { + if (check("not null", notNull, label)) { + expect(label).not.toBeNull(); + expectTypeOf(label).toEqualTypeOf(); + } + + expect(() => { + assert("not null", notNull, label); + expect(label).not.toBeNull(); + expectTypeOf(label).toEqualTypeOf(); + }).not.toThrowError(); } - expect(() => { - assert("not null", notNull, label); - expect(label).not.toBeNull(); - expectTypeOf(label).toEqualTypeOf(); - }).not.toThrowError(); + test(label); expect.assertions(3); }); @@ -444,16 +484,20 @@ describe("Inference", () => { const post: Post = { userId: "1", comments: [], status: "published" }; - if (check(guard.post.policy("published post"), post)) { - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); + function test(post: Post) { + if (check(guard.post.policy("published post"), post)) { + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + } + + expect(() => { + assert(guard.post.policy("published post"), post); + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + }).not.toThrowError(); } - expect(() => { - assert(guard.post.policy("published post"), post); - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); - }).not.toThrowError(); + test(post); expect.assertions(3); }); @@ -463,23 +507,31 @@ describe("Inference", () => { const post: Post = { userId: "1", comments: [], status: "published" }; - // type predicate - if ( - check("published post", (post: Post): post is Post & { status: "published" } => post.status === "published", post) - ) { - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); + function test(post: Post) { + // type predicate + if ( + check( + "published post", + (post: Post): post is Post & { status: "published" } => post.status === "published", + post + ) + ) { + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + } + + expect(() => { + assert( + "published post", + (post: Post): post is Post & { status: "published" } => post.status === "published", + post + ); + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + }).not.toThrowError(); } - expect(() => { - assert( - "published post", - (post: Post): post is Post & { status: "published" } => post.status === "published", - post - ); - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); - }).not.toThrowError(); + test(post); expect.assertions(3); }); @@ -500,16 +552,20 @@ describe("Inference", () => { const post: Post = { userId: "1", comments: [], status: "published" }; - if (check(guard.post.policy("published post"), post)) { - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); + function test(post: Post) { + if (check(guard.post.policy("published post"), post)) { + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + } + + expect(() => { + assert(guard.post.policy("published post"), post); + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + }).not.toThrowError(); } - expect(() => { - assert(guard.post.policy("published post"), post); - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); - }).not.toThrowError(); + test(post); expect.assertions(3); }); @@ -524,16 +580,20 @@ describe("Inference", () => { const post: Post = { userId: "1", comments: [], status: "published" }; - if (check("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post)) { - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); + function test(post: Post) { + if (check("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post)) { + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + } + + expect(() => { + assert("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post); + expect(post.status).toBe("published"); + expectTypeOf(post.status).toEqualTypeOf<"published">(); + }).not.toThrowError(); } - expect(() => { - assert("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post); - expect(post.status).toBe("published"); - expectTypeOf(post.status).toEqualTypeOf<"published">(); - }).not.toThrowError(); + test(post); expect.assertions(3); }); diff --git a/src/index.ts b/src/index.ts index 24c3dd0..4340768 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ type PolicyConditionTypeGuardResult

= P extends Polic ? U : PolicyConditionArg

; -type PolicyConditionNoArg = () => boolean; +type PolicyConditionNoArg = (() => boolean) | boolean; type PolicyCondition = | PolicyConditionTypeGuard @@ -55,7 +55,7 @@ class Policy< check( arg: TPolicyCondition extends PolicyConditionNoArg ? void : TPolicyConditionArg ): arg is TPolicyCondition extends PolicyConditionNoArg ? void : TResult { - return this.condition(arg); + return typeof this.condition === "boolean" ? this.condition : this.condition(arg); } } @@ -340,34 +340,43 @@ export function and( /* --------------------------------- Assert; -------------------------------- */ /** - * Assert an implicit policy with a no-arg condition function + * Assert an implicit policy with a no-arg condition function (lazy evaluation) or a boolean value * * @param name - The name of the policy - * @param condition - The condition to assert (no-arg) + * @param condition - The condition to assert (no-arg) or a boolean value * * @example * ```ts * const post = await getPost(id); + * + * // lazy evaluation * assert("post has comments", () => post.comments.length > 0); + * + * // boolean value + * assert("post has comments", post.comments.length > 0); * ``` */ export function assert(name: string, condition: PolicyConditionNoArg): void; /** - * Assert an implicit policy with a condition function that takes an argument + * Assert an implicit policy with a condition function that takes an argument (lazy evaluation) or a boolean value * * The condition function can be a type guard or a predicate * * @param name - The name of the policy - * @param condition - The condition to assert (with arg) + * @param condition - The condition to assert (with arg) or a boolean value * @param arg - The argument to pass to the condition * * @example * ```ts + * // lazy evaluation * assert("post has comments", (post: Post) => post.comments.length > 0, await getPost(id)); * * // type guard * assert("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft", await getPost(id)); + * + * // boolean value + * assert("post has comments",(await getPost(id)).comments.length > 0); * ``` */ export function assert | PolicyConditionWithArg>( @@ -379,13 +388,15 @@ export function assert | : PolicyConditionTypeGuardResult; /** - * Assert a policy with a no-arg condition function + * Assert a policy with a no-arg condition function (lazy evaluation) or a boolean value * - * @param policy - The policy to assert + * @param policy - The policy to assert or a boolean value * * @example * ```ts * const AdminPolicies = definePolicies((context: Context) => [ + * definePolicy("is admin", context.role === "admin"), + * // lazy evaluation * definePolicy("is admin", () => context.role === "admin"), * ]); * @@ -446,7 +457,7 @@ export function assert( arg = args[0]; } - if (policy.condition(arg)) { + if (typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg)) { return; } @@ -456,17 +467,24 @@ export function assert( /* --------------------------------- Check; --------------------------------- */ /** - * Check an implicit policy with a no-arg condition function + * Check an implicit policy with a no-arg condition function or a boolean value * * @param name - The name of the policy - * @param condition - The condition to check (no-arg) + * @param condition - The condition to check (no-arg) or a boolean value * * @example * ```ts * const post = await getPost(id); + * + * // lazy evaluation * if (check("post has comments", () => post.comments.length > 0)) { * // post has comments * } + * + * // boolean value + * if (check("post has comments", post.comments.length > 0)) { + * // post has comments + * } * ``` */ export function check(name: string, condition: PolicyConditionNoArg): boolean; @@ -568,7 +586,7 @@ export function check( arg = args[0]; } - return policy.condition(arg); + return typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg); } /* -------------------------------------------------------------------------- */