Skip to content

03 - Reading our first user

Documentation

Have a look at the official docs: https://docs.evolveum.com/connectors/connid/1.x/rest-connector-superclass/

Now that we have a better understanding on the structure of a Connector, we can finally start with the coding.

Using the REST Connector superclass

To use the REST Connector superclass we first need to import in our project the connector-rest dependency (the version will vary depending on the polygon version):

pom.xml
    <dependencies>
        <dependency>
            <groupId>com.evolveum.polygon</groupId>
            <artifactId>connector-rest</artifactId>
            <version>1.4.2.15-SNAPSHOT</version>
        </dependency>
    </dependencies>

Then we can go to the SampleRestConnector and refactor it:

SampleRestConnector
// package and imports...

@ConnectorClass(displayNameKey = "samplerest.connector.display", configurationClass = AbstractRestConfiguration.class)
public class SampleRestConnector extends AbstractRestConnector<AbstractRestConfiguration> implements TestOp {
    private static final Log LOG = Log.getLog(SampleRestConnector.class);

    @Override
    public void test() {
        LOG.ok("TODO: Add a proper test");
    }
}

As you can see we removed the Connector interface, extended the AbstractRestConnector, changed the configuration to AbstractRestConfiguration and also removed all previously implemented methods and properties, excluding the test method.

A proper test

We can now implement a proper test that will send a request to our API and throw and error if the status code is not 200.

To do that we can leverage the tools that the AbstractRestConnector gives us like this:

SampleRestConnector
// package and imports...

@ConnectorClass(displayNameKey = "samplerest.connector.display", configurationClass = AbstractRestConfiguration.class)
public class SampleRestConnector extends AbstractRestConnector<AbstractRestConfiguration> implements TestOp {
    private static final Log LOG = Log.getLog(SampleRestConnector.class);

    @Override
    public void test() {
        URIBuilder uriBuilder = getURIBuilder();
        URI uri;
        try {
            uri = uriBuilder.build();
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e.getMessage(), e);
        }
        HttpGet request = new HttpGet(uri);

        CloseableHttpResponse response = execute(request);

        processResponseErrors(response);
    }
}

This code is using the getURIBuilder method that creates a URI based on the configuration, then executes the GET request to that URI via the method executes that takes care of setting up authentication for us and finally parses the response using processResponseErrors that will check for the status code and if it's not 200 OK error out with a self describing error message.

A (very) simple schema

All midpoint connectors define a "schema", a Java object that describes the attributes of each of our users (and more).

To define a schema for our connector we need to implement the SchemaOp interface and it's corresponding method.

SampleRestConnector
public class SampleRestConnector extends AbstractRestConnector<AbstractRestConfiguration> implements SchemaOp, TestOp{

    //... test method

    @Override
    public Schema schema() {
        SchemaBuilder builder = new SchemaBuilder(SampleRestConnector.class);

        // more soon

        return builder.build();
    }

}

To create a Schema object we use the utility class provided by Midpoint called SchemaBuilder. A schema can contain object definitions for more than just users (called Accounts in Midpoint) but we will focus on just them for this guide.

Given that our users contain only two attributes "id" and "name" our schema will be really simple, so simple in fact that we don't even need to specify any attributes as "id" and "name" are automatically created.

SampleRestConnector
    @Override
    public Schema schema() {
        SchemaBuilder builder = new SchemaBuilder(SampleRestConnector.class);

        ObjectClassInfoBuilder objClassBuilder = new ObjectClassInfoBuilder();
        // Here we would add custom attributes

        builder.defineObjectClass(objClassBuilder.build());
        return builder.build();
    }

A (not so) simple query

We are now able to implement the SearchOp interface effectively. This is the operation that actually fetches the users (or more technically accounts) and gives them to Midpoint.

While this operation sounds simple it's actually quite complex, let's see why:

  • Midpoint uses the same SearchOp for any kind of reading, that means this operation should be able (based on the parameters) to fetch all users, a single users by id, or a filtered collection of users.
  • Midpoint only understands ConnectorObject instances, so we need to translate our accounts to this class.
  • This operation is also used for other kinds of objects, like for example roles (called "groups").
  • As always, we need to handle errors

We will implement the strict necessary in this guide, that means that there will be no filtering and we only fetch all the users.

The (useless) filter

Let's start by creating an empty filter class, this class would represent a filter on the resource but in our case it will remain empty. The SearchOp requires a filter class so let's make one.

SampleRestFilter
public class SampleRestFilter {

}

The SearchOp interface

We then implement the SearchOp interface in our connector passing our filter class as a type parameter.

SampleRestConnector
@ConnectorClass(displayNameKey = "samplerest.connector.display", configurationClass = AbstractRestConfiguration.class)
public class SampleRestConnector extends AbstractRestConnector<AbstractRestConfiguration> implements SchemaOp, TestOp, SearchOp<SampleRestFilter> {

    // ...test, schema

    @Override
    public FilterTranslator<SampleRestFilter> createFilterTranslator(ObjectClass objectClass, OperationOptions operationOptions) {
        return null; 
    }

    @Override
    public void executeQuery(ObjectClass objectClass, SampleRestFilter filter, ResultsHandler resultsHandler, OperationOptions operationOptions) {

    }

}

The search operation required two methods:

  • createFilterTranslator, which returns an instance of FilterTranslator, a class that converts a Midpoint filter in to our filter class
  • executeQuery, which executes the query to the resource and feeds the results to the ResultsHandler

The simplest FilterTranslator

Creating a filter translator is an easy task. Since our filter is always empty we can just create a class that extends AbstractFilterTranslator and leave everything as is.

SampleRestFilterTranslator
import org.identityconnectors.framework.common.objects.filter.AbstractFilterTranslator;

public class SampleRestFilterTranslator extends AbstractFilterTranslator<SampleRestFilter> {

}

Executing the query

Executing the query on the other hand is more work.

Firstly, the project's archetype does not include any JSON processing libraries, so we must add one. For it's simplicity we are going to use org.json, also called JSON-java.

pom.xml
    <dependencies>
        ...
        <dependency>
            <groupId>org.json</groupId>
            <artifactId>json</artifactId>
            <version>20240303</version>
        </dependency>
    </dependencies>

Then we can add a function that does the GET call and returns the JSON array containing the accounts, let's call it getAccounts().

SampleRestConnector
private JSONArray getAccounts(){
    try {
        URIBuilder uriBuilder = getURIBuilder();
        URI uri =  uriBuilder.build();

        HttpGet request = new HttpGet(uri);
        CloseableHttpResponse response = execute(request);
        processResponseErrors(response);

        String result = EntityUtils.toString(response.getEntity());
        return new JSONArray(result);
    } catch (IOException | URISyntaxException e) {
        throw new ConnectorIOException("Could not execute users query", e);
    }
}

Converting the accounts

Now, we need to a way to convert each account in a ConnectorObject, let's create the function convertAccountToConnectorObject for that purpose.

SampleRestConnector
private ConnectorObject convertAccountToConnectorObject(JSONObject account) {
    ConnectorObjectBuilder builder = new ConnectorObjectBuilder();

    String id = account.get("id").toString();
    builder.setUid(new Uid(id));

    if (account.has("name")) {
        builder.setName(account.getString("name"));
    }

    // Here we would add more fields

    return builder.build();
}

This method maps field from a JSONObject to a ConnectorObject in the following way:

  • The "id" field of the JSONObject is first converted to a String and then mapped to the ConnectorObject's UID
  • If present, the "name" field is directly mapped to the ConnectorObject's name

Putting everything together

Let's bring everything together and finally implement the executeQuery method properly.

SampleRestConnector
@Override
public void executeQuery(ObjectClass objectClass, SampleRestFilter filter, ResultsHandler resultsHandler, OperationOptions operationOptions) {
    if (objectClass.is(ObjectClass.ACCOUNT_NAME)) {
        JSONArray accounts = getAccounts();

        for (int i = 0; i < accounts.length(); i++){
            JSONObject account = accounts.getJSONObject(i);
            ConnectorObject connectorObject = convertAccountToConnectorObject(account);
            resultsHandler.handle(connectorObject);
        }
    }else {
        throw new UnsupportedOperationException("Unsupported object class " + objectClass);
    }
}

Commenting on the above code:

  1. We first check if the requested ObjectClass is the "account" class, if not throw an exception. Remember that this method is also used for "groups", that we currently not support.
  2. We then get all the accounts completely ignoring the filter parameters, a more advanced connector would take the filter into consideration and pass the filter to the resource.
  3. Now, iterating on all accounts we first convert them to a ConnectorObject, then we pass them to the resultsHandler which is how Midpoint is notified of the accounts.

The big picture

Our connector is now completed, the code should look something similar to this:

SampleRestConnector
@ConnectorClass(displayNameKey = "samplerest.connector.display", configurationClass = AbstractRestConfiguration.class)
public class SampleRestConnector extends AbstractRestConnector<AbstractRestConfiguration> implements SchemaOp, TestOp, SearchOp<SampleRestFilter> {
    private static final Log LOG = Log.getLog(SampleRestConnector.class);

    @Override
    public void test() {
        URIBuilder uriBuilder = getURIBuilder();
        URI uri;
        try {
            uri = uriBuilder.build();
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e.getMessage(), e);
        }
        HttpGet request = new HttpGet(uri);


        CloseableHttpResponse response = execute(request);

        processResponseErrors(response);
    }

    @Override
    public Schema schema() {
        SchemaBuilder builder = new SchemaBuilder(SampleRestConnector.class);

        ObjectClassInfoBuilder objClassBuilder = new ObjectClassInfoBuilder();
        ObjectClassInfo objectClass = objClassBuilder.build();
        builder.defineObjectClass(objectClass);

        return builder.build();
    }

    @Override
    public FilterTranslator<SampleRestFilter> createFilterTranslator(ObjectClass objectClass, OperationOptions operationOptions) {
        return new SampleRestFilterTranslator();
    }

    @Override
    public void executeQuery(ObjectClass objectClass, SampleRestFilter filter, ResultsHandler resultsHandler, OperationOptions operationOptions) {
        if (objectClass.is(ObjectClass.ACCOUNT_NAME)) {
            JSONArray accounts = getAccounts();

            for (int i = 0; i < accounts.length(); i++){
                JSONObject account = accounts.getJSONObject(i);
                ConnectorObject connectorObject = convertAccountToConnectorObject(account);
                resultsHandler.handle(connectorObject);
            }
        }else {
            throw new UnsupportedOperationException("Unsupported object class " + objectClass);
        }
    }

    private JSONArray getAccounts(){
        try {
            URIBuilder uriBuilder = getURIBuilder();
            URI uri =  uriBuilder.build();

            HttpGet request = new HttpGet(uri);
            CloseableHttpResponse response = execute(request);
            processResponseErrors(response);

            String result = EntityUtils.toString(response.getEntity());
            return new JSONArray(result);
        } catch (IOException | URISyntaxException e) {
            throw new ConnectorIOException("Could not execute users query", e);
        }
    }

    private ConnectorObject convertAccountToConnectorObject(JSONObject account) {
        ConnectorObjectBuilder builder = new ConnectorObjectBuilder();

        String id = account.get("id").toString();
        builder.setUid(new Uid(id));

        if (account.has("name")) {
            builder.setName(account.getString("name"));
        }

        // Here we would add more fields

        return builder.build();
    }

}

Testing our connector

Let's now build our project, copy the resulting jar inside the MidPoint container and finally read the users from our API.

mvn clean package
cd your/compose/folder
docker compose cp your/connector/folder/target/connector-sample-rest-1.0-SNAPSHOT.jar midpoint_server:/opt/midpoint/var/connid-connectors/
docker compose restart midpoint_server