Extend the Built-In OData Features with Custom Code
- What custom event handlers are
- Where and how to define a simple event handler
- How to use a custom event handler to define an OData function import
This tutorial assumes you’ve completed the tutorial Extend your Simple Data Model with a Second Entity. If you have done, you’ll have an OData service Northbreeze with two related entities. All OData operations - create, read, update, delete and query - are supported out of the box.
In this tutorial, you’ll learn how to add custom behaviour, in the form of handlers, to make your OData service do what you want it to do, beyond the standard operation handling.
Before you start, open up the workspace in the SAP Business Application Studio (App Studio) dev space you were using in that previous tutorial, ready to add code.
- Step 1
Let’s take the
Productsentity as the target for our explorations of custom functions. Remind yourself of what the data looks like by starting up the service withcds watchin a terminal, just like you’ve done in the previous tutorial.Open up the service in a new browser tab or window, and navigate to the
Productsentity set. You should see the familiar list of products, with values for the properties in each case, and it should look like this (only the first two products are shown here):JSONCopy{ "@odata.context": "$metadata#Products", "value": [ { "ProductID": 1, "ProductName": "Chai", "UnitsInStock": 39, "Category_CategoryID": 1 }, { "ProductID": 2, "ProductName": "Chang", "UnitsInStock": 17, "Category_CategoryID": 1 } ] }Remember that at this stage your fully functioning OData service is a result of purely declarative definitions. Now it’s time to add some simple business logic.
- Step 2
Business logic in OData services belongs in a service implementation. The simplest way to do this is to create a
service.jsfile in the same directory as yourservice.cdsfile, i.e. in thesrv/directory. The framework will automatically recognize and use this “sibling” file.In a new
srv/service.jsfile, add the following JavaScript:JavaScriptCopymodule.exports = srv => { srv.after('READ', 'Products', items => { return items.map(item => { if (item.UnitsInStock > 100) { item.ProductName += ' SALE NOW ON!' } return item }) }) }Let’s stare at this for a few moments. You won’t be far wrong if you guess that it’s something to do with adding an indication of a product sale for items where there’s a high number of units in stock. But how does it work, and in what context?
First, in order to be used by CAP’s runtime framework, a service implementation file such as this needs to offer a function definition for the framework to call on startup. This “offer” is via Node.js’s module export mechanism, and what’s exported here is the anonymous function which (apart from the
module.exports =part itself) is the entire file contents.When the framework finds and invokes this anonymous function, it passes a server object, which we can use to define event handlers via the Handler Registration API. That’s why we have a single
srvparameter defined, and that’s what we use to access thesrv.afterAPI to declare a function to be run under specific circumstances (more on that shortly).Examining that API call, we see this pattern:
JavaScriptCopysrv.after('READ', 'Products', items => { ... })This is how we can add custom business logic to extend the standard handling that is provided for us out of the box. Specifically, this call defines a function (
items => { ... }) that should be executed whenever there’s an OData READ (or QUERY) operation on theProductsentity data.The use of the specific
afterAPI call is quite common, and allows us to jump onto the request processing flow towards the end, when the heavy lifting of data retrieval from the persistence layer has been done for us. As well asafter, the Handler Registration API supportsbeforeandonevents, but right now,afteris what we want here.What does the function specified in this API call do? As you’d correctly guessed, it just adds a string on to the end of the value for each of the product names, specifically for the cases where the number of units in stock is high.
In its simplest form, the function provided is given the data retrieved, and whatever the function does ends up in the response to the original request. Note, however, that in the context of the
afterAPI call, the handler function cannot change the “shape” of the data, such as omit specific items. We’ll look at how to do that later on in this tutorial.So with the simple
mapinvocation, we are modifying the values for theProductNameproperties of those items where theUnitsInStockvalue is more than 100.Once you’ve added this code and saved the file, check that the
cds watchprocess has restarted the service successfully, and have another look at theProductsentity set.Here’s an example of what you should see; this data was retrieved using the system query options
$skip=4and$top=2to narrow in on just two of the products, with “Grandma’s Boysenberry Spread” having 120 units in stock and the extra “SALE NOW ON!” text:JSONCopy{ "@odata.context": "$metadata#Products", "value": [ { "ProductID": 5, "ProductName": "Chef Anton's Gumbo Mix", "UnitsInStock": 0, "Category_CategoryID": 2 }, { "ProductID": 6, "ProductName": "Grandma's Boysenberry Spread SALE NOW ON!", "UnitsInStock": 120, "Category_CategoryID": 2 } ] } - Step 3
That’s great, but let’s look now at a simple example of where we might want to change the shape of the data, or, as the documentation describes it, to make “asynchronous modifications”.
If we wanted to reduce the list of products returned - to omit those products that had a low stock count - we would not use the
afterAPI call, but theonAPI call, and provide a function that effectively replaces the standard processing.The prospect of doing this isn’t as daunting as it first seems, as we’re given everything that we need to be able to do this.
Remove the entire call to
srv.afterand replace it with a call tosrv.on, so that the resultingservice.jscontent looks like this:JavaScriptCopymodule.exports = srv => { srv.on('READ', 'Products', async (req, next) => { const items = await next() return items.filter(item => item.UnitsInStock > 100) }) }This differs from the previous step thus in a number of ways.
First, we’re using the
onAPI call to provide a function that should be run instead of standard processing when product data is requested.Next, the function we provide doesn’t expect the data (like we did in the previous function, with the
itemsparameter), as the data will not be provided to it. Instead, it’s expecting to be given the original request object (req), and a reference to the subsequent standard handler (next). We can use thisnexthandler to actually do the work of retrieving the data for us, and are then free to do what we want with it.Finally, because we’re wanting to call that
nextfunction synchronously (withawait), we must declare our function with theasynckeyword.Once we have the data, in
items, we return a filtered subset that only includes those products where the value of theUnitsInStockproperty is greater than 100.Once you have this new implementation saved, and your service has restarted, check the
Productsentity set once more, and you should see only a small number of entries; if you’re still using the data provided in the tutorials prior to this, there should be 10. - Step 4
That’s great, but there’s more that can be done in such a service implementation file.
The two JavaScript functions you’ve provided so far have been to affect the processing of standard OData operations on the
Productsentity. But OData V4 defines actions and functions, in addition to entities. Actions and functions can be bound, or unbound. Think of such things as the next generation of function imports that you might know from OData V2.So to round off this tutorial, let’s define a simple unbound function on our OData service.
Bear in mind the distinction between “function” in the JavaScript sense, and “function” in the OData sense.
While the custom logic that we’ve written so far has been implicit in our OData service’s definition, as they work as handlers for existing operations, an OData function needs to be explicitly declared and described in the service’s metadata.
To do this, extend the CDS definition in
srv/service.cds, where you should add a line to define a functionTotalStockCountin theMainservice. The resulting content ofsrv/service.cdsshould look like this:CDSCopyusing northbreeze from '../db/schema'; service Main { entity Products as projection on northbreeze.Products; entity Categories as projection on northbreeze.Categories; function TotalStockCount() returns Integer; }At this point, it’s worth checking to see if this has any effect on your OData service. Once the CDS file is saved, and your service has restarted, navigate to the metadata document (that’s the relative path
/odata/v4/main/$metadata, but you knew that already, right?). It should look something like this:XMLCopy<edmx:Edmx xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0"> <edmx:Reference Uri="https://sap.github.io/odata-vocabularies/vocabularies/Common.xml"> <edmx:Include Alias="Common" Namespace="com.sap.vocabularies.Common.v1"/> </edmx:Reference> <edmx:Reference Uri="https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.xml"> <edmx:Include Alias="Core" Namespace="Org.OData.Core.V1"/> </edmx:Reference> <edmx:DataServices> <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Main"> <Annotation Term="Core.Links"> <Collection> <Record> <PropertyValue Property="rel" String="author"/> <PropertyValue Property="href" String="https://cap.cloud.sap"/> </Record> </Collection> </Annotation> <EntityContainer Name="EntityContainer"> <EntitySet Name="Products" EntityType="Main.Products"> <NavigationPropertyBinding Path="Category" Target="Categories"/> </EntitySet> <EntitySet Name="Categories" EntityType="Main.Categories"> <NavigationPropertyBinding Path="Products" Target="Products"/> </EntitySet> <FunctionImport Name="TotalStockCount" Function="Main.TotalStockCount"/> </EntityContainer> <EntityType Name="Products"> <Key> <PropertyRef Name="ProductID"/> </Key> <Property Name="ProductID" Type="Edm.Int32" Nullable="false"/> <Property Name="ProductName" Type="Edm.String"/> <Property Name="UnitsInStock" Type="Edm.Int32"/> <NavigationProperty Name="Category" Type="Main.Categories" Partner="Products"> <ReferentialConstraint Property="Category_CategoryID" ReferencedProperty="CategoryID"/> </NavigationProperty> <Property Name="Category_CategoryID" Type="Edm.Int32"/> </EntityType> <EntityType Name="Categories"> <Key> <PropertyRef Name="CategoryID"/> </Key> <Property Name="CategoryID" Type="Edm.Int32" Nullable="false"/> <Property Name="CategoryName" Type="Edm.String"/> <Property Name="Description" Type="Edm.String"/> <NavigationProperty Name="Products" Type="Collection(Main.Products)" Partner="Category"/> </EntityType> <Function Name="TotalStockCount" IsBound="false" IsComposable="false"> <ReturnType Type="Edm.Int32"/> </Function> </Schema> </edmx:DataServices> </edmx:Edmx>We can see that this simple declarative definition has already had an effect - there is evidence of this new function import:
- in the
EntityContainerelement where it’s listed alongside the two entity sets - defined near the bottom, after the definitions of the
CategoriesandProductsentity types
The function import definition here in the metadata document reflects what we intended; in particular, the function is called
TotalStockCount, is unbound, and has an integer return type:XMLCopy<Function Name="TotalStockCount" IsBound="false" IsComposable="false"> <ReturnType Type="Edm.Int32"/> </Function>Great. Now we can get to writing the implementation of this function import.
Our function import is referred to as unbound because it
- in the
- Step 5
The implementation of this function import might as well go in the same
srv/service.jsfile as before, to keep things simple. Here’s what the entire contents of the file should look like with all the additions:JavaScriptCopyconst { Products } = cds.entities('northbreeze') module.exports = srv => { srv.on('READ', 'Products', async (req, next) => { const items = await next() return items.filter(item => item.UnitsInStock > 100) }) srv.on('TotalStockCount', async (req) => { const items = await cds.tx(req).run(SELECT.from(Products)) return items.reduce((a, item) => a + item.UnitsInStock, 0) }) }Let’s look at what’s new.
First, at the top of the file, there is this new line:
JavaScriptCopyconst { Products } = cds.entities('northbreeze')Here, we’re using destructuring to pull out the
Productsentity definition from thenorthbreezeservice, via thecdsmodule.Next, directly below the existing
srv.on('READ', 'Products', async (req, next) => { ... })call that you already had, there is now a second call to the Handler Registration API to define a handler for theTotalStockCountfunction import.This handler is an anonymous function just like the other, except that it only expects and needs the request (in
req). It uses this as a context for the transaction that it creates, within which it then retrieves the product data.Note that
Productsis a constant, not a literal string, and refers to the entity set object that we retrieved viacds.entitiesearlier.The product data retrieved is stored in the
itemsconstant, and looks like this:JavaScriptCopy[ { ProductID: 1, ProductName: 'Chai', UnitsInStock: 39, Category_CategoryID: 1 }, { ProductID: 2, ProductName: 'Chang', UnitsInStock: 17, Category_CategoryID: 1 }, ... ]It’s then just a simple case of summing the values of the
UnitsInStockproperty for each of the items, which we do cleanly with a simple reduce function, and return the result. Being a numeric value, the result type corresponds to what we defined as what the function import returns, back in the CDS file:CDSCopyfunction TotalStockCount() returns Integer;Once you’ve saved the service implementation file and the
cds watchprocess has restarted the service, you should try this function import out. Switch to the other tab and navigate to the relative path:/odata/v4/main/TotalStockCount()The response should look something like this:
JSONCopy{ "@odata.context": "$metadata#Edm.Int32", "value": 3119 }That is, there are a total of 3119 stock units across all products.
Well done! You’ve now successfully implemented an OData V4 unbound function, and hopefully feel comfortable enough to implement your own custom business logic for your CAP-powered OData services.