Skip to content

Commit

Permalink
A GraphQL generic SCO. Illustrated with countries API but all concept…
Browse files Browse the repository at this point in the history
…s are applicable to any GraphQL APIs.
  • Loading branch information
thierryciot committed Jun 14, 2024
1 parent 77e518d commit e02ee51
Show file tree
Hide file tree
Showing 15 changed files with 5,430 additions and 0 deletions.
99 changes: 99 additions & 0 deletions ServiceCallOut/GraphQL/DataAccess/GetCountriesServiceCallout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
This SCO gets data using graphQL from URL specified in this file
The modeler simply references a user friendly query name in the Query entity and specifies the values for each of the query parameters (if needed)
Thus the modeler has the full power of the rule language to specify query names and parameters based on various conditions in rule execution.
You can see an example of this in PrepareQuery.ers.
The query names are defined as a string enum (See QueryName enum in the vocab.ecore file)
Internally, it works this way:
1) The queries and all associated metadata are defined in the Queries.js.
2) The modeler friendly query names are referenced in the module.exports file and points to the query metadata.
3) The query metadata contains the actual query as well as:
o Where to find the data of interest in the GraphQL result (pathToData)
o If adding results to root, the data type of entity to add (entityType)
o If adding results to a relationship, the external name of the relationship to which we will be adding the sub-payload (roleName)
So, in brief: the user friendly query name maps to metadata in Queries.js.
From that metadata, the GraphQL connector can process the query generically.
*/

const graphQLConnector = require('./GraphQLConnector');

const graphQLSCO = {
func: 'getCountriesFct',
type: 'ServiceCallout',
description: {'en_US': 'This function gets a set of countries from the end point https://countries.trevorblades.com/graphql.'},
extensionType: 'SERVICE_CALLOUT',
name: {'en_US': 'getCountries'}
};

async function getCountriesFct(corticonDataManager, serviceCalloutProperties) {
const logger = corticonDataManager.getLogger();
logger.logDebug(`SCO getCountriesFct V1.0`);

// This url (as well as authentication token) could be passed as a configuration property (do not add to runtime properties as these get bundled in the decision service)
// See this sample on how to use configuration properties: https://github.com/corticon/corticon.js-samples/tree/master/ServiceCallOut/AccessConfigurationProperties
const url = 'https://countries.trevorblades.com/graphql';
const queryMetadata = graphQLConnector.getQueryMetadata(corticonDataManager, logger);
logger.logDebug(`*** SCO: Got query metadata: ${JSON.stringify(queryMetadata, 2)}`);
const countriesList = await graphQLConnector.getData(url, queryMetadata.query, queryMetadata.pathToData, logger);
logger.logDebug(`*** SCO: Got countries: ${JSON.stringify(countriesList, 2)}`);
let countries;
// When we get by continent we need to get one more level down
if ( queryMetadata.pathToData2 !== undefined && queryMetadata.pathToData2 !== null )
countries = countriesList[queryMetadata.pathToData2];
else
countries = countriesList;

logger.logDebug(`*** SCO: Got countries: ${JSON.stringify(countries, 2)}`);

// If there is an entity, let's add to it, otherwise we just add all the countries to the top level of the payload
const nameEntityToAppendResultsTo = getNameEntityToAppendResultsTo(serviceCalloutProperties);
const entity = getEntityToAppendTo(nameEntityToAppendResultsTo, corticonDataManager, logger);
if ( entity !== null ) {
logger.logDebug(`*** SCO: Adding to ${nameEntityToAppendResultsTo} entity using external name: ${queryMetadata.roleName}`);
corticonDataManager.addAssociationsToEntity(entity, queryMetadata.roleName, countries); // second parameter is the attribute name as it appears in the payload
}
else {
logger.logDebug(`*** SCO: Adding to top level based on entity type: ${queryMetadata.entityType}`);
corticonDataManager.addEntitiesAndAssociations(queryMetadata.entityType, countries);
}
}


function getNameEntityToAppendResultsTo(serviceCalloutProperties) {
// Where do we append the result.
// If you pass a valid entity as a run time prop then the SCO will add the data to the entity using an association.
// If you do not pass one then it adds to root.
if (serviceCalloutProperties['entityToAppendTo'] !== undefined && serviceCalloutProperties['entityToAppendTo'] !== null)
return serviceCalloutProperties['entityToAppendTo'];
else
return null; // will append to root
}

/*
Generic function to find the entity to append to
*/
function getEntityToAppendTo(entityToAppendCountryTo, corticonDataManager, logger) {
logger.logDebug(`*** Searching for ${entityToAppendCountryTo} entities and returning first one`);
if ( entityToAppendCountryTo === undefined || entityToAppendCountryTo === null ) {
logger.logDebug(`*** nothing to search for`);
return null;
}

let entityToReturn = null; // default to not finding
const entitiesSet = corticonDataManager.getEntitiesByType(entityToAppendCountryTo);
for (const entity of entitiesSet) {
logger.logDebug(`*** one entity ${entity.id}`);
entityToReturn = entity;
break;
}

if ( entityToReturn === null )
logger.logDebug(`*** didn't find any ${entityToAppendCountryTo} `);

return entityToReturn;
}

exports.getCountriesFct = getCountriesFct;
97 changes: 97 additions & 0 deletions ServiceCallOut/GraphQL/DataAccess/GraphQLConnector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const queries = require('./Queries');

async function getData (url, graphQLQuery, pathToData, logger) {
const graphQLResult = await getDataUsingGraphQL( graphQLQuery, url, logger );
if ( graphQLResult !== null ) {
const results = graphQLResult[pathToData];
return results;
}
else
throw new Error ("GQL Connector: Bad data returned by GraphQL - check log");
}

function getQueryMetadata(corticonDataManager, logger) {
const queryEntity = getQueryEntity(corticonDataManager, logger); // The entity containing all the data to use in the query
const params = getQueryParams(queryEntity, logger); // the list of parameter values to use for substitution in the query
const queryMetadataResolverFct = getQueryMetadataResolverFct(queryEntity, logger); // the function to call to invoke param substitution

// do params substitution in the query and retrieve other metadata about that query
const queryMetadata = queryMetadataResolverFct(params);
return queryMetadata;
}

function getQueryEntity(corticonDataManager, logger) {
logger.logError(`*** GQL Connector: Searching for Query entity`);

let queryEntity = null; // default to not finding
const entitiesSet = corticonDataManager.getEntitiesByType('Query');
for (const entity of entitiesSet) {
logger.logError(`*** GQL Connector: Found one query`);
queryEntity = entity;
break;
}

if ( queryEntity === null ) {
logger.logError(`*** GQL Connector: didn't find any Query`);
throw new Error ('Payload does not have a Query entity');
}
else
return queryEntity;
}

function getQueryParams(queryEntity, logger) {
logger.logError(`*** Extracting parameters from Query entity `);

const params = [];

//todo: null/undefined checks + iterate through all param attributes
params[0] = queryEntity.parameter1;
params[1] = queryEntity.parameter2;

logger.logError(`*** GQL Connector: params added ${params[0]}`);
return params;
}

function getQueryMetadataResolverFct(queryEntity, logger) {
//todo: null/undefined checks
const name = queryEntity.name;
logger.logError(`*** GQL Connector: Extracting query name from Query entity. Found: ${name}`);

const queryResolverFct = queries[name];
if ( queryResolverFct === undefined || queryResolverFct === null )
throw new Error (`*** GQL Connector: missing query resolver fct for "${name}"` );

if ( typeof queryResolverFct !== 'function' )
throw new Error (`*** GQL Connector: query resolver for "${name}" is not a function` );

logger.logDebug(`*** GQL Connector: query resolver for ${name} found`);

return queryResolverFct;
}

/*
Generic function to get data from server using fetch API querying with a GraphQL query
*/
async function getDataUsingGraphQL( graphQLQuery, url, logger ) {
const results = await fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(graphQLQuery)
});

const text = await results.text(); // Parse it as text as sometimes we may get invalid json

try {
const graphQL = JSON.parse(text);
logger.logDebug(`***** GQL Connector: getDataUsingGraphQL got response:\n ${JSON.stringify(graphQL)}`);
return graphQL.data;
}
catch ( e ) {
logger.logError(`***** GQL Connector: getDataUsingGraphQL Bad data returned by GraphQL ${text}`);
return null;
}
}

module.exports = { getData, getQueryMetadata };
110 changes: 110 additions & 0 deletions ServiceCallOut/GraphQL/DataAccess/Queries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@

/*
This module defines the two queries available to modelers: see module.exports at bottom of the file.
Query can be tried at https://lucasconstantino.github.io/graphiql-online/
https://countries.trevorblades.com/graphql
query {
continent(code: "OC") {
countries {
name
code
currency
awsRegion
languages {
code
name
}
}
}
}
*/
function getCountriesByContinentMetadata(params) {
const countriesByContinent = {
query: `{
continent(code: "${params[0]}") {
countries {
awsRegion
name
code
phone
currency
languages {
code
name
}
}
}
}`
};

return { "query": countriesByContinent, // The GraphQL query with all the parameters resolved.
"pathToData": 'continent', // The name of the attribute where to find the list of countries
"pathToData2": 'countries', // The name of the attribute where to find countries array under pathToData - this is our JSON sub-payload in the GraphQL results.
'entityType': 'Countries', // The Corticon vocabulary datatype of the entity we will be adding sub-payload to the root of the payload (only used when adding to root)
'roleName': 'countries' // The Corticon vocabulary external name of the relationship to which we will be adding the sub-payload (only used when adding to a specific entity) - in our case the role name is "countriesOfInterest" but the external name is "countries" - see the properties of the relationship in vocab.ecore
};
}


/*
Query can be tried at https://lucasconstantino.github.io/graphiql-online/
https://countries.trevorblades.com/graphql
We intentionally return more attributes than the ones mapped into the vocabulary to show that more data
can be inserted into the payload; of course no rules can accessed these additional attributes but this
data will be output in the results. And it can be used by other services.
query {
country(code: "CH") {
awsRegion
name
code
phone
currency
native
capital
emoji
continent {
code
}
languages {
code
name
}
}
}
*/
function getCountryByCodeMetadata(params) {
const countriesByCode = {
query: `{
country(code: "${params[0]}") {
awsRegion
name
code
phone
currency
native
capital
emoji
continent {
code
}
languages {
code
name
}
}
}`
};

return { "query": countriesByCode, // The GraphQL query with all the parameters resolved.
"pathToData": 'country', // The name of the attribute where to find the JSON sub-payload in the GraphQL results.
'entityType': 'Countries', // The Corticon vocabulary datatype of the entity we will be adding sub-payload to the root of the payload (only used when adding to root)
'roleName': 'countries' // The Corticon vocabulary external name of the relationship to which we will be adding the sub-payload (only used when adding to a specific entity)
};
}

module.exports = { "Get Countries by Continent": getCountriesByContinentMetadata, "Get Country by Code": getCountryByCodeMetadata };
22 changes: 22 additions & 0 deletions ServiceCallOut/GraphQL/GetOneCountry.erf
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<com.corticon.rulesemf.assetmodel:RuleflowAsset xmi:version="2.0" xmlns:xmi="http://www.omg.org/XMI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:com.corticon.rulesemf.assetmodel="http:///com/corticon/rulesemf/assetmodel.ecore" xmlns:com.corticon.rulesemf.canonicalrulemodel.ruleflow="http:///com/corticon/rulesemf/canonicalrulemodel/ruleflow.ecore" xmlns:com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram="http:///com/corticon/rulesemf/viewrulemodel/ruleflow/flowdiagram.ecore" majorVersionNumber="7" minorVersionNumber="1" buildNumber="8022" updateStamp="_jpWUECivEe-SkvI28G1NTg" externalChecksum="2722078124:507989954:3409648236" studioType="Javascript" rulesheetAssets="SubFlows/PrepareQueryGetOneCountry.ers#/ ProcessCountries.ers#/">
<ruleflow majorVersion="1" vocabularyUpdateStamp="_NJew0PwlEe6XxI7TJTZ7WA" vocabulary="vocab.ecore#/">
<flowControlList xsi:type="com.corticon.rulesemf.canonicalrulemodel.ruleflow:ActivityNode" name="PrepareQueryGetOneCountry" order="1" ruleActivityUpdateStamp="_TGvkAfwmEe6XxI7TJTZ7WA" nextStep="#//@ruleflow/@flowControlList.1" invokes="SubFlows/PrepareQueryGetOneCountry.ers#//@ruleset"/>
<flowControlList xsi:type="com.corticon.rulesemf.canonicalrulemodel.ruleflow:ActivityNode" name="Get One Country with GraphQL" order="2" ruleActivityUpdateStamp="_jpTQwiivEe-SkvI28G1NTg" nextStep="#//@ruleflow/@flowControlList.2" invokes="#//@ruleflow/@connectorList.0"/>
<flowControlList xsi:type="com.corticon.rulesemf.canonicalrulemodel.ruleflow:ActivityNode" name="ProcessCountries" order="3" ruleActivityUpdateStamp="_cpzhkfwZEe6o1KyzUc0vrA" invokes="ProcessCountries.ers#//@ruleset"/>
<connectorList className="GetCountriesServiceCallout.js" serviceName="getCountries"/>
</ruleflow>
<ruleflowViewList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:FlowDiagram">
<flowShapeList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:ActivityShape" x="135" y="60" width="336" height="61" outboundEdges="#//@ruleflowViewList.0/@flowEdgeList.0" activityNode="#//@ruleflow/@flowControlList.0">
<annotations name="Color" value="4259584"/>
</flowShapeList>
<flowShapeList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:ActivityShape" x="135" y="225" width="331" height="69" outboundEdges="#//@ruleflowViewList.0/@flowEdgeList.1" inboundEdges="#//@ruleflowViewList.0/@flowEdgeList.0" activityNode="#//@ruleflow/@flowControlList.1">
<annotations name="Color" value="16777088"/>
</flowShapeList>
<flowShapeList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:ActivityShape" x="135" y="390" width="331" height="68" inboundEdges="#//@ruleflowViewList.0/@flowEdgeList.1" activityNode="#//@ruleflow/@flowControlList.2">
<annotations name="Color" value="12632256"/>
</flowShapeList>
<flowEdgeList sourceShape="#//@ruleflowViewList.0/@flowShapeList.0" targetShape="#//@ruleflowViewList.0/@flowShapeList.1"/>
<flowEdgeList sourceShape="#//@ruleflowViewList.0/@flowShapeList.1" targetShape="#//@ruleflowViewList.0/@flowShapeList.2"/>
</ruleflowViewList>
</com.corticon.rulesemf.assetmodel:RuleflowAsset>
17 changes: 17 additions & 0 deletions ServiceCallOut/GraphQL/GetOneCountrySimplest.erf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<com.corticon.rulesemf.assetmodel:RuleflowAsset xmi:version="2.0" xmlns:xmi="http://www.omg.org/XMI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:com.corticon.rulesemf.assetmodel="http:///com/corticon/rulesemf/assetmodel.ecore" xmlns:com.corticon.rulesemf.canonicalrulemodel.ruleflow="http:///com/corticon/rulesemf/canonicalrulemodel/ruleflow.ecore" xmlns:com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram="http:///com/corticon/rulesemf/viewrulemodel/ruleflow/flowdiagram.ecore" majorVersionNumber="7" minorVersionNumber="1" buildNumber="8022" updateStamp="_qnRvgSivEe-SkvI28G1NTg" externalChecksum="2722078124:507989954:3409648236" studioType="Javascript" rulesheetAssets="ProcessCountries.ers#/">
<ruleflow majorVersion="1" vocabularyUpdateStamp="_NJew0PwlEe6XxI7TJTZ7WA" vocabulary="vocab.ecore#/">
<flowControlList xsi:type="com.corticon.rulesemf.canonicalrulemodel.ruleflow:ActivityNode" name="Get Country from GraphQL" order="1" ruleActivityUpdateStamp="_qnPTQSivEe-SkvI28G1NTg" nextStep="#//@ruleflow/@flowControlList.1" invokes="#//@ruleflow/@connectorList.0"/>
<flowControlList xsi:type="com.corticon.rulesemf.canonicalrulemodel.ruleflow:ActivityNode" name="ProcessCountries" order="2" ruleActivityUpdateStamp="_cpzhkfwZEe6o1KyzUc0vrA" invokes="ProcessCountries.ers#//@ruleset"/>
<connectorList className="GetCountriesServiceCallout.js" serviceName="getCountries"/>
</ruleflow>
<ruleflowViewList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:FlowDiagram">
<flowShapeList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:ActivityShape" x="180" y="135" width="247" height="72" outboundEdges="#//@ruleflowViewList.0/@flowEdgeList.0" activityNode="#//@ruleflow/@flowControlList.0">
<annotations name="Color" value="16777088"/>
</flowShapeList>
<flowShapeList xsi:type="com.corticon.rulesemf.viewrulemodel.ruleflow.flowdiagram:ActivityShape" x="180" y="345" width="256" height="62" inboundEdges="#//@ruleflowViewList.0/@flowEdgeList.0" activityNode="#//@ruleflow/@flowControlList.1">
<annotations name="Color" value="12632256"/>
</flowShapeList>
<flowEdgeList sourceShape="#//@ruleflowViewList.0/@flowShapeList.0" targetShape="#//@ruleflowViewList.0/@flowShapeList.1"/>
</ruleflowViewList>
</com.corticon.rulesemf.assetmodel:RuleflowAsset>
Loading

0 comments on commit e02ee51

Please sign in to comment.