Skip to content

lij15/my-action

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SAP CAP — Actions & Functions (Bound / Unbound)

CAP provides four ways to define custom operations beyond standard CRUD.


The Four Types

Type HTTP URL pattern Use case
Unbound action POST /submitOrder Write operation, not tied to a specific record
Bound action POST /Books(key)/publish Write operation on a specific record
Unbound function GET /getStats() Read-only query, not tied to a specific record
Bound function GET /Books(key)/getDiscountPrice() Read-only query on a specific record

Two rules:

  • action = write operation (POST) — modifies data
  • function = read-only (GET) — never modifies data
  • bound = URL contains the record key
  • unbound = called directly without a key

Project Structure

my-actions/
├── db/
│   ├── schema.cds
│   └── data/
│       └── my.actions-Books.csv
├── srv/
│   ├── cat-service.cds    ← declarations
│   └── cat-service.js     ← implementations
└── package.json

Data Model

db/schema.cds

namespace my.actions;
using { cuid, managed } from '@sap/cds/common';

entity Books : cuid, managed {
  title     : String(200);
  price     : Decimal(9, 2);
  stock     : Integer default 0;
  published : Boolean default false;
  discount  : Decimal(4, 2) default 0;
}

entity Orders : cuid {
  book   : Association to Books;
  amount : Integer;
  total  : Decimal(9, 2);
}

db/data/my.actions-Books.csv

ID,title,price,stock,published,discount
b0000001-0000-0000-0000-000000000001,Clean Code,38.00,10,false,0
b0000001-0000-0000-0000-000000000002,The Pragmatic Programmer,45.00,5,false,0
b0000001-0000-0000-0000-000000000003,Domain-Driven Design,50.00,0,true,10

Service Definition

srv/cat-service.cds

using my.actions as db from '../db/schema';

service CatalogService {

  // Bound actions and functions go inside the same actions {} block
  // Do NOT declare entity Books twice — actions{} and functions{} cannot be separate blocks
  entity Books as projection on db.Books
  actions {
    // Bound action: POST /Books(key)/publish
    action publish() returns { message : String; };

    // Bound action: POST /Books(key)/applyDiscount
    action applyDiscount(percent : Decimal(4,2)) returns { newPrice : Decimal(9,2); };

    // Bound function: GET /Books(key)/getDiscountPrice()
    function getDiscountPrice() returns {
      originalPrice  : Decimal(9,2);
      discountPrice  : Decimal(9,2);
      discountAmount : Decimal(9,2);
    };
  };

  entity Orders as projection on db.Orders;

  // Unbound action: POST /submitOrder
  action submitOrder(
    bookID : UUID,
    amount : Integer
  ) returns {
    orderID : UUID;
    message : String;
    total   : Decimal(9,2);
  };

  // Unbound function: GET /getStats()
  function getStats() returns {
    totalBooks  : Integer;
    totalOrders : Integer;
    outOfStock  : Integer;
  };
}

Service Implementation

srv/cat-service.js

const cds = require('@sap/cds')

module.exports = class CatalogService extends cds.ApplicationService {
  async init() {
    const { Books, Orders } = this.entities

    // ── Unbound Action ──────────────────────────────────────────
    // this.on('actionName', handler)
    // Parameters come from req.data
    this.on('submitOrder', async req => {
      const { bookID, amount } = req.data

      const book = await SELECT.one.from(Books).where({ ID: bookID })
      if (!book)               return req.error(404, 'Book not found')
      if (book.stock == null)  return req.error(500, 'Stock data is invalid')
      if (book.stock < amount) return req.error(409, `Insufficient stock, available: ${book.stock}`)

      const unitPrice = book.discount > 0
        ? +(book.price * (1 - book.discount / 100)).toFixed(2)
        : book.price
      const total = +(unitPrice * amount).toFixed(2)

      await UPDATE(Books).set({ stock: book.stock - amount }).where({ ID: bookID })

      const orderID = cds.utils.uuid()
      await INSERT.into(Orders).entries({ ID: orderID, book_ID: bookID, amount, total })

      return { orderID, message: `Order placed for "${book.title}" x${amount}`, total }
    })

    // ── Unbound Function ────────────────────────────────────────
    this.on('getStats', async req => {
      const [bookCount]  = await SELECT`count(*) as cnt`.from(Books)
      const [orderCount] = await SELECT`count(*) as cnt`.from(Orders)
      const [outOfStock] = await SELECT`count(*) as cnt`.from(Books).where({ stock: 0 })

      return {
        totalBooks:  bookCount.cnt,
        totalOrders: orderCount.cnt,
        outOfStock:  outOfStock.cnt
      }
    })

    // ── Bound Action ────────────────────────────────────────────
    // this.on('actionName', Entity, handler)
    // The key of the bound record comes from req.params[0]
    this.on('publish', Books, async req => {
      const id = req.params[0].ID

      const book = await SELECT.one.from(Books).where({ ID: id })
      if (!book)          return req.error(404, 'Book not found')
      if (book.published) return req.error(409, 'Book is already published')

      await UPDATE(Books).set({ published: true }).where({ ID: id })

      return { message: `"${book.title}" has been published` }
    })

    this.on('applyDiscount', Books, async req => {
      const id = req.params[0].ID
      const { percent } = req.data   // bound action parameters from req.data

      if (percent <= 0 || percent >= 100) {
        return req.error(400, 'Discount must be between 1 and 99')
      }

      const book = await SELECT.one.from(Books).where({ ID: id })
      if (!book) return req.error(404, 'Book not found')

      await UPDATE(Books).set({ discount: percent }).where({ ID: id })

      const newPrice = +(book.price * (1 - percent / 100)).toFixed(2)
      return { newPrice }
    })

    // ── Bound Function ──────────────────────────────────────────
    this.on('getDiscountPrice', Books, async req => {
      const id = req.params[0].ID

      const book = await SELECT.one.from(Books).where({ ID: id })
      if (!book) return req.error(404, 'Book not found')

      const discountAmount = +(book.price * book.discount / 100).toFixed(2)
      const discountPrice  = +(book.price - discountAmount).toFixed(2)

      return { originalPrice: book.price, discountPrice, discountAmount }
    })

    return super.init()
  }
}

Running the Project

cds watch

HTTP Request Examples

Unbound action — place an order

POST /odata/v4/catalog/submitOrder
Content-Type: application/json

{
  "bookID": "b0000001-0000-0000-0000-000000000001",
  "amount": 2
}

Unbound function — get statistics

GET /odata/v4/catalog/getStats()

Bound action — publish a specific book

POST /odata/v4/catalog/Books(ID=b0000001-0000-0000-0000-000000000001)/publish
Content-Type: application/json
{}

Bound action — apply a discount to a specific book

POST /odata/v4/catalog/Books(ID=b0000001-0000-0000-0000-000000000002)/applyDiscount
Content-Type: application/json

{ "percent": 20 }

Bound function — get discount price for a specific book

GET /odata/v4/catalog/Books(ID=b0000001-0000-0000-0000-000000000002)/getDiscountPrice()

Trigger error — insufficient stock

POST /odata/v4/catalog/submitOrder
Content-Type: application/json

{
  "bookID": "b0000001-0000-0000-0000-000000000001",
  "amount": 999
}
# → 409 Insufficient stock

Key Differences: Unbound vs Bound

Unbound Bound
CDS declaration location Top level inside service {} Inside entity Books actions {}
URL format /submitOrder /Books(key)/publish
Record key in JS req.data (passed as parameter) req.params[0].ID
Action parameters in JS req.data req.data
Typical use case Global operations, batch processing Operations on a single specific record

Gotchas

actions {} and functions {} cannot be two separate blocks on the same entity

// ❌ Causes "Duplicate definition" error
entity Books as projection on db.Books actions { ... };
entity Books functions { ... };

// ✅ Put everything in one actions {} block
entity Books as projection on db.Books
actions {
  action publish() returns { ... };
  function getDiscountPrice() returns { ... };
};

Bound handler signature requires the entity as second argument

// ❌ Wrong — treated as unbound
this.on('publish', async req => { ... })

// ✅ Correct — entity as second argument makes it bound
this.on('publish', Books, async req => { ... })

Bound record key comes from req.params, not req.data

// ❌ Wrong
const id = req.data.ID

// ✅ Correct
const id = req.params[0].ID

Functions must be called with () in the URL

# ❌ 404 Not Found
GET /odata/v4/catalog/getStats

# ✅ Correct
GET /odata/v4/catalog/getStats()
GET /odata/v4/catalog/Books(key)/getDiscountPrice()

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors