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 .cds files into a CSN (Core Schema Notation) objects

  • Registers the definitions in the global cds.model

  • Mounts 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/getAvailableServices

Use the following to list the routes:

curl http://localhost:4004/admin/listRoutes

The 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

Popular Posts