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):
<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:
// 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:
// 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.
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.
@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
ConnectorObjectinstances, 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.
The SearchOp interface
We then implement the SearchOp interface in our connector passing our filter class as a type parameter.
@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 ofFilterTranslator, a class that converts a Midpoint filter in to our filter classexecuteQuery, which executes the query to the resource and feeds the results to theResultsHandler
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.
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.
<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().
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.
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.
@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:
- 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.
- We then get all the accounts completely ignoring the
filterparameters, a more advanced connector would take the filter into consideration and pass the filter to the resource. - Now, iterating on all accounts we first convert them to a
ConnectorObject, then we pass them to theresultsHandlerwhich is how Midpoint is notified of the accounts.
The big picture
Our connector is now completed, the code should look something similar to this:
@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.