Make use of cleaned up APIs

The InMemoryScimFilterMatcher should be moved into another module for reuse
Actual Implementations would not be able to make use of this class, but it works well for testing API changes and demo projects
diff --git a/scim-server-examples/scim-server-spring-boot/pom.xml b/scim-server-examples/scim-server-spring-boot/pom.xml
index 611fa2d..11f7b3a 100644
--- a/scim-server-examples/scim-server-spring-boot/pom.xml
+++ b/scim-server-examples/scim-server-spring-boot/pom.xml
@@ -67,6 +67,22 @@
       <artifactId>lombok</artifactId>
       <scope>provided</scope>
     </dependency>
+
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.springframework.boot</groupId>
+      <artifactId>spring-boot-test</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringBootApplication.java b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringBootApplication.java
index a4a65e4..0b51e77 100644
--- a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringBootApplication.java
+++ b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringBootApplication.java
@@ -20,7 +20,7 @@
 package org.apache.directory.scim.example.spring;
 
 import org.apache.directory.scim.server.configuration.ServerConfiguration;
-import org.apache.directory.scim.spec.schema.ServiceProviderConfiguration;
+import org.apache.directory.scim.spec.schema.ServiceProviderConfiguration.AuthenticationSchema;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.context.annotation.Bean;
@@ -34,10 +34,7 @@
 
   @Bean
   ServerConfiguration serverConfiguration() {
-    return new ServerConfiguration().addAuthenticationSchema(
-      new ServiceProviderConfiguration.AuthenticationSchema()
-      .setType(ServiceProviderConfiguration.AuthenticationSchema.Type.OAUTH_BEARER)
-        .setName(ServiceProviderConfiguration.AuthenticationSchema.Type.OAUTH_BEARER.name())
-        .setDescription("OAuth2 Bearer Token"));
+    return new ServerConfiguration()
+      .addAuthenticationSchema(AuthenticationSchema.oauthBearer());
   }
 }
diff --git a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringConfiguration.java b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringConfiguration.java
index 9b37dcb..c31b868 100644
--- a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringConfiguration.java
+++ b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/ScimpleSpringConfiguration.java
@@ -30,8 +30,7 @@
 import org.apache.directory.scim.server.rest.ScimResourceHelper;
 import org.apache.directory.scim.server.rest.UserResourceImpl;
 import org.apache.directory.scim.server.schema.Registry;
-import org.apache.directory.scim.server.utility.AttributeUtil;
-import org.apache.directory.scim.server.utility.EtagGenerator;
+import org.apache.directory.scim.server.rest.EtagGenerator;
 import org.apache.directory.scim.spec.extension.ScimExtensionRegistry;
 import org.apache.directory.scim.spec.protocol.UserResource;
 import org.apache.directory.scim.spec.resources.ScimResource;
@@ -103,7 +102,6 @@
 
         // basic beans, this could also be defined as @Beans above too
         bind(EtagGenerator.class).to(EtagGenerator.class);
-        bind(AttributeUtil.class).to(AttributeUtil.class);
 //        bind(ServerConfiguration.class).to(ServerConfiguration.class);
       }
     });
diff --git a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryGroupService.java b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryGroupService.java
index 973be05..22b3747 100644
--- a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryGroupService.java
+++ b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryGroupService.java
@@ -20,9 +20,12 @@
 package org.apache.directory.scim.example.spring.service;
 
 import jakarta.annotation.PostConstruct;
+import jakarta.ws.rs.core.Response;
+import org.apache.directory.scim.server.exception.UnableToCreateResourceException;
 import org.apache.directory.scim.server.exception.UnableToUpdateResourceException;
 import org.apache.directory.scim.server.provider.Provider;
 import org.apache.directory.scim.server.provider.UpdateRequest;
+import org.apache.directory.scim.server.schema.Registry;
 import org.apache.directory.scim.spec.protocol.filter.FilterResponse;
 import org.apache.directory.scim.spec.protocol.search.Filter;
 import org.apache.directory.scim.spec.protocol.search.PageRequest;
@@ -34,7 +37,9 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
+import org.apache.directory.scim.spec.resources.ScimUser;
 import org.springframework.stereotype.Service;
 
 @Service
@@ -42,6 +47,12 @@
 
   private final Map<String, ScimGroup> groups = new HashMap<>();
 
+  private final Registry registry;
+
+  public InMemoryGroupService(Registry registry) {
+    this.registry = registry;
+  }
+
   @PostConstruct
   public void init() {
     ScimGroup group = new ScimGroup();
@@ -55,7 +66,7 @@
   }
 
   @Override
-  public ScimGroup create(ScimGroup resource) {
+  public ScimGroup create(ScimGroup resource) throws UnableToCreateResourceException {
     String resourceId = resource.getId();
     int idCandidate = resource.hashCode();
     String id = resourceId != null ? resourceId : Integer.toString(idCandidate);
@@ -64,8 +75,17 @@
       id = Integer.toString(idCandidate);
       ++idCandidate;
     }
-    groups.put(id, resource);
+
+    // check to make sure the group doesn't already exist
+    boolean existingUserFound = groups.values().stream()
+      .anyMatch(group -> group.getExternalId().equals(resource.getExternalId()));
+    if (existingUserFound) {
+      // HTTP leaking into data layer
+      throw new UnableToCreateResourceException(Response.Status.CONFLICT, "Group '" + resource.getExternalId() + "' already exists.");
+    }
+
     resource.setId(id);
+    groups.put(id, resource);
     return resource;
   }
 
@@ -89,7 +109,18 @@
 
   @Override
   public FilterResponse<ScimGroup> find(Filter filter, PageRequest pageRequest, SortRequest sortRequest) {
-    return new FilterResponse<>(groups.values(), pageRequest, groups.size());
+    long count = pageRequest.getCount() != null ? pageRequest.getCount() : groups.size();
+    long startIndex = pageRequest.getStartIndex() != null
+      ? pageRequest.getStartIndex() - 1 // SCIM is 1-based indexed
+      : 0;
+
+    List<ScimGroup> result = groups.values().stream()
+      .skip(startIndex)
+      .limit(count)
+      .filter(user -> InMemoryScimFilterMatcher.matches(user, registry.getSchema(ScimGroup.SCHEMA_URI), filter))
+      .collect(Collectors.toList());
+
+    return new FilterResponse<>(result, pageRequest, result.size());
   }
 
   @Override
diff --git a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcher.java b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcher.java
index 3865e53..6da0d3b 100644
--- a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcher.java
+++ b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcher.java
@@ -19,17 +19,19 @@
 
 package org.apache.directory.scim.example.spring.service;
 
+import org.apache.directory.scim.spec.annotation.ScimResourceType;
 import org.apache.directory.scim.spec.exception.ScimResourceInvalidException;
-import org.apache.directory.scim.spec.protocol.filter.AttributeComparisonExpression;
-import org.apache.directory.scim.spec.protocol.filter.CompareOperator;
-import org.apache.directory.scim.spec.protocol.filter.FilterExpression;
+import org.apache.directory.scim.spec.protocol.filter.*;
 import org.apache.directory.scim.spec.protocol.search.Filter;
 import org.apache.directory.scim.spec.resources.ScimResource;
+import org.apache.directory.scim.spec.schema.AttributeContainer;
+import org.apache.directory.scim.spec.schema.Schema;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.util.ReflectionUtils;
 
-import java.lang.reflect.Field;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.function.Predicate;
 
 final class InMemoryScimFilterMatcher {
 
@@ -41,70 +43,251 @@
    * Basic FilterExpression support for in-memory objects, actual implementations should translate the Filter into
    * the appropriate query language.
    */
-  public static boolean matches(ScimResource resource, Filter filter) {
+  public static boolean matches(ScimResource resource, AttributeContainer attributeContainer, Filter filter) {
 
     // no filter defined, return everything
     if (filter == null) {
       return true;
     }
+    FilterExpression expression = filter.getExpression();
+    if (expression == null) {
+      return true;
+    }
 
-    // FIXME: This example ONLY supports simple Filter arguments, Filters can be more complex, you would likely translate
-    // them into a query instead of manually filtering anyway
+    ScimResourceType scimResourceType = resource.getClass().getAnnotation(ScimResourceType.class);
+    if (scimResourceType == null) {
+      throw new ScimResourceInvalidException("SCM resource has not been configured with a ScimResourceType annotation");
+    }
+    return toPredicate(expression, attributeContainer).test(resource);
+  }
 
-    if (filter.getExpression() instanceof AttributeComparisonExpression expression) {
-      String attribute = expression.getAttributePath().getAttributeName();
-      CompareOperator op = expression.getOperation();
-      Object compareValue = expression.getCompareValue();
+  static Predicate<Object> toPredicate(FilterExpression expression, AttributeContainer attributeContainer) {
 
+    // attribute EQ "something"
+    if (expression instanceof AttributeComparisonExpression) {
+      return new AttributeComparisonPredicate((AttributeComparisonExpression) expression, attributeContainer);
+    }
+    // (attribute EQ "something") AND (otherAttribute EQ "something else")
+    else if (expression instanceof LogicalExpression) {
+      LogicalExpression logicalExpression = (LogicalExpression) expression;
+      Predicate<Object> left = toPredicate(logicalExpression.getLeft(), attributeContainer);
+      Predicate<Object> right = toPredicate(logicalExpression.getRight(), attributeContainer);
 
-      Field field = ReflectionUtils.findField(resource.getClass(), attribute);
-      if (field == null) {
-        return false;
+      LogicalOperator op = logicalExpression.getOperator();
+      if (op == LogicalOperator.AND) {
+        return left.and(right);
+      } else {
+        return left.or(right);
       }
-      field.setAccessible(true);
+    }
+    // NOT (attribute EQ "something")
+    else if (expression instanceof GroupExpression) {
+      GroupExpression groupExpression = (GroupExpression) expression;
+      Predicate<Object> predicate = toPredicate(groupExpression.getFilterExpression(), attributeContainer);
+      return groupExpression.isNot()
+        ? predicate.negate()
+        : predicate;
+    }
+    // attribute PR
+    else if (expression instanceof AttributePresentExpression) {
+      return new AttributePresentPredicate((AttributePresentExpression) expression, attributeContainer);
+    }
+    // addresses[type EQ "work"]
+    else if (expression instanceof ValuePathExpression) {
+      ValuePathExpression valuePathExpression = (ValuePathExpression) expression;
+      return new ValuePathPredicate(valuePathExpression, attributeContainer);
+    }
+    log.debug("Unsupported Filter expression of type: " + expression.getClass());
+    return scimResource -> false;
+  }
 
-      Object actualValue = ReflectionUtils.getField(field, resource);
-      switch (op) {
-        case EQ -> {
-          return compareValue.equals(actualValue);
-        }
-        case NE -> {
-          return !compareValue.equals(actualValue);
-        }
-        case SW -> {
-          return isStringExpression(field, compareValue, expression)
-            && actualValue != null
-            && actualValue.toString().startsWith(compareValue.toString());
-        }
-        case EW -> {
-          return isStringExpression(field, compareValue, expression)
-            && actualValue != null
-            && actualValue.toString().endsWith(compareValue.toString());
-        }
-        case CO -> {
-          return isStringExpression(field, compareValue, expression)
-            && actualValue != null
-            && actualValue.toString().contains(compareValue.toString());
-        }
-        default -> throw new ScimResourceInvalidException("This example only supports basic string filters");
+  private static boolean isStringExpression(Schema.Attribute attribute, Object compareValue) {
+    if (attribute.getType() != Schema.Attribute.Type.STRING) {
+      log.debug("Invalid query, non String value for expression : " + attribute.getType());
+      return false;
+    }
+    if (compareValue == null) {
+      log.debug("Invalid query, empty value for expression : " + attribute.getType());
+      return false;
+    }
+    return true;
+  }
+
+  static class ValuePathPredicate extends AbstractAttributePredicate<ValuePathExpression> {
+
+    public ValuePathPredicate(ValuePathExpression expression, AttributeContainer attributeContainer) {
+      super(expression, attributeContainer, expression.getAttributePath().getFullAttributeName());
+    }
+
+    @Override
+    boolean test(Schema.Attribute attribute, Object actualValue) {
+      // actualValue must be a Collection
+      if (attribute.isMultiValued()) {
+        Predicate<Object> nestedPredicate = toPredicate(expression.getAttributeExpression(), attribute);
+        return ((Collection<?>) actualValue).stream().anyMatch(nestedPredicate);
       }
-    } else{
-      log.warn("Unsupported Filter expression of type: " + filter.getExpression().getClass());
+
       return false;
     }
   }
 
-  private static boolean isStringExpression(Field field, Object compareValue, FilterExpression expression) {
-    if (!field.getType().isAssignableFrom(String.class)) {
-      // TODO: maybe this should be a 400?
-      log.warn("Non String value for expression : " + expression.toFilter());
+  static class AttributeComparisonPredicate extends AbstractAttributePredicate<AttributeComparisonExpression> {
+
+    public AttributeComparisonPredicate(AttributeComparisonExpression expression, AttributeContainer attributeContainer) {
+      super(expression, attributeContainer, expression.getAttributePath().getFullAttributeName());
+    }
+
+    @Override
+    public boolean test(Schema.Attribute attribute, Object actualValue) {
+
+      if (actualValue == null) {
+        return false;
+      }
+
+      if (attribute.isMultiValued()) {
+        log.warn("Invalid expression, target is collection");
+        return false;
+      }
+
+      CompareOperator op = expression.getOperation();
+      Object compareValue = expression.getCompareValue();
+
+      if (op == CompareOperator.EQ) {
+        return compareValue.equals(actualValue);
+      }
+      if (op == CompareOperator.NE) {
+        return !compareValue.equals(actualValue);
+      }
+      if (op == CompareOperator.SW) {
+        return isStringExpression(attribute, compareValue)
+          && actualValue.toString().startsWith(compareValue.toString());
+      }
+      if (op == CompareOperator.EW) {
+        return isStringExpression(attribute, compareValue)
+          && actualValue.toString().endsWith(compareValue.toString());
+      }
+      if (op == CompareOperator.CO) {
+        return isStringExpression(attribute, compareValue)
+          && actualValue.toString().contains(compareValue.toString());
+      }
+
+      if (actualValue instanceof Comparable) {
+        Comparable actual = (Comparable) actualValue;
+        Comparable compare = (Comparable) compareValue;
+        return CompareOperatorPredicate.naturalOrder(op, compare).test(actual);
+      }
+
+      throw new ScimResourceInvalidException("Unsupported operation in filter: " + op.name());
+    }
+  }
+
+  static class CompareOperatorPredicate<T> implements Predicate<T> {
+    private final CompareOperator op;
+
+    private final Comparator<T> comparator;
+
+    private final T comparedValue;
+
+
+    public CompareOperatorPredicate(CompareOperator op, T comparedValue, Comparator<T> comparator) {
+      this.op = op;
+      this.comparator = comparator;
+      this.comparedValue = comparedValue;
+    }
+
+    @Override
+    public boolean test(T actualValue) {
+
+      int compareResult = comparator.compare(actualValue, comparedValue);
+
+      if (op == CompareOperator.LT) {
+        return compareResult < 0;
+      } else if (op == CompareOperator.GT) {
+        return compareResult > 0;
+      } else if (op == CompareOperator.LE) {
+        return compareResult <= 0;
+      } else if (op == CompareOperator.GE) {
+        return compareResult >= 0;
+      }
       return false;
     }
-    if (compareValue == null) {
-      // TODO: maybe this should be a 400?
-      log.warn("Empty value for expression : " + expression.toFilter());
-      return false;
+
+    static <T extends Comparable<T>> CompareOperatorPredicate<T> naturalOrder(CompareOperator op, T comparedValue) {
+      return new CompareOperatorPredicate<>(op, comparedValue, Comparator.naturalOrder());
     }
-    return true;
+  }
+
+  static class AttributePresentPredicate extends AbstractAttributePredicate<AttributePresentExpression> {
+    public AttributePresentPredicate(AttributePresentExpression expression, AttributeContainer attributeContainer) {
+      super(expression, attributeContainer, expression.getAttributePath().getFullAttributeName());
+    }
+
+    @Override
+    boolean test(Schema.Attribute attribute, Object actualValue) {
+      if (attribute.isMultiValued()) {
+        log.debug("Invalid expression, target is collection");
+        return false;
+      }
+
+      return actualValue != null;
+    }
+  }
+
+
+  static abstract class AbstractAttributePredicate<T extends FilterExpression> implements Predicate<Object> {
+
+    private static final Logger log = LoggerFactory.getLogger(InMemoryScimFilterMatcher.class);
+
+    final T expression;
+
+    final AttributeContainer attributeContainer;
+
+    final String attribute;
+
+    public AbstractAttributePredicate(T expression, AttributeContainer attributeContainer, String attribute) {
+      this.expression = expression;
+      this.attributeContainer = attributeContainer;
+      this.attribute = attribute;
+    }
+
+    abstract boolean test(Schema.Attribute attribute, Object actualValue);
+
+    @Override
+    public boolean test(Object actual) {
+
+      try {
+        String[] attributePaths = attribute.split("\\.");
+        Schema.Attribute schemaAttribute = attributeContainer.getAttribute(attributePaths[0]);
+        if (schemaAttribute == null) {
+          log.warn("Invalid filter: attribute '" + attribute + "' is NOT a valid SCIM attribute.");
+          return false;
+        }
+        actual = schemaAttribute.getAccessor().get(actual);
+
+        if (attributePaths.length > 1) {
+          for (int index = 1; index < attributePaths.length; index++) {
+            String attributePath = attributePaths[index];
+            schemaAttribute = schemaAttribute.getAttribute(attributePath);
+            if (schemaAttribute == null) {
+              log.warn("Invalid filter: attribute '" + attributePath + "' is NOT a valid SCIM attribute.");
+              return false;
+            }
+            actual = schemaAttribute.getAccessor().get(actual);
+          }
+        }
+
+        // filter out fields like passwords that should not be queried against
+        if (schemaAttribute.getReturned() == Schema.Attribute.Returned.NEVER) {
+          log.warn("Invalid filter: attribute '" + attribute + "' is filterable.");
+          return false;
+        }
+
+        return test(schemaAttribute, actual);
+      } catch (Exception e) {
+        // The SCIM spec states to ignore the query instead of rejecting it - rfc7644 - 3.4.2
+        log.debug("Invalid SCIM filter received", e);
+        return false;
+      }
+    }
   }
 }
diff --git a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryUserService.java b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryUserService.java
index a9855e1..16e52e1 100644
--- a/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryUserService.java
+++ b/scim-server-examples/scim-server-spring-boot/src/main/java/org/apache/directory/scim/example/spring/service/InMemoryUserService.java
@@ -22,6 +22,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 import jakarta.annotation.PostConstruct;
 
@@ -31,6 +32,7 @@
 import org.apache.directory.scim.server.exception.UnableToUpdateResourceException;
 import org.apache.directory.scim.server.provider.Provider;
 import org.apache.directory.scim.server.provider.UpdateRequest;
+import org.apache.directory.scim.server.schema.Registry;
 import org.apache.directory.scim.spec.protocol.filter.FilterResponse;
 import org.apache.directory.scim.spec.protocol.search.Filter;
 import org.apache.directory.scim.spec.protocol.search.PageRequest;
@@ -55,7 +57,13 @@
   static final int DEFAULT_USER_LUCKY_NUMBER = 7;
 
   private final Map<String, ScimUser> users = new HashMap<>();
-  
+
+  private final Registry registry;
+
+  public InMemoryUserService(Registry registry) {
+    this.registry = registry;
+  }
+
   @PostConstruct
   public void init() {
     ScimUser user = new ScimUser();
@@ -104,6 +112,7 @@
     boolean existingUserFound = users.values().stream()
       .anyMatch(user -> user.getUserName().equals(resource.getUserName()));
     if (existingUserFound) {
+      // HTTP leaking into data layer
       throw new UnableToCreateResourceException(Response.Status.CONFLICT, "User '" + resource.getUserName() + "' already exists.");
     }
 
@@ -153,8 +162,8 @@
     List<ScimUser> result = users.values().stream()
       .skip(startIndex)
       .limit(count)
-      .filter(user -> InMemoryScimFilterMatcher.matches(user, filter))
-      .toList();
+      .filter(user -> InMemoryScimFilterMatcher.matches(user, registry.getSchema(ScimUser.SCHEMA_URI), filter))
+      .collect(Collectors.toList());
 
     return new FilterResponse<>(result, pageRequest, result.size());
   }
diff --git a/scim-server-examples/scim-server-spring-boot/src/test/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcherTest.java b/scim-server-examples/scim-server-spring-boot/src/test/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcherTest.java
new file mode 100644
index 0000000..ad00ab4
--- /dev/null
+++ b/scim-server-examples/scim-server-spring-boot/src/test/java/org/apache/directory/scim/example/spring/service/InMemoryScimFilterMatcherTest.java
@@ -0,0 +1,249 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.directory.scim.example.spring.service;
+
+import org.apache.directory.scim.example.spring.extensions.LuckyNumberExtension;
+import org.apache.directory.scim.server.exception.InvalidProviderException;
+import org.apache.directory.scim.server.provider.ProviderRegistry;
+import org.apache.directory.scim.spec.protocol.filter.FilterBuilder;
+import org.apache.directory.scim.spec.protocol.search.Filter;
+import org.apache.directory.scim.spec.resources.*;
+import org.apache.directory.scim.spec.schema.Meta;
+import org.apache.directory.scim.spec.schema.Schema;
+import org.assertj.core.api.AbstractAssert;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.directory.scim.example.spring.service.InMemoryScimFilterMatcherTest.FilterAssert.assertThat;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class InMemoryScimFilterMatcherTest {
+
+  private final static ScimUser USER1 = user("user1", "User", "One")
+        .setNickName("one")
+        .setAddresses(List.of(
+          new Address()
+            .setType("home")
+            .setPrimary(true)
+            .setStreetAddress("742 Evergreen Terrace")
+            .setRegion("Springfield")
+            .setLocality("Unknown")
+            .setPostalCode("012345")
+            .setCountry("USA"),
+          new Address()
+            .setType("work")
+            .setPrimary(true)
+            .setStreetAddress("101 Reactor Way")
+            .setRegion("Springfield")
+            .setLocality("Unknown")
+            .setPostalCode("012345")
+            .setCountry("USA")
+        )); static {
+            USER1.addExtension(new LuckyNumberExtension().setLuckyNumber(111));
+        }
+
+  private final static ScimUser USER2 = user("user2", "User", "Two")
+        .setAddresses(List.of(
+          new Address()
+            .setType("home")
+            .setPrimary(true)
+            .setStreetAddress("11234 Slumvillage Pass")
+            .setRegion("Burfork")
+            .setLocality("CA")
+            .setPostalCode("221134")
+            .setCountry("USA")
+        )); static {
+            USER2.addExtension(new LuckyNumberExtension().setLuckyNumber(8));
+        }
+
+  @Test
+  public void userNameMatch() {
+    assertThat(FilterBuilder.create().equalTo("userName", "user1"))
+      .matches(USER1)
+      .notMatches(USER2);
+  }
+
+  @Test
+  public void familyNameMatches() {
+    assertThat(FilterBuilder.create().equalTo("name.familyName", "Two"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void startsWithMatches() {
+    assertThat(FilterBuilder.create().startsWith("name.familyName", "Tw"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void endsWithMatches() {
+    assertThat(FilterBuilder.create().endsWith("name.familyName", "wo"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void containsMatches() {
+    assertThat(FilterBuilder.create().contains("name.familyName", "w"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void givenAndFamilyNameMatches() {
+
+    FilterBuilder filter = FilterBuilder.create()
+      .equalTo("name.givenName", "User")
+      .and(builder -> builder.equalTo("name.familyName", "Two"));
+
+    assertThat(filter)
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void familyNameOrMatches() {
+    FilterBuilder filter = FilterBuilder.create()
+      .equalTo("name.familyName", "One")
+      .or(builder -> builder.equalTo("name.familyName", "Two"));
+
+    assertThat(filter)
+      .matches(USER2)
+      .matches(USER1);
+  }
+
+  @Test
+  public void noMatchPassword() {
+    assertThat(FilterBuilder.create().equalTo("password", "super-secret"))
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void invertUserNameMatches() {
+    assertThat(FilterBuilder.create().not(filter -> filter.equalTo("userName", "user1")))
+      .notMatches(USER1)
+      .matches(USER2);
+  }
+
+  @Test
+  public void presentAttributeMatches() {
+    assertThat(FilterBuilder.create().present("nickName"))
+      .matches(USER1)
+      .notMatches(USER2);
+  }
+
+  @Test
+  public void valuePathExpressionMatches() {
+    assertThat(FilterBuilder.create().attributeHas("addresses", filter -> filter.equalTo("type", "work")))
+      .matches(USER1)
+      .notMatches(USER2);
+  }
+
+//  @Test
+//  public void extensionValueMatches() {
+//    assertThat(FilterBuilder.create().equalTo("luckyNumber", 111))
+//      .matches(USER1)
+//      .notMatches(USER2);
+//  }
+
+  @Test
+  public void metaMatches() {
+    assertThat(FilterBuilder.create().lessThan("meta.lastModified", LocalDateTime.now()))
+      .matches(USER1);
+
+    assertThat(FilterBuilder.create().greaterThan("meta.lastModified", LocalDateTime.now().minusYears(1)))
+      .matches(USER1);
+
+    assertThat(FilterBuilder.create().greaterThanOrEquals("meta.lastModified", USER1.getMeta().getLastModified()))
+      .matches(USER1);
+
+    assertThat(FilterBuilder.create().lessThanOrEquals("meta.lastModified", USER1.getMeta().getLastModified()))
+      .matches(USER1);
+
+    assertThat(FilterBuilder.create().equalTo("meta.lastModified", USER1.getMeta().getLastModified()))
+      .matches(USER1);
+  }
+
+  static class FilterAssert extends AbstractAssert<FilterAssert, Filter> {
+
+    protected FilterAssert(Filter actual) {
+      super(actual, FilterAssert.class);
+    }
+
+    public FilterAssert matches(ScimUser user) {
+      isNotNull();
+      if (!InMemoryScimFilterMatcher.matches(user, schema(ScimUser.class), actual)) {
+        failWithMessage("Expected filter '%s' to match user '%s'", actual.toString(), user);
+      }
+      return this;
+    }
+
+    public FilterAssert notMatches(ScimUser user) {
+      isNotNull();
+      if (InMemoryScimFilterMatcher.matches(user, schema(ScimUser.class), actual)) {
+        failWithMessage("Expected filter '%s' to NOT match user '%s'", actual.toString(), user);
+      }
+      return this;
+    }
+
+    public static FilterAssert assertThat(Filter actual) {
+      return new FilterAssert(actual);
+    }
+
+    public static FilterAssert assertThat(FilterBuilder actual) {
+      return new FilterAssert(actual.build());
+    }
+
+    private static <T extends ScimResource> Schema schema(Class<T> resourceClass) {
+      try {
+        return ProviderRegistry.generateSchema(resourceClass);
+      } catch (InvalidProviderException e) {
+        throw new RuntimeException("Invalid configuration detected, failed to generate schema for class: " + resourceClass);
+      }
+    }
+  }
+
+  private static ScimUser user(String username, String givenName, String familyName) {
+    ScimUser user = new ScimUser()
+      .setUserName(username)
+      .setPassword("super-secret")
+      .setActive(true)
+      .setName(new Name()
+        .setGivenName(givenName)
+        .setFamilyName(familyName)
+      )
+      .setEmails(List.of(
+        new Email()
+          .setType("work")
+          .setPrimary(true)
+          .setValue(username + "@example.com"),
+        new Email()
+          .setType("personal")
+          .setValue(givenName + "." + familyName + "@example.com")
+      ));
+
+    user.setMeta(new Meta().setLastModified(LocalDateTime.now()));
+
+    return user;
+  }
+}