Skip to content

In this project I want to show you how to build a SOAP JAX-WS (Java API for XML Web Services) client using the Liferay infrastructure. Let's start with a concrete example.

License

Notifications You must be signed in to change notification settings

amusarra/liferay-72-soap-client-examples

Repository files navigation

Liferay 7.3 SOAP Client Examples

Antonio Musarra's Blog Build Status Twitter Follow

At the 2016 Liferay Symposium (Italy) I presented the topic: How to develop SOAP and REST services in JAX-WS and JAX-RS standard on Liferay. During the presentation I illustrated how to expose both REST (Representational State Transfer) and SOAP (Simple Object Access Protocol) services for each application using the Liferay Extender.

In this project I want to show you how to build a SOAP JAX-WS (Java API for XML Web Services) client and JAX-WS Handler using the Liferay infrastructure. Let's start with a concrete example. The current release of the project implemented and tested on Liferay 7.3 Community Edition GA6. I recommend that you read the project change logs.

It is important that you know that the current version of the project requires the version 11 of Java JDK.

The project code is still valid for the 7.0 and 7.1 versions of Liferay. Before trying the project on Liferay 7.0, I recommend reading this LPS – Supplied JAX-WS implementation not working([CLOSED]) and The Configuring Endpoints And Extenders Programmatically does not work([OPEN])

Let’s consider the SOAP Calculator service whose WSDL (Web Services Description Language) descriptor is available at the following address http://www.dneonline.com/calculator.asmx which exposes the four arithmetic operations between two integers.

This project is related to article How to implement a SOAP client using JAX-WS Liferay infrastructure published on Antonio Musarra's Blog which I recommend you read.

1. Quick Start

Follow the procedure below to try out the project immediately. You need to replace or set the variable $LIFERAY_HOME with the installation directory of your Liferay instance.

$ git clone https://github.com/amusarra/liferay-72-soap-client-examples.git
$ cd liferay-72-soap-client-examples/
$ echo "liferay.workspace.home.dir=$LIFERAY_HOME" > gradle-local.properties
$ ./gradlew clean deploy

Console 1 - Clone, build and debloy

The topics covered by the project are:

  1. JAX-WS Client
  2. JAX-WS Client with the SSL/TLS Mutual Authentication supports
  3. JAX-WS Service End Point
  4. JAX-WS Handler

The table shows the list of modules that are available within this project.

Module Description
Calculator Client API This module defines the APIs that each application can invoke to use the services of arithmetic operations (addition, subtraction, division and multiplication)
Calculator SOAP Client Implementation This module implements the APIs defined by the calculator-api module and acts as a client to the SOAP Calculator service
Calculator Client Gogo Shell Commands This module implements the Gogo Shell commands that use the APIs defined by the calculator-api module to perform the arithmetic operations
Calculator Web Application This module implements a standard MVC portlet that uses the APIs defined by the calculator-api module to perform the arithmetic operations
Calculator SOAP Client SSL/TLS Mutual Auth Impl This module implements the SOAP Calculator client that supports Mutual Authentication
Custom Users API (1.0.0) This module defines the APIs that each application can invoke to use the custom users operation (getUsersByCategory, getUsersByTag, getUsersByTags, etc.)
Custom Users Service Implementation This module implements the APIs defined by the custom-users-api module
Custom Users Service JAX-WS API End Point This module implements the JAX-WS endpoint and two JAX-WS Handlers (MacAddressValidatorHandler and AuditLogHandler)
Liferay Portal Remote SOAP Extender Impl Fragment This module is an OSGi Fragment to export some Apache CXF packages
UE VIES VAT Service Client API This module defines the APIs that each application can invoke to use the VIES VAT Service
UE VIES VAT Service SOAP Client Impl This module implements the APIs defined by the vat-service-client-api module and acts as a client to the SOAP VIES VAT Service
UE VIES VAT Service Client Gogo Shell This module implements the Gogo Shell commands that use the APIs defined by the vat-service-client-api module to perform the check VAT operations

Table 1 - The list of the modules of this project

The list to see are the nine bundles just installed against the deployment action.

g! lb it.dontesta.labs.liferay
START LEVEL 20
   ID|State      |Level|Name
 1359|Resolved   |   15|Liferay Portal Remote SOAP Extender Implementation Fragment (1.0.0)|1.0.0
 1360|Active     |   15|Calculator Web Application (2.0.0)|2.0.0
 1361|Active     |   15|Calculator Client API (2.0.0)|2.0.0
 1362|Active     |   15|Calculator SOAP Client Implementation (2.0.0)|2.0.0
 1363|Active     |   15|Calculator SOAP Client SSL/TLS Mutual Auth Implementation (2.0.0)|2.0.0
 1364|Active     |   15|Calculator Client Gogo Shell Commands (2.0.0)|2.0.0
 1365|Active     |   15|Custom Users API (2.0.0)|2.0.0
 1366|Active     |   15|Custom Users Service Implementation (2.0.0)|2.0.0
 1367|Active     |   15|Custom Users Service JAX-WS API End Point (2.0.0)|2.0.0
 1368|Active     |   15|UE VIES VAT Service Client API (2.0.0)|2.0.0
 1369|Active     |   15|UE VIES VAT Service Client Gogo Shell (2.0.0)|2.0.0
 1370|Active     |   15|UE VIES VAT Service SOAP Client Impl (2.0.0)|2.0.0

Console 2 - Check status of the Calculator bundle

2. How to implement a SOAP client that supports Mutual Authentication

There are situations where access to SOAP services can be protected through a mutual authentication mechanism over SSL/TLS.In this situation, how do you build the client?

Here I don't want to bore you in technical details about the mutual authentication mechanism, so I recommend reading my last article Apache HTTP 2.4: How to Build a Docker Image for SSL/TLS Mutual Authentication published on DZone Portal.

We recall that Liferay uses the Apache CXF framework (version 3.2.14 on Liferay 7.3 GA6). When using an https URL, Apache CXF will, by default, use the certs and keystore that are part of the JDK. I have in fact configured the keystore and truststore at the system level, so the mutual authentication works correctly.

-Djavax.net.ssl.trustStore=
-Djavax.net.ssl.trustStorePassword=
-Djavax.net.ssl.trustStoreType=
-Djavax.net.ssl.keyStore=
-Djavax.net.ssl.keyStorePassword=
-Djavax.net.ssl.keyStoreType=

Console 3 - System settings for configure truststore and keystore

For more information about Java security implementation, I recommend reading the Java Secure Socket Extension (JSSE) Reference Guide and Java Cryptography Architecture (JCA) Reference Guide

For many HTTPs applications, that is enough and no configuration is necessary. However, when using custom client certificates or self-signed server certificates or similar, you may need to specifically configure in the keystore and trust managers and such to establish the SSL connection.

Also we can't use the SSLContext setting via JAX-WS API because ignored by Apache CXF and now deprecated.

((BindingProvider)_calculatorSoap).getRequestContext().put(
		"com.sun.xml.internal.ws.transport.https.client.SSLSocketFactory",
  	_getSSLConnectionSocketFactory()
);

Source Code 1 - Setting SSLSocketFactory

We can't even use HttpsURLConnection.setDefaultSSLSocketFactory(_getSSLConnectionSocketFactory()) because ignored by Apache CXF unless I configure the useHttpsURLConnectionDefaultSslSocketFactory property.

In order to achieve our goal we need to be able to use Apache CXF directly. Therefore, it is necessary create an OSGi Fragment to export some Apache CXF packages. The fragment must be made on the module Liferay Portal Remote SOAP Extender Implementation

The module liferay-portal-remote-soap-extender-impl-fragment implements the fragment above. Following the source of the bnd.bnd where it is evident the fragment and the Apache CXF exported package.

Bundle-Name: Liferay Portal Remote SOAP Extender Implementation Fragment
Bundle-SymbolicName: it.dontesta.labs.liferay.portal.remote.soap.extender.impl
Bundle-Version: 1.0.0
Fragment-Host: com.liferay.portal.remote.soap.extender.impl;bundle-version="4.0.12"

Export-Package: \
	org.apache.cxf.frontend;version="3.2.14"

Source Code 2 - The bnd.bnd of the OSGi Fragment Liferay Portal Remote SOAP Extender Implementation

At this point we can use Apache CXF to build our SOAP client that is able to perform mutual authentication over SSL / TLS. Following is the base code of the calculator-service-ssl-tls-mutual-auth module that implements the SOAP client with the support of mutual authentication.

The example implemented refers to the SOAP Calculator service whose WSDL (Web Services Description Language) descriptor is available at the following address http://www.dneonline.com/calculator.asmx which exposes the four arithmetic operations between two integers.

  1. The first code block performs the SOAP endpoint setup
  2. The second block of code sets the proxy client setup, the http policy and finally the TLS connection properties
  3. The third code block sets the SSL context, in particular it performs the KeyStore and TrustStore setup
private CalculatorSoap _getService(boolean renew)
	throws CalculatorServiceException {

	try {
		org.tempuri.Calculator calculator =
			new org.tempuri.Calculator();
		_calculatorSoap = calculator.getCalculatorSoap();

		((BindingProvider)_calculatorSoap).getRequestContext(
		).put(
			BindingProvider.ENDPOINT_ADDRESS_PROPERTY,
			_getSOAPEndPointAddress()
		);

		...
		
	}
	catch (CertificateException | IOException | KeyManagementException |
		   KeyStoreException | NoSuchAlgorithmException |
		   UnrecoverableKeyException | WebServiceException wse) {

		if (_log.isErrorEnabled()) {
			_log.error(wse.getMessage(), wse);
		}

		throw new CalculatorServiceException(wse.getMessage(), wse);
	}

}

Source Code 3 - Core implementation of the _getService() method of the service CalculatorClientSSLTLSMutualAuthImpl

private void _setUpSecurityClientConnection()
	throws CalculatorServiceException, CertificateException, IOException,
		   KeyManagementException, KeyStoreException,
		   NoSuchAlgorithmException, UnrecoverableKeyException {

    ...

	// Get the underlying http conduit of the client proxy
	Client client = ClientProxy.getClient(_calculatorSoap);

	HTTPConduit http = (HTTPConduit)client.getConduit();

	HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy();

	httpClientPolicy.setConnectionTimeout(_getClientConnectionTimeOut());
	httpClientPolicy.setReceiveTimeout(_getClientReceiveTimeout());
	httpClientPolicy.setAllowChunking(false);

	http.setClient(httpClientPolicy);

	// Set the TLS client parameters
	TLSClientParameters parameters = new TLSClientParameters();

	parameters.setDisableCNCheck(_getCommonNameCheck());

	if (!_getHostNameVerifier()) {
		parameters.setHostnameVerifier((hostName, session) -> true);
	}

	parameters.setSSLSocketFactory(_getSSLConnectionSocketFactory());

	http.setTlsClientParameters(parameters);
}

Source Code 4 - Core implementation of the _setUpSecurityClientConnection() method of the service CalculatorClientSSLTLSMutualAuthImpl

private SSLSocketFactory _getSSLConnectionSocketFactory()
	throws CertificateException, IOException, KeyManagementException,
		   KeyStoreException, NoSuchAlgorithmException,
		   UnrecoverableKeyException {

	SSLContext sslContext = SSLContext.getInstance(_getHTTPSProtocol());

	// Create and load the truststore
	KeyStore trustStore = KeyStore.getInstance(_getTrustStoreType());

	trustStore.load(
		_getTrustStoreFile(), _getTrustStorePassword().toCharArray());

	// Create and load the keystore
	KeyStore keyStore = KeyStore.getInstance(_getKeyStoreType());

	keyStore.load(_getKeyStoreFile(), _getKeyStorePassword().toCharArray());

	// Create and initialize the truststore manager
	TrustManagerFactory trustManagerFactory =
		TrustManagerFactory.getInstance("SunX509");

	trustManagerFactory.init(trustStore);

	// Create and initialize the keystore manager
	KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(
		KeyManagerFactory.getDefaultAlgorithm());

	keyManagerFactory.init(keyStore, _getKeyStorePassword().toCharArray());

	sslContext.init(
		keyManagerFactory.getKeyManagers(),
		trustManagerFactory.getTrustManagers(), new SecureRandom());

	return sslContext.getSocketFactory();
}

Source Code 5 - Core implementation of the _getSSLConnectionSocketFactory() method of the service CalculatorClientSSLTLSMutualAuthImpl

To start a mutual authentication process we need two keystore, one that contains public certificates (truststore) and one that contains private certificates (keystore). This module contains within it both the keystore and truststore that can be used with the SOAP test service that I have prepared.

In the structure below we can see the keystore (tls-client.dontesta.it.p12) and the truststore (client-truststore.p12). Both are in the standard PKCS#12 format. If you are curious about how these certificates were generated read How The Certificates Were Generated.

├── calculator-service-ssl-tls-mutual-auth
   └── src
       └── main
           └── resources
               ├── META-INF
                  ├── keystore
                  │   ├── client-truststore.p12
                  │   └── tls-client.dontesta.it.p12
                  └── wsdl
                      └── calculator.wsdl

Console 4 - Resources of the calculator-service-ssl-tls-mutual-auth module

I also wanted to implement the configuration bonus for this SOAP client. The figures show a part of the module configuration. The following parameters can be configured:

  1. TrustStore and KeyStore Type
  2. Path of the TrustStore and KeyStore
  3. Password of the TrustStore and KeyStore
  4. HTTPS Protocol
  5. SOAP EndPoint Address
  6. Check CN (Common Name)
  7. Check Hostname
  8. Connection Timeout (ms)
  9. Receive Timeout (ms)

ConfigurationSOAPExternalService_1

Figure 1 - System Settings configuration for Calculator Service

ConfigurationSOAPExternalService_2

Figure 2 - Configuration detail of the Calculator Service

3. How to run a client test

To test our Calculator service SOAP client, we need the service to be protected by the mutual authentication mechanism. To facilitate the task we could take advantage of the Apache HTTP 2.4 - Docker image for SSL / TLS Mutual Authentication.

$ docker run -i -t -d \
	-p 10443:10443 \
	-e API_BASE_PATH='/secure/ws' \
	-e API_BACKEND_BASE_URL='http://www.dneonline.com' \
	-e APACHE_PROXY_PRESERVE_HOST=Off \
	--name=apache-ssl-tls-mutual-authentication \
	amusarra/apache-ssl-tls-mutual-authentication:1.2.8

Console 5 - Start of the SOAP Calculator service protected by the mutual authentication mechanism.

The new WSDL of the service will be available at the URL https://localhost:10443/secure/ws/calculator.asmx?WSDL. To avoid the SSL_ERROR_BAD_CERT_DOMAIN error from the browser, the following line must be added to your hosts file.

##
# Mutual authentication service via Apache HTTPD
##
127.0.0.1       tls-auth.dontesta.it

Source Code 6 - File /etc/hosts

After adding entry on hosts file, the new WSDL of the service will be available at the URL https://tls-auth.dontesta.it:10443/secure/ws/calculator.asmx?WSDL

The two figures below show the process of mutual authentication to access the WSDL via browser. In this case the client certificate was installed on the browser.

MutualAuthenticationViaBrowser_1

Figure 3 - Mutual Authentication request for the WSDL resource

MutualAuthenticationViaBrowser_2

Figure 4 - WSDL document of the Calculator SOAP Service

Now that the SOAP service is active, we can test our new client. I remind you that we have two SOAP clients for the same service:

  1. The client that directly accesses the service without any security mechanism (modulo calculator-service)
  2. The client that instead uses the mutual authentication mechanism (modulo calculator-service-ssl-tls-mutual-auth).

We must therefore stop the first service, in this way both the web module and the Gogo Shell module, will use the SOAP client in mutual authentication.

g! stop 1362
g! lb Calculator
START LEVEL 20
   ID|State      |Level|Name
 1360|Active     |   15|Calculator Web Application (2.0.0)|2.0.0
 1361|Active     |   15|Calculator Client API (2.0.0)|2.0.0
 1362|Active     |   15|Calculator SOAP Client Implementation (2.0.0)|2.0.0
 1363|Active     |   15|Calculator SOAP Client SSL/TLS Mutual Auth Implementation (2.0.0)|2.0.0
 1364|Active     |   15|Calculator Client Gogo Shell Commands (2.0.0)|2.0.0

Console 6 - Stop the bundle Calculator SOAP Client Implementation

If we wanted to make sure that both the Gogo Shell commands, and the MVC portlet were linked to the new service we could check with the command scr: info ${component name}. As you can see, the linked service is the correct one: it.dontesta.labs.liferay.webservice.calculator.client.tls

g! scr:info it.dontesta.labs.liferay.webservice.calculator.gogoshell.CalculatorCommand
Component Description: it.dontesta.labs.liferay.webservice.calculator.gogoshell.CalculatorCommand
=================================================================================================
Class:         it.dontesta.labs.liferay.webservice.calculator.gogoshell.CalculatorCommand
Bundle:        1374 (it.dontesta.labs.liferay.webservice.calculator.gogoshell:2.0.0)
Enabled:       true
Immediate:     false
Services:      [java.lang.Object]
Scope:         singleton
Config PID(s): [it.dontesta.labs.liferay.webservice.calculator.gogoshell.CalculatorCommand], Policy: optional
Base Props:    (2 entries)
  osgi.command.function<String[]> = [add, divide, multiply, subtract]
  osgi.command.scope<String> = calculator

Component Configuration Id: 8180
--------------------------------
State:        ACTIVE
Service:      21763 [java.lang.Object]
    Used by bundle 894 (com.liferay.portal.remote.cxf.common:6.0.13)
Config Props: (4 entries)
  component.id<Long> = 8180
  component.name<String> = it.dontesta.labs.liferay.webservice.calculator.gogoshell.CalculatorCommand
  osgi.command.function<String[]> = [add, divide, multiply, subtract]
  osgi.command.scope<String> = calculator
References:   (total 1)
  - _calculator: it.dontesta.labs.liferay.webservice.calculator.api.Calculator SATISFIED 1..1 dynamic+greedy
    target=(*) scope=bundle (1 binding):
    * Bound to [21775] from bundle 1373 (it.dontesta.labs.liferay.webservice.calculator.client.tls:2.0.0)
g!

Console 7 - src:info for checking the bound service for Gogo Shell Command

g! scr:info it.dontesta.labs.liferay.web.calculator.portlet.action.AddOperationMVCActionCommand
Component Description: it.dontesta.labs.liferay.web.calculator.portlet.action.AddOperationMVCActionCommand
==========================================================================================================
Class:         it.dontesta.labs.liferay.web.calculator.portlet.action.AddOperationMVCActionCommand
Bundle:        1376 (it.dontesta.labs.liferay.web.calculator:2.0.0)
Enabled:       true
Immediate:     true
Services:      [com.liferay.portal.kernel.portlet.bridges.mvc.MVCActionCommand]
Scope:         singleton
Config PID(s): [it.dontesta.labs.liferay.web.calculator.portlet.action.AddOperationMVCActionCommand], Policy: optional
Base Props:    (2 entries)
  javax.portlet.name<String> = it_dontesta_labs_liferay_web_calculator_CalculatorAppPortlet
  mvc.command.name<String> = /calculator/add-operation

Component Configuration Id: 8196
--------------------------------
State:        ACTIVE
Service:      21803 [com.liferay.portal.kernel.portlet.bridges.mvc.MVCActionCommand]
Config Props: (4 entries)
  component.id<Long> = 8196
  component.name<String> = it.dontesta.labs.liferay.web.calculator.portlet.action.AddOperationMVCActionCommand
  javax.portlet.name<String> = it_dontesta_labs_liferay_web_calculator_CalculatorAppPortlet
  mvc.command.name<String> = /calculator/add-operation
References:   (total 1)
  - _calculator: it.dontesta.labs.liferay.webservice.calculator.api.Calculator SATISFIED 1..1 static+greedy
    target=(*) scope=bundle (1 binding):
    * Bound to [21775] from bundle 1373 (it.dontesta.labs.liferay.webservice.calculator.client.tls:2.0.0)
g!

Console 8 - src:info for checking the bound service for AddOperationMVCActionCommand

4. How to configure JAX-WS Handlers

Using the Liferay JAX-WS infrastructure it is possible in a very simple way to configure one or more JAX-WS Handlers for each endpoint.

The endpoint /custom-user must be configured manually via the control panel. The custom-users-ws module has been set up for automatic configuration but at the moment it doesn't seem to work (see LPS-101642).

ConfigurationSOAPExternalService_3

Figure 5 - JAX-WS Handler configuration for endpoint /custom-user

Through the JAX-WS Handler Filters you can specify a set of OSGi filters that select certain services registered in the OSGi framework. The selected services should implement JAX-WS handlers and augment the JAX-WS services specified in the jax.ws.service.filters property. These JAX-WS handlers apply to each service selected in this extender.

The two code fragments show the two JAX-WS Handlers (which are OSGi components) configured in figure 5. Note the properties of the OSGi component where specified OSGi filter.

/**
 * @author Antonio Musarra
 */
@Component(
	configurationPid = MacAddressValidatorHandlerConfiguration.PID,
	immediate = true,
	property = "mac.address.validator.jax.ws.handler.filters=true",
	service = Handler.class
)
public class MacAddressValidatorHandler
	implements SOAPHandler<SOAPMessageContext> {
		...
}

Source Code 7 - Mac Address Validator JAX-WS Handler

The Handler MacAddressValidatorHandler performs the validation of the Mac Address that the SOAP client passes in the SOAP Headers.

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ws="http://ws.service.customusers.webservice.liferay.labs.dontesta.it/" xmlns:soap="soap">
   <soapenv:Header>
      <mac:MacAddress 
      	soapenv:actor="http://schemas.xmlsoap.org/soap/actor/next" 
      	xmlns:mac="http://ws.service.customusers.webservice.liferay.labs.dontesta.it/macaddress/value/">88:e9:fe:69:c6:88</mac:MacAddress>
   </soapenv:Header>
   <soapenv:Body>
      <ws:getUsersByTag>
         <!--Optional:-->
         <arg0>SoftwareArchitect</arg0>
      </ws:getUsersByTag>
   </soapenv:Body>
</soapenv:Envelope>

Source Code 8 - SOAP Request with evidence of the Mac Address header

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
   <soap:Body>
      <soap:Fault>
         <faultcode xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">soapenv:Server</faultcode>
         <faultstring>Invalid MAC Address, access is denied.</faultstring>
      </soap:Fault>
   </soap:Body>
</soap:Envelope>

Source Code 9 - SOAP Fault generated by the Mac Address validation Handler

The Mac Addresses to be enabled can be configured from the control panel.

ConfigurationSOAPExternalService_5

Figure 6 - Configuration of the JAX-WS Handler Mac Address Validator

The Handler AuditLogHandler audits SOAP messages in input and output via the Liferay Audit framework.

/**
 * @author Antonio Musarra
 */
@Component(
	immediate = true, property = "audit.log.jax.ws.handler.filters=true",
	service = Handler.class
)
public class AuditLogHandler implements SOAPHandler<SOAPMessageContext> {
    ...
}

Source Code 10 - Audit Log JAX-WS Handler

AuditLogJAXWSHandler_1

Figure 7 - Trace the Audit Event via Audit Log JAX-WS Handler

License

MIT License

Copyright 2009-present Antonio Musarra's Blog - https://www.dontesta.it

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

About

In this project I want to show you how to build a SOAP JAX-WS (Java API for XML Web Services) client using the Liferay infrastructure. Let's start with a concrete example.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages