Prior to the release of Developer Extensibility (Embedded Steampunk) integration scenario developers working with S/4HANA Cloud public edition were restricted to the use of released APIs.
A recent blog post and tutorial published by Dennis Leslie demonstrates how it’s now quite straightforward for customers to build their own Custom APIs. In this blog post I will share some experience from implementing this concept on a recent project. If you have not already, I strongly recommend following the tutorial on creating Custom HTTP Services. It covers some of the basic prerequisites involved in enable inbound communication to custom APIs that will not be covered in this blog. The example case we will explore will also require creation of a custom RAP business object. If you are not already familiar I would strongly recommend the tutorial on Creating Custom RAP Business Objects
Understanding the Use Case
We currently have a wide variety of released APIs, detailed on the API Hub, that can be used to read and manipulate many standard business objects from an external system. However, in some cases the options available may not meet our requirements and some extension may be required. Let’s take an abstract example using the available API for sales orders. This API provides a range of functionality for manipulating sales order data. Let’s imagine we, as the S/4HANA Cloud system owners, want to open up a way for our customers to manipulate the sales order using this API but we want to apply some custom logic to ensure they can only update the quantity of their sales orders. On top of this, we are required to cross reference the customer against a custom business object where we can maintain tolerance limits for quantity changes on a per transaction basis.
With those factors in mind, the process flow could look something like this:
This process could work, but has some major drawbacks.
- The middleware would be required to interpret the results of the response from the custom business object ZSales_Order_Tolerance and determine if the request is valid forcing us to build some logic in the middleware.
- There are 2 API calls in the process flow so which may negatively impact performance in the middleware.
This design might be completely acceptable, however the drawbacks are evident. In many cases it makes sense to limit custom logic in the middleware as much as possible and like to push as much processing logic as possible into S/4HANA Cloud to minimize the number of requests made. Using a custom API your process could look a little more like the below.
By moving the read of the custom business object and the update to the sales order into a custom API we can now limit the process to a single inbound request and handle all of the processing inside the custom logic. This significantly streamlines the process and simplifies the maintenance of the custom solution. In theory, we could eliminate the middleware from the solution; however, for security and monitoring purposes it often makes sense to keep it in place. Let’s now explore how this design can be built.
Building the Solution
Overview
Before we dive into building the solution, let us outline at a high level the required steps we need to complete to fully realize the custom solution.
- Create the custom RAP Business Object for holding tolerance values
- Create an HTTP service in ADT
- Create an inbound communication scenario and add the HTTP service
- Create a communication arrangement
- Create a communication system
- Create a communication user and assign it to the created communication system
- Implemented request and response handling in the handler class
- Implement the custom logic in the handler class
Creating the RAP Business Object
Here we are covering step 1. For this we need to define a table structure in ABAP Development Tools (ADT). In the example scenario we simply want to maintain a Business Partner key field and a configurable update tolerance value. The structure below meets these requirements.
@EndUserText.label : 'Sales Order Tolerance'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zsales_order_tol {
key client : abap.clnt;
key customer : idtw_kunnr;
@Semantics.quantity.unitOfMeasure : 'zsales_order_tol.unit_field'
change_tolerance : abap.quan(13,2);
unit_field : abap.unit(2);
local_created_by : abp_creation_user;
local_created_at : abp_creation_tstmpl;
local_last_changed_by : abp_locinst_lastchange_user;
local_last_changed_at : abp_locinst_lastchange_tstmpl;
last_changed_at : abp_lastchange_tstmpl;
}
With this table created we can now generate the required repository objects following the same steps as defined in the tutorial on Creating Custom RAP Business Objects. Once completed we have a UI service available where we can maintain some data relevant to a test customer.
This completes step 1 of the process.
Creating the Custom HTTP Service
The custom API takes the form of a custom class that implements the handle request method of interface IF_HTTP_SERVICE_EXTENSION. Following the steps outlined in the tutorial on creating custom APIs, we can create a handler class with an empty implementation of HTTP handle requests interface that will be executed upon receiving external requests and complete all necessary configuration steps to enable external requests to the custom API. This tutorial covers steps 2-6 of the process outlined and will leave us with an empty handler class like the below (you can skip the add method ‘get_html’ of the tutorial).
class ZCL_DEMO_SO_UPDATE definition
public
create public .
public section.
interfaces IF_HTTP_SERVICE_EXTENSION .
protected section.
private section.
ENDCLASS.
CLASS ZCL_DEMO_SO_UPDATE IMPLEMENTATION.
method IF_HTTP_SERVICE_EXTENSION~HANDLE_REQUEST.
endmethod.
ENDCLASS.
Request and Response Handling
Moving on to step 7, with the handler class in place, we now need to define a data structure that we expect the request payload to match. The XCO library provides a very useful JSON deserialization tool. We can reuse this with the input defined structure to map a JSON payload received to ABAP. In this example we designed the method to handle multiple sales order items. It is very important that the ABAP structure nesting matches the JSON payload exactly, otherwise you will face errors in parsing the request (note the nested items table of type ty_demo_req in type definition ty_inputs, this will be clearer when the example payload is shown later).
TYPES:
BEGIN OF ty_demo_req,
sales_order(10) TYPE c,
sales_order_item(6) TYPE c,
requested_quantity(13) TYPE p DECIMALS 2,
END OF ty_demo_req,
BEGIN OF ty_demo_response,
sales_order(10) TYPE c,
sales_order_item(6) TYPE c,
previous_quantity(13) TYPE p DECIMALS 2,
updated_quantity(13) TYPE p DECIMALS 2,
END OF ty_demo_response.
TYPES:
BEGIN OF ty_inputs,
customer_number TYPE idtw_kunnr,
items TYPE STANDARD TABLE OF ty_demo_req WITH EMPTY KEY,
END OF ty_inputs,
BEGIN OF ty_output,
items TYPE STANDARD TABLE OF ty_demo_response WITH EMPTY KEY,
END OF ty_output.
In the private section we declare some class variables and constants we will need during execution.
DATA: lt_req TYPE ty_inputs,
lv_error(1) TYPE c,
lv_text TYPE string,
ls_response TYPE ty_demo_response,
es_response TYPE ty_output,
lc_header_content TYPE string VALUE 'content-type',
lc_content_type TYPE string VALUE 'text/json'.
Then at the very start of the implementation of the handle request interface we retrieve the payload and deserialize it into the ABAP structure lt_req
DATA(lv_req_body) = request->get_text( ).
"first deserialize the request
xco_cp_json=>data->from_string( lv_req_body )->apply( VALUE #(
( xco_cp_json=>transformation->pascal_case_to_underscore )
) )->write_to( REF #( lt_req ) ).
We will also need a way to send a response to the requestor. Fortunately, XCO allows us to parse ABAP to JSON and populate the Body of the handle request interface’s response parameter. Some systems might also require a response status code and header fields detailing the content, so we include those in the successful response case.
response->set_status('200').
DATA(lv_json_string) = xco_cp_json=>data->from_abap( es_response )->apply( VALUE #(
( xco_cp_json=>transformation->underscore_to_pascal_case )
) )->to_string( ).
response->set_text( lv_json_string ).
response->set_header_field( i_name = lc_header_content
i_value = lc_content_type ).
Implementing the Custom Logic
Now that we have a framework in place to communicate with the sending system, we can move to step 8 and focus on building the custom logic. The first step in the process flow was to check the tolerance assigned to the customer making the request in the custom business object. The RAP generator created a CDS data definition ZI_SALES_ORDER_TOL. We can read the tolerance value directly from this and the conditionally proceed or terminate processing.
"check the customer number supplied against our custom business object
SELECT SINGLE ChangeTolerance, UnitField FROM zi_sales_order_tol WHERE Customer = @lt_req-customer_number INTO @DATA(ls_tolerance).
Now that we have retrieved the tolerance values from the custom business object we can loop through the input payload, retrieve the existing quantity from a publicly released CDS view for sales order items (I_SALESORDERITEM), perform a comparison against the tolerance limit, and then use the released behavior definition for sales orders (I_SALESORDERTP) to modify the sales order item entity. There is excellent help documentation available on the released behavior definitions from within ADT that includes sample entity manipulation logic, which you can use as a template.
LOOP AT lt_req-items INTO DATA(ls_req).
"retrieve the current quantity for comparison with our tolerance
SELECT SINGLE requestedquantity FROM i_salesorderitem WITH PRIVILEGED ACCESS WHERE
salesorder = @ls_req-sales_order AND
salesorderitem = @ls_req-sales_order_item
INTO @ls_response-previous_quantity.
"check for tolerance violations at item level and terminate entire process if any violations occur
IF abs( ls_req-requested_quantity - ls_response-previous_quantity ) > ls_tolerance-ChangeTolerance.
lv_error = 'X'.
lv_text = 'Tolerance Exceeded' .
EXIT.
ENDIF.
"Modify the quantity
MODIFY ENTITIES OF i_salesordertp PRIVILEGED
ENTITY salesorderitem
UPDATE
SET FIELDS
WITH VALUE #( ( requestedquantity = ls_req-requested_quantity
%key-salesorder = ls_req-sales_order
%key-salesorderitem = ls_req-sales_order_item ) )
FAILED DATA(ls_failed)
REPORTED DATA(ls_reported).
IF ls_failed IS NOT INITIAL.
lv_error = 'X'.
lv_text = 'Error during update'.
EXIT.
ENDIF.
"update our response structure
ls_response-sales_order = ls_req-sales_order.
ls_response-sales_order_item = ls_req-sales_order_item.
ls_response-updated_quantity = ls_req-requested_quantity.
APPEND ls_response TO es_response-items.
CLEAR ls_response.
ENDLOOP.
The core loop and conditions for tolerance violation are now in place. All that remains is some simple error handling before we commit the changes made to the sales order item entity, ensuring no errors have occurred up to this point.
"commit our changes if no errors
COMMIT ENTITIES BEGIN
RESPONSE OF i_salesordertp
FAILED DATA(ls_save_failed)
REPORTED DATA(ls_save_reported).
COMMIT ENTITIES END.
Putting all of this together we have a simple method that can compare the input parameters against data in a custom business object; based on the result it will then update a standard business object and return a response to the sending system. Let’s now test this from Postman using the following payload. Note how payload is structured to match the input data type ty_inputs. The “items” table in the payload is reflected here by a JSON array.
{
"CustomerNumber": "0010100001",
"items": [
{
"SalesOrder": "0000000011",
"SalesOrderItem": "000010",
"RequestedQuantity": "15.00"
},
{
"SalesOrder": "0000000011",
"SalesOrderItem": "000020",
"RequestedQuantity": "5.00"
}
]
}
This returns the below response payload, indicating the updates for both line items were successful.
{
"Items": [
{
"SalesOrder": "0000000011",
"SalesOrderItem": "000010",
"PreviousQuantity": 10.0,
"UpdatedQuantity": 15.0
},
{
"SalesOrder": "0000000011",
"SalesOrderItem": "000020",
"PreviousQuantity": 1.0,
"UpdatedQuantity": 5.0
}
]
}
Conclusion
Building on the well-documented core concepts of developer extensibility in S/4HANA Cloud, public edition, we could quite significantly simplify a previously complex integration scenario involving multiple API calls and some processing logic in middleware into a single custom API that is more far more easily maintainable. I hope this simple, and somewhat abstract, example, can be taken as a template for building your own custom APIs.
Appendix
Below is the custom API logic in its entirety. Please feel free to take, re-use, and improve upon it for your own requirements!
CLASS zcl_demo_so_update DEFINITION
PUBLIC
CREATE PUBLIC .
PUBLIC SECTION.
INTERFACES if_http_service_extension .
TYPES:
BEGIN OF ty_demo_req,
sales_order(10) TYPE c,
sales_order_item(6) TYPE c,
requested_quantity(13) TYPE p DECIMALS 2,
END OF ty_demo_req,
BEGIN OF ty_demo_response,
sales_order(10) TYPE c,
sales_order_item(6) TYPE c,
previous_quantity(13) TYPE p DECIMALS 2,
updated_quantity(13) TYPE p DECIMALS 2,
END OF ty_demo_response.
TYPES:
BEGIN OF ty_inputs,
customer_number TYPE idtw_kunnr,
items TYPE STANDARD TABLE OF ty_demo_req WITH EMPTY KEY,
END OF ty_inputs,
BEGIN OF ty_output,
items TYPE STANDARD TABLE OF ty_demo_response WITH EMPTY KEY,
END OF ty_output.
PROTECTED SECTION.
PRIVATE SECTION.
DATA: lt_req TYPE ty_inputs,
lv_error(1) TYPE c,
lv_text TYPE string,
ls_response TYPE ty_demo_response,
es_response TYPE ty_output,
lc_header_content TYPE string VALUE 'content-type',
lc_content_type TYPE string VALUE 'text/json'.
ENDCLASS.
CLASS zcl_demo_so_update IMPLEMENTATION.
METHOD if_http_service_extension~handle_request.
DATA(lv_req_body) = request->get_text( ).
"first deserialize the request
xco_cp_json=>data->from_string( lv_req_body )->apply( VALUE #(
( xco_cp_json=>transformation->pascal_case_to_underscore )
) )->write_to( REF #( lt_req ) ).
"check the customer number supplied against our custom business object
SELECT SINGLE ChangeTolerance, UnitField FROM zi_sales_order_tol WHERE Customer = @lt_req-customer_number INTO @DATA(ls_tolerance).
IF ls_tolerance IS NOT INITIAL.
LOOP AT lt_req-items INTO DATA(ls_req).
"retrieve the current quantity for comparison with our tolerance
SELECT SINGLE requestedquantity FROM i_salesorderitem WITH PRIVILEGED ACCESS WHERE
salesorder = @ls_req-sales_order AND
salesorderitem = @ls_req-sales_order_item
INTO @ls_response-previous_quantity.
"check for tolerance violations at item level and terminate entire process if any violations occur
IF abs( ls_req-requested_quantity - ls_response-previous_quantity ) > ls_tolerance-ChangeTolerance.
lv_error = 'X'.
lv_text = 'Tolerance Exceeded' .
EXIT.
ENDIF.
"Modify the quantity
MODIFY ENTITIES OF i_salesordertp PRIVILEGED
ENTITY salesorderitem
UPDATE
SET FIELDS
WITH VALUE #( ( requestedquantity = ls_req-requested_quantity
%key-salesorder = ls_req-sales_order
%key-salesorderitem = ls_req-sales_order_item ) )
FAILED DATA(ls_failed)
REPORTED DATA(ls_reported).
IF ls_failed IS NOT INITIAL.
lv_error = 'X'.
lv_text = 'Error during update'.
EXIT.
ENDIF.
"update our response structure
ls_response-sales_order = ls_req-sales_order.
ls_response-sales_order_item = ls_req-sales_order_item.
ls_response-updated_quantity = ls_req-requested_quantity.
APPEND ls_response TO es_response-items.
CLEAR ls_response.
ENDLOOP.
ELSE.
lv_error = 'X'.
lv_text = 'Customer Not Maintained in Tolerance Table'.
ENDIF.
IF lv_error IS NOT INITIAL.
"propagate any errors raised
response->set_status('500').
response->set_text( lv_text ).
ELSE.
"commit our changes if no errors
COMMIT ENTITIES BEGIN
RESPONSE OF i_salesordertp
FAILED DATA(ls_save_failed)
REPORTED DATA(ls_save_reported).
COMMIT ENTITIES END.
"propagate any errors during save
IF ls_save_failed IS NOT INITIAL.
response->set_status('500').
response->set_text( 'Error during save' ).
ELSE.
"respond with success payload
response->set_status('200').
DATA(lv_json_string) = xco_cp_json=>data->from_abap( es_response )->apply( VALUE #(
( xco_cp_json=>transformation->underscore_to_pascal_case )
) )->to_string( ).
response->set_text( lv_json_string ).
response->set_header_field( i_name = lc_header_content
i_value = lc_content_type ).
ENDIF.
ENDIF.
ENDMETHOD.
ENDCLASS.