The IProductInventoryManagementGET Apex interface provides extensibility for TMF637 GET operations through Apex pre- and post-hooks and GraphQL query customization. Implementations can validate incoming requests, modify query structures, and refine the response to support business-specific retrieval rules while maintaining TMF-compliant behavior.
This interface supports the following hooks.
- transformRequest
- configureDefaultValidations
- applyCustomValidations
- customiseGraphQLQuery
- handlePostOperation
Retrieval Lifecycle Use Cases and Hook Mapping
This table lists common use cases and the hooks required for the GET operation.
| Use Case | Hook(s) | Description <div style="width:290px"></div> | Example Scenarios <div style="width:290px"></div> | Benefit <div style="width:290px"></div> |
|---|---|---|---|---|
| Role-Based Data Filtering | applyCustomValidations, handlePostOperation |
Enforces access rules by validating user permissions before execution and filtering sensitive data after retrieval. Supports role-based visibility at field and record levels. | • Restrict access to VIP or high-value customer profiles• Hide sensitive fields (e.g., credit score) for frontline agents• Enforce region-based access boundaries | Strengthens data governance and ensures secure, compliant access to trouble tickets |
| Custom Field Enrichment | handlePostOperation |
Adds calculated or derived fields to API responses without changing the base TMF schema, allowing customers to extend or modify as needed. Enables enrichment using internal business rules or external systems. | • Add customer health score• Flag “preferred customer” status• Include aggregated financial or usage insights | Delivers richer, more contextual responses while preserving TMF schema integrity |
| Dynamic Query Customization | customiseGraphQLQuery |
Modifies GraphQL queries dynamically based on client or request context by adding fields, filters, or transformations. | • Mobile app requests only essential fields• Portal requires additional entitlement or hierarchy fields• Apply filters automatically for partner channels | Supports diverse client requirements using a single API and reduces need for API versioning |
| Business Rule Validation | applyCustomValidations |
Applies business and eligibility rules before processing requests, stopping invalid operations early in the lifecycle. | • Ensure customer is “active” before retrieving services• Reject unauthorized or unverified channel requests• Enforce lifecycle or dependency checks | Provides strong business consistency and reduces downstream errors by blocking invalid requests early |
| ProductSerialNumber to Asset Id Resolution | transformRequest |
Resolve ProductSerialNumber identifiers to Asset Id for API compatibility. | • An external system sends a ProductSerialNumber (e.g., SN-12345), which is resolved to a Salesforce Asset ID (e.g.,02ixx0000004HHiAAM) for API processing | Support external systems that use ProductSerialNumber instead of Salesforce Asset Id |
transformRequest
This hook transforms the request context before processing any operation. It is invoked early in the request lifecycle, allowing implementers to adjust the context for subsequent extensibility hooks.
Hook Method
Map<String, Object> transformRequest(Map<String, Object> context)
Sample Apex Implementation: Resolve ProductSerialNumber to Asset Id
global static Map<String, Object> transformRequest(Map<String, Object> context) {
if (context == null) {
return context;
}
Object idObj = context.get('id');
if (idObj == null || !(idObj instanceof String)) {
return context;
}
String idValue = (String) idObj;
// Check if the ID looks like a Salesforce ID (starts with 02i for Asset)
// If not, assume it's a ProductSerialNumber and resolve it
if (idValue.length() == 15 || idValue.length() == 18) {
return context;
}
// Query Asset objects where SerialNumber matches the provided ID
List<Asset> assets = [
SELECT Id, SerialNumber
FROM Asset
WHERE SerialNumber = :idValue
LIMIT 1
];
// If found, replace the ID in context with the Salesforce Asset Id
if (!assets.isEmpty()) {
context.put('id', assets[0].Id);
}
return context;
}
configureDefaultValidations
This hook controls whether built-in validation rules execute. Use it to disable specific validation checks (such as field name validation) when you need to allow custom or non-standard field names in the request.
Hook Method
Map<String, Boolean> configureDefaultValidations(Map<String, Boolean> defaultValidationConfiguration, Map<String, Object> context)
Sample Apex Implementation
global static Map<String, Boolean> configureDefaultValidations(
Map<String, Boolean> defaultValidationConfiguration,
Map<String, Object> context
) {
// Return empty map to disable all default validations
// This allows custom/non-standard field names in the request
return new Map<String, Boolean>();
}
applyCustomValidations
This hook validates custom business logic before retrieving trouble ticket data. If validation fails, it rejects the request and returns an error response.
The handler processes return values as follows.
Success:
- Return a map with validationStatus set to "pass" (case-insensitive).
- The API request continues normally.
- Other fields in the map are logged but not used.
Failure:
- Return a map with validationStatus set to "fail" (case-insensitive).
- The API request is terminated immediately.
- Raise a ValidationException containing
- message: value from validationMessage key (or "Validation failed" by default)
- details: value from validationDetails key (optional)
- The client receives an HTTP 400 error response.
Hook Method
Map<String, Object> applyCustomValidations(Map<String, Object> context)
Sample Apex Implementation: Validate Product Inventory Access Based on User Role and Status
global static Map<String, Object> applyCustomValidations(Map<String, Object> context) {
// Input context:
// {
// api: 'ProductInventoryManagement',
// version: '5.0',
// id: '02ixx0000004HHiAAM',
// userId: '005xx000001Sv5',
// status: 'Active',
// fields: ['name', 'status', 'description']
// }
String assetId = (String) context.get('id');
String userId = (String) context.get('userId');
if (String.isBlank(assetId) || String.isBlank(userId)) {
return null; // Pass - let default behavior handle
}
Map<String, Object> validationResult = new Map<String, Object>();
try {
User currentUser = [SELECT Id, Profile.Name FROM User WHERE Id = :userId LIMIT 1];
Asset inventoryItem = [SELECT Id, Status FROM Asset WHERE Id = :assetId LIMIT 1];
Set<String> authorizedProfiles = new Set<String>{
'System Administrator', 'Asset Manager', 'Comms Admin'
};
if (!authorizedProfiles.contains(currentUser.Profile.Name)) {
if ('Obsolete'.equals(inventoryItem.Status) || 'Suspended'.equals(inventoryItem.Status)) {
validationResult.put('validationStatus', 'FAIL');
validationResult.put('validationMessage', 'User does not have access to terminated/suspended product inventory items');
validationResult.put('validationDetails', new Map<String, Object>{
'denialReason' => 'STATUS_RESTRICTION',
'userProfile' => currentUser.Profile.Name,
'assetStatus' => inventoryItem.Status
});
return validationResult;
}
}
// PASS: User is authorized or asset is not in a restricted status
return null;
} catch (Exception e) {
validationResult.put('validationStatus', 'FAIL');
validationResult.put('validationMessage', 'Validation error: ' + e.getMessage());
validationResult.put('validationDetails', new Map<String, Object>{
'errorType' => e.getTypeName()
});
return validationResult;
}
}
customiseGraphQLQuery
This hook modifies the GraphQL query before execution to add fields, filters, or transformations. Returns a map with a nodes key containing a list of QueryTransformationNode objects.
Hook Method
Map<String, Object> customiseGraphQLQuery(Map<String, Object> graphQLAsMap, Map<String, Object> context)
Sample Apex Implementation: Filter by ProductSerialNumber and Add Custom Fields
global static Map<String, Object> customiseGraphQLQuery(
Map<String, Object> querySpec,
Map<String, Object> context
) {
List<Map<String, Object>> transformationNodes = new List<Map<String, Object>>();
// Node 1: Add custom fields to the Asset query
Map<String, Object> addFieldsNode = new Map<String, Object>{
'path' => 'productInventory',
'addFields' => new List<String>{'ProductCode { value }', 'TotalLifecycleAmount { value }'}
};
transformationNodes.add(addFieldsNode);
// Node 2: Add filter by SerialNumber
String serialNumber = (String) context.get('productserialnumber');
if (String.isNotBlank(serialNumber)) {
Boolean isLike = serialNumber.contains('%');
String filterSnippet;
if (isLike) {
filterSnippet = '{ SerialNumber: { like: "' + String.escapeSingleQuotes(serialNumber) + '" } }';
} else {
filterSnippet = '{ SerialNumber: { eq: "' + String.escapeSingleQuotes(serialNumber) + '" } }';
}
Map<String, Object> filterNode = new Map<String, Object>{
'path' => 'productInventory',
'addFilters' => new Map<String, Object>{
'clearExistingFilters' => false,
'filterGraphQlSnippet' => filterSnippet
}
};
transformationNodes.add(filterNode);
}
// Node 3: Order results by Name ascending
Map<String, Object> orderNode = new Map<String, Object>{
'path' => 'productInventory',
'orderBy' => new Map<String, Object>{
'clearExistingOrderBy' => true,
'orderGraphQlSnippet' => '{ Name: { order: ASC } }'
}
};
transformationNodes.add(orderNode);
return new Map<String, Object>{ 'nodes' => transformationNodes };
}
handlePostOperation
This hook post-processes and transforms the API response after data retrieval.
Hook Method
Map<String, Object> handlePostOperation(Map<String, Object> graphQLQueryResultAsMap, Map<String, Object> constructedTMFResponse, Map<String, Object> context)
Sample Apex Implementation: Enrich Response with Custom Fields from GraphQL
global static Map<String, Object> handlePostOperation(
Map<String, Object> graphQLResult,
Map<String, Object> tmfResponse,
Map<String, Object> context
) {
// Build lookup from GraphQL edges
Map<String, Object> data = (Map<String, Object>) graphQLResult.get('data');
Map<String, Object> uiapi = (Map<String, Object>) data.get('uiapi');
Map<String, Object> query = (Map<String, Object>) uiapi.get('query');
Map<String, Object> pi = (Map<String, Object>) query.get('productInventory');
List<Object> edges = (List<Object>) pi.get('edges');
// Create a lookup map: Asset Id -> custom field values
Map<String, Map<String, Object>> lookup = new Map<String, Map<String, Object>>();
for (Object e : edges) {
Map<String, Object> edge = (Map<String, Object>) e;
Map<String, Object> node = (Map<String, Object>) edge.get('node');
String nodeId = (String) node.get('Id');
Map<String, Object> pc = (Map<String, Object>) node.get('ProductCode');
Map<String, Object> tla = (Map<String, Object>) node.get('TotalLifecycleAmount');
Map<String, Object> sn = (Map<String, Object>) node.get('SerialNumber');
lookup.put(nodeId, new Map<String, Object>{
'productCode' => pc != null ? pc.get('value') : null,
'totalLifecycleAmount' => tla != null ? tla.get('value') : null,
'productSerialNumber' => sn != null ? sn.get('value') : null
});
}
// Merge custom fields into TMF response
if (tmfResponse.containsKey('recordList')) {
// List response
List<Object> records = (List<Object>) tmfResponse.get('recordList');
for (Object r : records) {
Map<String, Object> rec = (Map<String, Object>) r;
String recId = (String) rec.get('id');
if (lookup.containsKey(recId)) {
rec.putAll(lookup.get(recId));
}
}
} else {
// Single record response
String recId = (String) tmfResponse.get('id');
if (lookup.containsKey(recId)) {
tmfResponse.putAll(lookup.get(recId));
}
}
return new Map<String, Object>{
'payloadOverwritten' => true,
'updatedPayload' => tmfResponse
};
}
Full Implementation Example
Here's a complete sample Apex implementation using all supported hooks.
/**
* Complete Product Inventory Management GET API Extension
* Scenario: Fetch list of assets filtered by Product Serial Number
*
* Demonstrates all applicable lifecycle hooks with business logic
*/
global class ProductInventoryBySerialNumber implements comms_apex_ext.IProductInventoryManagementGET {
private static final String SERIAL_PARAM = 'productserialnumber';
/**
* Hook 1: Transform request
* Normalize the serial number parameter and resolve serial to Asset Id for single-GET
*/
global static Map<String, Object> transformRequest(Map<String, Object> context) {
if (context == null) {
return context;
}
// Normalize serial number parameter
String serial = (String) context.get(SERIAL_PARAM);
if (String.isNotBlank(serial)) {
context.put(SERIAL_PARAM, serial.trim());
}
// For single-GET: if id looks like a serial number, resolve it
Object idObj = context.get('id');
if (idObj != null && idObj instanceof String) {
String idValue = (String) idObj;
if (idValue.length() != 15 && idValue.length() != 18) {
List<Asset> assets = [
SELECT Id, SerialNumber
FROM Asset
WHERE SerialNumber = :idValue
LIMIT 1
];
if (!assets.isEmpty()) {
context.put('id', assets[0].Id);
}
}
}
return context;
}
/**
* Hook 2: Configure default validations
* Keep defaults - no changes needed for serial number filtering
*/
global static Map<String, Boolean> configureDefaultValidations(
Map<String, Boolean> defaultValidationConfiguration,
Map<String, Object> context
) {
return defaultValidationConfiguration;
}
/**
* Hook 3: Apply custom validations
* Require the productserialnumber parameter for list operations
*/
global static Map<String, Object> applyCustomValidations(Map<String, Object> context) {
if (context == null) {
return null;
}
// Only enforce for list operations (no id present)
Object idObj = context.get('id');
if (idObj != null) {
return null; // Single GET - skip serial number validation
}
String serial = (String) context.get(SERIAL_PARAM);
if (String.isBlank(serial)) {
return new Map<String, Object>{
'validationStatus' => 'fail',
'validationMessage' => 'Query parameter "productserialnumber" is required for list operations.',
'validationDetails' => new Map<String, Object>{
'parameter' => SERIAL_PARAM,
'reason' => 'The productserialnumber filter must be provided to fetch assets by serial number.'
}
};
}
return null;
}
/**
* Hook 4: Customize GraphQL query
* Add SerialNumber filter and field to the GraphQL query
*/
global static Map<String, Object> customiseGraphQLQuery(
Map<String, Object> querySpec,
Map<String, Object> context
) {
String serial = (String) context.get(SERIAL_PARAM);
if (String.isBlank(serial)) {
return null;
}
Boolean isLike = serial.contains('%');
String filterSnippet;
if (isLike) {
filterSnippet = '{ SerialNumber: { like: "' + String.escapeSingleQuotes(serial) + '" } }';
} else {
filterSnippet = '{ SerialNumber: { eq: "' + String.escapeSingleQuotes(serial) + '" } }';
}
List<Map<String, Object>> transformationNodes = new List<Map<String, Object>>();
// Add SerialNumber field and filter
transformationNodes.add(new Map<String, Object>{
'path' => 'productInventory',
'addFields' => new List<String>{'SerialNumber { value }'},
'addFilters' => new Map<String, Object>{
'clearExistingFilters' => false,
'filterGraphQlSnippet' => filterSnippet
}
});
return new Map<String, Object>{ 'nodes' => transformationNodes };
}
/**
* Hook 5: Handle post operation
* Enrich response with the productSerialNumber from the GraphQL result
*/
global static Map<String, Object> handlePostOperation(
Map<String, Object> graphQLResult,
Map<String, Object> tmfResponse,
Map<String, Object> context
) {
if (tmfResponse == null || graphQLResult == null) {
return null;
}
// Extract serial numbers from GraphQL result
Map<String, String> serialLookup = new Map<String, String>();
try {
Map<String, Object> data = (Map<String, Object>) graphQLResult.get('data');
Map<String, Object> uiapi = (Map<String, Object>) data.get('uiapi');
Map<String, Object> query = (Map<String, Object>) uiapi.get('query');
Map<String, Object> pi = (Map<String, Object>) query.get('productInventory');
List<Object> edges = (List<Object>) pi.get('edges');
for (Object e : edges) {
Map<String, Object> edge = (Map<String, Object>) e;
Map<String, Object> node = (Map<String, Object>) edge.get('node');
String nodeId = (String) node.get('Id');
Map<String, Object> sn = (Map<String, Object>) node.get('SerialNumber');
if (sn != null && sn.get('value') != null) {
serialLookup.put(nodeId, (String) sn.get('value'));
}
}
} catch (Exception e) {
System.debug('TMF637 handlePostOperation: Error parsing GraphQL result - ' + e.getMessage());
return null;
}
// Enrich TMF response with productSerialNumber
if (tmfResponse.containsKey('recordList')) {
List<Object> records = (List<Object>) tmfResponse.get('recordList');
for (Object r : records) {
Map<String, Object> rec = (Map<String, Object>) r;
String recId = (String) rec.get('id');
if (serialLookup.containsKey(recId)) {
rec.put('productSerialNumber', serialLookup.get(recId));
}
}
} else {
String recId = (String) tmfResponse.get('id');
if (serialLookup.containsKey(recId)) {
tmfResponse.put('productSerialNumber', serialLookup.get(recId));
}
}
return new Map<String, Object>{
'payloadOverwritten' => true,
'updatedPayload' => tmfResponse
};
}
}
Response Structure
Single Product Inventory Response (GraphQL)
{
"id": "02ixx0000004HHiAAM",
"name": "Fiber Internet 100Mbps",
"@type": "Product",
"href": "/services/data/v68.0/connect/comms/productinventorymanagement/v5/productinventory/02ixx0000004HHiAAM",
"description": "High-speed fiber internet service",
"status": "Active",
"isBundle": false,
"productSerialNumber": "SN-12345",
"startDate": "2024-01-15",
"terminationDate": null,
"productOffering": {
"id": "01txx0000001ABC",
"name": "Fiber 100 Offering"
},
"relatedParty": [
{
"id": "001xx000003ABC",
"name": "Acme Corporation",
"role": "Customer"
}
],
"product": [
{
"id": "02ixx0000004HHkAAM",
"name": "Router Device",
"@type": "Product",
"isBundle": false,
"productSerialNumber": "RTR-001"
}
]
}
List Response (GraphQL)
[
{
"id": "02ixx0000004HHiAAM",
"name": "Fiber Internet 100Mbps",
"@type": "Product",
"href": "/services/data/v68.0/connect/comms/productinventorymanagement/v5/productinventory/02ixx0000004HHiAAM",
"status": "Active",
"isBundle": true,
"productSerialNumber": "SN-12345"
},
{
"id": "02ixx0000004HHjAAM",
"name": "Monitor",
"@type": "Product",
"href": "/services/data/v68.0/connect/comms/productinventorymanagement/v5/productinventory/02ixx0000004HHjAAM",
"status": "Active",
"isBundle": false,
"productSerialNumber": "SN-12346"
}
]
List Response with PageInfo
{
"recordList": [
{
"id": "02ixx0000004HHiAAM",
"name": "Fiber Internet 100Mbps",
"@type": "Product",
"status": "Active"
}
],
"pageInfo": {
"startCursor": "YXJyYXljb25uZWN0aW9uOjA=",
"endCursor": "YXJyYXljb25uZWN0aW9uOjQ=",
"hasNextPage": true,
"hasPreviousPage": false,
"totalCount": 25
}
}
GraphQL Query Transformations
Query transformations provide the ability to enhance GraphQL queries by adding fields, applying filters, or modifying the sort order, while preserving the original query definition.
note: For the Customer Management API, use the uiapi.query.Account path to reference the primary Account field.
Query Enhancements: Fields and Filters
Original Query (GraphQL):
query {
myAccounts: accounts {
id
name
}
}
Transformation Instructions (JSON):
{
"nodes": [
{
"path": "myAccounts",
"addFields": ["email", "phone"],
"addFilters": {
"clearExistingFilters": true,
"filterGraphQlSnippet": "{ status: { eq: \"ACTIVE\" } }"
}
}
]
}
Result (GraphQL):
query {
myAccounts: accounts(where: { status: { eq: "ACTIVE" } }) {
id
name
email
phone
}
}
Query Enhancements: Fields, Filters, and Ordering
Original Query (GraphQL):
query {
myAccounts: accounts {
id
name
}
}
Transformation Instructions (JSON):
{
"nodes": [
{
"path": "myAccounts",
"addFields": ["revenue", "industry"],
"addFilters": {
"clearExistingFilters": true,
"filterGraphQlSnippet": "{
and: [
{ status: { eq: \"ACTIVE\" } },
{ revenue: { gte: 1000000 } }
]
}"
},
"orderBy": {
"clearExistingOrderBy": true,
"orderGraphQlSnippet": "{ revenue: { order: DESC } }"
}
}
]
}
Result (GraphQL):
query {
myAccounts: accounts(
where: {
and: [
{ status: { eq: "ACTIVE" } },
{ revenue: { gte: 1000000 } }
]
},
orderBy: { revenue: { order: DESC } }
) {
id
name
revenue
industry
}
}
Query Enhancements: Multiple Query Paths with Different Transformations
Original Query (GraphQL):
query {
myAccounts: accounts {
id
name
myContacts: contacts {
id
name
}
}
}
Transformation Instructions (JSON):
{
"nodes": [
{
"path": "myAccounts",
"addFields": ["email"],
"addFilters": {
"clearExistingFilters": true,
"filterGraphQlSnippet": "{ status: { eq: \"ACTIVE\" } }"
}
},
{
"path": "myAccounts.myContacts",
"addFields": ["email", "phone"],
"orderBy": {
"clearExistingOrderBy": true,
"orderGraphQlSnippet": "{ name: { order: ASC } }"
}
}
]
}
Result (GraphQL):
query {
myAccounts: accounts(where: { status: { eq: "ACTIVE" } }) {
id
name
email
myContacts: contacts(orderBy: { name: { order: ASC } }) {
id
name
email
phone
}
}
}
Query Enhancements: Fields and Snippets
Original Query (GraphQL):
query {
myAccounts: accounts {
id
name
}
}
Transformation Instructions (JSON):
{
"nodes": [
{
"path": "myAccounts",
"addFields": ["email"]
},
{
"path": "metrics",
"insertGraphQlSnippet": "{
metrics: systemMetrics {
totalCount
activeCount
lastSyncTime
}
}"
}
]
}
Result (GraphQL):
query {
myAccounts: accounts {
id
name
email
}
metrics: systemMetrics {
totalCount
activeCount
lastSyncTime
}
}