Deploy an OData API to SAP BTP
Build and deploy a CAP purchase-order OData API to SAP BTP with HANA Cloud.
Introduction
30 Second Summary
A purchase order can look complete while still carrying a negative total or an unsupported status. One bad value can disrupt every process that depends on it.
In this project, you will build a purchase-order API with the SAP Cloud Application Programming Model through an OData V4 service. You will deploy the validated API to SAP BTP with data stored in SAP HANA Cloud.
What You'll Build
You call a live purchase-order endpoint to see seeded orders return with their supplier details plus line items.
By the end of this project, you'll have:
- A queryable purchase-order service that returns seeded orders through generated OData V4 operations. You can filter results or expand related records.
- A validated write path that rejects non-positive totals with structured errors. Unsupported order statuses receive their own clear rejection.
- A live cloud deployment backed by an HDI container. The same purchase-order endpoint responds from its SAP BTP application URL.
- Secret Mission: Add ETag-based optimistic concurrency to reject an update made with a stale record version.
Are there any prerequisites?
You need access to SAP Business Application Studio in an SAP BTP subaccount with a Cloud Foundry space.
That space needs a running SAP HANA Cloud database with the hdi-shared entitlement. Your user also needs Space Developer permissions.
Before We Start
This first checkpoint locks in your goal before the hands-on work begins. You are building a procurement API for clients that need to query and manage purchase orders. Deploying the validated OData V4 service to SAP BTP makes it available in a cloud environment.
Verify Your SAP BTP Environment
Your procurement API needs a complete SAP BTP deployment path before you create any project files. An outdated tool can stop that path.
A mismatched Cloud Foundry target creates the same risk. An unavailable SAP HANA Cloud database also blocks deployment.
In this step, get ready to:
- Verify the development tools in a full-stack SAP Business Application Studio dev space.
- Confirm the database entitlement plus your deployment permission.
- Sign in to the intended Cloud Foundry space and verify its target.
Verify the BAS toolchain
A full-stack SAP Business Application Studio dev space provides the shell where you build the service. It also gives you access to the deployment tools used later.
This check covers Node.js, the SAP Cloud Application Programming Model, the Cloud Foundry CLI, and the Cloud MTA Build Tool.
- Sign in to the SAP BTP cockpit.
- Launch SAP Business Application Studio from the cockpit.
You should now see the dev spaces available in your account.
- Start an existing dev space whose type is Full-Stack Application Using Productivity Tools.
- Create a dev space with the same type if no suitable space exists.
The BAS editor loads once the selected dev space is ready.
- Open a terminal in the dev space.
- Check the complete deployment toolchain by running these commands:
node --version
cds version
cf -v
mbt -v
cf plugins
What do these checks prove?
- The Node.js check should report major version 24.
- The CAP report should show major version 10.
- The Cloud Foundry CLI check should report major version 8 or newer.
- The MBT check should print an installed version.
- The plugin list should include multiapps.
✔️ I see the required versions
Your BAS toolchain is ready when every check below passes.
- Confirm Node.js reports major version 24.
- Confirm CAP reports major version 10.
- Confirm the Cloud Foundry CLI reports major version 8 or newer.
- Confirm MBT prints its installed version.
- Confirm multiapps appears in the plugin list.
Your terminal can now reach every development tool required by the deployment workflow.
ⓧ I see an older version
An older Node.js runtime or Cloud Foundry CLI can fail during later build stages. A previous CAP major version can also produce different project behavior.
- Return to the SAP Business Application Studio dev-space manager if Node.js differs from 24.
- Create a fresh dev space with the Full-Stack Application Using Productivity Tools type if the Cloud Foundry CLI is older than 8.
- Update the CAP development kit by running this command:
npm i -g @sap/cds-dk
What does this command do?
The command installs the current CAP development kit globally in your dev space. The global installation makes the CDS commands available from its terminal.
- Run the toolchain checks again after correcting the older component:
node --version
cds version
cf -v
mbt -v
cf plugins
What confirms the update?
Node.js should now report 24. CAP should report 10.
The Cloud Foundry CLI should report major version 8 or newer. MBT plus the multiapps plugin should also appear.
ⓧ A command is missing
A missing Node.js or Cloud Foundry command usually means the current dev space lacks the required toolset.
- Return to the SAP Business Application Studio dev-space manager if the Node.js command is unavailable.
- Create a dev space with the Full-Stack Application Using Productivity Tools type if the Cloud Foundry command is unavailable.
- Install any missing CAP development kit, MBT, or multiapps component with the relevant command below:
npm i -g @sap/cds-dk
npm install -g mbt
cf add-plugin-repo CF-Community https://plugins.cloudfoundry.org
cf install-plugin -f multiapps
What do these commands install?
- The first command installs the global CAP development kit.
- The second command installs the Cloud MTA Build Tool.
- The final two commands register the community plugin repository before installing the multiapps plugin.
- Run the full toolchain check again after the installations finish:
node --version
cds version
cf -v
mbt -v
cf plugins
What confirms the installation?
Every command should now print version information or a plugin listing. The results should match the required versions from the successful tab.
Tool check still failing?
Global installations can fail when the dev space lacks network access or installation permission. Confirm that you are using the full-stack dev-space type before retrying.
Share the command that failed plus its terminal output through help me diagnose my BAS toolchain check.
Confirm SAP BTP prerequisites
The later deployment stores database artifacts inside an HDI container. The hdi-shared service plan makes that container available in the target subaccount.
Starting a stopped SAP HANA Cloud database can take several minutes. A quiet wait during startup is expected.
- Return to the SAP BTP cockpit from earlier.
- Select the subaccount you intend to use for this project.
- Confirm that its Cloud Foundry environment contains the intended space.
- Open SAP HANA Cloud within the selected subaccount.
- Start the database if it is stopped.
- Confirm that the database reports it is running.
- Check the subaccount entitlements for the hdi-shared service plan.
- Confirm that your user has Space Developer permission in the intended space.
Why do these checks matter?
The running database provides the production persistence layer. The entitled service plan lets the deployment create a project-specific HDI container.
The Space Developer permission allows your user to deploy applications into the chosen space.
Cannot confirm the entitlement or role?
Company-managed SAP BTP accounts can restrict entitlements or space roles. Use a subaccount where these prerequisites are already available.
Get help deciding what to ask your administrator through help me describe the missing SAP BTP prerequisite.
Target the Cloud Foundry space
The browser sign-in gives you a one-time passcode for the terminal. Paste that passcode only into the CLI prompt.
- Start the Cloud Foundry SSO login flow in the BAS terminal by running this command:
cf login --sso
What does this command do?
The command connects the Cloud Foundry CLI to SAP BTP through browser-based single sign-on. The terminal pauses while you complete the one-time passcode flow.
- Open the authorization URL shown in the terminal.
- Complete the browser sign-in with your SAP BTP account.
- Copy the one-time passcode shown after authorization.
The browser now gives you the short-lived value needed to finish the terminal login.
- Return to the BAS terminal.
- Paste the one-time passcode into the prompt.
- Choose the intended organization when prompted.
- Choose the intended space when prompted.
- Inspect the current service inventory by running this command:
cf services
What does the service inventory show?
The command lists service instances already provisioned in the targeted space. Your project-specific HDI container appears later after deployment.
The entitlement check in the cockpit proves that the required plan is available for that future deployment.
Before the final check, predict whether the CLI is targeting the intended API endpoint, organization, and space.
- Verify the active Cloud Foundry target by running this command:
cf target
What should you see?
You should see the intended API endpoint. You should also see the correct organization plus the correct space.
Those values prove that later deployment commands will reach the SAP BTP environment you just checked.
Targeting the wrong space?
Repeat the SSO login flow above. Select the intended organization when the CLI prompts you.
Choose the intended space at the next prompt. Use help me correct my Cloud Foundry target if the reported target still looks wrong.
That's the deployment groundwork locked in. Next, you'll build the local OData V4 service that this environment will eventually host.
Build, Query, and Validate the Service
Your SAP Business Application Studio environment is ready for a real service. This step turns the empty workspace into a SAP Cloud Application Programming Model service for purchase orders.
A local OData V4 endpoint gives you a working baseline before cloud packaging adds complexity. Seed data makes the result visible as soon as the server starts.
Generated CRUD handles standard writes based on your model. You will test whether that behavior also covers the procurement policy.
In this step, get ready to:
- Build a local purchase-order service with suppliers and line items.
- Query the generated API through metadata and OData query options.
- Test purchase-order writes against the required business rules.
Build the local service
A CDS domain model defines the data shape that CAP serves. The project configuration also prepares the same model for a later cloud deployment.
- Create a folder named purchase-order-service from the BAS Explorer sidebar.
- Create a db subfolder inside purchase-order-service.
- Create a data subfolder inside db.
- Create an srv subfolder inside purchase-order-service.
- Create package.json inside purchase-order-service.
- Paste the following project configuration into package.json:
{
"name": "purchase-order-service",
"version": "1.0.0",
"description": "A CAP OData V4 purchase-order service for SAP BTP.",
"private": true,
"dependencies": {
"@cap-js/hana": "3.0.2",
"@sap/cds": "10.0.6"
},
"devDependencies": {
"@cap-js/sqlite": "3.0.2"
},
"engines": {
"node": "^24"
},
"scripts": {
"start": "cds-serve",
"watch": "cds watch"
}
}
What does this configuration do?
- The dependencies provide the CAP runtime plus the SAP HANA adapter used for deployment.
- The development dependency provides SQLite for the fast local feedback loop.
- The engine field keeps the service aligned with Node.js 24.
- The scripts expose commands for starting the service normally or with automatic restarts.
- Save package.json.
- Create mta.yaml inside purchase-order-service.
- Paste this first configuration section into mta.yaml:
_schema-version: "3.1"
ID: purchase-order-service
version: 1.0.0
description: "A CAP OData V4 purchase-order service."
parameters:
enable-parallel-deployments: true
build-parameters:
before-all:
- builder: custom
commands:
- npm ci
- npx cds build --production
How does the build section help?
The MTA build installs the locked dependencies before generating production artifacts. This gives the cloud build a repeatable starting point.
- Add the deployment modules below the existing build section:
modules:
- name: purchase-order-service-srv
type: nodejs
path: gen/srv
parameters:
buildpack: nodejs_buildpack
build-parameters:
builder: npm-ci
requires:
- name: purchase-order-service-db
- name: purchase-order-service-db-deployer
type: hdb
path: gen/db
parameters:
buildpack: nodejs_buildpack
build-parameters:
ignore:
- "node_modules/"
requires:
- name: purchase-order-service-db
resources:
- name: purchase-order-service-db
type: com.sap.xs.hdi-container
parameters:
service: hana
service-plan: hdi-shared
properties:
hdi-service-name: ${service-name}
What will these modules deploy?
The service module runs the Node.js application. The database deployer sends generated artifacts to an HDI container backed by SAP HANA Cloud.
Both modules reference the same hdi-shared resource. That shared binding connects the application to its deployed schema.
- Save mta.yaml.
- Open a terminal in purchase-order-service.
- Install the project dependencies by running:
npm install
What does this command create?
The npm command reads package.json before downloading the declared packages. It also creates package-lock.json with the exact resolved dependency tree.
- Confirm that package-lock.json is listed beside package.json in the BAS Explorer.
Good progress. Your project now has reproducible dependencies plus the packaging instructions needed for its later deployment.
Dependency installation failed?
Check that the terminal path ends with purchase-order-service. Also check package.json for a missing comma or quotation mark.
If package installation still fails, help me diagnose the npm installation error.
- Create schema.cds inside the db folder.
- Paste the domain model into db/schema.cds:
namespace nextwork.procurement;
using { cuid, managed } from '@sap/cds/common';
entity Suppliers : cuid, managed {
name : String(100) @mandatory;
countryCode : String(2);
orders : Association to many PurchaseOrders
on orders.supplier = $self;
}
entity PurchaseOrders : cuid, managed {
orderNumber : String(20) @mandatory;
supplier : Association to Suppliers @mandatory;
status : String(20) default 'NEW';
totalAmount : Decimal(13, 2);
currency : String(3);
items : Composition of many PurchaseOrderItems
on items.order = $self;
}
entity PurchaseOrderItems : cuid {
order : Association to PurchaseOrders;
product : String(100) @mandatory;
quantity : Integer;
unitPrice : Decimal(13, 2);
}
How is the domain connected?
- The supplier association links each purchase order to one existing supplier.
- The items composition makes line items part of their owning purchase order.
- The managed aspect adds creation and modification metadata to suppliers plus purchase orders.
- The cuid aspect supplies UUID keys for all three entities.
- Save db/schema.cds.
- Create procurement-service.cds inside the srv folder.
- Paste the service definition into srv/procurement-service.cds:
using { nextwork.procurement as db } from '../db/schema';
service ProcurementService @(path: 'procurement') {
entity Suppliers as projection on db.Suppliers;
entity PurchaseOrders as projection on db.PurchaseOrders;
entity PurchaseOrderItems as projection on db.PurchaseOrderItems;
}
What does the service expose?
The projections expose all three domain entities through ProcurementService. The path annotation places them under /odata/v4/procurement.
- Save srv/procurement-service.cds.
- Start the local CAP development server by running:
cds watch
What does watch mode do?
The command starts the service at http://localhost:4004. It also restarts the service when you save project files.
You will see the local service link in the terminal. Keep this process running while you add data and test requests.
Local service did not start?
Check that the terminal is inside purchase-order-service. A CDS syntax error usually points to a line in db/schema.cds or srv/procurement-service.cds.
If the service still does not start, help me diagnose my cds watch output.
CAP loads CSV files from db/data as initial records. The filenames connect each dataset to its fully qualified entity.
- Create nextwork.procurement-Suppliers.csv inside db/data.
- Paste the supplier records into the CSV file:
ID,name,countryCode
11111111-1111-4111-8111-111111111111,Acme Components,US
22222222-2222-4222-8222-222222222222,Nordic Office Supplies,SE
What do these rows provide?
Each row provides a stable supplier UUID plus fields from the Suppliers entity. Stable IDs let the purchase-order data reference these suppliers reliably.
- Save db/data/nextwork.procurement-Suppliers.csv.
- Create nextwork.procurement-PurchaseOrders.csv inside db/data.
- Paste the purchase-order records into the CSV file:
ID,orderNumber,supplier_ID,status,totalAmount,currency
aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa,PO-1001,11111111-1111-4111-8111-111111111111,NEW,1250.00,USD
bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb,PO-1002,22222222-2222-4222-8222-222222222222,APPROVED,480.50,EUR
How do the orders reference suppliers?
The supplier_ID column stores the generated foreign key for the supplier association. Each value matches a supplier UUID from the first CSV file.
- Save db/data/nextwork.procurement-PurchaseOrders.csv.
- Open http://localhost:4004/odata/v4/procurement/PurchaseOrders in your browser.
You will see seeded responses containing PO-1001 plus PO-1002. That is your first working local API result.
- Create nextwork.procurement-PurchaseOrderItems.csv inside db/data.
- Paste the line-item records into the CSV file:
ID,order_ID,product,quantity,unitPrice
cccccccc-cccc-4ccc-8ccc-ccccccccccc1,aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa,Industrial Sensor,5,250.00
cccccccc-cccc-4ccc-8ccc-ccccccccccc2,bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb,Ergonomic Chair,3,160.00
How are line items attached?
The order_ID column points each line item at its owning purchase order. CAP uses that relationship when an OData request expands the composed items.
- Save db/data/nextwork.procurement-PurchaseOrderItems.csv.
The running watcher reloads the seed data after the save. Your model now contains two suppliers plus two orders plus two composed line items.
Seeded orders missing?
Check that every CSV file sits directly inside db/data. The filenames must match the fully qualified entity names exactly.
If the endpoint remains empty, help me check why CAP is not loading my CSV seed data.
Query the generated API
An HTTP request file keeps repeatable API checks beside your code. These requests reveal the generated metadata plus the relationships CAP exposes.
- Create requests.http inside purchase-order-service.
- Paste the supplied requests into requests.http:
@server = http://localhost:4004
@cloud = https://your-cloud-url-here
### Read OData metadata
GET {{server}}/odata/v4/procurement/$metadata
### Query orders with selection, expansion, filtering, and sorting
GET {{server}}/odata/v4/procurement/PurchaseOrders?$select=ID,orderNumber,status,totalAmount,currency&$expand=supplier($select=name)&$filter=totalAmount%20gt%20500&$orderby=totalAmount%20desc
### Read an order and its composed items
GET {{server}}/odata/v4/procurement/PurchaseOrders/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa?$expand=supplier,items
### Create an invalid order before validation, then resend after validation
POST {{server}}/odata/v4/procurement/PurchaseOrders
Content-Type: application/json
{
"orderNumber": "PO-INVALID-1",
"supplier_ID": "11111111-1111-4111-8111-111111111111",
"status": "NEW",
"totalAmount": -25.00,
"currency": "USD"
}
### Query the deployed cloud service
GET {{cloud}}/odata/v4/procurement/PurchaseOrders?$expand=supplier,items
What do these requests test?
- The metadata request asks the service to describe its exposed entity types.
- The query combines selection plus expansion plus filtering plus sorting in one call.
- The composed-item request loads one order with its supplier plus its line items.
- The POST request tests how generated CRUD handles a purchase order with a negative total.
- Save requests.http.
- Send the Read OData metadata request from requests.http.
You will see an EDMX document containing Suppliers plus PurchaseOrders plus PurchaseOrderItems. This proves the service exposes all three projections.
- Send the Query orders with selection, expansion, filtering, and sorting request.
You will see PO-1001 with its supplier name. The total filter excludes PO-1002 because its value is below the requested threshold.
Request did not return the seeded order?
Confirm that cds watch is still running. Also confirm that the request uses the local server variable.
If the response is still unexpected, help me diagnose my CAP OData query result.
Before you send the POST request, do you think the generated provider will accept or reject the negative total?
- Send the Create an invalid order before validation, then resend after validation request.
You will see HTTP 201 even though totalAmount is -25.00. The generic provider created the record because the CDS structure contains no rule that rejects this value.
The business-rule gap is intentional
Generated CRUD understands the entity shape plus its types. Procurement policy requires an explicit rule for values that are structurally valid but unacceptable to the business.
You have now seen the exact gap that custom service logic needs to close.
Enforce purchase-order rules
A request handler can inspect incoming data before CAP writes it. The first rule rejects non-positive totals through req.reject.
- Create procurement-service.js inside the srv folder.
- Paste the first validation handler into srv/procurement-service.js:
const cds = require('@sap/cds')
module.exports = class ProcurementService extends cds.ApplicationService {
init() {
const { PurchaseOrders } = this.entities
this.before(['CREATE', 'UPDATE'], PurchaseOrders, function validateOrder(req) {
const { totalAmount, status } = req.data
if (totalAmount !== undefined && totalAmount <= 0) {
return req.reject({
status: 400,
code: 'INVALID_TOTAL',
message: 'totalAmount must be greater than 0',
target: 'totalAmount'
})
}
})
return super.init()
}
}
How does the handler protect writes?
- The matching filename lets CAP discover this service implementation beside its CDS definition.
- The handler runs before CREATE plus UPDATE events on PurchaseOrders.
- The validateOrder function reads the incoming values from req.data.
- The rejection identifies totalAmount as the field that violated the rule.
- Save srv/procurement-service.js.
The watcher restarts the service with the new handler. Your next request now passes through the total validation before CAP reaches the database.
Before you resend the same request, do you expect its status to stay at HTTP 201?
- Resend the invalid POST request from requests.http.
You will now see HTTP 400 with code INVALID_TOTAL plus target totalAmount. The same request now fails before any invalid record is written.
Negative total still accepted?
Confirm that procurement-service.js sits beside procurement-service.cds in the srv folder. Also confirm that the terminal shows a restart after you save.
If the request still succeeds, help me find why my CAP before handler is not running.
The same handler can enforce the status vocabulary. A purchase order may use NEW plus APPROVED plus REJECTED.
- Find the closing brace of the totalAmount check in srv/procurement-service.js.
- Insert this status check immediately below that brace:
if (status !== undefined && !['NEW', 'APPROVED', 'REJECTED'].includes(status)) {
return req.reject({
status: 400,
code: 'INVALID_STATUS',
message: 'status must be NEW, APPROVED, or REJECTED',
target: 'status'
})
}
How does the status rule work?
The condition runs when the request includes a status. It rejects any value missing from the allowed list.
The structured error uses INVALID_STATUS plus target status so an API client can identify the failed field.
- Save srv/procurement-service.js.
- Replace the status value NEW with INVALID_STATUS in the POST request.
- Send the modified POST request.
You will see HTTP 400 with code INVALID_STATUS plus target status. This proves the second branch is active.
- Restore the request status to NEW.
For the final check, which validation branch should the restored negative-value request reach?
- Resend the restored POST request.
You will see HTTP 400 with code INVALID_TOTAL plus target totalAmount. Your local service now rejects both invalid total values and unsupported statuses.
Wrong validation branch returned?
Confirm that the final request uses status NEW plus total -25.00. Also confirm that the status branch sits after the total branch inside validateOrder.
If the response still differs, help me trace the validation branch in my CAP handler.
Your files should now match the complete local service below. Use the second tab if you want to compare every artifact.
✔️ Awesome, I've got everything!
Great. Double-check that every file is saved while cds watch continues running.
ⓧ I'd like to double check the full code
package.json
{
"name": "purchase-order-service",
"version": "1.0.0",
"description": "A CAP OData V4 purchase-order service for SAP BTP.",
"private": true,
"dependencies": {
"@cap-js/hana": "3.0.2",
"@sap/cds": "10.0.6"
},
"devDependencies": {
"@cap-js/sqlite": "3.0.2"
},
"engines": {
"node": "^24"
},
"scripts": {
"start": "cds-serve",
"watch": "cds watch"
}
}
mta.yaml
_schema-version: "3.1"
ID: purchase-order-service
version: 1.0.0
description: "A CAP OData V4 purchase-order service."
parameters:
enable-parallel-deployments: true
build-parameters:
before-all:
- builder: custom
commands:
- npm ci
- npx cds build --production
modules:
- name: purchase-order-service-srv
type: nodejs
path: gen/srv
parameters:
buildpack: nodejs_buildpack
build-parameters:
builder: npm-ci
requires:
- name: purchase-order-service-db
- name: purchase-order-service-db-deployer
type: hdb
path: gen/db
parameters:
buildpack: nodejs_buildpack
build-parameters:
ignore:
- "node_modules/"
requires:
- name: purchase-order-service-db
resources:
- name: purchase-order-service-db
type: com.sap.xs.hdi-container
parameters:
service: hana
service-plan: hdi-shared
properties:
hdi-service-name: ${service-name}
db/schema.cds
namespace nextwork.procurement;
using { cuid, managed } from '@sap/cds/common';
entity Suppliers : cuid, managed {
name : String(100) @mandatory;
countryCode : String(2);
orders : Association to many PurchaseOrders
on orders.supplier = $self;
}
entity PurchaseOrders : cuid, managed {
orderNumber : String(20) @mandatory;
supplier : Association to Suppliers @mandatory;
status : String(20) default 'NEW';
totalAmount : Decimal(13, 2);
currency : String(3);
items : Composition of many PurchaseOrderItems
on items.order = $self;
}
entity PurchaseOrderItems : cuid {
order : Association to PurchaseOrders;
product : String(100) @mandatory;
quantity : Integer;
unitPrice : Decimal(13, 2);
}
db/data/nextwork.procurement-Suppliers.csv
ID,name,countryCode
11111111-1111-4111-8111-111111111111,Acme Components,US
22222222-2222-4222-8222-222222222222,Nordic Office Supplies,SE
db/data/nextwork.procurement-PurchaseOrders.csv
ID,orderNumber,supplier_ID,status,totalAmount,currency
aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa,PO-1001,11111111-1111-4111-8111-111111111111,NEW,1250.00,USD
bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb,PO-1002,22222222-2222-4222-8222-222222222222,APPROVED,480.50,EUR
db/data/nextwork.procurement-PurchaseOrderItems.csv
ID,order_ID,product,quantity,unitPrice
cccccccc-cccc-4ccc-8ccc-ccccccccccc1,aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa,Industrial Sensor,5,250.00
cccccccc-cccc-4ccc-8ccc-ccccccccccc2,bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb,Ergonomic Chair,3,160.00
srv/procurement-service.cds
using { nextwork.procurement as db } from '../db/schema';
service ProcurementService @(path: 'procurement') {
entity Suppliers as projection on db.Suppliers;
entity PurchaseOrders as projection on db.PurchaseOrders;
entity PurchaseOrderItems as projection on db.PurchaseOrderItems;
}
srv/procurement-service.js
const cds = require('@sap/cds')
module.exports = class ProcurementService extends cds.ApplicationService {
init() {
const { PurchaseOrders } = this.entities
this.before(['CREATE', 'UPDATE'], PurchaseOrders, function validateOrder(req) {
const { totalAmount, status } = req.data
if (totalAmount !== undefined && totalAmount <= 0) {
return req.reject({
status: 400,
code: 'INVALID_TOTAL',
message: 'totalAmount must be greater than 0',
target: 'totalAmount'
})
}
if (status !== undefined && !['NEW', 'APPROVED', 'REJECTED'].includes(status)) {
return req.reject({
status: 400,
code: 'INVALID_STATUS',
message: 'status must be NEW, APPROVED, or REJECTED',
target: 'status'
})
}
})
return super.init()
}
}
requests.http
@server = http://localhost:4004
@cloud = https://your-cloud-url-here
### Read OData metadata
GET {{server}}/odata/v4/procurement/$metadata
### Query orders with selection, expansion, filtering, and sorting
GET {{server}}/odata/v4/procurement/PurchaseOrders?$select=ID,orderNumber,status,totalAmount,currency&$expand=supplier($select=name)&$filter=totalAmount%20gt%20500&$orderby=totalAmount%20desc
### Read an order and its composed items
GET {{server}}/odata/v4/procurement/PurchaseOrders/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa?$expand=supplier,items
### Create an invalid order before validation, then resend after validation
POST {{server}}/odata/v4/procurement/PurchaseOrders
Content-Type: application/json
{
"orderNumber": "PO-INVALID-1",
"supplier_ID": "11111111-1111-4111-8111-111111111111",
"status": "NEW",
"totalAmount": -25.00,
"currency": "USD"
}
### Query the deployed cloud service
GET {{cloud}}/odata/v4/procurement/PurchaseOrders?$expand=supplier,items
Your local service now models procurement data and proves its business rules through repeatable requests. Next, you will package this validated service for SAP BTP.
Deploy the Service to SAP BTP
Your purchase-order service now rejects invalid totals locally. It also rejects unsupported statuses.
The next goal is to make that validated API reachable from SAP BTP. Local SQLite only proves its behavior inside your development workspace.
An MTA packages the Node.js module for Cloud Foundry. An HDI container gives the data model a home in SAP HANA Cloud.
In this step, get ready to:
- Generate production artifacts for your CAP service.
- Deploy the MTA to Cloud Foundry with SAP HANA Cloud persistence.
- Call the live OData V4 endpoint from requests.http.
Build the production artifacts
The local watcher is using the development database. The production build prepares separate service artifacts for Cloud Foundry plus database artifacts for SAP HANA Cloud.
- Switch back to the terminal running cds watch in SAP Business Application Studio.
- Stop the watcher with Ctrl+C.
You'll see the terminal prompt return. Your local service has stopped cleanly.
Before you check, which Cloud Foundry organization and space do you expect to see?
- Confirm the deployment target by running this command:
cf target
What does this check prove?
- The output identifies the Cloud Foundry API endpoint receiving your deployment.
- The organization identifies the account boundary for the deployment.
- The space identifies where the application and HDI container are created.
You should see the intended API endpoint. You should also see the organization and space you selected earlier.
Seeing the wrong target?
Return to the Cloud Foundry sign-in flow from Step 1. Select the organization that contains your prepared space.
Choose the space with the running SAP HANA Cloud database. Confirm that the space has the required HDI entitlement before continuing.
Help me correct my Cloud Foundry target.
- Generate the production artifacts by running this command:
cds build --production
What does this build create?
- The command compiles your CDS model for the production database.
- The gen/srv folder contains the deployable service module.
- The gen/db folder contains the generated HANA deployment artifacts.
- Expand gen in the BAS Explorer.
You'll see both gen/srv and gen/db. These folders prove that the Node.js service and HANA deployer are ready for packaging.
Missing a generated folder?
If gen/srv is missing, confirm that srv/procurement-service.cds remains inside the srv folder.
If gen/db is missing, confirm that @cap-js/hana remains in package.json.
Help me diagnose my missing CAP production artifacts.
Deploy the MTA
The MTA coordinates the service module with its database deployer. One deployment flow can now provision persistence before staging the API.
Check your account's cost model
This deployment creates an application plus an HDI container. Eligible trial or free-tier resources can keep the project free.
A company-managed productive account can record consumption under its SAP BTP commercial agreement. Continue only with the plans approved for your selected subaccount.
A cloud deployment can take several minutes while the database artifacts deploy and the application stages. Pauses in terminal output are normal during these phases.
- Build and deploy the MTA by running this command:
cds up
What does this deployment do?
- The command builds the MTA described by mta.yaml.
- Cloud Foundry creates the purchase-order-service-db HDI container.
- The database deployer loads the CDS schema into SAP HANA Cloud.
- The database deployer also loads the three CSV seed files.
- Cloud Foundry stages the purchase-order-service-srv application and prints its URL.
That's the cloud deployment complete. Your validated service now runs beside its SAP HANA Cloud persistence layer.
- Record the application URL printed at the end of the deployment here: https://your-cloud-url-here.
Deployment did not finish?
Confirm that your terminal still targets the prepared Cloud Foundry space. A deployment to another space may lack the required service plan.
Confirm that the existing SAP HANA Cloud database is running. Also confirm that the selected subaccount still has the HDI entitlement from Step 1.
Help me troubleshoot my CAP deployment.
Verify the live OData service
The same request file can now compare the local address with the deployed address. The cloud query also expands suppliers and line items to prove that the relationships survived deployment.
- Select requests.http in the BAS Explorer.
- Find the line that starts with @cloud =.
- Replace https://your-cloud-url-here with https://your-cloud-url-here.
- Save requests.http.
✔️ Awesome, I've got everything!
Great. Your request file now points to the deployed application URL.
ⓧ I'd like to double check the full code
Compare your request structure with this reference. The @cloud placeholder represents the deployed URL you pasted.
@server = http://localhost:4004
@cloud = https://your-cloud-url-here
### Read OData metadata
GET {{server}}/odata/v4/procurement/$metadata
### Query orders with selection, expansion, filtering, and sorting
GET {{server}}/odata/v4/procurement/PurchaseOrders?$select=ID,orderNumber,status,totalAmount,currency&$expand=supplier($select=name)&$filter=totalAmount%20gt%20500&$orderby=totalAmount%20desc
### Read an order and its composed items
GET {{server}}/odata/v4/procurement/PurchaseOrders/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa?$expand=supplier,items
### Create an invalid order before validation, then resend after validation
POST {{server}}/odata/v4/procurement/PurchaseOrders
Content-Type: application/json
{
"orderNumber": "PO-INVALID-1",
"supplier_ID": "11111111-1111-4111-8111-111111111111",
"status": "NEW",
"totalAmount": -25.00,
"currency": "USD"
}
### Query the deployed cloud service
GET {{cloud}}/odata/v4/procurement/PurchaseOrders?$expand=supplier,items
How to compare this file
The local requests continue to use server. The final request uses cloud so the same model can be queried after deployment.
Before you send the cloud query, do you expect its seeded relationships to match the local response?
- Send this existing cloud request from requests.http:
### Query the deployed cloud service
GET {{cloud}}/odata/v4/procurement/PurchaseOrders?$expand=supplier,items
What does this request verify?
- The cloud variable sends the request to the deployed application.
- The endpoint reads purchase orders from the HDI container.
- The expansion includes each order's supplier and composed line items.
You'll see PO-1001 and PO-1002 in the response. Each order also includes its related supplier and items.
Cloud request not returning orders?
Check that the value after @cloud = contains the application URL printed by the deployment. Remove any path that you added after the base URL.
Confirm that the final request still ends with /odata/v4/procurement/PurchaseOrders?$expand=supplier,items.
Help me debug my deployed OData request.
- Change the invalid-order request's variable from server to cloud.
Before you resend it, do you expect the deployed service to accept the negative total?
- Send the invalid-order request with the same HTTP client.
You'll receive HTTP 400 with code INVALID_TOTAL and target totalAmount. This proves that the deployed validateOrder handler still enforces your business rules.
- Change the invalid-order request's variable back to server.
Before the final check, which two seeded order numbers should the browser response contain?
- Open a new browser tab.
- Enter https://your-cloud-url-here followed by /odata/v4/procurement/PurchaseOrders in the address bar.
You'll see a live OData response containing PO-1001 and PO-1002. Your purchase-order API is now running on SAP BTP with SAP HANA Cloud persistence.
You have taken the same domain model from a local SQLite loop to a live Cloud Foundry application. Its seeded data and structured validation now work from the deployed URL.
Secret mission
Prevent Lost Updates with ETags
Protect purchase orders from stale clients with ETag-based optimistic concurrency. Update PO-1001 once with its current ETag. Replay the old value to prove CAP rejects the overwrite.
Clean Up Your Resources
Trial or eligible free-tier resources can make this project free. A company-managed productive account can accrue usage charges according to its SAP BTP commercial agreement.
- Confirm the selected service plans before keeping the deployment.
- Choose the pause or delete option if you no longer need the deployed service.
Nice Work!
Nice Work!
Excellent work! Your purchase-order OData V4 service is live on SAP BTP.
What you learned:
- Built a CAP domain model that links suppliers to purchase orders through associations. Used a composition to keep line items inside each order.
- Exposed generated CRUD operations through an OData V4 service. Proved the business-rule gap by creating a negative-value order before closing it with structured CAP validation.
- Packaged the service as an MTA for Cloud Foundry. Deployed its data to an SAP HANA Cloud HDI container.
- Secret Mission: Added ETag-based optimistic concurrency. Replayed a stale If-Match request to reproduce HTTP 412 Precondition Failed.
Ready to quiz yourself?