Skip to content

Commit 4c4ee01

Browse files
kaihaaseNicoKaempf
andauthored
Release 11.1.12 (#428)
* feat(DEV-444): Integrate Scim Helper * feat(DEV-444): Fix linting * feat(DEV-444): Fix value type * feat(DEV-444): Enhance SCIM helper with error handling and logical operator support, add scim tests * feat(DEV-444): Add value parsing for numbers and booleans in SCIM helper, update tests * Packages updated --------- Co-authored-by: nicokampf <nico.kaempf@lenne.tech>
1 parent b6dc6b6 commit 4c4ee01

12 files changed

+649
-408
lines changed

package-lock.json

Lines changed: 64 additions & 404 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lenne.tech/nest-server",
3-
"version": "11.1.11",
3+
"version": "11.1.12",
44
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
55
"keywords": [
66
"node",
@@ -85,7 +85,7 @@
8585
"bcrypt": "5.1.1",
8686
"class-transformer": "0.5.1",
8787
"class-validator": "0.14.2",
88-
"compression": "1.8.0",
88+
"compression": "1.8.1",
8989
"cookie-parser": "1.4.7",
9090
"dotenv": "16.5.0",
9191
"ejs": "3.1.10",
@@ -135,7 +135,7 @@
135135
"@typescript-eslint/eslint-plugin": "8.32.0",
136136
"@typescript-eslint/parser": "8.32.0",
137137
"coffeescript": "2.7.0",
138-
"eslint": "9.26.0",
138+
"eslint": "9.32.0",
139139
"eslint-config-prettier": "10.1.5",
140140
"eslint-plugin-unused-imports": "4.1.4",
141141
"find-file-up": "2.0.1",

spectaql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ servers:
1111
info:
1212
title: lT Nest Server
1313
description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).
14-
version: 11.1.11
14+
version: 11.1.12
1515
contact:
1616
name: lenne.Tech GmbH
1717
url: https://lenne.tech
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import type { Comparator } from '../types/scim-comparator.type';
2+
import type { LogicalOperator } from '../types/scim-logical-operator.type';
3+
4+
import { ScimNode } from '../types/scim-node.type';
5+
6+
7+
export function scimToMongo(scim: string): any {
8+
if (!scim) {
9+
return {};
10+
}
11+
const tokens = tokenize(scim);
12+
const ast = parseTokens(tokens);
13+
return transformAstToMongo(ast);
14+
}
15+
16+
/** Escapes Regex Chars */
17+
function escapeRegex(input: string): string {
18+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19+
}
20+
21+
/** Flattens consecutive logical operators of the same type to avoid nested structures */
22+
function flattenLogicalOperator(node: ScimNode, targetOperator: LogicalOperator): ScimNode[] {
23+
if (!('operator' in node)) {
24+
return [node];
25+
}
26+
27+
if (node.operator !== targetOperator) {
28+
return [node];
29+
}
30+
31+
const leftConditions = flattenLogicalOperator(node.left, targetOperator);
32+
const rightConditions = flattenLogicalOperator(node.right, targetOperator);
33+
34+
return [...leftConditions, ...rightConditions];
35+
}
36+
37+
/** Parses tokenized SCIM filter into an Abstract Syntax Tree (AST) */
38+
function parseTokens(tokens: string[]): ScimNode {
39+
let pos = 0;
40+
41+
/** Parses a full logical expression (e.g., A and B or C) */
42+
function parseExpression(): ScimNode {
43+
let left = parseTerm();
44+
while (tokens[pos] && /^(and|or)$/i.test(tokens[pos])) {
45+
const op: LogicalOperator = tokens[pos++].toLowerCase() as LogicalOperator;
46+
const right = parseTerm();
47+
left = { left, operator: op, right };
48+
}
49+
return left;
50+
}
51+
52+
/** Parses a single term: either a nested expression, array filter, or condition */
53+
function parseTerm(): ScimNode {
54+
if (tokens[pos] === '(') { // Start of a nested filter
55+
pos++; // skip '('
56+
const expr = parseExpression();
57+
if (tokens[pos] !== ')') {
58+
throw new Error(`Expected ')' at position ${pos}`);
59+
}
60+
pos++; // skip ')'
61+
return expr;
62+
}
63+
if (tokens[pos + 1] === '[') { // Start of an array Filter
64+
const path = tokens[pos++];
65+
pos++; // skip '['
66+
const expr = parseExpression();
67+
if (tokens[pos] !== ']') {
68+
throw new Error(`Expected ']' at position ${pos}`);
69+
}
70+
pos++; // skip ']'
71+
return { expr, path, type: 'array' };
72+
}
73+
return parseCondition(); // If its neither a nested nor array filter its a simple "propertyKey eq Value"
74+
}
75+
76+
/** Parses a basic SCIM condition (e.g., userName eq "Joe") */
77+
function parseCondition(): ScimNode {
78+
const attr = tokens[pos++]; // First token is the attribute
79+
const op: Comparator = tokens[pos++].toLowerCase() as Comparator; // Second token is the operator
80+
if (!['aco', 'co', 'eq', 'ew', 'ge', 'gt', 'le', 'lt', 'pr', 'sw'].includes(op)) {
81+
throw new Error(`Unsupported comparator: ${op}`);
82+
}
83+
84+
let value: any = null;
85+
86+
if (op !== 'pr') { // "Is Present" doesnt require a value
87+
let rawValue = tokens[pos++]; // Third token is the value
88+
if (!attr || !op || rawValue === undefined) {
89+
throw new Error(`Invalid condition syntax at token ${pos}`);
90+
}
91+
92+
// Handle quoted strings
93+
if (rawValue?.startsWith('"')) {
94+
rawValue = rawValue.slice(1, -1);
95+
// For quoted strings, keep as string (don't parse to number/boolean)
96+
value = rawValue;
97+
} else {
98+
// For unquoted values, parse to appropriate type (number, boolean, or string)
99+
value = parseValue(rawValue);
100+
}
101+
}
102+
103+
return { attributePath: attr, comparator: op, type: 'condition', value };
104+
}
105+
106+
return parseExpression();
107+
}
108+
109+
110+
/** Converts string values to appropriate types (number, boolean, or string) */
111+
function parseValue(value: string): any {
112+
if (value === null || value === undefined) {
113+
return value;
114+
}
115+
116+
// Check if it's a number (integer or float)
117+
if (/^-?\d+(\.\d+)?$/.test(value)) {
118+
const numValue = Number(value);
119+
return isNaN(numValue) ? value : numValue;
120+
}
121+
122+
// Check if it's a boolean
123+
if (value.toLowerCase() === 'true') {
124+
return true;
125+
}
126+
if (value.toLowerCase() === 'false') {
127+
return false;
128+
}
129+
130+
// Return as string for everything else
131+
return value;
132+
}
133+
134+
/**
135+
* Tokenizes a SCIM filter string into meaningful parts.
136+
* e.g., 'userName eq "john"' → ['userName', 'eq', '"john"']
137+
*/
138+
function tokenize(input: string): string[] {
139+
// Space out brackets, but not inside quoted strings
140+
let result = '';
141+
let insideQuotes = false;
142+
143+
for (let i = 0; i < input.length; i++) {
144+
const char = input[i];
145+
146+
if (char === '"' && (i === 0 || input[i - 1] !== '\\')) {
147+
insideQuotes = !insideQuotes;
148+
result += char;
149+
} else if (!insideQuotes && /[()[\]]/.test(char)) {
150+
result += ` ${char} `;
151+
} else {
152+
result += char;
153+
}
154+
}
155+
156+
return result
157+
.replace(/\s+/g, ' ') // Normalise whitespaces
158+
.trim()
159+
.match(/\[|]|\(|\)|[a-zA-Z0-9_.]+|"(?:[^"\\]|\\.)*"/g) || []; // Match tokens: brackets, identifiers, quoted strings
160+
}
161+
162+
/** Converts the parsed SCIM AST to an equivalent MongoDB query object */
163+
function transformAstToMongo(node: ScimNode): any {
164+
if (!node) {
165+
return {};
166+
}
167+
168+
if ('operator' in node) {
169+
const operator = node.operator;
170+
const conditions = flattenLogicalOperator(node, operator);
171+
172+
return {
173+
[`$${operator}`]: conditions.map(transformAstToMongo),
174+
};
175+
}
176+
177+
if (node.type === 'array') {
178+
return {
179+
[node.path]: {
180+
$elemMatch: transformAstToMongo(node.expr),
181+
},
182+
};
183+
}
184+
185+
const { attributePath, comparator, value } = node;
186+
switch (comparator) {
187+
case 'aco': // ARRAY-CONTAINS (Not case sensitive)
188+
return { [attributePath]: value };
189+
case 'co': // CONTAINS
190+
return { [attributePath]: { $options: 'i', $regex: escapeRegex(value) } };
191+
case 'eq': // EQUALS
192+
return { [attributePath]: { $eq: value } };
193+
case 'ew': // ENDSWITH
194+
return { [attributePath]: { $options: 'i', $regex: `${escapeRegex(value)}$` } };
195+
case 'ge': // GREATER THAN OR EQUAL
196+
return { [attributePath]: { $gte: value } };
197+
case 'gt': // GREATER THAN
198+
return { [attributePath]: { $gt: value } };
199+
case 'le': // LESS THAN OR EQUAL
200+
return { [attributePath]: { $lte: value } };
201+
case 'lt': // LESS THAN
202+
return { [attributePath]: { $lt: value } };
203+
case 'pr': // PRESENT (exists)
204+
return { [attributePath]: { $exists: true } };
205+
case 'sw': // STARTSWITH
206+
return { [attributePath]: { $options: 'i', $regex: `^${escapeRegex(value)}` } };
207+
default:
208+
throw new Error(`Unsupported comparator: ${comparator}`);
209+
}
210+
}
211+
212+
/*
213+
================ EXAMPLES ================
214+
215+
Simple condition:
216+
SCIM: 'userName eq "Joe"'
217+
→ Tokens: ['userName', 'eq', '"Joe"']
218+
→ AST: { attributePath: 'userName', comparator: 'eq', value: 'Joe' }
219+
→ Mongo: { userName: { $eq: 'Joe' } }
220+
221+
Logical combination:
222+
SCIM: 'userName eq "Joe" and drinksCoffee eq true'
223+
→ Tokens: ['userName', 'eq', '"Joe"', 'and', 'drinksCoffee', 'eq', 'true']
224+
→ AST:
225+
{
226+
operator: 'and',
227+
left: { attributePath: 'userName', comparator: 'eq', value: 'Joe' },
228+
right: { attributePath: 'drinksCoffee', comparator: 'eq', value: 'true' }
229+
}
230+
→ Mongo:
231+
{
232+
$and: [
233+
{ userName: { $eq: 'Joe' } },
234+
{ drinksCoffee: { $eq: 'true' } }
235+
]
236+
}
237+
238+
Array filter:
239+
SCIM: 'emails[type eq "work"]'
240+
→ Tokens: ['emails', '[', 'type', 'eq', '"work"', ']']
241+
→ AST:
242+
{
243+
type: 'array',
244+
path: 'emails',
245+
expr: { attributePath: 'type', comparator: 'eq', value: 'work' }
246+
}
247+
→ Mongo:
248+
{
249+
emails: {
250+
$elemMatch: { type: { $eq: 'work' } }
251+
}
252+
}
253+
254+
*/
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ScimNode } from '../types/scim-node.type';
2+
3+
/** Represents a SCIM array filter node, e.g. emails[type eq "work"] */
4+
export interface ArrayFilterNode {
5+
expr: ScimNode;
6+
path: string;
7+
type: 'array';
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Comparator } from '../types/scim-comparator.type';
2+
3+
/** Represents a single SCIM condition such as userName eq "Joe" */
4+
export interface ConditionNode {
5+
attributePath: string;
6+
comparator: Comparator;
7+
type: 'condition';
8+
value?: string;
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { LogicalOperator } from '../types/scim-logical-operator.type';
2+
import { ScimNode } from '../types/scim-node.type';
3+
4+
/** Represents a logical operator node (e.g., X and Y, A or B) */
5+
export interface LogicalNode {
6+
left: ScimNode;
7+
operator: LogicalOperator;
8+
right: ScimNode;
9+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** Supported SCIM comparison operators */
2+
export type Comparator = 'aco' | 'co' | 'eq' | 'ew' | 'ge' | 'gt' | 'le' | 'lt' | 'pr' | 'sw';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type LogicalOperator = 'and' | 'or';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { ArrayFilterNode } from '../interfaces/scim-array-filter-node.interface';
2+
import { ConditionNode } from '../interfaces/scim-condition-node.interface';
3+
import { LogicalNode } from '../interfaces/scim-logical-node.interface';
4+
5+
/** Union type representing any valid SCIM node */
6+
export type ScimNode = ArrayFilterNode | ConditionNode | LogicalNode;

0 commit comments

Comments
 (0)