Introduction
Recently I had an undoubted pleasure to work with SOAP based web services. The client I was using was
JAX-WS compliant and generated using wsimport
tool. In theory for modern systems they are
basically thing of the past but in reality they are still present. Let’s say that your nice and clean Java code uses
RxJava and tries to be reactive and asynchronous. In that case you really want to call SOAP web service as you would
any other Rx Observable/Completable source. If all you have to do is to just grab WSDL file,
generate proxy classes and methods once…then you are good. Just wrap them in Observables/Completables. No need to be sad.
But if the service you are consuming is under active development or you need to consume a bunch of such services
with multiple WSDL files, then you would end up with a lot of mundane work.
That’s what happened to me. While being a bit upset I quickly hacked simple solution to automatically generate file with
service class that provides all Rx wrapping original proxy service calls. A small comment before the details:
this is quick and dirty hack. I know that it could be done by using annotation processing or some advance use of
templating engine or really create proper tool, something similar to wsimport
. I just wanted something quickly. As a bonus I was able to dig into Gradle and Groovy scripting
potential.
The Solution
You can find whole solution on our GitHub Repo
The solution is actually quite simple. All we have to do is to generate Java proxy client classes by wsimport
tool
and then analyze some of those files and run a couple of regexes against their contents. Finally fill small templates and
dump everything into final Java source file. So let’s do this.
Generating JAX-WS service client
For our example we can take this service with corresponding WSDL:
http://ws.cdyne.com/emailverify/Emailvernotestemail.asmx?wsdl
and generate client using wsimport
. Just make sure that directories tmp
and src/main/java
are created before because
wsimport will complain:
wsimport 'http://ws.cdyne.com/emailverify/Emailvernotestemail.asmx?wsdl' -keep -d tmp -s src/main/java
tmp
does not interest us, we are only concerned with generated sources. They will be generated in src/main/java/com/cdyne/ws
.
This post is not about automating wsimport
generation so let’s move on to what we really want to do.Extending Gradle build file with task that generates RxJava file for our service
Our build.gradle
file is short and simple:
group 'com.pattern-match'
version '1.0-SNAPSHOT'
apply plugin: 'java'
apply plugin: 'application'
mainClassName = "com.patternmatch.WebServiceCaller"
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile group: 'io.reactivex.rxjava2', name: 'rxjava', version: '2.1.13'
testCompile group: 'junit', name: 'junit', version: '4.12'
}
GroovyScriptEngine gse() {
def pathToFolderOfToolScripts = "${projectDir}/tools/"
def gse = new GroovyScriptEngine([pathToFolderOfToolScripts] as String[])
gse
}
task genrx {
doLast {
def serviceDirValue = propertyOrDefault("serviceDir", "src/main/java/com/cdyne/ws")
def rxFileDirValue = propertyOrDefault("rxFileDir", "src/main/java/com/patternmatch")
def rxFileNameValue = propertyOrDefault("rxFileName", "Backend.java")
def rxFilePackageNameValue = propertyOrDefault("rxFilePackageName", "com.patternmatch")
gse().run('rx.groovy', new Binding(['serviceDir' : serviceDirValue,
'rxFileDir' : rxFileDirValue,
'rxFileName' : rxFileNameValue,
'rxFilePackageName': rxFilePackageNameValue]))
}
}
String propertyOrDefault(String propertyName, String defaultValue) {
project.hasProperty(propertyName) ? project.property(propertyName) : defaultValue
}
Task is called genrx
and it executes script tools/rx.groovy
passing 4 parameters. You can either provide default
values for parameters by changing second arguments of propertyOrDefault
function calls. I have set them up to work with
our example service. Executing task without arguments:
./gradlew genrx
build.gradle
. If you want to pass custom params just fire:./gradlew genrx -PrxFileName=Service.java -PserviceDir=/Users/me/projects/blog/rxjava_soapws_final/src/main/java/com/package/ -PrxFileDir=src/main/java/com/package -PrxFilePackageName=com.package
Params are:
serviceDir
-> Directory with wsimport generated Java source files. Can start with/
and then it is absolute path, or without/
then it is subdirectory of current gradle projectrxFileDir
-> Directory into which file with Rx wrappers will be generated. The same with/
as aboverxFileName
-> Rx wrappers will be generated into file of this namerxFilePackageName
-> Name of package that will be put into generated Rx wrappers file
rx.groovy - main high level steps
Generating Rx file with Rx wrappers consists of few steps:
//
// Main script steps
//
(baseServiceDirectory, serviceFilePath) = figureOutWebServiceDirectoryAndFileDetails()
(servicePackageName, serviceClassName, portClassName, getPortMethodName) = extractWebServiceDetails(serviceFilePath)
portFileBareContent = extractBareContentFromPortFile(baseServiceDirectory, portClassName)
publicMethodNamesParamsAndReturns = extractPublicMethodNamesParamsAndReturns(portFileBareContent)
rxMethods = wrapMethodsWithRxCall(portClassName, publicMethodNamesParamsAndReturns)
backendFilePath = generateRxServiceFile(rxMethods, servicePackageName, portClassName, getPortMethodName)
println "Successfully generated RX service file: ${backendFilePath}"
Figuring out JAX-WS service file details
This is some grunt work. Basically it normalizes directory path and finds out in which file there is a
web service by looking for class extending JAX-WS Service: extends Service
. Nothing interesting.
Extracting details from service file
List<String> extractWebServiceDetails(String serviceFilePath) {
println "Loading WebService file: ${serviceFilePath}"
String serviceFileContents = new File(serviceFilePath).text
servicePackageName = (serviceFileContents =~ /package ([\w.]+);/)[0][1]
println "Found service package named ${servicePackageName}"
serviceClassName = (serviceFileContents =~ /public class (\w+)/)[0][1]
println "Found service class named ${serviceClassName}"
portClassName = (serviceFileContents =~ /(\w+)\.class/)[0][1]
println "Found service port named ${portClassName}"
getPortMethodName = (serviceFileContents =~ /public \w+ (get\w+)\(/)[0][1]
println "Found get port method named ${getPortMethodName}"
[servicePackageName, serviceClassName, portClassName, getPortMethodName]
}
get
and in its body it has Port class defined. For example: @WebEndpoint(name = "EmailVerNoTestEmailSoap")
public EmailVerNoTestEmailSoap getEmailVerNoTestEmailSoap() {
return super.getPort(new QName("http://ws.cdyne.com/", "EmailVerNoTestEmailSoap"), EmailVerNoTestEmailSoap.class);
}
get
and for text before .class
to get Port details.Extracting bare contents from Port file
So now we know how Port class is named and we can load Port source file. This file is most interesting for us because it contains all service method calls. So we need to analyze it thoroughly. But first let’s strip it from useless details like comments, annotations and blank lines.
String extractBareContentFromPortFile(baseServiceDirectory, portClassName) {
portFilePath = baseServiceDirectory + portClassName + ".java"
println "Applying RX Java goodness on file: ${portFilePath}"
portFileContents = new File(portFilePath).text
// remove annotations, comments, empty lines. Not strictly necessary but helpful
// for any future manipulations and extractions
annotationRegex = /(?s)@\w+\((.*?)\)/
commentsRegex = /(?m)^(.*?)\*(.*?)$/
emptyLinesRegex = /(?m)^(?:[\t ]*(?:\r?\n|\r))+/
portFileContentsStripped = portFileContents.
replaceAll(annotationRegex, "").
replaceAll(commentsRegex, "").
replaceAll(emptyLinesRegex, "")
portFileContentsStripped
}
(?s)
- turns regex into treating new line characters as being matchable by .
- so basically multiline string becomes one long text for regex.
@\w+\((.*?)\)
- searches for @
followed by word characters or _
and (
)
with potential empty params. .*?
question mark makes capture group non-greedy so it will
stop when finding closest matching )
.
Matching empty lines uses mode (?m)
- which turns regex into multiline mode so it won’t stop at first line ending and allows to
use ^
and $
to match beginnig and end of line. Then comes pattern for matching different line ending conventions: (?:\r?\n|\r))+
- there needs to be at least one \n
or \r
or \r\n
.
Doing all this is not strictly necessary but helpful for any future manipulations and extractions.Extracting service public methods with their return types and parameters
This is what we are really interested in. And we can do it with one regex:
Matcher extractPublicMethodNamesParamsAndReturns(String portFileBareContent) {
(portFileBareContent =~ /(?s)(?:\r?\n|\r)\s+public (.*?) (.*?)\((.*?)\);/)
}
public
. We then capture return value,
method name and all that is inside ()
. All non-greedy of course.Wrapping public service methods into RxJava Observables/Completables
So we have captured all service methods. Now it is time to generate big string with code that wraps these methods into Observables/Completables. We can use Groovy templating engine:
String wrapMethodsWithRxCall(String portClassName, Matcher publicMethodNamesParamsAndReturns) {
def rxMethodTemplateString = '''\
public ${rxReturnType} ${rxMethodName}(${parameters}) {
return ${rxWrapperCall}(() -> ${serviceMethodCall}(${originalParameters}));
}
'''
def rxMethodTemplate = new groovy.text.StreamingTemplateEngine().createTemplate(rxMethodTemplateString)
rxMethods = StringBuilder.newInstance()
for (i in 0..<publicMethodNamesParamsAndReturns.count) {
returnType = publicMethodNamesParamsAndReturns[i][1]
methodName = publicMethodNamesParamsAndReturns[i][2]
parameters = publicMethodNamesParamsAndReturns[i][3]
def binding = [
rxReturnType : makeRxReturnType(returnType),
rxWrapperCall : makeRxWrapperCall(returnType),
rxMethodName : methodName,
parameters : parameters,
serviceMethodCall : portClassName.uncapitalize() + "." + methodName,
originalParameters: paramsExtractor(parameters)
]
rxMethods << rxMethodTemplate.make(binding)
if (i < publicMethodNamesParamsAndReturns.count - 1) {
rxMethods << newLineSeparator()
}
}
rxMethods.toString()
}
public Observable<ReturnIndicator> verifyEmail(
String email,
String licenseKey) {
return Observable.fromCallable(() -> emailVerNoTestEmailSoap.verifyEmail(email, licenseKey));
}
void
return type we should generate Completable instead of Observable. RxJava 2.0 does not allow to return null, so
we can’t create Observable<Void>
for example and return this null as we would in RxJava 1.0.
So the method would look like: public Completabled doAction(
int startTime,
int endTime) {
return Completable.fromRunnable(() -> portInstance.doAction(startTime, endTime));
}
String makeRxReturnType(type) {
type == "void" ?
"Completable" :
"Observable<${validGenericsType(type)}>"
}
String makeRxWrapperCall(type) {
type == "void" ?
"Completable.fromRunnable" :
"Observable.fromCallable"
}
Generating final RxJava service file
We now have complete data required to generate final output file. We have its directory, name, package (from input parameters). We know original service name, package, port and how to get this port instance. And we have all Observable/Completable methods proxying to original ones. So let`s compose the file:
String generateRxServiceFile(String rxMethods, String servicePackageName, String portClassName,
String getPortMethodName) {
def rxFileTemplateString = '''\
package ${rxFilePackageName};
import ${servicePackageName}.*;
import io.reactivex.Observable;
import io.reactivex.Completable;
import java.util.List;
public class ${rxServiceClassName} {
private ${portClassName} ${portClassName.uncapitalize()};
public ${rxServiceClassName}() {
${serviceClassName} service = new ${serviceClassName}();
${portClassName.uncapitalize()} = service.${getPortMethodName}();
}
${rxMethods}
}
'''
def rxFileTemplate = new groovy.text.StreamingTemplateEngine().createTemplate(rxFileTemplateString)
def binding = [
rxFilePackageName : rxFilePackageName,
servicePackageName: servicePackageName,
rxServiceClassName: rxServiceClassName(),
portClassName : portClassName,
serviceClassName : serviceClassName,
getPortMethodName : getPortMethodName,
rxMethods : rxMethods
]
backendFile = StringBuilder.newInstance()
backendFile << rxFileTemplate.make(binding)
if (!rxFileDir.endsWith("/")) {
rxFileDir = rxFileDir + "/"
}
String backendFilePath = new File(figureOutRxFileDirectory() + rxFileName)
File backend = new File(backendFilePath)
backend.text = backendFile.toString()
backendFilePath
}
package com.patternmatch;
import com.cdyne.ws.*;
import io.reactivex.Observable;
import io.reactivex.Completable;
import java.util.List;
public class Backend {
private EmailVerNoTestEmailSoap emailVerNoTestEmailSoap;
public Backend() {
EmailVerNoTestEmail service = new EmailVerNoTestEmail();
emailVerNoTestEmailSoap = service.getEmailVerNoTestEmailSoap();
}
public Observable<Integer> verifyMXRecord(
String email,
String licenseKey) {
return Observable.fromCallable(() -> emailVerNoTestEmailSoap.verifyMXRecord(email, licenseKey));
}
public Observable<ReturnIndicator> advancedVerifyEmail(
String email,
int timeout,
String licenseKey) {
return Observable.fromCallable(() -> emailVerNoTestEmailSoap.advancedVerifyEmail(email, timeout, licenseKey));
}
public Observable<ReturnIndicator> verifyEmail(
String email,
String licenseKey) {
return Observable.fromCallable(() -> emailVerNoTestEmailSoap.verifyEmail(email, licenseKey));
}
public Observable<ArrayOfAnyType> returnCodes() {
return Observable.fromCallable(() -> emailVerNoTestEmailSoap.returnCodes());
}
}
public class WebServiceCaller {
public static void main(String[] args) {
Backend backend = new Backend();
Observable<ReturnIndicator> integerObservable = backend.verifyEmail("myprivateemail@yahoo.com", "somelicense");
integerObservable.subscribe(new Observer<ReturnIndicator>() {
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(ReturnIndicator returnIndicator) {
System.out.println("Got results:");
System.out.println(returnIndicator.getResponseText());
}
@Override
public void onError(Throwable e) {
}
@Override
public void onComplete() {
}
});
}
}
)
Got results:
Mail Server will accept email
Final Words
I left out some minor helper method details. You can easily find them in full source code on our GitHub Repo.
All in all I had a lot of fun when creating this small tool. It shows the potential we have when using Groovy and it is especially good match for Gradle based builds due to its tight integration and ease of use. A couple of regexes and few templates can do much.
Tactical Support From A Veteran Team
Partner with our experienced, full-stack development, self-organized team to design and develop a tailored product or improve your existing solution. We are engineers, but we always reach for more - we are the team of full-stack employees.
Let's talk