Skip to content

Commit 0ffa301

Browse files
committed
feat(keto-relations-parser): build relation tuple builder using a fluent API
1 parent 00a2c3f commit 0ffa301

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed

packages/keto-relations-parser/src/lib/relation-tuple-parser.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ export function parseRelationTuple(
127127
}
128128
}
129129

130+
/**
131+
* @description Converts a {@link RelationTuple} to a string
132+
* @example
133+
* ```typescript
134+
* const relationTuple = new RelationTuple('namespace', 'object', 'relation', 'subjectId');
135+
* relationTuple.toString() // => 'namespace:object#relation@subjectId'
136+
* ```
137+
*
138+
* @param tuple
139+
* @returns
140+
*/
130141
export const relationTupleToString = (
131142
tuple: Partial<RelationTuple>
132143
): RelationTupleString => {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { RelationTupleBuilder } from './relation-tuple';
2+
3+
describe('RelationTupleBuilder', () => {
4+
let builder: RelationTupleBuilder;
5+
6+
beforeEach(() => {
7+
builder = new RelationTupleBuilder();
8+
});
9+
10+
it('should create an instance', () => {
11+
expect(builder).toBeDefined();
12+
});
13+
14+
it('should throw error when setting empty relation', () => {
15+
expect(() => {
16+
builder.relation = '';
17+
}).toThrow('relation cannot be empty');
18+
});
19+
20+
it('should throw error when setting relation with forbidden characters', () => {
21+
expect(() => {
22+
builder.relation = 'relation@';
23+
}).toThrow('relation cannot contain any of the following characters: :@#');
24+
});
25+
26+
it('should set and get relation', () => {
27+
builder.relation = 'relation';
28+
expect(builder.relation).toBe('relation');
29+
});
30+
31+
it('should throw error when setting empty namespace', () => {
32+
expect(() => {
33+
builder.namespace = '';
34+
}).toThrow('namespace cannot be empty');
35+
});
36+
37+
it('should throw error when setting namespace with forbidden characters', () => {
38+
expect(() => {
39+
builder.namespace = 'namespace@';
40+
}).toThrow('namespace cannot contain any of the following characters: :@#');
41+
});
42+
43+
it('should set and get namespace', () => {
44+
builder.namespace = 'namespace';
45+
expect(builder.namespace).toBe('namespace');
46+
});
47+
48+
it('should throw error when setting empty object', () => {
49+
expect(() => {
50+
builder.object = '';
51+
}).toThrow('object cannot be empty');
52+
});
53+
54+
it('should throw error when setting object with forbidden characters', () => {
55+
expect(() => {
56+
builder.object = 'object@';
57+
}).toThrow('object cannot contain any of the following characters: :@#');
58+
});
59+
60+
it('should set and get object', () => {
61+
builder.object = 'object';
62+
expect(builder.object).toBe('object');
63+
});
64+
65+
it('should throw error when setting empty subjectIdOrSet', () => {
66+
expect(() => {
67+
builder.subjectIdOrSet = '';
68+
}).toThrow('subjectIdOrSet cannot be empty');
69+
});
70+
71+
it('should throw error when setting subjectIdOrSet with forbidden characters', () => {
72+
expect(() => {
73+
builder.subjectIdOrSet = 'subjectIdOrSet@';
74+
}).toThrow(
75+
'subjectIdOrSet cannot contain any of the following characters: :@#'
76+
);
77+
});
78+
79+
it('should set and get subjectIdOrSet', () => {
80+
builder.subjectIdOrSet = 'subjectIdOrSet';
81+
expect(builder.subjectIdOrSet).toBe('subjectIdOrSet');
82+
});
83+
84+
it('should throw error when setting empty relation tuple', () => {
85+
expect(() => {
86+
builder.isIn('').of('', '');
87+
}).toThrow('relation cannot be empty');
88+
});
89+
90+
it('should build a relation tuple string with only subjectId', () => {
91+
builder.subject('subjectId').isIn('relation').of('namespace', 'object');
92+
expect(builder.toString()).toBe('namespace:object#relation@subjectId');
93+
});
94+
95+
it('should build a relation tuple string with subjectSet', () => {
96+
builder
97+
.subject('subjectNamespace', 'subjectObject', 'subjectRelation')
98+
.isIn('relation')
99+
.of('namespace', 'object');
100+
expect(builder.toString()).toBe(
101+
'namespace:object#relation@subjectNamespace:subjectObject#subjectRelation'
102+
);
103+
});
104+
105+
it('should build a human readable relation tuple string with subjectId', () => {
106+
builder.subject('subjectId').isIn('relation').of('namespace', 'object');
107+
expect(builder.toHumanReadableString()).toBe(
108+
'subjectId is in relation of namespace:object'
109+
);
110+
});
111+
112+
it('should build a human readable relation tuple string with subjectSet', () => {
113+
builder
114+
.subject('subjectNamespace', 'subjectObject', 'subjectRelation')
115+
.isIn('relation')
116+
.of('namespace', 'object');
117+
expect(builder.toHumanReadableString()).toBe(
118+
'subjectRelation of subjectNamespace:subjectObject is in relation of namespace:object'
119+
);
120+
});
121+
});

packages/keto-relations-parser/src/lib/relation-tuple.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ export class SubjectSet {
99
this.object = object;
1010
this.relation = relation;
1111
}
12+
13+
toString(): string {
14+
return `${this.namespace}:${this.object}${
15+
this.relation ? `#${this.relation}` : ''
16+
}`;
17+
}
1218
}
1319

1420
export class RelationTuple {
1521
namespace: string;
1622
object: string;
1723
relation: string;
1824
subjectIdOrSet: string | SubjectSet;
25+
1926
constructor(
2027
namespace: string,
2128
object: string,
@@ -32,3 +39,123 @@ export class RelationTuple {
3239
return relationTupleToString(this);
3340
}
3441
}
42+
43+
export class RelationTupleBuilder {
44+
private readonly forbiddenCharacters = /[:@#]/g;
45+
private tuple: RelationTuple;
46+
47+
constructor() {
48+
this.tuple = new RelationTuple('', '', '', '');
49+
}
50+
51+
private validateInput(key: keyof RelationTuple, value: string) {
52+
if (!value) {
53+
throw new Error(`${key} cannot be empty`);
54+
}
55+
if (this.forbiddenCharacters.test(value)) {
56+
throw new Error(
57+
`${key} cannot contain any of the following characters: :@#`
58+
);
59+
}
60+
}
61+
62+
get relation(): string {
63+
return this.tuple.relation;
64+
}
65+
66+
set relation(relation: string) {
67+
this.validateInput('relation', relation);
68+
this.tuple.relation = relation;
69+
}
70+
71+
get namespace(): string {
72+
return this.tuple.namespace;
73+
}
74+
75+
set namespace(namespace: string) {
76+
this.validateInput('namespace', namespace);
77+
this.tuple.namespace = namespace;
78+
}
79+
80+
get object(): string {
81+
return this.tuple.object;
82+
}
83+
84+
set object(object: string) {
85+
this.validateInput('object', object);
86+
this.tuple.object = object;
87+
}
88+
89+
get subjectIdOrSet(): string | SubjectSet {
90+
return this.tuple.subjectIdOrSet;
91+
}
92+
93+
set subjectIdOrSet(subjectIdOrSet: string | SubjectSet) {
94+
if (typeof subjectIdOrSet === 'string') {
95+
this.validateInput('subjectIdOrSet', subjectIdOrSet);
96+
} else {
97+
this.validateInput('namespace', subjectIdOrSet.namespace);
98+
this.validateInput('object', subjectIdOrSet.object);
99+
if (subjectIdOrSet.relation) {
100+
this.validateInput('relation', subjectIdOrSet.relation);
101+
}
102+
}
103+
this.tuple.subjectIdOrSet = subjectIdOrSet;
104+
}
105+
106+
isIn(relation: string): this {
107+
this.relation = relation;
108+
return this;
109+
}
110+
111+
of(namespace: string, object: string): this {
112+
this.namespace = namespace;
113+
this.object = object;
114+
return this;
115+
}
116+
117+
subject(subjectId: string): this;
118+
subject(namespace: string, object: string): this;
119+
subject(namespace: string, object: string, relation: string): this;
120+
subject(
121+
namespaceOrSubjectId: string,
122+
object?: string,
123+
relation?: string
124+
): this {
125+
if (object) {
126+
this.subjectIdOrSet = new SubjectSet(
127+
namespaceOrSubjectId,
128+
object,
129+
relation
130+
);
131+
} else {
132+
this.subjectIdOrSet = namespaceOrSubjectId;
133+
}
134+
return this;
135+
}
136+
137+
toString(): string {
138+
return this.tuple.toString();
139+
}
140+
141+
/**
142+
* @description Returns a human readable string
143+
* @example
144+
* ```typescript
145+
* const relationTuple = new RelationTupleBuilder()
146+
* .subject('subject_namespace', 'subject_object', 'subject_relation')
147+
* .of('namespace', 'object')
148+
* .isIn('relations');
149+
* relationTuple.toHumanReadableString(); // => subject_relation of subject_namespace:subject_object is in relations of namespace:object
150+
* ```
151+
* @returns {string} human readable string
152+
*/
153+
toHumanReadableString(): string {
154+
if (typeof this.subjectIdOrSet === 'string') {
155+
return `${this.subjectIdOrSet} is in ${this.relation} of ${this.namespace}:${this.object}`;
156+
}
157+
const { namespace, object, relation } = this.subjectIdOrSet;
158+
const base = `${namespace}:${object} is in ${this.relation} of ${this.namespace}:${this.object}`;
159+
return relation ? `${relation} of ${base}` : base;
160+
}
161+
}

0 commit comments

Comments
 (0)