Skip to content

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

  1. Architecture Overview
  2. Module Setup
  3. Step 1: Remote DTOs — Representing Supplier Data
  4. Step 2: HTTP Client — Communicating with the Supplier API
  5. Step 3: Remote Service Layer — Domain-Specific API Wrappers
  6. Step 4: DB Cache Entities — Local Persistence of Supplier Data
  7. Step 5: Native Entities — The Bridge to Canonical
  8. Step 6: Plugin Services — The Framework Integration Point
  9. Step 7: Service Factory — Creating Services on Demand
  10. Step 8: Plugin Class — Initialization and Lifecycle
  11. Step 9: Plugin Configuration File
  12. Step 10: Framework Registration — tourlinq-config.xml
  13. Step 11: Entity Configuration — XML Field Mappings
  14. Step 12: Caching and Scheduled Refresh
  15. Step 13: Ticketing Integration (Optional)
  16. Step 14: Facade Integration — Multi-Factory Routing
  17. Step 15: Testing
  18. Full Data Flow — End-to-End Call Chain
  19. 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 EntityTransformer can 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:

include 'tq<prefix>'

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_id for a JSON key "experience_id").
  • Expose getters that normalize the data (e.g., converting Integer id to String 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., ProductsResponse with 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:

  1. Plugin constructor called (load config singleton)
  2. setProperty() called for each property from tourlinq-config.xml (with ##placeholder resolution)
  3. initializePlugin() called — perform heavyweight initialization
  4. 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 productIdString productId
ArrayMapping Source and target are both lists/arrays List<SpVariant>List<CVariant>
IndexMapping Extract one element from a source array String[] categoriesString categoryName
ModelLookup Source value is a foreign key, need to resolve to a display value Integer supplierIdString supplierName
NestedMapping Source value is an ID, need to load the full nested entity Integer cityIdCCity 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:

  1. The supplierId on the product record in the local DB
  2. The SupplierCache mapping from supplier ID to service factory name
  3. The entity configuration in tourlinq-config.xml that 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 code attribute 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 AbstractPlugin and implements initializePlugin(), getProperty(), setProperty()
  • [ ] Service factory implements RemoteServiceFactoryI with public 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.xml with service definitions
  • [ ] Entity configuration added to config/entities/*.xml with 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 build passes with the new module