How to use the server implementation - Captain-P-Goldfish/SCIM-SDK Wiki

Original URL: https://github.com/Captain-P-Goldfish/SCIM-SDK/wiki/How-to-use-the-server-implementation

to start with the server implementation you simply need to perform the following steps:

  1. create a JSON "Resource Type" definition
  2. create a JSON "Resource Schema" definition
  3. create a "ServiceProvider" configuration
  4. create a "ResourceNode" implementation that is a java object that represents the "Resource Schema"
  5. create a "ResourceHandler" implementation to handle your "ResourceNode"
  6. create an "EndpointDefinition" object that reads the previous defined schemas
  7. create the ResourceEndpoint instance
  8. register the "EndpointDefinition" on the ResourceEndpoint
  9. create a web-endpoint on your application that has a wildcard behind the scim-endpoint-basepath
  10. call the "ResourceEndpoint" if a client request is received

This looks much more as it really is!

First of all lets start with a simple "Resource Type" definition:

Resource Type

With a resource type definition you add a new endpoint to your application. Each resource type is bound to a "Resource Schema" definition that describes the JSON structure handled by that endpoint.

Lets say you got an application that must handle keystores for some reason. An appropriate "Resource Type" definition might look like this:

    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
      ],
      "id": "Keystore",
      "name": "Keystore",
      "description": "handles the companies keystores",
      "schema": "urn:custom:params:scim:schemas:mycompany:2.0:Keystore",
      "endpoint": "/Keystores",
      "meta": {
        "resourceType": "ResourceType",
        "created": "2020-05-02T14:51:11+02:00",
        "lastModified": "2020-05-02T14:51:11+02:00",
        "location": "/ResourceTypes/Keystore"
      }
    }

The meta attribute here is optional and may be omitted but the other fields should be set.

  1. The fields "id" and "name" are basically the same and should contain the same value.
  2. the field "description" is just a human readable explanation of what the endpoint does
  3. the field "schema" references the "Resource Schema" definitions "id" field (will be explained next)
  4. the field "endpoint" defines the context path of the URL to access the endpoints of this resource type
  5. the field "meta" is optional. If you do not add it manually to the resource type it will be created when the endpoint is registered. This leads to different values in the created and lastModified fields after each restart.

Thats all to step 1. You just defined an endpoint.

Resource Schema

A resource schema defines how the json structure that is accepted by your endpoint looks like. If a client sends other attributes that are not defined by this schema the application will filter these attributes and remove them from the structure before you get your resource object.

To define a "Resource Schema" we will define a schema that is usable by our previous defined "Resource Type":

{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:Schema"
  ],
  "id": "urn:custom:params:scim:schemas:mycompany:2.0:Keystore",
  "name": "Keystore",
  "description": "the data of a keystore handled by the company",
  "attributes": [
    {
      "name": "id",
      "type": "string",
      "description": "Unique identifier for the SCIM Resource as defined by the Service Provider.",
      "mutability": "readOnly",
      "returned": "always",
      "uniqueness": "server",
      "multiValued": false,
      "required": true,
      "caseExact": true
    },
    {
      "name": "name",
      "type": "string",
      "description": "a filename or a human readable identifier that describes what this keystore is for.",
      "mutability": "readWrite",
      "returned": "default",
      "uniqueness": "server",
      "multiValued": false,
      "required": false,
      "caseExact": true
    },
    {
      "name": "keystorePassword",
      "type": "string",
      "description": "the password of a keystore.",
      "mutability": "writeOnly",
      "returned": "never",
      "uniqueness": "none",
      "multiValued": false,
      "required": true,
      "caseExact": true
    },
    {
      "name": "keystoreType",
      "type": "string",
      "canonicalValues": [
        "JKS",
        "JCEKS",
        "PKCS12"
      ],
      "description": "the type of the keystore that is important to parse it.",
      "mutability": "readWrtie",
      "returned": "default",
      "uniqueness": "none",
      "multiValued": false,
      "required": true,
      "caseExact": false
    },
    {
      "name": "entries",
      "type": "complex",
      "description": "Describes the contained entries within the keystore",
      "mutability": "readWrite",
      "returned": "default",
      "uniqueness": "none",
      "multiValued": true,
      "required": true,
      "caseExact": false,
      "subAttributes": [
        {
          "name": "alias",
          "type": "string",
          "description": "the alias of an entry",
          "mutability": "readWrite",
          "returned": "always",
          "uniqueness": "none",
          "multiValued": false,
          "required": true,
          "caseExact": true
        },
        {
          "name": "keyPassword",
          "type": "string",
          "description": "the password to access the secret key on this entry",
          "mutability": "writeOnly",
          "returned": "never",
          "uniqueness": "none",
          "multiValued": false,
          "required": true,
          "caseExact": true
        },
        {
          "name": "certificateChain",
          "type": "string",
          "description": "a base64 encoded certificate array in UTF-8 encoding. The chain should be resolved from index 0 to last-index to get its correct order",
          "mutability": "readWrite",
          "returned": "default",
          "uniqueness": "none",
          "multiValued": true,
          "required": true,
          "caseExact": true
        },
        {
          "name": "privateKey",
          "type": "string",
          "description": "the private key of this entry. Should be a base64 encoded string encoded in UTF-8",
          "mutability": "writeOnly",
          "returned": "never",
          "uniqueness": "none",
          "multiValued": false,
          "required": false,
          "caseExact": true
        }
      ]
    }
  ],
  "meta": {
    "resourceType": "Schema",
    "created": "2020-05-02T14:51:11+02:00",
    "lastModified": "2020-05-02T14:51:11+02:00",
    "location": "/ResourceTypes/Keystore"
  }
}

Alright there is a lot to see in the schema structure but this is actually pretty simple and will be explained in the following:

  1. the field "id" is the "Resource Schema"s identifier. You will find this value also in the "Resource Type" definition under the field "schema". This is how you link these two files.
  2. the field "name" should contain a direct identifier that is human readable.
  3. the field "description" is a simple human readable description of the schema
  4. the "attributes" field is an array that contains the field-definitions of how your json document should look like. These attributes are eventually validated in the request that they are matching the defined requirements.

Now explaining the attributes:

  1. The "id" attribute is a required attribute and MUST be defined for each schema. The type does not matter but it is per default defined as string.
  2. The "name" attribute is an optional attribute that might be used as a filename or something similiar
  3. the "keystorePassword" attribute holds the password to access the keystore
    • the "mutability" field is set to "writeOnly"
    • the "returned" field is set to "never"
      • the mutability "writeOnly" must always go along with returned value "never"
    • Even if you put the keystorePassword accidentially into your response the scim-sdk will notice this and remove this attribute from the response.
  4. the "keystoreType" attribute is an enum. The predefined values are identified by the field "canonicalValues"
  5. the "entries" attribute is a multivalued complex type with several sub-attributes
    1. the "alias" attribute defines the name of an alias within the keystore
    2. the "keyPassword" attribute is again a "writeOnly" string representation
    3. the "certificateChain" attribute is a multivalued string attribute that contains the whole chain of the given alias from the keystore
    4. the "privateKey" attribute is the secret key from the given alias in the keystore and is again a "writeOnly" attribute

That's it you defined an endpoint and the schema representation that is handled by that endpoint.

provide a "ServiceProvider" configuration

This is very simple just create an instance of ServiceProvideras follows:

import de.captaingoldfish.scim.sdk.common.resources.ServiceProvider;
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.AuthenticationScheme;
...
public ServiceProvider getServiceProviderConfig()
  {
    AuthenticationScheme authScheme = AuthenticationScheme.builder()
                                                          .name("OAuth Bearer Token")
                                                          .description("Authentication scheme using the OAuth "
                                                                       + "Bearer Token Standard")
                                                          .specUri("http://www.rfc-editor.org/info/rfc6750")
                                                          .type("oauthbearertoken")
                                                          .build();
    return ServiceProvider.builder()
                          .filterConfig(FilterConfig.builder().supported(true).maxResults(50).build())
                          .sortConfig(SortConfig.builder().supported(true).build())
                          .changePasswordConfig(ChangePasswordConfig.builder().supported(true).build())
                          .bulkConfig(BulkConfig.builder().supported(true).maxOperations(10).build())
                          .patchConfig(PatchConfig.builder().supported(true).build())
                          .authenticationSchemes(Collections.singletonList(authScheme))
                          .eTagConfig(ETagConfig.builder().supported(true).build())
                          .build();
  }

The "ServiceProvider" configuration can be changed during runtime and the changes will take effect immedtiately.

  1. AuthenticationScheme: A required object that must be added to the "ServiceProvider" configuration. Eventhough it will have no effects on the application you must describe the authentication methods that may be used to access your scim application.
  2. FilterConfig: if support is set to false the handler implementation will get only "null" values on the method parameter "FilterNode" even if the client has set a filter. (Note that also the auto-filtering feature will not work even if you have set the support for auto-filtering to true. The "ServiceProvider" configuration takes precedence)
    • maxResults: The server will never return more entries than set here. Even if you return more entries within your implementation the api will remove all entries beyond the maximum size
  3. SortConfig: if support is set to false the handler implementation will get only "null" values on the method parameters "sortBy" and "sortOrder" even if the client send them along. (Note that also the auto-sorting feature will not work even if you have set the support for auto-sorting to true. The "ServiceProvider" configuration takes precedence)
  4. ChangePasswordConfig: The api does ignore this configuration. This is something you must handle on your own because there is no concrete or reliable way in which the api might give support for this feature
  5. BulkConfig: if set to false requests to the BulkEndpoint will cause a status of 501 (NotImplemented)
    • maxOpertions: the server will not accept more operations in a single bulk response than this value
  6. PatchConfig: if set to false requests to the PatchEndpoint will cause a status of 501 (NotImplemented)
  7. ETagConfig: if set to true you should provide a version to each resource within the "meta" attribute. If you do not set the version field the api will calculate an SHA-1 hash on all resources that do not contain a version and fill the version-attribute with the hash value.

provide a "ResourceNode" implementation

The "ResourceNode" is basically your DTO (data transfer object) implementation. The "ResourceNode" is a jackson "ObjectNode" extension and should provide methods you can use to directly access the fields within your JSON structure. The implementation should look similiar to this

NOTE: You'll be able to let the ResourceNode be auto-generated by one of the submodules: https://github.com/Captain-P-Goldfish/SCIM-SDK/tree/master/scim-sdk-schema-pojo-creator. This module provides a JavaFX interface that allows you to copy your schema definition into it and generate a complete ResourceNode POJO from it.

package de.captaingoldfish.scim.sdk.sample.common;

import java.util.List;

import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
import de.captaingoldfish.scim.sdk.common.resources.base.ScimObjectNode;


/**
 * <br>
 * <br>
 * created at: 02.05.2020
 *
 * @author Pascal Knüppel
 */
public class ScimKeystore extends ResourceNode
{

  /**
   * the URI set in the keystore schema definition under the "id" attribute
   */
  private static final String KEYSTORE_URI = "urn:custom:params:scim:schemas:mycompany:2.0:Keystore";

  public ScimKeystore()
  {
    setSchemas(Collections.singleton(KEYSTORE_URI));
  }

  public String getName()
  {
    return getStringAttribute(FieldNames.NAME).orElse(null);
  }

  public void setName(String name)
  {
    setAttribute(FieldNames.NAME, name);
  }

  public String getKeystorePassword()
  {
    return getStringAttribute(FieldNames.KEYSTORE_PASSWORD).orElse(null);
  }

  public void setKeystorePassword(String keystorePassword)
  {
    setAttribute(FieldNames.KEYSTORE_PASSWORD, keystorePassword);
  }

  public KeystoreType getKeystoreType()
  {
    // there will never be an exception here because the api will make sure that only legal values are present so
    // the optional will also never return null here because it is a required type
    return KeystoreType.valueOf(getStringAttribute(FieldNames.KEYSTORE_TYPE).map(String::toUpperCase).orElse(null));
  }

  public void setKeystoreType(KeystoreType keystoreType)
  {
    setAttribute(FieldNames.KEYSTORE_TYPE, keystoreType.name());
  }

  public KeystoreEntries getKeystoreEntries()
  {
    return getObjectAttribute(FieldNames.ENTRIES, KeystoreEntries.class).orElse(null);
  }

  public void setKeystoreEntries(KeystoreEntries keystoreEntries)
  {
    setAttribute(FieldNames.ENTRIES, keystoreEntries);
  }

  public static class KeystoreEntries extends ScimObjectNode
  {

    public String getAlias()
    {
      return getStringAttribute(FieldNames.ALIAS).orElse(null);
    }

    public void setAlias(String alias)
    {
      setAttribute(FieldNames.ALIAS, alias);
    }

    public String getKeyPassword()
    {
      return getStringAttribute(FieldNames.KEY_PASSWORD).orElse(null);
    }

    public void setKeyPassword(String keyPassword)
    {
      setAttribute(FieldNames.KEY_PASSWORD, keyPassword);
    }

    public List<String> getCertificateChain()
    {
      return getSimpleArrayAttribute(FieldNames.CERTIFICATE_CHAIN);
    }

    public void setCertificateChain(List<String> certificateChain)
    {
      setStringAttributeList(FieldNames.CERTIFICATE_CHAIN, certificateChain);
    }

    public String getPrivateKey()
    {
      return getStringAttribute(FieldNames.PRIVATE_KEY).orElse(null);
    }

    public void setPrivateKey(String privateKey)
    {
      setAttribute(FieldNames.PRIVATE_KEY, privateKey);
    }
  }

  /**
   * contains the field names that are defined in the keystore schema definition
   */
  private static final class FieldNames
  {

    private static final String NAME = "name";

    private static final String KEYSTORE_PASSWORD = "keystorePassword";

    private static final String KEYSTORE_TYPE = "keystoreType";

    private static final String ENTRIES = "entries";

    private static final String ALIAS = "alias";

    private static final String KEY_PASSWORD = "keyPassword";

    private static final String CERTIFICATE_CHAIN = "certificateChain";

    private static final String PRIVATE_KEY = "privateKey";

  }
}

There is a submodule "scim-sdk-schema-pojo-creator" that tries to auto-generate such "ResourceNode"s but you should always check the generated source code base on the following conditions:

  1. All "ResourceNode" objects that are directly handled by the api MUST extend "ResourceNode"
  2. You SHOULD fill the "schemas" attribute in the constructor - or on another way - with the URI that was defined in the JSON schema. This api tries its best to fix a missing "schemas" attribute on its own but if the attribute is missing it will most probably crash on other SCIM implementations
  3. We defined a multivalued complex type under the json attribute name "entries". This was implemented with an inner class named "KeystoreEntries" within the class "ScimKeystore". This class does extend the class "ScimObjectNode". The "ScimObjectNode" is also the base class of "ResourceNode" and provides several "protected" methods that make it easy for you to access the fields in your object. You should prefer the "ScimObjectNode" extension for subtypes in the json structures.
  4. Write unit tests for your getter and setter methods... will save you a lot of pain. I know what I am talking about :-)

provide a ResourceHandler implementation

the resource handler is the actual handler where you must provide your implementation to manage your objects. An easy in memory implementation for the keystore example might look like this:

package de.captaingoldfish.scim.sdk.sample.common;

import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.apache.commons.lang3.StringUtils;

import de.captaingoldfish.scim.sdk.common.constants.enums.SortOrder;
import de.captaingoldfish.scim.sdk.common.exceptions.ConflictException;
import de.captaingoldfish.scim.sdk.common.exceptions.ResourceNotFoundException;
import de.captaingoldfish.scim.sdk.common.schemas.SchemaAttribute;
import de.captaingoldfish.scim.sdk.server.endpoints.ResourceHandler;
import de.captaingoldfish.scim.sdk.server.endpoints.Context;
import de.captaingoldfish.scim.sdk.server.filter.FilterNode;
import de.captaingoldfish.scim.sdk.server.response.PartialListResponse;


/**
 * <br>
 * <br>
 * created at: 02.05.2020
 *
 * @author Pascal Knüppel
 */
public class KeystoreHandler extends ResourceHandler<ScimKeystore>
{

  private Map<String, ScimKeystore> keystoreMap = new HashMap<>();

  @Override
  public ScimKeystore createResource(ScimKeystore resource, Context context)
  {
    // names should be unique so find if the name of the new resource is already taken
    ScimKeystore oldResource = keystoreMap.values()
                                          .stream()
                                          .filter(keystore -> StringUtils.equalsIgnoreCase(keystore.getName(),
                                                                                           resource.getName()))
                                          .findAny()
                                          .orElse(null);
    if (oldResource != null)
    {
      throw new ConflictException("keystore with name '" + resource.getName() + "' is already taken");
    }
    final String id = UUID.randomUUID().toString();
    resource.setId(id);
    resource.getMeta().ifPresent(meta -> {
      meta.setCreated(Instant.now());
      meta.setLastModified(Instant.now());
    });
    keystoreMap.put(id, resource);
    return resource;
  }

  @Override
  public ScimKeystore getResource(String id, 
                                  List<SchemaAttribute> attributes,
                                  List<SchemaAttribute> excludedAttributes,
                                  Context context)
  {
    return keystoreMap.get(id);
  }

  /**
   * @param attributes For convenience only: If you wish to only select the specific attributes on database
   *          level (note that the api will take care of this. You may only use this parameter to increase
   *          database performance)
   * @param excludedAttributes For convenience usage only: if you wish to exclude parameters like big-subtypes
   *          on database level (note that the api will take care of this. You may only use this parameter to
   *          increase * database performance)
   * @param authorization optional attribute: contains authorization information and may contain additional
   *          request based information if such data is passed to the implementation of the authorization object
   */
  @Override
  public PartialListResponse<ScimKeystore> listResources(long startIndex,
                                                         int count,
                                                         FilterNode filter,
                                                         SchemaAttribute sortBy,
                                                         SortOrder sortOrder,
                                                         List<SchemaAttribute> attributes,
                                                         List<SchemaAttribute> excludedAttributes,
                                                         Context context)
  {
    // filtering is not performed here. Note that the api provides an auto-filtering feature
    // sorting is not performed here. Note that the api provides an auto-sorting feature
    List<ScimKeystore> scimKeystores = new ArrayList<>(keystoreMap.values());
    int effectiveStartIndex = (int)Math.min(startIndex, scimKeystores.size() - 1);
    int effectiveCount = (int)Math.min(startIndex + count, scimKeystores.size() - 1);
    scimKeystores = scimKeystores.subList(effectiveStartIndex, effectiveCount);
    return PartialListResponse.<ScimKeystore> builder()
                              .totalResults(scimKeystores.size())
                              .resources(scimKeystores)
                              .build();
  }

  @Override
  public ScimKeystore updateResource(ScimKeystore resourceToUpdate, Context context)
  {
    final String id = resourceToUpdate.getId().orElse(null);
    ScimKeystore oldResource = keystoreMap.get(id);
    if (oldResource == null)
    {
      throw new ResourceNotFoundException("resource with id '" + id + "' does not exist");
    }
    resourceToUpdate.getMeta().ifPresent(meta -> meta.setLastModified(Instant.now()));
    keystoreMap.put(id, resourceToUpdate);
    return resourceToUpdate;
  }

  @Override
  public void deleteResource(String id, Context context)
  {
    ScimKeystore oldResource = keystoreMap.get(id);
    if (oldResource == null)
    {
      throw new ResourceNotFoundException("resource with id '" + id + "' does not exist");
    }
    keystoreMap.remove(id);
  }

  /**
   * optionally add a request validator that can be used to validate the request for create and 
   * update operations just before the handler-method is called. It allows you also to return
   * error messages with attribute specific error messages telling the client exactly what was
   * done wrong.
   * @see https://github.com/Captain-P-Goldfish/SCIM-SDK/wiki/Validation-of-resources
   */
  @Override
  public RequestValidator<User> getRequestValidator()
  {
    return new KeystoreRequestValidator();
  }
}

provide an EndpointDefinition

The endpoint definition simply reads the json files and binds them with the "ResourceHandler" implementation. this might look like this:

package de.captaingoldfish.scim.sdk.sample.common;

import de.captaingoldfish.scim.sdk.common.utils.JsonHelper;
import de.captaingoldfish.scim.sdk.server.endpoints.EndpointDefinition;


/**
 * <br>
 * <br>
 * created at: 02.05.2020
 *
 * @author Pascal Knüppel
 */
public class KeystoreEndpoint extends EndpointDefinition
{

  /**
   * the basepath to the resources
   */
  private static final String RESOURCE_BASE_PATH = "de/captaingoldfish/scim/sdk/sample/common";

  /**
   * the location of the keystore resource type definition
   */
  private static final String KEYSTORE_RESOURCE_TYPE_LOCATION = RESOURCE_BASE_PATH
                                                                + "/resourcetypes/keystore-resource-type.json";

  /**
   * the location of the keystore schema definition
   */
  private static final String KEYSTORE_SCHEMA_LOCATION = RESOURCE_BASE_PATH + "/schemas/keystore.json";

  public KeystoreEndpoint()
  {
    super(JsonHelper.loadJsonDocument(KEYSTORE_RESOURCE_TYPE_LOCATION),
          JsonHelper.loadJsonDocument(KEYSTORE_SCHEMA_LOCATION), null, new KeystoreHandler());
  }
}
  1. The first parameter that is passed to the super constructor is the "Resource Type" definition.
  2. The second parameter is the "Resource Schema" definition
  3. the third parameter is optional and is used if "schemaExtensions" are used. Please refer to RFC7643 for schema extensions or take a look into the project resources in the "scim-sdk-commons" project to the user-resource-type.json file
  4. the last parameter is the "ResourceHandler" implementation.

create a ResourceEndpoint instance

The "ResourceEndpoint" is the main instance, the heart of the api. You can simply create an instance like this:

ResourceEndpoint resourceEndpoint = new ResourceEndpoint(getServiceProviderConfig());

register your EndpointDefinition

now you can register the new keystore endpoint on your "ResourceEndpoint".

ResourceType keystoreResourceType = resourceEndpoint.registerEndpoint(new KeystoreEndpoint());

with registering you will be provided with a "ResourceType" instance that you can manipulate during runtime. All changes made are accepted immediately.

examples:

keystoreResourceType.setFeatures(ResourceTypeFeatures.builder().autoFiltering(true).autoSorting(true).build());
Schema keystore = keystoreResourceType.getMainSchema();
SchemaAttribute name = keystore.getSchemaAttribute("name");
name.setPattern("[a-z\\-_ ]+");

in this example the auto-sorting and auto-filtering feature have been enabled and a regular expression has been forced on the attribute "name". All requests containing an attribute-value that does not match this pattern will cause a 400 (BAD_REQUEST) status with an appropriate error message. You can set these features also directly in the json files there is no need to configure this on code-level. Please refer to the other pages in this wiki for explanations and examples.

create a web endpoint on your application

you must provide a simple endpoint for your application that accepts wildcards on the end of the basepath. You can use each technology JAX-RS, servlet-api or whatever. Here an example with JAX-RS

/**
   * handles all SCIM requests
   *
   * @param request the server request object
   * @return the jax-rs response
   */
  @POST
  @GET
  @PUT
  @PATCH
  @DELETE
  @Path("/v2/{s:.*}")
  @Produces(HttpHeader.SCIM_CONTENT_TYPE)
  public Response get(@Context HttpServletRequest request)
  {
    ScimServiceProviderService scimServiceProviderService = new ScimServiceProviderService(getKeycloakSession());
    Optional<ScimServiceProviderEntity> serviceProviderEntity = scimServiceProviderService.getServiceProviderEntity();
    if (serviceProviderEntity.isPresent() && !serviceProviderEntity.get().isEnabled())
    {
      throw new NotFoundException();
    }
    ResourceEndpoint resourceEndpoint = getResourceEndpoint();

    ScimAuthorization scimAuthorization = new ScimAuthorization(getKeycloakSession(), authentication);
    ScimKeycloakContext scimKeycloakContext = new ScimKeycloakContext(getKeycloakSession(), scimAuthorization);

    String query = request.getQueryString() == null ? "" : "?" + request.getQueryString();
    ScimResponse scimResponse = resourceEndpoint.handleRequest(request.getRequestURL().toString() + query,
                                                               HttpMethod.valueOf(request.getMethod()),
                                                               getRequestBody(request),
                                                               getHttpHeaders(request),
                                                               null,
                                                               commitOrRollback(),
                                                               scimKeycloakContext);
    return scimResponse.buildResponse();
  }

  /**
   * reads the request body from the input stream of the request object
   *
   * @param request the request object
   * @return the request body as string
   */
  private String getRequestBody(HttpServletRequest request)
  {
    try (InputStream inputStream = request.getInputStream())
    {
      return IOUtils.toString(inputStream, StandardCharsets.UTF_8);
    }
    catch (IOException e)
    {
      throw new IllegalStateException(e.getMessage(), e);
    }
  }

  /**
   * extracts the http headers from the request and puts them into a map
   *
   * @param request the request object
   * @return a map with the http-headers
   */
  private Map<String, String> getHttpHeaders(HttpServletRequest request)
  {
    Map<String, String> httpHeaders = new HashMap<>();
    Enumeration<String> enumeration = request.getHeaderNames();
    while (enumeration != null && enumeration.hasMoreElements())
    {
      String headerName = enumeration.nextElement();
      httpHeaders.put(headerName, request.getHeader(headerName));
    }
    return httpHeaders;
  }

And we are finished. (You can find a real world scenario in the scim-for-keycloak project: https://github.com/Captain-P-Goldfish/scim-for-keycloak)

If you are able to define a dynamic ResourceHandler for your data-repository you will be able to add new endpoints during runtime by using this dynamic ResourceHandler.