Developer Guide: Integrating a New Supplier Plugin¶
Overview¶
This guide explains how to build a plugin that integrates an external supplier (activity provider, GDS, hotel aggregator, etc.) into TQPro. It covers the complete data pipeline — from raw supplier API responses through to canonical entities consumed by the REST API layer.
The guide is based on the architecture of existing plugins: Tiqets (tqtiqets), Rayna B2B (tqryb2b), Amadeus (tqamds), and Google Flights (tqgflights). Use them as reference implementations alongside this document.
Table of Contents¶
- Architecture Overview
- Module Setup
- Step 1: Remote DTOs — Representing Supplier Data
- Step 2: HTTP Client — Communicating with the Supplier API
- Step 3: Remote Service Layer — Domain-Specific API Wrappers
- Step 4: DB Cache Entities — Local Persistence of Supplier Data
- Step 5: Native Entities — The Bridge to Canonical
- Step 6: Plugin Services — The Framework Integration Point
- Step 7: Service Factory — Creating Services on Demand
- Step 8: Plugin Class — Initialization and Lifecycle
- Step 9: Plugin Configuration File
- Step 10: Framework Registration — tourlinq-config.xml
- Step 11: Entity Configuration — XML Field Mappings
- Step 12: Caching and Scheduled Refresh
- Step 13: Ticketing Integration (Optional)
- Step 14: Facade Integration — Multi-Factory Routing
- Step 15: Testing
- Full Data Flow — End-to-End Call Chain
- Reference: Existing Plugins at a Glance
1. Architecture Overview¶
A supplier plugin sits between the TQPro entity framework and the external supplier API. It is responsible for:
- Fetching data from the supplier (via HTTP/SDK)
- Caching catalog data locally in PostgreSQL (optional but recommended for catalog-heavy suppliers)
- Exposing data as native entities that the
EntityTransformercan map to canonical entities - Accepting write operations (bookings, orders) and forwarding them to the supplier
The data flows through four distinct entity layers:
┌─────────────────────────────────────────────────────────────────────────┐
│ Supplier REST API │
│ (external, e.g. api.tiqets.com, api.raynab2b.com) │
└────────────────────────────────┬────────────────────────────────────────┘
│ HTTP / SDK
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Layer 1: Remote DTOs │
│ POJOs that mirror the supplier's JSON/XML response structures. │
│ e.g. TiqetsProduct, TourBase, FlightOfferSearch │
│ Package: <plugin>/remote/entity/ │
└────────────────────────────────┬────────────────────────────────────────┘
│ initFromRemote()
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Layer 2: DB Cache Entities (optional) │
│ JPA @Entity classes stored in a plugin-specific PostgreSQL schema. │
│ Extend AbstractCachedEntity. Flattened for relational storage. │
│ e.g. ProductEntity (tiqets schema), TourEntity (rayna schema) │
│ Package: <plugin>/db/ │
└────────────────────────────────┬────────────────────────────────────────┘
│ initFromEntity()
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Layer 3: Native Entities │
│ Implement RemoteEntityI. Annotated with @TlinqClientEntity and │
│ @TlinqEntityField. These participate in EntityTransformer mapping. │
│ e.g. TqProduct, RyProduct, AmdFlightOffer │
│ Package: <plugin>/entity/ │
└────────────────────────────────┬────────────────────────────────────────┘
│ EntityTransformer (XML field mappings)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Layer 4: Canonical Entities │
│ Extend TlinqEntity. Platform-independent, shared across all plugins. │
│ e.g. CProduct, CFlightOffer, CCustomer │
│ Package: com.perun.tlinq.entity.* │
└─────────────────────────────────────────────────────────────────────────┘
When to skip the DB Cache layer: If the supplier provides only real-time data that is never reused (e.g., live flight search results from Amadeus), you can skip Layer 2 entirely. The native entities are populated directly from the remote DTOs at service invocation time.
2. Module Setup¶
Create a new Gradle submodule for the plugin:
tq<prefix>/
src/main/java/com/perun/tlinq/client/<prefix>/
db/ # JPA cache entities + Hibernate session helper
entity/ # Native entities (RemoteEntityI)
framework/ # Plugin class
remote/
entity/ # Remote DTOs
service/ # HTTP client + remote service wrappers
service/ # Plugin services + service factory
util/ # Client config, cache manager
src/main/resources/
META-INF/
persistence.xml # JPA persistence unit (if using DB cache)
In settings.gradle, add the new module:
In the module's build.gradle, declare dependencies on tqcommon and tqapp:
dependencies {
implementation project(':tqcommon')
implementation project(':tqapp')
// HTTP client, JSON parsing, supplier SDK, etc.
}
Step 1: Remote DTOs — Representing Supplier Data¶
Remote DTOs are plain Java classes whose fields match the supplier API's JSON/XML response structure. They have no framework dependencies — their only job is deserialization.
Example — A product DTO from the Tiqets plugin (tqtiqets/remote/entity/TiqetsProduct.java):
package com.perun.tlinq.client.<prefix>.remote.entity;
/**
* DTO for Supplier Product API response.
* Maps to GET /products/{id} response.
*/
public class SupplierProduct {
private Integer id;
private String title;
private String description;
private BigDecimal price;
private String currency;
private Boolean active;
private List<SupplierVariant> variants;
// Nested DTOs for complex response structures
public static class SupplierLocation {
private BigDecimal lat;
private BigDecimal lng;
public BigDecimal getLat() { return lat; }
public BigDecimal getLng() { return lng; }
}
// Getters only — no setters needed if using Gson/Jackson deserialization
public String getProductId() { return id != null ? String.valueOf(id) : null; }
public String getTitle() { return title; }
// ...
}
Guidelines:
- Use field names matching the supplier's JSON keys for direct Gson/Jackson deserialization (e.g.,
experience_idfor a JSON key"experience_id"). - Expose getters that normalize the data (e.g., converting
Integer idtoString getProductId()). - Use nested static classes for embedded JSON objects (venues, locations, ratings).
- Group related DTOs in the
remote/entity/package. Also create request DTOs here (e.g.,CreateBookingRequest,AvailabilityRequest). - Keep response wrapper classes here too (e.g.,
ProductsResponsewith a list field and pagination metadata).
Step 2: HTTP Client — Communicating with the Supplier API¶
Create a centralized HTTP client class that encapsulates all communication with the supplier API. This class handles:
- Authentication (API keys, OAuth tokens, JWT signing)
- HTTP transport (GET, POST, PUT, DELETE)
- JSON serialization/deserialization
- Error handling and status code mapping
Example — Based on TiqetsHttpClient:
package com.perun.tlinq.client.<prefix>.remote;
public class SupplierHttpClient {
private final Client httpClient; // jakarta.ws.rs.client.Client
private final Gson gson;
private final String baseUrl;
private final String apiKey;
public SupplierHttpClient(SupplierClientConfig config) {
this.httpClient = ClientBuilder.newClient();
this.gson = new GsonBuilder().setDateFormat("yyyy-MM-dd").create();
this.baseUrl = config.getApiBaseUrl();
this.apiKey = config.getApiKey();
}
/**
* GET request with typed response deserialization.
*/
public <T> T get(String path, Class<T> responseClass, Map<String, String> queryParams)
throws TlinqClientException {
WebTarget target = httpClient.target(baseUrl).path(path);
for (Map.Entry<String, String> param : queryParams.entrySet()) {
target = target.queryParam(param.getKey(), param.getValue());
}
Response response = target.request(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + apiKey)
.get();
return handleResponse(response, responseClass);
}
/**
* POST request with JSON body.
*/
public <T> T post(String path, Object requestBody, Class<T> responseClass)
throws TlinqClientException {
String jsonBody = gson.toJson(requestBody);
Response response = httpClient.target(baseUrl).path(path)
.request(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + apiKey)
.post(Entity.json(jsonBody));
return handleResponse(response, responseClass);
}
private <T> T handleResponse(Response response, Class<T> responseClass)
throws TlinqClientException {
if (response.getStatus() >= 400) {
throw new TlinqClientException(
TlinqErr.REMOTE_ERROR,
"Supplier API error: HTTP " + response.getStatus()
);
}
String json = response.readEntity(String.class);
return gson.fromJson(json, responseClass);
}
}
Alternatives by plugin:
| Plugin | HTTP approach |
|---|---|
| Tiqets | JAX-RS Client + Gson, with JWT RS256 signing for order endpoints |
| Rayna B2B | Custom HTTP service classes per domain, each with their own HTTP handling |
| Amadeus | Official com.amadeus Java SDK — no raw HTTP code needed |
| Google Flights | Java HttpClient + Gson DTO deserialization via @SerializedName POJOs |
If the supplier provides an official SDK (like Amadeus), use it instead of building a custom HTTP client.
Step 3: Remote Service Layer — Domain-Specific API Wrappers¶
Thin wrapper classes that expose domain-specific operations on top of the HTTP client. Each class groups related API endpoints.
Example — Based on tqtiqets/remote/service/ProductsService.java:
package com.perun.tlinq.client.<prefix>.remote.service;
public class ProductsRemoteService {
private final SupplierHttpClient httpClient;
public ProductsRemoteService(SupplierHttpClient httpClient) {
this.httpClient = httpClient;
}
public ProductListResponse listProducts(String cityId, int page, int pageSize)
throws TlinqClientException {
Map<String, String> params = new HashMap<>();
if (cityId != null) params.put("city_id", cityId);
params.put("page", String.valueOf(page));
params.put("page_size", String.valueOf(pageSize));
return httpClient.get("/products", ProductListResponse.class, params);
}
public SupplierProduct getProduct(String productId) throws TlinqClientException {
return httpClient.get("/products/" + productId, SupplierProduct.class, Map.of());
}
public AvailabilityResponse getAvailability(String productId, String dateFrom, String dateTo)
throws TlinqClientException {
Map<String, String> params = Map.of("date_from", dateFrom, "date_to", dateTo);
return httpClient.get(
"/products/" + productId + "/availability",
AvailabilityResponse.class, params
);
}
}
Create one remote service class per domain: ProductsRemoteService, OrdersRemoteService, BookingRemoteService, etc.
Step 4: DB Cache Entities — Local Persistence of Supplier Data¶
For suppliers with a large catalog (products, cities, countries, experiences), cache the data in a dedicated PostgreSQL schema. This avoids hitting the supplier API for every read/search request.
4.1 Database Schema¶
Create a dedicated schema (e.g., <prefix>) with tables mirroring the supplier's catalog structure. Use integer sequences for local primary keys, and store the supplier's IDs as separate unique business keys.
4.2 JPA Entity Class¶
DB cache entities extend AbstractCachedEntity and implement initFromRemote() to populate themselves from a remote DTO.
Example — Based on tqtiqets/db/ProductEntity.java:
package com.perun.tlinq.client.<prefix>.db;
@Entity
@Table(name = "product", schema = "<prefix>", catalog = "tlinq")
@NamedQueries({
@NamedQuery(name = "ProductEntity.findBySupplierId",
query = "SELECT p FROM ProductEntity p WHERE p.supplierProductId = :supplierId")
})
public class ProductEntity extends AbstractCachedEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_id_gen")
@SequenceGenerator(name = "product_id_gen",
sequenceName = "<prefix>.product_seq", allocationSize = 1)
@Column(name = "id", nullable = false)
private Integer id;
@Basic
@Column(name = "supplier_product_id", nullable = false, unique = true)
private String supplierProductId;
@Basic
@Column(name = "title")
private String title;
// ... flattened fields ...
@Basic @Column(name = "hash")
private long hash;
@Column(name = "last_update")
@Temporal(TemporalType.TIMESTAMP)
private Date lastUpdate;
// Required by AbstractCachedEntity
@Override
public long getHash() { return hash; }
@Override
public void setHash(long hash) { this.hash = hash; }
@Override
public Date getLastupdate() { return lastUpdate; }
@Override
public void setLastupdate(Date lastupdate) { this.lastUpdate = lastupdate; }
/**
* Populate this DB entity from a supplier API DTO.
* Handles type conversions (List -> comma-separated String, nested objects -> flat columns).
*/
@Override
public void initFromRemote(Object remoteObj) {
SupplierProduct rp = (SupplierProduct) remoteObj;
this.supplierProductId = rp.getProductId();
this.title = rp.getTitle();
// ... map all fields, converting types as needed ...
// List<String> -> String.join("\n", list)
// Nested object -> flatten to individual columns
// Date strings -> parsed Date objects
}
// Getters and setters ...
}
4.3 Hibernate Session Helper¶
Create a <Prefix>DBSession utility class to manage Hibernate sessions for the plugin's persistence unit.
4.4 persistence.xml¶
Register all JPA entities in src/main/resources/META-INF/persistence.xml:
<persistence-unit name="<prefix>PU" transaction-type="RESOURCE_LOCAL">
<class>com.perun.tlinq.client.<prefix>.db.ProductEntity</class>
<class>com.perun.tlinq.client.<prefix>.db.CityEntity</class>
<!-- ... -->
</persistence-unit>
Key design decisions for the DB cache layer:
| Concern | Approach |
|---|---|
| Primary keys | Use local integer sequences, not supplier IDs |
| Supplier IDs | Store as supplierProductId (unique business key) |
| List fields | Flatten to comma-separated or newline-separated strings |
| Nested objects | Flatten to individual columns |
| Change detection | Use hash field to detect if remote data has changed |
| Timestamps | Use lastUpdate to track when data was last synced |
Step 5: Native Entities — The Bridge to Canonical¶
Native entities are the TQPro framework's view of the supplier's data. They implement RemoteEntityI and are the entities that EntityTransformer maps to/from canonical entities via XML configuration.
5.1 Base Class¶
Create a base class for all native entities in the plugin:
package com.perun.tlinq.client.<prefix>.entity;
@TlinqClientEntity
public class PrefixEntity extends AbstractEntity implements RemoteEntityI {
@Override
public List<Field> listEntityFields() {
return AbstractEntity.allFieldsList(this.getClass(), PrefixEntity.class);
}
@Override
public String getSourceFieldName(String fieldName) {
// Return null for simple field name matching
// Override in subclasses if source names differ
return null;
}
@Override
public Integer getId() {
return null; // Override in subclasses
}
}
5.2 Concrete Native Entity¶
Each native entity maps to a DB cache entity (or directly to a remote DTO if no caching is used). Fields are annotated with @TlinqEntityField to indicate their source in the DB entity.
Example — Based on tqtiqets/entity/TqProduct.java:
package com.perun.tlinq.client.<prefix>.entity;
@TlinqClientEntity
public class SpProduct extends PrefixEntity {
@TlinqEntityField(sourceName = "ProductEntity.supplierProductId")
String productId;
@TlinqEntityField(sourceName = "ProductEntity.title")
String title;
@TlinqEntityField(sourceName = "ProductEntity.minPrice")
BigDecimal minPrice;
@TlinqEntityField(sourceName = "ProductEntity.currency")
String currency;
@TlinqEntityField(sourceName = "ProductEntity.active")
Boolean active;
// Getters and setters ...
}
The @TlinqEntityField(sourceName = "ProductEntity.fieldName") annotation tells the framework how to populate this native entity from the DB cache entity using initFromEntity() (inherited from AbstractEntity).
5.3 Relationship between the entity layers¶
Remote DTO (SupplierProduct)
│
│ initFromRemote() — explicit code in DB entity
▼
DB Cache Entity (ProductEntity)
│
│ initFromEntity() — reflection via @TlinqEntityField annotations
▼
Native Entity (SpProduct implements RemoteEntityI)
│
│ EntityTransformer — XML FieldMappingConfig
▼
Canonical Entity (CProduct extends TlinqEntity)
Each transition is handled by a different mechanism:
| Transition | Mechanism | Defined in |
|---|---|---|
| Remote DTO → DB Cache | initFromRemote() method |
DB entity class (Java code) |
| DB Cache → Native | initFromEntity() via @TlinqEntityField |
Native entity annotations |
| Native → Canonical | EntityTransformer.toCanonicalObject() |
XML entity config |
| Canonical → Native | EntityTransformer.toNative() |
XML entity config (reverse) |
Step 6: Plugin Services — The Framework Integration Point¶
Plugin services implement the TQPro service interfaces (EntityReadServiceI, EntitySearchServiceI, EntityWriteServiceI) and are the point where the framework calls into the plugin.
6.1 Service Base Class¶
Create a base service class that implements common functionality:
package com.perun.tlinq.client.<prefix>.service;
public class PrefixEntityService implements RemoteServiceI, EntityReadServiceI, EntitySearchServiceI {
protected Properties serviceProperties;
protected ServiceConfig serviceConfig;
protected List<Object[]> criteriaList = new ArrayList<>();
protected Map<String, Object> namedParams = new HashMap<>();
protected String entityClassName;
protected String entityIdField;
@Override
public void initialize(Properties appProperties) {
this.serviceProperties = appProperties;
this.serviceConfig = (ServiceConfig) appProperties.get("service-config");
if (serviceConfig != null) {
this.entityClassName = serviceConfig.getServiceEntity();
this.entityIdField = serviceConfig.getIdField();
}
}
@Override
public void addCriterion(Object field, String oper, Object value) {
criteriaList.add(new Object[]{field, oper, value});
}
@Override
public void addNamedParameter(String key, Object value) {
namedParams.put(key, value);
}
/**
* Default search — queries the local DB cache.
* Override in subclass for remote API searches.
*/
@Override
public List search() throws TlinqClientException {
return defaultSearch();
}
/**
* Invoke a named method on this service via reflection.
* The method name comes from the service configuration XML.
*/
@Override
public <T> T invokeMethod(String methodName, Class<T> returnClass, RemoteEntityI inputData)
throws TlinqClientException {
try {
Method m = this.getClass().getMethod(methodName, RemoteEntityI.class);
return returnClass.cast(m.invoke(this, inputData));
} catch (Exception e) {
throw new TlinqClientException(TlinqErr.SYSTEM_ERROR, e.getMessage());
}
}
/**
* Query the local DB and convert results to native entities.
*/
protected List defaultSearch() throws TlinqClientException {
Session session = PluginDBSession.getSession();
try {
// Build JPQL query from criteriaList
// Execute query
// Convert DB entities to native entities via initFromEntity()
List<PrefixEntity> results = new ArrayList<>();
for (Object dbEntity : dbResults) {
PrefixEntity native = createNativeEntity();
native.initFromEntity(dbEntity);
results.add(native);
}
return results;
} finally {
session.close();
}
}
}
6.2 Domain-Specific Service¶
Create services for each domain that override search() or add custom methods callable via invokeMethod():
package com.perun.tlinq.client.<prefix>.service.product;
public class SupplierCatalogService extends PrefixEntityService {
/**
* Called via invokeMethod() when service config maps action "listProducts".
* Reads from cache, falls back to remote API if needed.
*/
public List<SpProduct> listProducts(RemoteEntityI criteria) throws TlinqClientException {
// 1. Try cache first
List<SpProduct> cached = cacheManager.getProducts(/* filter from criteria */);
if (cached != null && !cached.isEmpty()) {
return cached;
}
// 2. Fall back to remote API
ProductsRemoteService remote = new ProductsRemoteService(httpClient);
List<SupplierProduct> dtos = remote.listProducts(cityId, 1, 100).getProducts();
// 3. Persist to local DB and populate native entities
List<SpProduct> results = new ArrayList<>();
for (SupplierProduct dto : dtos) {
ProductEntity dbEntity = new ProductEntity();
dbEntity.initFromRemote(dto);
saveToDb(dbEntity);
SpProduct native = new SpProduct();
native.initFromEntity(dbEntity);
results.add(native);
}
return results;
}
/**
* Called via invokeMethod() for real-time availability checks.
* Always hits the remote API (no caching).
*/
public Object getAvailability(RemoteEntityI inputEntity) throws TlinqClientException {
String productId = ((SpProduct) inputEntity).getProductId();
String dateFrom = (String) namedParams.get("dateFrom");
String dateTo = (String) namedParams.get("dateTo");
ProductsRemoteService remote = new ProductsRemoteService(httpClient);
return remote.getAvailability(productId, dateFrom, dateTo);
}
}
Key principle: Services that read catalog data should prefer the local DB cache. Services that need real-time data (availability, pricing, booking) should call the remote API directly.
Step 7: Service Factory — Creating Services on Demand¶
The service factory is a singleton that creates service instances by looking up the service configuration from the plugin's config file.
Example — Based on TiqetsServiceFactory:
package com.perun.tlinq.client.<prefix>.service;
public class SupplierServiceFactory implements RemoteServiceFactoryI {
private static volatile SupplierServiceFactory _instance;
private SupplierServiceFactory() {}
// Must be public static — called via reflection by ClientServiceFactory
public static SupplierServiceFactory getInstance() {
if (_instance == null) {
synchronized (SupplierServiceFactory.class) {
if (_instance == null) {
_instance = new SupplierServiceFactory();
}
}
}
return _instance;
}
@Override
public RemoteServiceI createService(String serviceName, String sessionToken)
throws TlinqClientException {
// 1. Look up service configuration from plugin config XML
SupplierClientConfig cfg = SupplierClientConfig.instance();
ServiceConfig scfg = cfg.getService(serviceName);
if (scfg == null) {
throw new TlinqClientException(TlinqErr.CONFIG_ERROR,
"Service " + serviceName + " not configured");
}
// 2. Instantiate the service class via reflection
Class<?> sclass = Class.forName(scfg.getServiceClass());
PrefixEntityService service =
(PrefixEntityService) sclass.getDeclaredConstructor().newInstance();
// 3. Initialize with configuration properties
Properties prop = new Properties();
prop.put("service-config", scfg);
service.initialize(prop);
return service;
}
@Override
public String authenticateUser(String user, String pwd) {
return null; // API key authentication — no user sessions
}
@Override
public Integer getSessionId(String sessionToken) {
return null;
}
}
Critical requirement: The getInstance() method must be public static with no parameters. The framework's ClientServiceFactory discovers it via reflection:
// From ClientServiceFactory.java
Method singletonConstructor = sfClass.getMethod("getInstance", (Class<?>[]) null);
return (RemoteServiceFactoryI) singletonConstructor.invoke(null);
If the plugin also supports ticketing, the factory should additionally implement TicketingServiceFactoryI:
public class SupplierServiceFactory
implements RemoteServiceFactoryI, TicketingServiceFactoryI {
@Override
public TicketingServiceI getTicketingService() {
return new SupplierTicketingService();
}
}
Step 8: Plugin Class — Initialization and Lifecycle¶
The plugin class extends AbstractPlugin and is the entry point for the framework to initialize and shut down the plugin.
Example — Based on TiqetsPlugin:
package com.perun.tlinq.client.<prefix>.framework;
public class SupplierPlugin extends AbstractPlugin {
private static final Logger logger = Logger.getLogger(SupplierPlugin.class.getName());
private SupplierClientConfig config;
private ScheduledExecutorService scheduler;
/**
* Constructor — loads configuration early.
* Framework calls setProperty() between constructor and initializePlugin().
*/
public SupplierPlugin() {
config = SupplierClientConfig.instance();
}
/**
* Called by TlinqFrameworkInitializer after all properties are set.
* This is where heavyweight initialization happens.
*/
@Override
public void initializePlugin() {
// 1. Verify database connectivity (if using DB cache)
Session session = SupplierDBSession.getSession();
session.close();
// 2. Initialize the service factory singleton
SupplierServiceFactory.getInstance();
// 3. Initialize the cache (if applicable)
SupplierCacheManager cache = SupplierCacheManager.instance();
cache.initialize(); // Loads from DB into in-memory maps
// 4. Schedule periodic data refresh (if applicable)
if (config.isAutoRefreshEnabled()) {
scheduleDataRefresh(config.getRefreshIntervalHours());
}
logger.info("Supplier Plugin initialization complete.");
}
@Override
public String getProperty(String propertyName) {
return config != null ? config.getProp(propertyName) : null;
}
@Override
public void setProperty(String propertyName, String propertyValue) {
if (config != null) {
config.setProp(propertyName, propertyValue);
}
}
@Override
public void shutdown() {
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdown();
}
}
}
Initialization order managed by the framework:
- Plugin constructor called (load config singleton)
setProperty()called for each property fromtourlinq-config.xml(with##placeholderresolution)initializePlugin()called — perform heavyweight initialization- On JVM shutdown:
shutdown()called (in reverse plugin order)
Step 9: Plugin Configuration File¶
Create a plugin-specific configuration XML file at config/<prefix>-client.xml. This file is parsed by a SupplierClientConfig singleton (modeled after TiqetsClientConfig or RaynaClientConfig).
<?xml version="1.0" encoding="UTF-8"?>
<PluginConfig>
<PluginProperties>
<property name="api.baseUrl" value="https://api.supplier.com/v2"/>
<property name="api.key" value="YOUR_API_KEY"/>
<property name="db.name" value="tlinq"/>
<property name="cache.refreshHours" value="6"/>
<property name="cache.autoRefresh" value="true"/>
</PluginProperties>
<Databases>
<Database name="supplierDB" schema="<prefix>" catalog="tlinq"
persistenceUnit="<prefix>PU"/>
</Databases>
<Services>
<!-- Catalog services -->
<Service name="findProducts"
class="com.perun.tlinq.client.<prefix>.service.product.SupplierCatalogService"
method="listProducts"
entity="com.perun.tlinq.client.<prefix>.entity.SpProduct"
idField="productId"/>
<Service name="readProduct"
class="com.perun.tlinq.client.<prefix>.service.product.SupplierCatalogService"
method="getProduct"
entity="com.perun.tlinq.client.<prefix>.entity.SpProduct"
idField="productId"/>
<Service name="getAvailability"
class="com.perun.tlinq.client.<prefix>.service.product.SupplierCatalogService"
method="getAvailability"
entity="com.perun.tlinq.client.<prefix>.entity.SpProduct"
idField="productId"/>
<!-- Booking services -->
<Service name="createBooking"
class="com.perun.tlinq.client.<prefix>.service.booking.SupplierBookingService"
method="createBooking"
entity="com.perun.tlinq.client.<prefix>.entity.SpBooking"
idField="bookingId"/>
</Services>
</PluginConfig>
Service attributes:
| Attribute | Purpose |
|---|---|
name |
Unique service name, referenced from entity XML and by createService() |
class |
Fully qualified class implementing the service |
method |
Method name invoked via reflection by invokeMethod() |
entity |
Native entity class this service operates on |
idField |
ID field name in the native entity |
Step 10: Framework Registration — tourlinq-config.xml¶
Register the plugin and its service factory in config/tourlinq-config.xml.
10.1 Plugin Registration¶
Add the plugin to the <Plugins> section:
<Plugin name="SupplierPlugin"
constructorClass="com.perun.tlinq.client.<prefix>.framework.SupplierPlugin">
<properties>
<!-- Properties with ##placeholders are resolved from AppProperties -->
<property name="dbname" value="##tlinq.dbname"/>
<property name="api.key" value="##supplier.api.key"/>
<property name="api.baseUrl" value="##supplier.api.url"/>
</properties>
</Plugin>
10.2 Service Factory Registration¶
Add the service factory to the <ServiceFactories> section:
<ServiceFactory name="SupplierServiceFactory"
code="SUPPLIER"
class="com.perun.tlinq.client.<prefix>.service.SupplierServiceFactory"
enabled="true">
<Services configFile="<prefix>-client.xml"/>
<properties>
<!-- If the factory supports ticketing -->
<property name="ticketing.factory"
value="com.perun.tlinq.client.<prefix>.service.SupplierServiceFactory"/>
</properties>
</ServiceFactory>
The code attribute is a short identifier used by SupplierCache to route requests to the correct factory at runtime. It must be unique across all factories.
Step 11: Entity Configuration — XML Field Mappings¶
Define how EntityTransformer maps between native entities and canonical entities. Add entries to the appropriate file in config/entities/ (e.g., product-entities.xml).
<Entity name="SupplierProduct"
class="com.perun.tlinq.entity.product.CProduct"
idField="productId"
defaultFactory="SupplierServiceFactory">
<EntityFactoryList>
<Factory name="SupplierServiceFactory"
nativeEntity="com.perun.tlinq.client.<prefix>.entity.SpProduct">
<ServiceList>
<Service name="findProducts" action="search"/>
<Service name="readProduct" action="read"/>
<Service name="getAvailability" action="getAvailability"
returnClass="com.perun.tlinq.entity.product.CItemPriceInfo">
<NamedParams>
<NamedParam name="dateFrom" field="dateFrom"/>
<NamedParam name="dateTo" field="dateTo"/>
</NamedParams>
</Service>
</ServiceList>
<FieldMappingList>
<!-- Direct field-to-field mapping -->
<FieldMapping targetField="productId" sourceField="productId"
mapping="DirectMapping"/>
<FieldMapping targetField="productName" sourceField="title"
mapping="DirectMapping"/>
<FieldMapping targetField="productDescription" sourceField="description"
mapping="DirectMapping"/>
<FieldMapping targetField="price" sourceField="minPrice"
mapping="DirectMapping"/>
<FieldMapping targetField="currency" sourceField="currency"
mapping="DirectMapping"/>
<!-- Array mapping for nested collections -->
<FieldMapping targetField="variants" sourceField="variants"
targetFieldEntity="SupplierProductVariant"
mapping="ArrayMapping"/>
<!-- Index mapping to extract one element from an array -->
<FieldMapping targetField="categoryName" sourceField="categories"
mapping="IndexMapping">
<IndexMapping index="0"/>
</FieldMapping>
<!-- Method-based source (prefixed with #) -->
<FieldMapping targetField="formattedDate" sourceField="#getFormattedDate"
mapping="DirectMapping"/>
<!-- Fields with no mapping (skipped) -->
<FieldMapping targetField="internalNotes" sourceField=""
mapping="NoMapping"/>
</FieldMappingList>
</Factory>
</EntityFactoryList>
</Entity>
Field Mapping Types Reference¶
| Mapping | When to use | Example |
|---|---|---|
DirectMapping |
1:1 field copy, with automatic type coercion | String productId → String productId |
ArrayMapping |
Source and target are both lists/arrays | List<SpVariant> → List<CVariant> |
IndexMapping |
Extract one element from a source array | String[] categories → String categoryName |
ModelLookup |
Source value is a foreign key, need to resolve to a display value | Integer supplierId → String supplierName |
NestedMapping |
Source value is an ID, need to load the full nested entity | Integer cityId → CCity city |
MapConversion |
Source is a Map<String, Object>, target is an entity or array |
Complex nested structures |
NoMapping |
Explicitly skip this field | Fields that have no supplier equivalent |
Custom services (action other than create/read/update/delete/search) are invoked via EntityFacade.callCustomService(). Parameters are declared in <NamedParams> and populated from the canonical entity's fields.
Step 12: Caching and Scheduled Refresh¶
For catalog-heavy suppliers, implement an in-memory cache backed by the local DB and refreshed periodically from the supplier API.
12.1 Cache Manager¶
Example — Based on TiqetsCacheManager:
package com.perun.tlinq.client.<prefix>.util;
public class SupplierCacheManager {
private static volatile SupplierCacheManager _instance;
// Thread-safe maps with atomic reference swap on refresh
private volatile Map<String, SpProduct> productCache = new ConcurrentHashMap<>();
private volatile Map<String, List<SpProduct>> productsByCity = new ConcurrentHashMap<>();
public static SupplierCacheManager instance() {
if (_instance == null) {
synchronized (SupplierCacheManager.class) {
if (_instance == null) {
_instance = new SupplierCacheManager();
}
}
}
return _instance;
}
/**
* Load all data from DB into memory. Called once at startup.
*/
public void initialize() {
refresh();
}
/**
* Rebuild all caches from DB. Uses atomic reference swap
* so readers are never blocked.
*/
public void refresh() {
Map<String, SpProduct> newProducts = new ConcurrentHashMap<>();
Map<String, List<SpProduct>> newByCity = new ConcurrentHashMap<>();
// Load from DB, build indices
Session session = SupplierDBSession.getSession();
try {
List<ProductEntity> dbProducts = session.createQuery(
"FROM ProductEntity WHERE active = true", ProductEntity.class
).list();
for (ProductEntity pe : dbProducts) {
SpProduct sp = new SpProduct();
sp.initFromEntity(pe);
newProducts.put(sp.getProductId(), sp);
newByCity.computeIfAbsent(sp.getCityId(), k -> new ArrayList<>()).add(sp);
}
} finally {
session.close();
}
// Atomic swap
this.productCache = newProducts;
this.productsByCity = newByCity;
// Notify other cluster instances via Hazelcast
notifyCluster();
}
public SpProduct getProduct(String productId) {
return productCache.get(productId);
}
public List<SpProduct> getProductsByCity(String cityId) {
return productsByCity.getOrDefault(cityId, Collections.emptyList());
}
}
12.2 Distributed Cache Invalidation¶
If TQPro runs in a cluster, use Hazelcast via TlinqClusterCache to notify other instances when cache data changes:
private void notifyCluster() {
try {
IMap<String, Long> cacheMap = TlinqClusterCache.instance()
.getHazelcastInstance().getMap("supplierCacheVersions");
cacheMap.put("products", System.currentTimeMillis());
} catch (Exception e) {
logger.warning("Could not notify cluster of cache refresh: " + e.getMessage());
}
}
Other instances listen for changes and call refresh() on their local cache manager.
12.3 Scheduled Data Refresher¶
Schedule a background task in the plugin's initializePlugin() to periodically pull fresh data from the supplier API and update the local DB:
private void scheduleDataRefresh(int intervalHours) {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "SupplierDataRefresher");
t.setDaemon(true);
return t;
});
Runnable refreshTask = () -> {
// Acquire Hazelcast distributed lock to prevent duplicate refreshes
IMap<String, Long> lockMap = TlinqClusterCache.instance()
.getHazelcastInstance().getMap("schedulerLocks");
boolean acquired = lockMap.tryLock("SupplierRefresh",
0, TimeUnit.SECONDS,
(long) intervalHours * 60 - 5, TimeUnit.MINUTES);
if (!acquired) return;
try {
refresher.refreshAll(); // Fetch from API, upsert into DB, refresh cache
} finally {
lockMap.unlock("SupplierRefresh");
}
};
scheduler.scheduleAtFixedRate(refreshTask,
intervalHours * 60L, intervalHours * 60L, TimeUnit.MINUTES);
}
Step 13: Ticketing Integration (Optional)¶
If the supplier supports transactional booking (placing orders, issuing tickets), implement the TicketingServiceI interface. This provides a standardized booking lifecycle across all suppliers.
package com.perun.tlinq.client.<prefix>.service;
public class SupplierTicketingService extends PrefixEntityService
implements TicketingServiceI {
/**
* Prepare the booking request (validate inputs, build supplier-specific request).
*/
@Override
public TicketingRequestI initTicketRequest(TicketingRequestI request)
throws TlinqClientException {
// Build supplier-specific request from TicketingRequestI items
// Store pending request state
return request;
}
/**
* Send the booking to the supplier API.
*/
@Override
public TicketingResponseI sendTicketRequest(TicketingRequestI request)
throws TlinqClientException {
// Call supplier booking API via remote service
// Save order record in local DB
// Map response to TicketingResponseI
}
/**
* Confirm a pending booking.
*/
@Override
public TicketingResponseI confirmTicketRequest(TicketingRequestI request) {
// Call supplier confirmation endpoint
// Poll for completion if async
// Retrieve ticket/voucher data
}
/**
* Check booking status.
*/
@Override
public TicketingResponseI checkTicketRequest(TicketingRequestI request) {
// Call supplier status endpoint
}
/**
* Cancel a booking.
*/
@Override
public TicketingResponseI cancelTicketRequest(TicketingRequestI request,
TicketingDataI prevResponse) {
// Call supplier cancellation endpoint
}
/**
* Amend a booking (not all suppliers support this).
*/
@Override
public TicketingResponseI amendTicketingRequest(TicketingRequestI request,
TicketingDataI prevResponse) {
throw new RuntimeException("Amendment not supported by this supplier");
}
}
The ticketing lifecycle flows as:
initTicketRequest() → sendTicketRequest() → confirmTicketRequest()
│
checkTicketRequest() (polling)
│
cancelTicketRequest() (if needed)
Step 14: Facade Integration — Multi-Factory Routing¶
The facade layer (in tqapp) routes requests to the correct plugin based on the supplier associated with a product. This is how TQPro supports multiple suppliers for the same entity type.
The ProductFacade demonstrates this pattern:
ProductFacade.search("Product", criteria)
│
├── super.search("Product", criteria) ← Queries default factory (NTS/Odoo)
│
│ For each product with hasVendorBooking=true:
│
├── SupplierCache.instance().getFactory(supplierId) ← Resolves factory name
│ e.g., returns "SupplierServiceFactory"
│
└── read("SupplierServiceFactory", "SupplierProduct", vendorProductId)
← Fetches supplier-specific data, merges into the canonical product
When adding a new supplier, you typically do not need to modify ProductFacade — the routing is driven by:
- The
supplierIdon the product record in the local DB - The
SupplierCachemapping from supplier ID to service factory name - The entity configuration in
tourlinq-config.xmlthat registers the factory
To register your supplier for automatic routing, ensure:
- A supplier record exists in the NTS database with a reference to the factory code (the
codeattribute from<ServiceFactory>) - The entity configuration maps your factory name to the correct native entity and services
Step 15: Testing¶
15.1 Test Structure¶
Tests should cover three levels:
Database integration tests — Verify CRUD on DB cache entities:
public class ProductEntityTest {
@BeforeAll
static void init() {
System.setProperty("TLINQ_HOME", "config/");
}
@Test
void testSaveAndRetrieve() {
ProductEntity pe = new ProductEntity();
pe.setSupplierProductId("12345");
pe.setTitle("Test Product");
// save, retrieve, assert
}
}
Facade integration tests — Verify the full mapping chain (native → canonical):
public class SupplierProductFacadeTest {
@BeforeAll
static void init() {
System.setProperty("TLINQ_HOME", "config/");
}
@Test
void testSearchProducts() throws TlinqClientException {
ProductFacade facade = new ProductFacade();
SelectCriteriaList criteria = new SelectCriteriaList();
criteria.addCriterion("cityId", "=", "123");
List results = facade.search("SupplierProduct", criteria);
assertNotNull(results);
// Verify canonical entity fields are correctly mapped
}
}
Remote API integration tests (optional, for E2E validation):
public class SupplierApiIntegrationTest {
@Test
void testListProducts() throws TlinqClientException {
SupplierHttpClient client = new SupplierHttpClient(config);
ProductsRemoteService service = new ProductsRemoteService(client);
ProductListResponse response = service.listProducts(null, 1, 10);
assertNotNull(response.getProducts());
}
}
15.2 Running Tests¶
# Run all tests for the plugin module
./gradlew :tq<prefix>:test
# Run a specific test class
./gradlew :tq<prefix>:test --tests "com.perun.tlinq.client.<prefix>.ProductEntityTest"
Full Data Flow — End-to-End Call Chain¶
Here is the complete call chain for a product search request through a supplier plugin:
1. REST API Layer
ProductApi.searchProducts(request) [tqapi]
│
▼
2. Facade Layer
ProductFacade.search("SupplierProduct", criteria) [tqapp]
│
▼
3. EntityFacade — Service Orchestration
EntityFacade.search("SupplierServiceFactory", ...) [tqcommon]
│── Reads EntityConfig from tourlinq-config.xml
│── Finds service name "findProducts" for action "search"
│
▼
4. Client Service Factory — Factory Resolution
ClientServiceFactory.instance("SupplierServiceFactory") [tqcommon]
│── Calls SupplierServiceFactory.getInstance() via reflection
│
▼
5. Plugin Service Factory — Service Creation
SupplierServiceFactory.createService("findProducts", session) [tq<prefix>]
│── Looks up ServiceConfig from <prefix>-client.xml
│── Instantiates SupplierCatalogService via reflection
│── Calls service.initialize() with config properties
│
▼
6. Plugin Service — Data Retrieval
SupplierCatalogService.search() / invokeMethod("listProducts") [tq<prefix>]
│── Checks in-memory cache (SupplierCacheManager)
│── If cache miss: queries local DB (Hibernate)
│── If DB miss: calls remote API (SupplierHttpClient)
│── Deserializes JSON → Remote DTO (SupplierProduct)
│── Converts DTO → DB entity (ProductEntity.initFromRemote)
│── Converts DB entity → Native entity (SpProduct.initFromEntity)
│── Returns List<SpProduct>
│
▼
7. Entity Transformer — Native to Canonical
EntityTransformer.toCanonicalObject(factory, config, native) [tqcommon]
│── Reads FieldMappingList from entity XML config
│── For each field mapping:
│ ├── DirectMapping: copy value, coerce types
│ ├── ArrayMapping: convert each element recursively
│ ├── IndexMapping: extract element at index
│ └── etc.
│── Returns CProduct (canonical entity)
│
▼
8. Response
ProductFacade returns List<CProduct>
│
▼
ProductApi serializes to JSON response
Reference: Existing Plugins at a Glance¶
| Aspect | Tiqets (tqtiqets) |
Rayna B2B (tqryb2b) |
Amadeus (tqamds) |
|---|---|---|---|
| Domain | Activities/Experiences | Activities/Tours | Flights/Hotels |
| Data source | REST API + DB cache | REST API + DB cache | SDK (live queries) |
| HTTP client | TiqetsHttpClient (JAX-RS + Gson + JWT) |
Per-service HTTP classes | Amadeus Java SDK |
| Remote DTOs | remote/entity/ |
remote/entity/ |
model/ (SDK models) |
| DB cache | Yes (tiqets schema) |
Yes (rayna schema) |
Minimal (AirportEntity only) |
| DB entity base | AbstractCachedEntity |
AbstractCachedEntity |
N/A |
| Native entity base | TqEntity |
RyActEntity |
Various |
| Service base | TiqetsEntityService |
RaynaEntityService |
AmadeusClientService |
| Cache manager | TiqetsCacheManager |
RaynaCacheManager |
None |
| Scheduled refresh | TiqetsDataRefresher |
ProductRefresher |
None |
| Ticketing | TiqetsTicketingService |
RaynaTicketingService |
N/A |
| Plugin config | tiqets-client.xml |
rayna-client.xml |
amadeus-client.xml |
| Factory code | TIQETS |
RAYNAB2BACT |
(none) |
When to use each pattern¶
-
Tiqets pattern (recommended for new plugins): Full three-layer entity model with centralized HTTP client, DB caching, scheduled refresh, and distributed cache invalidation. Best for suppliers with a large catalog that changes infrequently.
-
Rayna pattern: Similar to Tiqets but with per-domain HTTP service classes instead of a centralized HTTP client. Use when different API endpoints have very different authentication or transport requirements.
-
Amadeus pattern: SDK-based with minimal local caching. Best when the supplier provides an official SDK and data is always queried live (e.g., GDS flight searches).
Checklist¶
Before considering your plugin complete, verify:
- [ ] Plugin class extends
AbstractPluginand implementsinitializePlugin(),getProperty(),setProperty() - [ ] Service factory implements
RemoteServiceFactoryIwithpublic static getInstance() - [ ] All native entities implement
RemoteEntityI - [ ] Plugin registered in
tourlinq-config.xml(<Plugins>section) - [ ] Service factory registered in
tourlinq-config.xml(<ServiceFactories>section) - [ ] Plugin config file created at
config/<prefix>-client.xmlwith service definitions - [ ] Entity configuration added to
config/entities/*.xmlwith field mappings - [ ] DB cache entities registered in
persistence.xml(if applicable) - [ ] Database schema and tables created (if applicable)
- [ ] Cache manager with cluster invalidation (if applicable)
- [ ] Scheduled refresh configured (if applicable)
- [ ] Ticketing service implemented (if the supplier supports booking)
- [ ] Tests written: DB integration, facade mapping, optional E2E
- [ ]
./gradlew buildpasses with the new module