OSGI & Java

CRX

https://localhost:4502/crx/de

System Console

https://localhost:4502/system/console

Demo: OSGI Config

Using CRXDE, navigate to /apps/PROJECT/osgiconfig/config

Create a node named com.day.cq.commons.servlets.RootMappingServlet.cfg.json
Enter the following:

{
    "rootmapping.target": "/crx/de"
}

Click Save All. Now try to access http://localhost:4502.

End of demo.


Activity: Create OSGI Configs

In your project, go to ui.config/src/main/content/jcr_root/apps/ercot/osgiconfig/config and create a file named:

org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended~stockImport.cfg.json

Then copy the following json and paste it into that file:

{
    "service.ranking:Integer":0,
    "user.mapping":"ercot-training.core:training=[stock-import-service]"
}

In the same ui.config/src/main/content/jcr_root/apps/ercot/osgiconfig/config folder, now create a file named:

org.apache.sling.jcr.repoinit.RepositoryInitializer~stockImport.cfg.json

Then copy the following json into that file:

{
  "scripts":[
    "create service user stock-import-service\n set ACL on /content\n allow jcr:all for stock-import-service\n end\n"
  ]
}

End of activity.


Activity: Create Scheduled Job

In the code project, go to core/src/main/java/com/ercot/training/core/schedulers.

Create a file named StockImportScheduler.java

In that file, place the following:

package com.ercot.training.core.schedulers;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

//org.apache.sling.event.*  api available in Sling 9 documentation:
import org.apache.sling.event.jobs.JobBuilder;
import org.apache.sling.event.jobs.JobBuilder.ScheduleBuilder;
import org.apache.sling.event.jobs.JobManager;
import org.apache.sling.event.jobs.ScheduledJobInfo;

import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.AttributeType;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class adds a Sling Job to the job queue so that a job consumer can process
 * work. Sling Jobs are guaranteed to be proceed and the scheduler can be configured
 * based on an OSGi config node.
 */
@Component(immediate = true,
			configurationPid = "com.ercot.training.core.schedulers.StockImportScheduler",
			configurationPolicy = ConfigurationPolicy.REQUIRE)
@Designate(ocd = StockImportScheduler.StockImportConfiguration.class, factory=true)
public class StockImportScheduler {
	public static final String JOB_TOPIC_STOCKIMPORT = "com/adobe/training/core/jobs/stockimportjob";
	public static final String JOB_PROP_SYMBOL = "symbol";
	public static final String JOB_PROP_URL = "url";
	public static final String DEFAULT_IMPORT_URL = "https://raw.githubusercontent.com/Adobe-Marketing-Cloud/ADLS-Samples/master/stock-data/";
	
	private final Logger logger = LoggerFactory.getLogger(getClass());
	// Convenience string to find the log messages for this training example class
	// Logs can be found in crx-quickstart/logs/error.log
	private String searchableLogStr = "*****";

	@ObjectClassDefinition(name = "Training Stock Importer")
	public @interface StockImportConfiguration {
		@AttributeDefinition(
				name = "Stock Symbol",
				description = "Characters representing the stock to be imported",
				type = AttributeType.STRING
		)
		public String symbol() default "";

		@AttributeDefinition(
			name = "Expression",
			description = "Run every so often as defined in the cron-job expression.",
			type = AttributeType.STRING
		)
		public String cronExpression() default "0 0/2 * * * ?";

		@AttributeDefinition(
				name = "Stock URL",
				description = "URL to request the stock data to be imported",
				type = AttributeType.STRING
		)
		public String stock_url() default DEFAULT_IMPORT_URL;
	}

	@Reference
	private JobManager jobManager;
	
    private int schedulerID;

	private JobBuilder jobBuilder;
	private ScheduleBuilder scheduleBuilder;
	private ScheduledJobInfo theScheduledJob;
	
    @Activate
	protected void activate(StockImportConfiguration config) {
		logger.info(searchableLogStr + "StockImport ScheduledJob '{}' with ID: '{}' Activated", config.symbol(), schedulerID);
		schedulerID = config.symbol().hashCode();
		startJobScheduler(config);
	}
    @Modified
    protected void modified(StockImportConfiguration config) {
    	removeJobScheduler(config);
    	schedulerID = config.symbol().hashCode() + 1;  //updates schedulerID
    	startJobScheduler(config);
    }

	@Deactivate
	protected void deactivate(StockImportConfiguration config) {
		removeJobScheduler(config);
	}

    private void startJobScheduler(StockImportConfiguration config){
    	
    	jobBuilder = jobManager.createJob(StockImportScheduler.JOB_TOPIC_STOCKIMPORT);
        // Create a properties map that contains the configurations we want to pass to the job
		HashMap<String, Object> jobProps = new HashMap<>();
		jobProps.put(JOB_PROP_SYMBOL, config.symbol());
		jobProps.put(JOB_PROP_URL, config.stock_url());
		
        jobBuilder.properties(jobProps);
        scheduleBuilder = jobBuilder.schedule();
    	scheduleBuilder.cron(config.cronExpression());
    	theScheduledJob = scheduleBuilder.add();
        if(theScheduledJob == null){
            List<String> errors = new ArrayList<>();
            scheduleBuilder.add(errors);
        } else {
	        logger.info(searchableLogStr + "ScheduledJob added to the Queue.  Topic: " + theScheduledJob.getJobTopic() + "  "
	        		+ "Properties: " + theScheduledJob.getJobProperties().toString() + " "
	        		+ "Next Execution: " + theScheduledJob.getNextScheduledExecution().toString());
        }
    }
    
	private void removeJobScheduler(StockImportConfiguration config) {
		if(theScheduledJob != null) {
			logger.info(searchableLogStr + "Removing '{}' ScheduledJob, with ID: '{}'", config.symbol(), schedulerID);		
			theScheduledJob.unschedule();
		}
	}
}

Build with

mvn clean install -pl core -PautoInstallBundle

This will only build the java code in the core module, instead of the entire project.

Now, create configurations to activate the scheduled jobs.

In the ui.config/src/main/content/jcr_root/apps/ercot/osgiconfig/config folder, create:

com.ercot.training.core.schedulers.StockImportScheduler~adbe.cfg.json

Fill it with:

{
    "symbol":"ADBE",
    "stock.url":"https://raw.githubusercontent.com/Adobe-Marketing-Cloud/ADLS-Samples/master/stock-data/",
    "cronExpression":"0 0/2 * * * ?"
}

Now create com.ercot.training.core.schedulers.StockImportScheduler~msft.cfg.json and fill it with:

{
    "symbol":"MSFT",
    "stock.url":"https://raw.githubusercontent.com/Adobe-Marketing-Cloud/ADLS-Samples/master/stock-data/",
    "cronExpression":"0 0/2 * * * ?"
}

Run the following to deploy the configuration package

mvn clean install -pl ui.config -PautoInstallPackage

If your editor has an AEM sync plugin, that can be used instead.

Then create the job consumer.
Go to core/src/main/java/com/ercot/training/core and create StockDataWriterJob.java
Fill it with the following:

package com.ercot.training.core;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;

import javax.jcr.RepositoryException;
import javax.net.ssl.HttpsURLConnection;

import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.ModifiableValueMap;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.ResourceUtil;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.day.cq.commons.jcr.JcrConstants;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import com.ercot.training.core.schedulers.StockImportScheduler;

/**
* This job consumer takes in a data source url and stock symbol
* and saves the stock quote response to the JCR
* 
* /content/stocks/
*   + <STOCK_SYMBOL> [sling:OrderedFolder]
*/
@Component(
        immediate = true,
        service = JobConsumer.class,
        property ={
                JobConsumer.PROPERTY_TOPICS +"="+ StockImportScheduler.JOB_TOPIC_STOCKIMPORT
        }
)
public class StockDataWriterJob implements JobConsumer {

	private final Logger logger = LoggerFactory.getLogger(getClass());
	// Convenience string to find the log messages for this training example class
	// Logs can be found in crx-quickstart/logs/error.log
	private String searchableLogStr = "&&&&&";
	
	//Public values for stock data
	public static final String STOCK_IMPORT_FOLDER = "/content/stocks";
	public static final String COMPANY = "companyName";
	public static final String SECTOR = "sector";
	public static final String LASTTRADE = "lastTrade";
	public static final String UPDATETIME = "timeOfUpdate";
	public static final String DAYOFUPDATE = "dayOfLastUpdate";
	public static final String OPENPRICE = "openPrice";
	public static final String RANGEHIGH = "rangeHigh";
	public static final String RANGELOW = "rangeLow";
	public static final String VOLUME = "volume";
	public static final String UPDOWN = "upDown";
	public static final String WEEK52LOW = "week52Low";
	public static final String WEEK52HIGH = "week52High";
	public static final String YTDCHANGE = "ytdPercentageChange";
	
	@Reference
	private ResourceResolverFactory resourceResolverFactory;
		
	/**
	 * Method that runs on the desired schedule. 
	 * Request the data with the stock symbol and get the returned JSON
	 * Write the JSON to the JCR
	 * @return JobConsumer.JobResult.[ OK | FAILED ]
	 */
	@Override
	public JobResult process(Job job) {

		//extract properties added to the Job in Scheduler: 
		String symbol = job.getProperty(StockImportScheduler.JOB_PROP_SYMBOL).toString().toUpperCase();
		String stock_url = job.getProperty(StockImportScheduler.JOB_PROP_URL).toString();
		
		//https://raw.githubusercontent.com/Adobe-Marketing-Cloud/ADLS-Samples/master/stock-data/
		String stockUrl = stock_url + symbol + ".json";
		
		HttpsURLConnection request = null;
		try {
			URL sourceUrl = new URL(stockUrl);
			request = (HttpsURLConnection) sourceUrl.openConnection();
			request.setConnectTimeout(5000);
			request.setReadTimeout(10000);
			request.connect();
		
			// Convert data return to a JSON object
			ObjectMapper objMapper = new ObjectMapper();
			JsonFactory factory = new JsonFactory();
			JobResult jobResult = null;
			if(request != null) {
				//Create a JsonParser based on the stream from the request content
				try(JsonParser parser  = factory.createParser(new InputStreamReader((InputStream) request.getContent()))){

					//Create a Map from the JsonParser
					Map<String, String> allQuoteData = objMapper.readValue(parser,
							new TypeReference<Map<String,String>>(){});

					logger.info("Last trade for stock symbol {} was {}", symbol, allQuoteData.get("latestPrice"));
					//Use the map to write nodes and properties to the JCR
					jobResult = writeToRepository(symbol, allQuoteData);
				}
				catch (RepositoryException e) {
					logger.error(searchableLogStr + "Cannot write stock info for " + symbol + " to the JCR: ", e);
					return JobConsumer.JobResult.FAILED;
				} catch (JsonParseException e) {
					logger.error(searchableLogStr + "Cannot parse stock info for " + symbol, e);
					return JobConsumer.JobResult.FAILED;
				} catch (IOException e) {
					logger.error(searchableLogStr + "IOException: ", e);
					return JobConsumer.JobResult.FAILED;
				}
			}
			return jobResult;
		} catch (SocketTimeoutException e) {
			logger.error(searchableLogStr + "Five Second Timeout occured.");
			return JobConsumer.JobResult.FAILED;
		}
		catch (IOException e) {
			logger.error(searchableLogStr + "The stock symbol: " + symbol + " does not exist...");
			return JobConsumer.JobResult.FAILED;
		}
	}
	
	/**
	 * Creates the stock data structure
	 * 
	 *  + <STOCK_SYMBOL> [sling:OrderedFolder]
	 *     + trade [nt:unstructured]
	 *     	 - companyName = <value>
	 *     	 - sector = <value>
	 *       - lastTrade = <value>
	 *       - timeOfUpdate = <value>
	 *       - dayOfLastUpdate = <value>
	 *       - openPrice = <value>
	 *       - rangeHigh = <value>
	 *       - rangeLow = <value>
	 *       - volume = <value>
	 *       - upDownPrice = <value>
	 *       - week52High = <value>
	 *       - week52Low = <value>
	 *       - ytdChange = <value>
	 * @return JobConsumer.JobResult.[ OK | FAILED ]
	 */
	private JobResult writeToRepository(String stockSymbol, Map<String, String> quoteData) throws RepositoryException {

		logger.info(searchableLogStr + "Stock Symbol: " + stockSymbol);
		logger.info(searchableLogStr + "JsonObject to Write: " + quoteData.toString());

		//Get the service user (stock-import-service) that belongs to the training.core:training subservice
		Map<String, Object> serviceParams = new HashMap<>();
		serviceParams.put(ResourceResolverFactory.SUBSERVICE, "training");

		try (ResourceResolver resourceResolver = resourceResolverFactory
				.getServiceResourceResolver(serviceParams)) {

			// Transform the time stamp into a readable format
			ZoneId timeZone = ZoneId.of("America/New_York");
			long latestUpdateTime = Long.parseLong(quoteData.get("latestUpdate"));
			LocalDateTime timePerLatestUpdate = LocalDateTime.ofInstant(Instant.ofEpochMilli(latestUpdateTime),
						timeZone);
			ZonedDateTime timeWithZone = ZonedDateTime.of(timePerLatestUpdate, timeZone);
			DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a zz");
			//will store timeOfUpdate as:  Hour:Minute AM/PM, TimeZone    e.g.   11:34 AM, EDT
			String UpdateTimeOfDay = timeWithZone.format(timeFormatter);
			DateTimeFormatter dayFormatter = DateTimeFormatter.ofPattern("E MMMM d, yyyy");
			String dayOfUpdate = timeWithZone.format(dayFormatter);

			//Create variables in specific data type and put them into a map
			Double lastPrice = Double.parseDouble(quoteData.get("latestPrice"));
			Double open = Double.parseDouble(quoteData.get("open"));
			Double high = Double.parseDouble(quoteData.get("high"));
			Double low = Double.parseDouble(quoteData.get("low"));
			Long latestVolume = Long.parseLong(quoteData.get("latestVolume"));
			Double change = Double.parseDouble(quoteData.get("change"));
			Double week52High = Double.parseDouble(quoteData.get("week52High"));
			Double week52Low = Double.parseDouble(quoteData.get("week52Low"));
			Double ytdChange = Double.parseDouble(quoteData.get("ytdChange"));

			String stockPath = STOCK_IMPORT_FOLDER + "/" + stockSymbol;
			//Test if stock import folder exists, otherwise create it
			Resource stockFolder = ResourceUtil.getOrCreateResource(resourceResolver, stockPath, "", "", false);

			//Check to see if the trade node exists. If not, create it
			String tradePath = stockPath + "/trade";
			Resource trade = resourceResolver.getResource(tradePath);
			if (trade == null) {
				// set jcr:primaryType to nt:unstructured when resource is created
				Map<String,Object> stockData = new HashMap<String,Object>() {
					private static final long serialVersionUID = 1L;
				{
					put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
				}};
				trade = resourceResolver.create(stockFolder, "trade", stockData);
			}

			ModifiableValueMap stockData = trade.adaptTo(ModifiableValueMap.class);
			stockData.put(COMPANY, quoteData.get("companyName"));
			stockData.put(SECTOR, quoteData.get("sector"));
			stockData.put(UPDATETIME, UpdateTimeOfDay);
			stockData.put(DAYOFUPDATE, dayOfUpdate);
			stockData.put(LASTTRADE, lastPrice);
			stockData.put(OPENPRICE, open);
			stockData.put(RANGEHIGH, high);
			stockData.put(RANGELOW, low);
			stockData.put(VOLUME, latestVolume);
			stockData.put(UPDOWN,change );
			stockData.put(WEEK52HIGH,week52High);
			stockData.put(WEEK52LOW,week52Low);
			stockData.put(YTDCHANGE,ytdChange);
			resourceResolver.commit(); //Write data into the JCR

			logger.info(searchableLogStr + "Updated trade data for " + stockSymbol);

		} catch (LoginException | PersistenceException e) {
			logger.error(searchableLogStr + "Exception with writing resource: ", e);
			return JobConsumer.JobResult.FAILED;
		}
		return JobConsumer.JobResult.OK;
	}
}

End of activity.


Activity: Create Sling Resource Listener

Go to core/src/main/java/com/ercot/training/core/listeners, and create StockListener.java

Fill it with:

package com.ercot.training.core.listeners;

import java.io.IOException;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;

import com.ercot.training.core.StockDataWriterJob;
import com.ercot.training.core.schedulers.StockImportScheduler;

import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.ResourceUtil;
import org.apache.sling.api.resource.observation.ResourceChange;
import org.apache.sling.api.resource.observation.ResourceChangeListener;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.service.cm.Configuration;
import org.osgi.service.cm.ConfigurationAdmin;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This Sling listener listens to the StockDataWriterJob.STOCK_IMPORT_FOLDER location and creates a 
 * new scheduler config for each new stock folder added.
 * 
 * To add a symbol from the UI, go to AEM Navigation > Sites > stocks and click the blue Create > Folder
 * Add the Stock symbol as the Title. Dummy stock data is available for ADBE,MSFT,GOOG,AMZN,APPL,WDAY
 * 
 * Learn more about creating OSGi configurations programmatically:
 * http://www.nateyolles.com/blog/2015/10/updating-osgi-configurations-in-aem-and-sling
 */

@Component( immediate = true,
property = {"resource.paths=" + StockDataWriterJob.STOCK_IMPORT_FOLDER,
		"resource.change.types=ADDED",
		"resource.change.types=REMOVED"
		})

public class StockListener implements ResourceChangeListener{
	private final String stockImportSchedulerPID = "com.ercot.training.core.schedulers.StockImportScheduler";

	private final Logger logger = LoggerFactory.getLogger(getClass());
	// Convenience string to find the log messages for this training example class
	// Logs can be found in crx-quickstart/logs/error.log
	private String searchableLogStr = "$$$$$";

	// Service to get OSGi configurations
    @Reference
    private ConfigurationAdmin configAdmin;
	// Service to add/remove resources if needed
	@Reference
	private ResourceResolverFactory resourceResolverFactory;

	@Override
	public void onChange(List<ResourceChange> changes) {

		for (final ResourceChange change : changes) {
			logger.info(searchableLogStr + "Resource Change Detected: {}", change);

			//Get the folder name from the path. Ex: /content/stocks/adbe > adbe
			String folderName = change.getPath().substring(change.getPath().lastIndexOf("/")+1);
			//In this example a stock symbol must be 4 characters and not be the 'trade' node from the StockDataWriterJob
			if((folderName.length() == 4) && (folderName.matches("^[a-zA-Z]*$")) && !folderName.equals("trade")) {
				
				//Check if the added folder is uppercase. If it's not, autofix
				if(!folderName.equals(folderName.toUpperCase())&&change.getType().equals(ResourceChange.ChangeType.ADDED)){
					//Get the service user (stock-import-service) that belongs to the training.core:training subservice
					Map<String, Object> serviceParams = new HashMap<>();
					serviceParams.put(ResourceResolverFactory.SUBSERVICE, "training");
					try (ResourceResolver resourceResolver = resourceResolverFactory.getServiceResourceResolver(serviceParams)) {
							logger.info(searchableLogStr + "Folder added is not uppercase. Recreating resource: "+ change.getPath());
							//Remove resource
							resourceResolver.delete(resourceResolver.getResource(change.getPath()));
							//Recreate resource as uppercase
							ResourceUtil.getOrCreateResource(resourceResolver, change.getPath().replace(folderName, folderName.toUpperCase()), "", "", false);
							resourceResolver.commit();
					} catch (LoginException | PersistenceException e) {
						logger.error(searchableLogStr + "Exception with updating resource to uppercase ", e);
					}
				}

				//Create a StockImportScheduler config for the symbol folder added
				else if(change.getType().equals(ResourceChange.ChangeType.ADDED)) {
					try{
							//Get the StockImportScheduler factory from the config admin
							Configuration config = configAdmin.createFactoryConfiguration(stockImportSchedulerPID);
								
							//Add the folder name to the configuration
							Dictionary<String, Object> properties = new Hashtable<String, Object>();
							properties.put(StockImportScheduler.JOB_PROP_SYMBOL, folderName);
							config.update(properties);
							logger.info(searchableLogStr + "Added " + folderName + " config with PID: "+ config.getPid());
					} catch (IOException e) {
						logger.error(searchableLogStr + "Could not add OSGi config for: " + folderName);
					}
				}
				//Remove the StockImportScheduler config for the symbol folder removed
				else if (change.getType().equals(ResourceChange.ChangeType.REMOVED)) {
					try {
						String filter = '(' + ConfigurationAdmin.SERVICE_FACTORYPID + '=' + stockImportSchedulerPID + ')';
						//Find all the StockImportScheduler configs
						Configuration[] configArray = configAdmin.listConfigurations(filter);
						//Find the config that matches the removed folder name and delete the config
						for( Configuration config :configArray) {
							Object configSymbolPropVal = config.getProperties().get(StockImportScheduler.JOB_PROP_SYMBOL);
							if(configSymbolPropVal.equals(folderName)) {
								logger.info(searchableLogStr + "Removed " + folderName + " config with PID: "+ config.getPid());
								config.delete();
							}
						}
					} catch (IOException e) {
						logger.error(searchableLogStr + "Could not delete OSGi config for: " + folderName);
					} catch (InvalidSyntaxException e) {
						logger.error(searchableLogStr + "Could not delete OSGi config for: " + folderName);
					}
				}
			}
		}
	}
}
mvn clean install -pl core -PautoInstallBundle

In the Sites console, navigate to the Stocks folder.

Inside of it, create a folder with one of these sample stock names:

On the next 2-minute interval, check in CRXDE to see if the chosen folder has a node named trade with properties.

End of activity.


Configurations and the Configuration Browser

Configuring OSGi for Adobe Experience Manager as a Cloud Service | Adobe Experience Manager as a Cloud Service

Apache Sling :: Repository Initialization (repoinit)

Apache Sling :: Service Authentication