Dynamically Loading Services in SAP CAP at Runtime
Introduction
Here we look at loading services after the server is already running. You can read the original article here.
CAP’s startup does three things you need to replicate at runtime:
It compiles
.cdsfiles into a CSN (Core Schema Notation) objectsRegisters the definitions in the global
cds.modelMounts the OData adapter onto the app
CAP exposes enough internal APIs to pull it off after the server has started.
Loading a Service
const csn = await cds.load([’custom/schema.cds’, ‘custom/service.cds’])
// deploy schema to database
const db = await cds.connect.to(’db’)
await cds.deploy(csn).to(db)
// merge into the live model
const linked = cds.linked(csn)
Object.assign(cds.model.definitions, linked.definitions)
// mount the OData adapter
await cds.serve(’BookService’).from(csn).in(cds.app)The not so obvious part is the Object.assign on cds.model.definitions. When a request hits /$metadata, CAP looks up the service in cds.model.definitions. If it’s not there, you get a weird crash:
TypeError: Cannot read properties of undefined (reading ‘metadataCache’)Unloading a Service
Unloading is trickier because you need to manually remove the routes relating to your service. You have to walk the router stack manually and splice out the layers:
function removePath(stack, targetPath) {
for (let i = stack.length - 1; i >= 0; i--) {
const layer = stack[i]
const p = layer.path || layer.handle?.path || layer.route?.path || ‘’
if (p === targetPath) { stack.splice(i, 1); continue }
if (Array.isArray(layer.handle?.stack)) removePath(layer.handle.stack, targetPath)
if (Array.isArray(layer.route?.stack)) removePath(layer.route.stack, targetPath)
}
}
We can then remove the routes and delete the service as follows:
removePath(cds.app.router.stack, service.path)
delete cds.services[’BookService’]
for (const key in cds.model.definitions) {
if (key === ‘BookService’ || key.startsWith(’BookService.’))
delete cds.model.definitions[key]
}Exposing this as Service
The cleanest way to expose this is as a CDS service itself:
service AdminService {
action loadService(paths: array of String) returns String;
action unloadService(serviceName: String) returns String;
function listRoutes() returns array of String;
function getAvailableServices() returns array of String;
}Here is the full implementation:
const path = require(’path’)
const cds = require(’@sap/cds’);
module.exports = cds.service.impl(async function() {
async function shutDownService(srvName) {
const srv = cds.services[srvName]
if (!srv) return false
if (typeof srv.close === ‘function’) await srv.close()
delete cds.services[srvName]
if (cds.model && cds.model.definitions) {
for (const key in cds.model.definitions) {
if (key.startsWith(srvName)) {
delete cds.model.definitions[key]
}
}
}
if (cds.services.handler) {
delete cds.services.handler[srvName]
}
return true
}
function listPaths(stack, prefix = ‘’, result = new Set()) {
for (const layer of stack || []) {
const p = layer.path || layer.handle?.path || layer.route?.path || ‘’
const full = p ? (prefix + p) : prefix
if (full && full !== prefix) result.add(full)
if (Array.isArray(layer.handle?.stack)) listPaths(layer.handle.stack, full || prefix, result)
if (Array.isArray(layer.route?.stack)) listPaths(layer.route.stack, full || prefix, result)
}
return [...result]
}
function removePath(stack, targetPath) {
for (let i = stack.length - 1; i >= 0; i--) {
const layer = stack[i]
const p = layer.path || layer.handle?.path || layer.route?.path || ‘’
if (p === targetPath) {
stack.splice(i, 1)
continue
}
if (Array.isArray(layer.handle?.stack)) removePath(layer.handle.stack, targetPath)
if (Array.isArray(layer.route?.stack)) removePath(layer.route.stack, targetPath)
}
}
this.on(’listRoutes’, async (req) => {
let routes=listPaths(cds.app.router.stack);
return routes;
})
this.on(’removeRoute’, ({ data: { path } }) => {
const before = listPaths(cds.app.router.stack).length
removePath(cds.app.router.stack, path)
const after = listPaths(cds.app.router.stack).length
return before !== after ? `Removed: ${path}` : `Not found: ${path}`
})
this.on(’loadService’, async (req) => {
const { paths } = req.data
if (!paths?.length)
return req.error(400, ‘paths array is required’)
const resolvedPaths = paths.map(p => path.resolve(__dirname, ‘..’, p))
let csn
try {
csn = await cds.load(resolvedPaths)
} catch (e) {
return req.error(400, `Failed to load CDS files: ${e.message}`)
}
let db
try {
db = await cds.connect.to(’db’)
} catch (e) {
return req.error(500, `Failed to connect to DB: ${e.message}`)
}
try {
await cds.deploy(csn).to(db)
} catch (e) {
return req.error(500, `Failed to deploy to DB: ${e.message}`)
}
const linked = cds.linked(csn)
Object.assign(cds.model.definitions, linked.definitions)
const serviceNames = Object.values(linked.definitions)
.filter(d => d.kind === ‘service’)
.map(d => d.name)
if (!serviceNames.length)
return req.error(400, ‘No services found in provided CDS files’)
for (const name of serviceNames) {
try {
await cds.serve(name).from(csn).in(cds.app)
} catch (e) {
return req.error(500, `Failed to serve ${name}: ${e.message}`)
}
}
return `Loaded services: ${serviceNames.join(’, ‘)}`
})
this.on(’unloadService’, async (req) => {
const { serviceName } = req.data
if(serviceName ==null){
return req.error(500, `serviceName not provided`);
}
const service=cds.services[serviceName];
if(service===null){
return req.error(500, `service not found`);
}
const path=service.path
const before = listPaths(cds.app.router.stack).length
removePath(cds.app.router.stack, path)
const after = listPaths(cds.app.router.stack).length
delete cds.services[serviceName]
for (const key in cds.model.definitions) {
if (key.startsWith(serviceName+ ‘.’) || key === serviceName) {
delete cds.model.definitions[key]
}
}
return”unload done”;
});
this.on(’getAvailableServices’, () => {
return Object.keys(cds.services)
.filter(srv => ![’db’, ‘messaging’, ‘outbox’, ‘error-log’].includes(srv))
.map(name => cds.services[name].name)
})
});
Testing
You can now test this by first setting up custom/schema.cds as follows:
namespace my.test;
using { managed } from ‘@sap/cds/common’;
entity Test : managed {
key ID : UUID;
value : String(100);
a: String(100);
b: String(100);
c: String(100);
}Also create custom/service.cds as follows:
using my.test from ‘./schema’;
service BookService {
entity Test as projection on test.Test;
}Now, with the server started, you can use the following to load in the above created files:
curl \
-X POST \
http://localhost:4004/admin/loadService \
-H “Content-Type: application/json” \
--data ‘{
“paths”: [”custom/schema.cds”,
“custom/service.cds”]
}’The new service should now be available. To unload the service, run the following:
curl \
-X POST \
http://localhost:4004/admin/unloadService \
-H “Content-Type: application/json” \
--data ‘{”serviceName”:”BookService”}’Extra functionality to verify loading and unloading of service are also provided. Use the following to list the services:
curl http://localhost:4004/admin/getAvailableServicesUse the following to list the routes:
curl http://localhost:4004/admin/listRoutesThe results should vary as you load and unload services. You can also verify that the schemas loaded in properly by inspecting the database. Changes made to entities should be reflected in the table structures.


Comments
Post a Comment