Skip to content

Developing OpenWIS v4 functionality

Nassos A. Michas edited this page Dec 13, 2016 · 1 revision

Scope

This document describes the architecture behind OpenWIS4 application and explains the process used in adding custom content to Core GeoNetwork (CGN).

Architectural Overview

The architectural approach used in OpenWIS4 was designed to allow for minimum interference with the original CGN code. In order to achieve this, OpenWIS4 uses CGN as an external artefact instead of forking its code and continue development on its own code-base. This is achieved by overlaying modifications and injecting features into CGN.

Server-Side

On the server side, apart from defining its own Maven modules that will become part the final WAR file, OpenWIS4 uses the Maven Shade plugin in order to extend the CGN domain Maven module.

This process will unpack the original domain.jar, add/overwrite any modifications, and package it again under the name openwis-domain.jar. The original jar will be excluded from the final WAR file.

As a result, any potential modifications to the entities have to be performed in the openwis-domain module, in order to be used by the application.

User Interface

On the User Interface, OpenWIS4 had to conform to (and extend) a custom architectural approach defined in CGN. CGN defines a custom method of Javascript dependency management by utilising a custom version of wro4j, coupled with a CGN-esoteric approach in combining Google Closure. It relies heavily on Angular modules and declares the relationships between them similarly to the following fragment:

(function() {
  goog.provide('gn_admin');

  goog.require('gn_admin_controller');
  goog.require('gn_module');

  var module = angular.module('gn_admin', [
    'gn_module',
    'gn_admin_controller'
  ]);


  module.config(['$LOCALES',
    function($LOCALES) {
      $LOCALES.push('admin');
    }]);
})();

However, 'goog.provide' and 'goog.require' declarations in the above snippet are not actually Google Closure code as one would expect. They are actually markers for the CGN-customised version of wro4j module. The customised module will search through every Javascript file for such declarations and will process all the provides/requires dependencies, build a dependency tree in-memory, and remove the declarations from the source files. When the tree is complete, it will read the associated files (with provides/requires now removed) and append their content in one javascript file. In this fashion, the application will end up with a single dynamically-generated Javascript file per root angular module (there are several root angular modules, one per application page).

The Problem

The problem we found with this approach while trying to extend CGN was that the custom wro4j processing only allowed a single provide declaration for a particular module. As a result of that, in order to modify/extend a module, one had to overlay its Javascript file in OpenWIS4, something that would result in duplication of files in CGN and OpenWIS4 increasing maintenance considerably.

The Solution, Step 1: Enabling O4 Javascript Code Loading

The solution we came up with was to enhance the custom wro4j module allowing it to accept multiple provide declarations found in different files. This allows developers to leave the original file untouched and only provide their enhancements in a new file as demonstrated on the example below.

This is the original code of the Angular module (file AdminMetadataController.js) as defined in CGN:

(function() {
  goog.provide('gn_adminmetadata_controller');


  goog.require('gn_schematronadmin_controller');

  var module = angular.module('gn_adminmetadata_controller',
      ['gn_schematronadmin_controller']);


  /**
   * GnAdminMetadataController provides administration tools
   * for metadata and templates
   */
  module.controller('GnAdminMetadataController', [
    '$scope', '$routeParams', '$http', '$rootScope', '$translate', '$compile',
    'gnSearchManagerService',
    'gnUtilityService',
    function($scope, $routeParams, $http, $rootScope, $translate, $compile,
            gnSearchManagerService,
            gnUtilityService) {

      $scope.pageMenu = {
        folder: 'metadata/',
        defaultTab: 'metadata-and-template',
        tabs:
            [{
              type: 'metadata-and-template',
              label: 'metadataAndTemplates',
              icon: 'fa-archive',
              href: '#/metadata/metadata-and-template'
            },{
              type: 'formatter',
              label: 'metadataFormatter',
              icon: 'fa-eye',
              href: '#/metadata/formatter'
            },{
              type: 'schematron',
              label: 'schematron',
              icon: 'fa-check',
              href: '#/metadata/schematron'
            },{
              type: 'metadata-identifier-templates',
              icon: 'fa-key',
              label: 'manageMetadataIdentifierTemplates',
              href: '#/metadata/metadata-identifier-templates'
            }]
      };

      $scope.schemas = [];
      $scope.selectedSchemas = [];
      $scope.loadReport = null;
      $scope.loadTplReport = null;
      $scope.tplLoadRunning = false;
      $scope.sampleLoadRunning = false;

      /*
      .
      .
      .
      Removed code for brevity
      .
      .
      .
      */      

      $scope.testFormatter = function(mode) {
        var service = 'md.format.' + (mode == 'HTML' ? 'html' : 'xml');
        var url = service + '?id=' + $scope.metadataId +
            '&xsl=' + $scope.formatterSelected.id;
        if ($scope.formatterSelected.schema) {
          url += '&schema=' + $scope.formatterSelected.schema;
        }

        if (mode == 'DEBUG') {
          url += '&debug=true';
        }

        window.open(url, '_blank');
      };

      if ($routeParams.tab === 'formatter') {
        loadFormatter();
      } else if ($routeParams.schemaName || $routeParams.tab === 'schematron') {
        $routeParams.tab = 'schematron';
      } else {
        loadSchemas();
      }

    }]);

})();

This is a new file (file provider.gn_adminmetadata_cotroller.js) which declares some extra dependencies for wro4j to process. As a result, apart from the original files, the ones declared here will also be added to the dynamically generated Javascript, thus injecting external code into CGN:

/**
 * In this file we add to the original dependencies of the "module".
 * The custom Wro4j will search for files providing the required modules and package them into its resources 
 */

(function() {
	
	goog.provide('gn_adminmetadata_controller');
	  
	goog.require('injector.gn_adminmetadata_controller');
	goog.require('gn_openwis_productmetadata_controller');
	goog.require('gn_openwis_admin_subscription_controller');

})();
The Solution, Step 2: Extending CGN Angular Controllers

In the previous section the goal was to manage and load OpenWIS4 Javascript files without breaking the existing CGN setup. On this section, we look into how we can make use of that approach to extend CGN code, in particular existing Angular controllers.

In the previous example we loaded some extra Javascript files; two of them contained pure Angular modules, and the other one contained an injector file. In order to understand the need for this file the following must be considered.

The current goal is to extend an existing controller. In order for that to succeed that controller must already be initialised in Angular when the new Javascript tries to extend it. So lets take a look at how wro4j loads the its dynamic javascript.

Supposing that we have the following dependencies between the application files:

1.js
|-- 1.1.js
|   |-- 1.1.1.js
|   |-- 1.1.2.js
|-- 1.2.js
|   |-- 1.2.1.js
|   |-- 1.2.2.js

This will result in wro4j appending the files in the reverse order because dependencies of modules must be loaded before the modules themselves. So wro4j will append them in the dynamically-generated javascript file in this order:

1.1.1.js
1.1.2.js
1.1.js

1.2.1.js
1.2.2.js
1.2.js

1.js

In addition to the above, what must also be taken into consideration is that Angular dependencies may need to be modified. This can only be done after the Angular module is fully loaded. In order for that to happen its dependencies must only be declared once, in the original CGN file and not in the extension module.

This whole chain of restrictions leads to the injector pattern. In this, the injector file will call a javascript function that will be responsible to append the actual extension code at the end of the <body> element, on document.ready() and before Angular parses the document as presented next:

/**
 * This module only exists because of the wro4j limitations (no JS loads unless it's in lib.js or unless it's on a dependency tree).
 * The code in here will just inject a <script></script> at the end of the <body>.
 * It is injected at that place because may contain references to other angular modules and this ensures that they are already defined. 
 */

(function() {
	goog.provide('injector.gn_adminmetadata_controller');

	var module = angular.module('injector.gn_adminmetadata_controller', []);


})();

/**
 * Push a function to be executed on document.ready. This function will load an
 * html fragment containing a script and append it to the body. That script
 * contains further instructions for angular that could not be loaded because of
 * the wro4j limitations.
 * 
 */
JsUtil.FunctionsToExecuteOnReady
		.push(function() {
			JsUtil
					.appendHtmlContentIntoElement(
							$(document.body),
							'/geonetwork/catalog/components/openwis-extensions/gn_adminmetadata_controller/script.gn_adminmetadata_controller.html');
		});

The above snippet will load the contents of the following HTML file:

<script type="text/javascript">

	/**
	 * Add new angular dependencies
	 */
	angular.module('gn_adminmetadata_controller').requires.push('gn_openwis_productmetadata_controller','gn_openwis_admin_subscription_controller');
			
	/**
	 * This is the extended controller that will replace the original one
	 * 
	 */
	
	angular.module('gn_adminmetadata_controller').controller('EXT_GnAdminMetadataController', [
	    '$scope', '$routeParams', '$http', '$rootScope', '$translate', '$compile', '$controller',
	    'gnSearchManagerService', 'gnUtilityService',
	    function($scope, $routeParams, $http, $rootScope, $translate, $compile, $controller, 
	            gnSearchManagerService, gnUtilityService) {
	    	
	    	angular.extend(this, $controller('GnAdminMetadataController', {
	            $scope : $scope,
	            $routeParams : $routeParams,
	            $http : $http,
	            $rootScope : $rootScope,
	            $translate : $translate,
	            $compile : $compile,
	            gnSearchManagerService : gnSearchManagerService,
	            gnUtilityService : gnUtilityService
	          }));
	    	
	    	$scope.pageMenu = {
	    	        folder: 'metadata/',
	    	        defaultTab: 'metadata-and-template',
	    	        tabs:
	    	            [{
	    	              	type: 'metadata-and-template',
	    	              	label: 'metadataAndTemplates',
	    	              	icon: 'fa-archive',
	    	              	href: '#/metadata/metadata-and-template'
	    	            },{
	    	             	type: 'formatter',
	    	              	label: 'metadataFormatter',
	    	              	icon: 'fa-eye',
	    	              	href: '#/metadata/formatter'
	    	            },{
							type: 'subscriptions-admin',
							label: 'Manage Subscriptions',
							icon: 'fa-rss',
							href: '#/metadata/subscriptions-admin'
						},{
							type: 'product-metadata',
							label: 'Product Metadata',
							icon: 'fa-check',
							href: '#/metadata/product-metadata'
						},{
	    	              	type: 'schematron',
	    	              	label: 'schematron',
	    	              	icon: 'fa-check',
	    	              	href: '#/metadata/schematron'
	    	            },{
	    	              	type: 'metadata-identifier-templates',
	    	              	icon: 'fa-key',
	    	              	label: 'manageMetadataIdentifierTemplates',
	    	              	href: '#/metadata/metadata-identifier-templates'
	    	            }]
	    	      };
	    	
	    function loadSchemas() {
            	$http.get('admin.schema.list?_content_type=json').
	                success(function(data) {
	                  for (var i = 0; i < data.length; i++) {
	                    $scope.schemas.push(data[i]['#text'].trim());
	                  }
	                  $scope.schemas.sort();
	
	                  // Trigger load action according to route params
	                  launchActions();
                });
          	}
	    	
	    	$scope.loadTemplates = function() {
	            $scope.tplLoadRunning = true;
	            $http.get('admin.load.templates?_content_type=json&schema=' +
	                $scope.selectedSchemas.join(',')
	            ).success(function(data) {
	              $scope.loadTplReport = data;
	              $scope.tplLoadRunning = false;
	            }).error(function(data) {
	              $scope.tplLoadRunning = false;
	            });
	          };

	          $scope.loadSamples = function() {
	            $scope.sampleLoadRunning = true;
	            $http.get('admin.load.samples?_content_type=json&' +
	                      'file_type=mef&uuidAction=overwrite' +
	                      '&schema=' +
	                $scope.selectedSchemas.join(',')
	            ).success(function(data) {
	              $scope.loadReport = data;
	              $scope.sampleLoadRunning = false;
	            }).error(function(data) {
	              $scope.sampleLoadRunning = false;
	            });
	          };
	          
	          var loadTemplates = function() {
	              $http.get('admin.templates.list?_content_type=json')
	              .success(function(data) {
	                    $scope.templates = data;
	                  });
	            };
	    	
    }]);
	
	/**
	 * Register the replacement
	 * 
	 */
	JsUtil.registerReplacementController('GnAdminMetadataController','EXT_GnAdminMetadataController');

	
</script>

On the above HTML file initially some Angular module dependencies are appended to the already existing ones. Next, an Angular controller named EXT_GnAdminMetadataController is created and declared to extend the existing GnAdminMetadataController. What it actually does is providing a different menu for the administrator metadata section, and overriding a couple of Javascript functions so that they use OpenWIS-specific implementations. The rest of the functionality remains as it was originally conceived in CGN.

Finally, a helper Javascript function is used that replaces usages of the original controller with the new one. What is actually achieved here is that the original controller still exists as an object, but all calls using it will be replaced with calls to the new controller.

Note: To support the above functionality we have introduced a small patch to the code of Angular at the points where controllers are resolved. On our patch, for every controller that Angular tries to bind, a check is performed to see if a replacement has been provided by the OpenWIS4 code. If it has, the replacement is bound instead of the original.

The Solution: Summary

To summarise, the following components are required in order to extend an Angular controller and provide extra functionality:

  • A provider Javascript file, where by using a Google Closure-like provide declaration new files may be loaded together with the original CGN ones. This file will load the injector below, plus any other Angular modules if necessary.
  • An injector javascript file, where an HTML file with a <script> element will be appended at the end of document body.
  • The script HTML file, where the new controller will be declared and registered to replace the existing one, and any potential extra Angular dependencies will be processed.

Modifying HTML Content

Extending controllers is the favoured approach to provide new features in the UI. If for some reason the required modification cannot be performed in such a way, elements can be added to the DOM dynamically using Javascript. When doing so, the following situations may be encountered:

  1. Modified content occurs outside of Angular scope/lifecycle
    This is the easiest case to handle and doesn't require any special handling. If necessary, for the new code to execute before Angular loads, there is an array already provided in OpenWIS4 named JsUtil.FunctionsToExecuteOnReady. Pushing a function to this array will ensure that it gets executed after document.ready and before Angular loads.
  2. An new Angular element must be registered
    Using the array above, Javascript code can write into the DOM before Angular processes it. Then, when Angular does go through the DOM it will also find the new elements and bind them.
  3. Modifying an existing Angular element
    This is a tricky one if not done through a controller. Most of the existing Angular elements are declared in ng-includes. This has the downside of Angular processing them dynamically before adding them to the DOM, thus cancelling the above approaches. What can be done in this case is to register a listener for includeContentLoaded events on the module in question. When that listener fires, the element will already be in the DOM and can be replaced.
  4. Modifying Angular routes
    Angular provides a straight-forward API to modify and reload routes, so the route target may be changed dynamically.

Build Process

The OpenWIS4 war is generated by using the Maven WAR Overlay plugin. In order to successfully build OpenWIS4 the following must be installed:

  • Java 1.8
  • Maven 3

OpenWIS4 modules have dependencies on CGN maven artecafts and since these artefacts are not available in a public repository, CGN will have to be cloned from here and built on the same machine that will be used to build OpenWIS4. The currently tested CGN version is tag 3.2.0.

Once CGN has been successfully built, OpenWIS4 can be built by:

$ cd openwis-parent
$ mvn clean install

The above will create a geonetwork.war file in the openwis-war directory. Deploying the generated WAR on-the-fly by using the Maven Jetty plugin is possible like this:

$ cd openwis-war
$ mvn jetty:run-war

Vagrant Deployment

OpenWIS4 includes the openwis-deployment directory which contains the scripts required for its deployment on Vagrant. The file in path openwis-deployment/vagrant-allinone/config.yaml must be edited and the portal_workspace property must be set to point to the directory where OpenWIS4 was cloned.

Once the above file is correctly configured, Vagrant box(es) can be started as:

$ cd openwis-deployment/vagrant-allinone
$ vagrant box add --force openwis/centos7 https://repository-openwis-association.forge.cloudbees.com/artifacts/vagrant/openwis-centos-7.box
$ vagrant up

Clone this wiki locally