Skip to content

Commit

Permalink
📦 NEW: extends conditions
Browse files Browse the repository at this point in the history
  • Loading branch information
rphlmr committed Sep 30, 2024
1 parent f0d1875 commit d077bf8
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 90 deletions.
40 changes: 21 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,20 @@ type MyContext = { userId: string; rolesByOrg: Record<string, "user" | "admin" |
```
```typescript
// define all your policies
import { z } from 'zod';
import { matchSchema, notNull, definePolicy, definePolicies, check, assert, or } from 'comply';
import { assert, check, definePolicies, definePolicy, matchSchema, notNull, or } from "comply";
import { z } from "zod";

const OrgPolicies = definePolicies((context: MyContext) => (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
Expand Down Expand Up @@ -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.
Expand All @@ -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() })))
]);
```

Expand All @@ -127,7 +126,7 @@ Here's a quick example:
type Context = { userId: string; rolesByOrg: Record<string, "user" | "admin" | "superadmin">; 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️⃣
Expand All @@ -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
];
});

Expand Down Expand Up @@ -233,10 +232,7 @@ type Context = { userId: string; rolesByOrg: Record<string, "user" | "admin"> };

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

Expand Down Expand Up @@ -277,7 +273,7 @@ type PolicyConditionTypeGuard<T = any, U extends T = T> = (arg: T) => arg is U;
type PolicyConditionTypeGuardResult<P extends PolicyCondition> = P extends PolicyConditionTypeGuard<any, infer U>
? U
: PolicyConditionArg<P>;
type PolicyConditionNoArg = () => boolean;
type PolicyConditionNoArg = (() => boolean) | boolean;
type PolicyCondition<T = any, U extends T = T> =
| PolicyConditionTypeGuard<T, U>
| PolicyConditionWithArg<T>
Expand Down Expand Up @@ -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")
),
];
});
```
Expand Down Expand Up @@ -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")
),
];
});
```
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
172 changes: 116 additions & 56 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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) => [
Expand Down Expand Up @@ -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<string>();
function test(label: Label) {
if (check(guard.input.policy("not null"), label)) {
expect(label).not.toBeNull();
expectTypeOf(label).toEqualTypeOf<string>();
}

expect(() => {
assert(guard.input.policy("not null"), label);
expect(label).not.toBeNull();
expectTypeOf(label).toEqualTypeOf<string>();
}).not.toThrowError();
}

expect(() => {
assert(guard.input.policy("not null"), label);
expect(label).not.toBeNull();
expectTypeOf(label).toEqualTypeOf<string>();
}).not.toThrowError();
test(label);

expect.assertions(3);
});
Expand All @@ -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<string>();
function test(label: Label) {
if (check("not null", notNull, label)) {
expect(label).not.toBeNull();
expectTypeOf(label).toEqualTypeOf<string>();
}

expect(() => {
assert("not null", notNull, label);
expect(label).not.toBeNull();
expectTypeOf(label).toEqualTypeOf<string>();
}).not.toThrowError();
}

expect(() => {
assert("not null", notNull, label);
expect(label).not.toBeNull();
expectTypeOf(label).toEqualTypeOf<string>();
}).not.toThrowError();
test(label);

expect.assertions(3);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand Down
Loading

0 comments on commit d077bf8

Please sign in to comment.