IProductInventoryManagementGET Apex Interface

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.

Retrieval Lifecycle Use Cases and Hook Mapping 

This table lists common use cases and the hooks required for the GET operation.

Use CaseHook(s)Description
Example Scenarios
Benefit
Role-Based Data FilteringapplyCustomValidations, handlePostOperationEnforces 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 boundariesStrengthens data governance and ensures secure, compliant access to trouble tickets
Custom Field EnrichmenthandlePostOperationAdds 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 insightsDelivers richer, more contextual responses while preserving TMF schema integrity
Dynamic Query CustomizationcustomiseGraphQLQueryModifies 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 channelsSupports diverse client requirements using a single API and reduces need for API versioning
Business Rule ValidationapplyCustomValidationsApplies 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 checksProvides strong business consistency and reduces downstream errors by blocking invalid requests early
ProductSerialNumber to Asset Id ResolutiontransformRequestResolve 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 processingSupport 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 

1global static Map<String, Object> transformRequest(Map<String, Object> context) {
2    if (context == null) {
3        return context;
4    }
5
6    Object idObj = context.get('id');
7    if (idObj == null || !(idObj instanceof String)) {
8        return context;
9    }
10
11    String idValue = (String) idObj;
12
13    // Check if the ID looks like a Salesforce ID (starts with 02i for Asset)
14    // If not, assume it's a ProductSerialNumber and resolve it
15    if (idValue.length() == 15 || idValue.length() == 18) {
16        return context;
17    }
18
19    // Query Asset objects where SerialNumber matches the provided ID
20    List<Asset> assets = [
21        SELECT Id, SerialNumber
22        FROM Asset
23        WHERE SerialNumber = :idValue
24        LIMIT 1
25    ];
26
27    // If found, replace the ID in context with the Salesforce Asset Id
28    if (!assets.isEmpty()) {
29        context.put('id', assets[0].Id);
30    }
31
32    return context;
33}

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 

1global static Map<String, Boolean> configureDefaultValidations(
2    Map<String, Boolean> defaultValidationConfiguration,
3    Map<String, Object> context
4) {
5    // Return empty map to disable all default validations
6    // This allows custom/non-standard field names in the request
7    return new Map<String, Boolean>();
8}

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 

1global static Map<String, Object> applyCustomValidations(Map<String, Object> context) {
2    // Input context:
3    // {
4    //   api: 'ProductInventoryManagement',
5    //   version: '5.0',
6    //   id: '02ixx0000004HHiAAM',
7    //   userId: '005xx000001Sv5',
8    //   status: 'Active',
9    //   fields: ['name', 'status', 'description']
10    // }
11
12    String assetId = (String) context.get('id');
13    String userId = (String) context.get('userId');
14
15    if (String.isBlank(assetId) || String.isBlank(userId)) {
16        return null; // Pass - let default behavior handle
17    }
18
19    Map<String, Object> validationResult = new Map<String, Object>();
20
21    try {
22        User currentUser = [SELECT Id, Profile.Name FROM User WHERE Id = :userId LIMIT 1];
23        Asset inventoryItem = [SELECT Id, Status FROM Asset WHERE Id = :assetId LIMIT 1];
24
25        Set<String> authorizedProfiles = new Set<String>{
26            'System Administrator', 'Asset Manager', 'Comms Admin'
27        };
28
29        if (!authorizedProfiles.contains(currentUser.Profile.Name)) {
30            if ('Obsolete'.equals(inventoryItem.Status) || 'Suspended'.equals(inventoryItem.Status)) {
31                validationResult.put('validationStatus', 'FAIL');
32                validationResult.put('validationMessage', 'User does not have access to terminated/suspended product inventory items');
33                validationResult.put('validationDetails', new Map<String, Object>{
34                    'denialReason' => 'STATUS_RESTRICTION',
35                    'userProfile' => currentUser.Profile.Name,
36                    'assetStatus' => inventoryItem.Status
37                });
38                return validationResult;
39            }
40        }
41
42        // PASS: User is authorized or asset is not in a restricted status
43        return null;
44
45    } catch (Exception e) {
46        validationResult.put('validationStatus', 'FAIL');
47        validationResult.put('validationMessage', 'Validation error: ' + e.getMessage());
48        validationResult.put('validationDetails', new Map<String, Object>{
49            'errorType' => e.getTypeName()
50        });
51        return validationResult;
52    }
53}

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 

1global static Map<String, Object> customiseGraphQLQuery(
2    Map<String, Object> querySpec,
3    Map<String, Object> context
4) {
5    List<Map<String, Object>> transformationNodes = new List<Map<String, Object>>();
6
7    // Node 1: Add custom fields to the Asset query
8    Map<String, Object> addFieldsNode = new Map<String, Object>{
9        'path' => 'productInventory',
10        'addFields' => new List<String>{'ProductCode { value }', 'TotalLifecycleAmount { value }'}
11    };
12    transformationNodes.add(addFieldsNode);
13
14    // Node 2: Add filter by SerialNumber
15    String serialNumber = (String) context.get('productserialnumber');
16    if (String.isNotBlank(serialNumber)) {
17        Boolean isLike = serialNumber.contains('%');
18        String filterSnippet;
19        if (isLike) {
20            filterSnippet = '{ SerialNumber: { like: "' + String.escapeSingleQuotes(serialNumber) + '" } }';
21        } else {
22            filterSnippet = '{ SerialNumber: { eq: "' + String.escapeSingleQuotes(serialNumber) + '" } }';
23        }
24
25        Map<String, Object> filterNode = new Map<String, Object>{
26            'path' => 'productInventory',
27            'addFilters' => new Map<String, Object>{
28                'clearExistingFilters' => false,
29                'filterGraphQlSnippet' => filterSnippet
30            }
31        };
32        transformationNodes.add(filterNode);
33    }
34
35    // Node 3: Order results by Name ascending
36    Map<String, Object> orderNode = new Map<String, Object>{
37        'path' => 'productInventory',
38        'orderBy' => new Map<String, Object>{
39            'clearExistingOrderBy' => true,
40            'orderGraphQlSnippet' => '{ Name: { order: ASC } }'
41        }
42    };
43    transformationNodes.add(orderNode);
44
45    return new Map<String, Object>{ 'nodes' => transformationNodes };
46}

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 

1global static Map<String, Object> handlePostOperation(
2    Map<String, Object> graphQLResult,
3    Map<String, Object> tmfResponse,
4    Map<String, Object> context
5) {
6    // Build lookup from GraphQL edges
7    Map<String, Object> data = (Map<String, Object>) graphQLResult.get('data');
8    Map<String, Object> uiapi = (Map<String, Object>) data.get('uiapi');
9    Map<String, Object> query = (Map<String, Object>) uiapi.get('query');
10    Map<String, Object> pi = (Map<String, Object>) query.get('productInventory');
11    List<Object> edges = (List<Object>) pi.get('edges');
12
13    // Create a lookup map: Asset Id -> custom field values
14    Map<String, Map<String, Object>> lookup = new Map<String, Map<String, Object>>();
15    for (Object e : edges) {
16        Map<String, Object> edge = (Map<String, Object>) e;
17        Map<String, Object> node = (Map<String, Object>) edge.get('node');
18        String nodeId = (String) node.get('Id');
19
20        Map<String, Object> pc = (Map<String, Object>) node.get('ProductCode');
21        Map<String, Object> tla = (Map<String, Object>) node.get('TotalLifecycleAmount');
22        Map<String, Object> sn = (Map<String, Object>) node.get('SerialNumber');
23
24        lookup.put(nodeId, new Map<String, Object>{
25            'productCode' => pc != null ? pc.get('value') : null,
26            'totalLifecycleAmount' => tla != null ? tla.get('value') : null,
27            'productSerialNumber' => sn != null ? sn.get('value') : null
28        });
29    }
30
31    // Merge custom fields into TMF response
32    if (tmfResponse.containsKey('recordList')) {
33        // List response
34        List<Object> records = (List<Object>) tmfResponse.get('recordList');
35        for (Object r : records) {
36            Map<String, Object> rec = (Map<String, Object>) r;
37            String recId = (String) rec.get('id');
38            if (lookup.containsKey(recId)) {
39                rec.putAll(lookup.get(recId));
40            }
41        }
42    } else {
43        // Single record response
44        String recId = (String) tmfResponse.get('id');
45        if (lookup.containsKey(recId)) {
46            tmfResponse.putAll(lookup.get(recId));
47        }
48    }
49
50    return new Map<String, Object>{
51        'payloadOverwritten' => true,
52        'updatedPayload' => tmfResponse
53    };
54}

Full Implementation Example 

Here’s a complete sample Apex implementation using all supported hooks.

1/**
2 * Complete Product Inventory Management GET API Extension
3 * Scenario: Fetch list of assets filtered by Product Serial Number
4 *
5 * Demonstrates all applicable lifecycle hooks with business logic
6 */
7global class ProductInventoryBySerialNumber implements comms_apex_ext.IProductInventoryManagementGET {
8
9    private static final String SERIAL_PARAM = 'productserialnumber';
10
11    /**
12     * Hook 1: Transform request
13     * Normalize the serial number parameter and resolve serial to Asset Id for single-GET
14     */
15    global static Map<String, Object> transformRequest(Map<String, Object> context) {
16        if (context == null) {
17            return context;
18        }
19
20        // Normalize serial number parameter
21        String serial = (String) context.get(SERIAL_PARAM);
22        if (String.isNotBlank(serial)) {
23            context.put(SERIAL_PARAM, serial.trim());
24        }
25
26        // For single-GET: if id looks like a serial number, resolve it
27        Object idObj = context.get('id');
28        if (idObj != null && idObj instanceof String) {
29            String idValue = (String) idObj;
30            if (idValue.length() != 15 && idValue.length() != 18) {
31                List<Asset> assets = [
32                    SELECT Id, SerialNumber
33                    FROM Asset
34                    WHERE SerialNumber = :idValue
35                    LIMIT 1
36                ];
37                if (!assets.isEmpty()) {
38                    context.put('id', assets[0].Id);
39                }
40            }
41        }
42
43        return context;
44    }
45
46    /**
47     * Hook 2: Configure default validations
48     * Keep defaults - no changes needed for serial number filtering
49     */
50    global static Map<String, Boolean> configureDefaultValidations(
51        Map<String, Boolean> defaultValidationConfiguration,
52        Map<String, Object> context
53    ) {
54        return defaultValidationConfiguration;
55    }
56
57    /**
58     * Hook 3: Apply custom validations
59     * Require the productserialnumber parameter for list operations
60     */
61    global static Map<String, Object> applyCustomValidations(Map<String, Object> context) {
62        if (context == null) {
63            return null;
64        }
65
66        // Only enforce for list operations (no id present)
67        Object idObj = context.get('id');
68        if (idObj != null) {
69            return null; // Single GET - skip serial number validation
70        }
71
72        String serial = (String) context.get(SERIAL_PARAM);
73        if (String.isBlank(serial)) {
74            return new Map<String, Object>{
75                'validationStatus' => 'fail',
76                'validationMessage' => 'Query parameter "productserialnumber" is required for list operations.',
77                'validationDetails' => new Map<String, Object>{
78                    'parameter' => SERIAL_PARAM,
79                    'reason' => 'The productserialnumber filter must be provided to fetch assets by serial number.'
80                }
81            };
82        }
83
84        return null;
85    }
86
87    /**
88     * Hook 4: Customize GraphQL query
89     * Add SerialNumber filter and field to the GraphQL query
90     */
91    global static Map<String, Object> customiseGraphQLQuery(
92        Map<String, Object> querySpec,
93        Map<String, Object> context
94    ) {
95        String serial = (String) context.get(SERIAL_PARAM);
96        if (String.isBlank(serial)) {
97            return null;
98        }
99
100        Boolean isLike = serial.contains('%');
101        String filterSnippet;
102        if (isLike) {
103            filterSnippet = '{ SerialNumber: { like: "' + String.escapeSingleQuotes(serial) + '" } }';
104        } else {
105            filterSnippet = '{ SerialNumber: { eq: "' + String.escapeSingleQuotes(serial) + '" } }';
106        }
107
108        List<Map<String, Object>> transformationNodes = new List<Map<String, Object>>();
109
110        // Add SerialNumber field and filter
111        transformationNodes.add(new Map<String, Object>{
112            'path' => 'productInventory',
113            'addFields' => new List<String>{'SerialNumber { value }'},
114            'addFilters' => new Map<String, Object>{
115                'clearExistingFilters' => false,
116                'filterGraphQlSnippet' => filterSnippet
117            }
118        });
119
120        return new Map<String, Object>{ 'nodes' => transformationNodes };
121    }
122
123    /**
124     * Hook 5: Handle post operation
125     * Enrich response with the productSerialNumber from the GraphQL result
126     */
127    global static Map<String, Object> handlePostOperation(
128        Map<String, Object> graphQLResult,
129        Map<String, Object> tmfResponse,
130        Map<String, Object> context
131    ) {
132        if (tmfResponse == null || graphQLResult == null) {
133            return null;
134        }
135
136        // Extract serial numbers from GraphQL result
137        Map<String, String> serialLookup = new Map<String, String>();
138        try {
139            Map<String, Object> data = (Map<String, Object>) graphQLResult.get('data');
140            Map<String, Object> uiapi = (Map<String, Object>) data.get('uiapi');
141            Map<String, Object> query = (Map<String, Object>) uiapi.get('query');
142            Map<String, Object> pi = (Map<String, Object>) query.get('productInventory');
143            List<Object> edges = (List<Object>) pi.get('edges');
144
145            for (Object e : edges) {
146                Map<String, Object> edge = (Map<String, Object>) e;
147                Map<String, Object> node = (Map<String, Object>) edge.get('node');
148                String nodeId = (String) node.get('Id');
149                Map<String, Object> sn = (Map<String, Object>) node.get('SerialNumber');
150                if (sn != null && sn.get('value') != null) {
151                    serialLookup.put(nodeId, (String) sn.get('value'));
152                }
153            }
154        } catch (Exception e) {
155            System.debug('TMF637 handlePostOperation: Error parsing GraphQL result - ' + e.getMessage());
156            return null;
157        }
158
159        // Enrich TMF response with productSerialNumber
160        if (tmfResponse.containsKey('recordList')) {
161            List<Object> records = (List<Object>) tmfResponse.get('recordList');
162            for (Object r : records) {
163                Map<String, Object> rec = (Map<String, Object>) r;
164                String recId = (String) rec.get('id');
165                if (serialLookup.containsKey(recId)) {
166                    rec.put('productSerialNumber', serialLookup.get(recId));
167                }
168            }
169        } else {
170            String recId = (String) tmfResponse.get('id');
171            if (serialLookup.containsKey(recId)) {
172                tmfResponse.put('productSerialNumber', serialLookup.get(recId));
173            }
174        }
175
176        return new Map<String, Object>{
177            'payloadOverwritten' => true,
178            'updatedPayload' => tmfResponse
179        };
180    }
181}

Response Structure 

Single Product Inventory Response (GraphQL) 

1{
2  "id": "02ixx0000004HHiAAM",
3  "name": "Fiber Internet 100Mbps",
4  "@type": "Product",
5  "href": "/services/data/v68.0/connect/comms/productinventorymanagement/v5/productinventory/02ixx0000004HHiAAM",
6  "description": "High-speed fiber internet service",
7  "status": "Active",
8  "isBundle": false,
9  "productSerialNumber": "SN-12345",
10  "startDate": "2024-01-15",
11  "terminationDate": null,
12  "productOffering": {
13    "id": "01txx0000001ABC",
14    "name": "Fiber 100 Offering"
15  },
16  "relatedParty": [
17    {
18      "id": "001xx000003ABC",
19      "name": "Acme Corporation",
20      "role": "Customer"
21    }
22  ],
23  "product": [
24    {
25      "id": "02ixx0000004HHkAAM",
26      "name": "Router Device",
27      "@type": "Product",
28      "isBundle": false,
29      "productSerialNumber": "RTR-001"
30    }
31  ]
32}

List Response (GraphQL) 

1[
2  {
3    "id": "02ixx0000004HHiAAM",
4    "name": "Fiber Internet 100Mbps",
5    "@type": "Product",
6    "href": "/services/data/v68.0/connect/comms/productinventorymanagement/v5/productinventory/02ixx0000004HHiAAM",
7    "status": "Active",
8    "isBundle": true,
9    "productSerialNumber": "SN-12345"
10  },
11  {
12    "id": "02ixx0000004HHjAAM",
13    "name": "Monitor",
14    "@type": "Product",
15    "href": "/services/data/v68.0/connect/comms/productinventorymanagement/v5/productinventory/02ixx0000004HHjAAM",
16    "status": "Active",
17    "isBundle": false,
18    "productSerialNumber": "SN-12346"
19  }
20]

List Response with PageInfo 

1{
2  "recordList": [
3    {
4      "id": "02ixx0000004HHiAAM",
5      "name": "Fiber Internet 100Mbps",
6      "@type": "Product",
7      "status": "Active"
8    }
9  ],
10  "pageInfo": {
11    "startCursor": "YXJyYXljb25uZWN0aW9uOjA=",
12    "endCursor": "YXJyYXljb25uZWN0aW9uOjQ=",
13    "hasNextPage": true,
14    "hasPreviousPage": false,
15    "totalCount": 25
16  }
17}

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.

For the Customer Management API, use the uiapi.query.Account path to reference the primary Account field.

Note

Query Enhancements: Fields and Filters 

Original Query (GraphQL):

1query {
2myAccounts: accounts {
3id
4name
5}
6}

Transformation Instructions (JSON):

1{
2"nodes": [
3{
4"path": "myAccounts",
5"addFields": ["email", "phone"],
6"addFilters": {
7"clearExistingFilters": true,
8"filterGraphQlSnippet": "{ status: { eq: \"ACTIVE\" } }"
9}
10}
11]
12}

Result (GraphQL):

1query {
2myAccounts: accounts(where: { status: { eq: "ACTIVE" } }) {
3id
4name
5email
6phone
7}
8}

Query Enhancements: Fields, Filters, and Ordering 

Original Query (GraphQL):

1query {
2myAccounts: accounts {
3id
4name
5}
6}

Transformation Instructions (JSON):

1{
2"nodes": [
3{
4"path": "myAccounts",
5"addFields": ["revenue", "industry"],
6"addFilters": {
7"clearExistingFilters": true,
8"filterGraphQlSnippet": "{
9and: [
10{ status: { eq: \"ACTIVE\" } },
11{ revenue: { gte: 1000000 } }
12]
13}"
14},
15"orderBy": {
16"clearExistingOrderBy": true,
17"orderGraphQlSnippet": "{ revenue: { order: DESC } }"
18}
19}
20]
21}

Result (GraphQL):

1query {
2myAccounts: accounts(
3where: {
4and: [
5{ status: { eq: "ACTIVE" } },
6{ revenue: { gte: 1000000 } }
7]
8},
9orderBy: { revenue: { order: DESC } }
10) {
11id
12name
13revenue
14industry
15}
16}

Query Enhancements: Multiple Query Paths with Different Transformations 

Original Query (GraphQL):

1query {
2myAccounts: accounts {
3id
4name
5myContacts: contacts {
6id
7name
8}
9}
10}

Transformation Instructions (JSON):

1{
2"nodes": [
3{
4"path": "myAccounts",
5"addFields": ["email"],
6"addFilters": {
7"clearExistingFilters": true,
8"filterGraphQlSnippet": "{ status: { eq: \"ACTIVE\" } }"
9}
10},
11{
12"path": "myAccounts.myContacts",
13"addFields": ["email", "phone"],
14"orderBy": {
15"clearExistingOrderBy": true,
16"orderGraphQlSnippet": "{ name: { order: ASC } }"
17}
18}
19]
20}

Result (GraphQL):

1query {
2myAccounts: accounts(where: { status: { eq: "ACTIVE" } }) {
3id
4name
5email
6myContacts: contacts(orderBy: { name: { order: ASC } }) {
7id
8name
9email
10phone
11}
12}
13}

Query Enhancements: Fields and Snippets 

Original Query (GraphQL):

1query {
2myAccounts: accounts {
3id
4name
5}
6}

Transformation Instructions (JSON):

1{
2"nodes": [
3{
4"path": "myAccounts",
5"addFields": ["email"]
6},
7{
8"path": "metrics",
9"insertGraphQlSnippet": "{
10metrics: systemMetrics {
11totalCount
12activeCount
13lastSyncTime
14}
15}"
16}
17]
18}

Result (GraphQL):

1query {
2myAccounts: accounts {
3id
4name
5email
6}
7metrics: systemMetrics {
8totalCount
9activeCount
10lastSyncTime
11}
12}