Skip to content

feat: support tenant-aware ORD for extensible multitenant apps#391

Open
zongqichen wants to merge 3 commits intomainfrom
feat/tenant-aware-ord
Open

feat: support tenant-aware ORD for extensible multitenant apps#391
zongqichen wants to merge 3 commits intomainfrom
feat/tenant-aware-ord

Conversation

@zongqichen
Copy link
Contributor

Summary

  • ORD routes now go through cds.middlewares.before/after so that cds.context.tenant and cds.context.model are populated per request
  • When a tenant has extensions (via cds push) or feature toggles, the ORD document and metadata (OpenAPI, EDMX) reflect the tenant-specific model
  • Config endpoint (/.well-known/open-resource-discovery) remains outside middleware per ORD spec

Closes #389

Wrap ORD routes with cds.middlewares.before/after so that
cds.context.tenant and cds.context.model are populated per request.
This enables tenant-specific CSN (from extensibility/toggles) to be
used for ORD document and metadata generation.

Follows the same pattern as @cap-js/graphql.

Closes #389
Previously cds.middlewares.before/after was mounted on "/" which caused
all HTTP requests (including SaaS provisioning, OData) to go through
CDS middlewares twice, resulting in "Cannot redefine property: authInfo".

Now mounted on "/ord" so only ORD routes go through the tenant-aware
middleware stack. Router paths adjusted to be relative (/v1/... instead
of /ord/v1/...) since Express strips the mount prefix.

External URLs remain unchanged: /ord/v1/documents/ord-document
@hyperspace-insights
Copy link
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Support Tenant-Aware ORD for Extensible Multitenant CAP Applications

New Feature

✨ ORD endpoints now support tenant-aware model resolution for extensible multitenant CAP applications. When a tenant has model extensions (via cds push) or feature toggles, the ORD document and all metadata responses (OpenAPI, EDMX, CSN) will reflect the tenant-specific model rather than the global base model.

This is achieved by routing ORD requests through cds.middlewares.before/after, which populates cds.context.tenant and cds.context.model per request — consistent with how other CDS plugins (e.g. @cap-js/graphql) handle tenant awareness.

Note: The ORD config endpoint (/.well-known/open-resource-discovery) intentionally remains outside the middleware stack per the ORD specification, as it contains no tenant-specific information.

Changes

  • lib/ord-service.js: Refactored route registration to use an Express Router instead of direct cds.app.get() calls for all tenant-aware endpoints. The router is mounted via cds.app.use("/ord", cds.middlewares.before, router, cds.middlewares.after) so cds.context.tenant and cds.context.model are populated on every request. The ORD document handler and metadata handler now resolve the model using cds.context?.model || cds.model. Only the config endpoint remains on cds.app directly.

  • lib/metaData.js: Updated model resolution in getMetadata to fall back to cds.context?.model before cds.services[serviceName]?.model, ensuring tenant-specific CSN is used when available.

  • __tests__/unit/mtx-tenant-aware.test.js: Added a new test suite verifying the tenant-aware behavior — including middleware integration, correct model resolution in the ORD document handler and metadata handler, and that the ORD document reflects tenant-extended models.

Jira Issues

  • #389: Support tenant-aware ORD for extensible multitenant CAP applications

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.18.5 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback


💌 Have ideas or want to contribute? Create an issue and share your thoughts with us!
📑 Check out the documentation for more information.
📬 Subscribe to the Hyperspace PR Bot DL to get the latest announcements and pilot features!

Made with ❤️ by Hyperspace.

Copy link
Contributor

@hyperspace-insights hyperspace-insights bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The production change in lib/ord-service.js is sound in intent, but the new test file has several correctness issues: the jest.mock is called inside a test callback (never hoisted, therefore ineffective), test 4 doesn't actually exercise the production cds.context.model read path (it manually resolves the model before passing it in), and tests 2–3 rely on fragile source-text string matching rather than behavioural assertions. Additionally, lib/ord-service.js lacks a guard for cds.middlewares being undefined, which would cause a startup crash in non-MTX or older CDS environments.

PR Bot Information

Version: 1.18.5 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback

  • Correlation ID: ffa85910-1e3c-11f1-91ef-e0e746f7c3b4
  • LLM: anthropic--claude-4.6-sonnet
  • Agent Instructions:
  • Event Trigger: pull_request.opened

Comment on lines +14 to +26
const { getMetadata } = require("../../lib/index");
const { compile: openapi } = require("@cap-js/openapi");

// Provide a context model (simulates MTX-populated tenant model)
const contextModel = {
definitions: {
TestService: {},
},
};
const savedContext = cds.context;
cds.context = { model: contextModel };

jest.mock("@cap-js/openapi", () => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: jest.mock called after module is already required, making the mock ineffective

jest.mock("@cap-js/openapi", ...) is called at line 26, but require("@cap-js/openapi") at line 15 has already loaded the real module. Jest hoists jest.mock calls to the top of the file only when they appear at the top level of the module scope—not when nested inside a test() callback. Inside a callback, the mock is applied after the module cache is already populated, so the real openapi implementation runs instead of the mock.

The test comment at line 34 ("We only verify the call did not throw and returned a result") hints that the author already acknowledged the mock may not be working as intended—confirming the test only provides very weak coverage.

Should restructure: move the jest.mock("@cap-js/openapi", ...) call to the top level (before any describe/test), or use jest.spyOn inside the test to replace the already-imported openapi function on its module object, similar to how metaData.test.js does it at line 30 (openapi.mockImplementation(...)).

Suggested change
const { getMetadata } = require("../../lib/index");
const { compile: openapi } = require("@cap-js/openapi");
// Provide a context model (simulates MTX-populated tenant model)
const contextModel = {
definitions: {
TestService: {},
},
};
const savedContext = cds.context;
cds.context = { model: contextModel };
jest.mock("@cap-js/openapi", () => ({
const { getMetadata } = require("../../lib/index");
const { compile: openapi } = require("@cap-js/openapi");
// Provide a context model (simulates MTX-populated tenant model)
const contextModel = {
definitions: {
TestService: {},
},
};
const savedContext = cds.context;
cds.context = { model: contextModel };
openapi.mockReturnValue("openapi-from-context-model");

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

expect(result.contentType).toBe("application/json");
} finally {
cds.context = savedContext;
jest.unmock("@cap-js/openapi");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: jest.unmock cannot undo an in-callback jest.mock and leaves mock state dirty

jest.unmock scheduled in finally does not actually reset an in-callback jest.mock. Module mocks set at runtime (not hoisted) stay in the module registry for the rest of the test suite. If the suggestion above (switching to mockReturnValue) is applied, this cleanup line should be removed—jest.clearAllMocks() or mockReset() in an afterEach is the right approach for spy-based mocks.

Suggested change
jest.unmock("@cap-js/openapi");
cds.context = savedContext;

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +117 to +120
// With context model set: ord() should use the extended model
cds.context = { model: extendedModel };
const csn = cds.context?.model || cds.model;
const tenantDocument = ord(csn);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: Test 4 does not actually assert that cds.context.model is used over cds.model

The test manually computes const csn = cds.context?.model || cds.model at line 119 and then passes csn directly into ord(csn) at line 120. This means the test is calling ord() with the extended model as an explicit argument—it does not verify that ord() itself (or the service handler) reads from cds.context.model. The test would pass even if nothing in the production code ever reads cds.context.

To meaningfully validate the tenant-aware path, the test should call ord() with no arguments (or leave cds.model pointing at baseModel) and check the returned document reflects the extended model—relying on the production code to pull cds.context.model internally.

Suggested change
// With context model set: ord() should use the extended model
cds.context = { model: extendedModel };
const csn = cds.context?.model || cds.model;
const tenantDocument = ord(csn);
// With context model set: ord() should use the extended model.
// Set cds.model to baseModel so the only way to get the extended
// model is via cds.context.model (which the production code reads).
cds.model = baseModel;
cds.context = { model: extendedModel };
// Call ord() without an explicit model to exercise the context path
const tenantDocument = ord();

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +44 to +76
describe("2. ord-service.js ORD document handler reads cds.context.model", () => {
test("ord() is called with cds.context.model when available", async () => {
const ordServiceSource = fs.readFileSync(path.join(__dirname, "../../lib/ord-service.js"), "utf8");

// The handler must use cds.context?.model || cds.model (not just cds.model)
expect(ordServiceSource).toContain("cds.context?.model || cds.model");
});
});

describe("3. ord-service.js uses CDS middlewares for tenant context", () => {
test("ord-service.js mounts routes through cds.middlewares (not directly on cds.app.get)", () => {
const ordServiceSource = fs.readFileSync(path.join(__dirname, "../../lib/ord-service.js"), "utf8");

// Must use cds.middlewares.before to populate cds.context.tenant/model
expect(ordServiceSource).toContain("cds.middlewares.before");
expect(ordServiceSource).toContain("cds.middlewares.after");

// The tenant-aware routes (ORD document + metadata) must NOT be registered
// directly via cds.app.get — they should go through the Router + middleware stack.
// Only the config endpoint (this.path) is allowed to be on cds.app directly.
const appGetCalls = [...ordServiceSource.matchAll(/cds\.app\.get\(/g)];
// Exactly one cds.app.get call: the config endpoint (this.path)
expect(appGetCalls).toHaveLength(1);
expect(ordServiceSource).toMatch(/cds\.app\.get\(`\$\{this\.path\}`/);

// The ORD document endpoint must be on the router, not cds.app
expect(ordServiceSource).toContain("router.get(`/v1/documents/ord-document`");

// The router must be mounted on /ord with CDS middlewares
expect(ordServiceSource).toContain(
'cds.app.use("/ord", cds.middlewares.before, router, cds.middlewares.after)',
);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: Source-text inspection in tests 2 and 3 are brittle regression guards, not behavioral tests

Tests 2 and 3 grep the raw source of ord-service.js for specific string literals (e.g. "cds.context?.model || cds.model", "cds.middlewares.before", exact mount string). This approach:

  1. Breaks on any equivalent refactor (e.g. extracting a helper, whitespace change, renaming a variable) without any change in behaviour.
  2. Passes even if the code is present but dead (guarded by a never-true condition).
  3. Does not guard against cds.middlewares being undefined at runtime—the middleware mount would silently fail or throw.

Should replace with behavioral tests: spin up a minimal Express app, mount the service, and assert that routes respond correctly with and without cds.context.model set—similar to the integration tests already present in __tests__/integration/.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

router.get(`/v1`, authMiddleware, metadataHandler);

// Mount the router with CDS middlewares so tenant/model context is set
cds.app.use("/ord", cds.middlewares.before, router, cds.middlewares.after);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: cds.middlewares may be undefined in non-MTX / older CDS environments

cds.middlewares.before and cds.middlewares.after are only available when the CDS framework has initialised its middleware stack (typically in CAP 7+ with MTX). In environments where cds.middlewares is undefined (e.g. older CDS versions, unit-test environments, or apps that opt out of the middleware stack), this line will throw TypeError: Cannot read properties of undefined (reading 'before') and the entire service will fail to start.

Consider guarding the mount:

if (cds.middlewares) {
    cds.app.use("/ord", cds.middlewares.before, router, cds.middlewares.after);
} else {
    cds.app.use("/ord", router);
}
Suggested change
cds.app.use("/ord", cds.middlewares.before, router, cds.middlewares.after);
// Mount the router with CDS middlewares so tenant/model context is set
if (cds.middlewares) {
cds.app.use("/ord", cds.middlewares.before, router, cds.middlewares.after);
} else {
cds.app.use("/ord", router);
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support tenant-aware ORD for extensible multitenant CAP applications

1 participant