Skip to content

fix: suppress $batch path for REST services - #194

Open
daogrady wants to merge 7 commits into
mainfrom
fix/rest-batch
Open

daogrady wants to merge 7 commits into
mainfrom
fix/rest-batch

Conversation

@daogrady

@daogrady daogrady commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #192

The field is specifc for odata and should therefore not be produced for other protocols (REST, specifically):
https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part1-protocol/odata-v4.0-errata03-os-part1-protocol-complete.html#_Toc453752313

@daogrady daogrady changed the title fix: suppress $batch path for REST services fix: suppress $batch path for REST services Sep 23, 2026
@daogrady
daogrady requested a review from tim-sh September 29, 2026 07:23

@tim-sh tim-sh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me.

I just found one gap: in REST-collection GETs we still have the OData query options $filter, $top, $skip, $select, $count, $search, $orderby, whereas CAP's REST adapter doesn't support them. So docs still show unsupported features (as with /$batch).

@daogrady

Copy link
Copy Markdown
Contributor Author

Fair observation. I adjusted the output accordingly.

@daogrady
daogrady requested a review from tim-sh September 30, 2026 13:27

@tim-sh tim-sh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

$batch suppression is correct (returns 404 on POST and GET; confirmed with a live REST service).

But I made a mistake earlier in terms of the query-option suppression (statement wasn't verified at runtime). The REST adapter does support all eight options, so dropping them removes valid docs. Let's keep only the $batch change and revert the query options (i.e. the second CHANGELOG bullet, the isODataProtocol guard, and the associated test).

This repro checks both cases in one go:

repro.mjs – Node, no deps, run with node repro.mjs
#!/usr/bin/env node
// PR #194 repro: real CAP REST service on in-memory sqlite. Asserts /$batch -> 404
// (claim correct) and that all eight OData query options work end-to-end (claim wrong).
import { spawn } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import net from 'node:net';

const freePort = () =>
  new Promise((resolve, reject) => {
    const srv = net.createServer();
    srv.on('error', reject);
    srv.listen(0, () => {
      const { port } = srv.address();
      srv.close(() => resolve(port));
    });
  });

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function killTree(child) {
  if (!child || child.exitCode !== null || child.signalCode !== null) return;
  if (process.platform === 'win32') {
    spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
  } else {
    try {
      process.kill(-child.pid, 'SIGTERM');
    } catch {
      /* group already gone */
    }
  }
}

async function main() {
  const app = await mkdtemp(join(tmpdir(), 'claim-repro-'));
  const port = await freePort();
  let server;
  const cleanup = async () => {
    killTree(server);
    await rm(app, { recursive: true, force: true });
  };
  process.on('exit', () => killTree(server));

  try {
    await mkdir(join(app, 'db', 'data'), { recursive: true });
    await mkdir(join(app, 'srv'), { recursive: true });

    await writeFile(
      join(app, 'db', 'schema.cds'),
      `namespace my;
entity Authors { key ID : Integer; name : String;
  books : Association to many Books on books.author = $self; }
entity Books { key ID : Integer; title : String; stock : Integer;
  author : Association to Authors; }
`,
    );
    await writeFile(
      join(app, 'srv', 'cat.cds'),
      `using my from '../db/schema';
@path: '/rest/books'
@protocol: 'rest'
service RestService {
  entity Books   as projection on my.Books;
  entity Authors as projection on my.Authors;
}
`,
    );
    await writeFile(join(app, 'db', 'data', 'my-Authors.csv'), 'ID,name\n1,Austen\n2,Tolkien\n');
    await writeFile(
      join(app, 'db', 'data', 'my-Books.csv'),
      'ID,title,stock,author_ID\n1,Alpha,10,1\n2,Beta,20,2\n3,Gamma,30,1\n',
    );
    await writeFile(
      join(app, '.cdsrc.json'),
      JSON.stringify({ requires: { db: { kind: 'sqlite', credentials: { url: ':memory:' } } } }),
    );

    console.log(`## building & starting REST service on :${port} (cds-dk 10.x) ...`);
    server = spawn('npx', ['cds', 'run', '--port', String(port), '--in-memory'], {
      cwd: app,
      detached: process.platform !== 'win32',
      stdio: 'ignore',
    });

    const base = `http://localhost:${port}/rest/books`;
    const ready = async () => {
      try {
        const r = await fetch(`${base}/Books`);
        return (await r.text()).includes('Alpha');
      } catch {
        return false;
      }
    };
    let up = false;
    for (let i = 0; i < 60 && !(up = await ready()); i++) await sleep(500);
    if (!up) {
      console.error(`ABORT: data not available at ${base}/Books – server did not deploy cleanly`);
      process.exitCode = 2;
      return;
    }

    let pass = 0;
    let fail = 0;
    const get = async (q) => (await fetch(`${base}/Books${q}`)).text();
    const check = (label, needle, got) => {
      if (got.includes(needle)) {
        console.log(`PASS  ${label}`);
        pass++;
      } else {
        console.log(`FAIL  ${label}  (expected to contain: ${needle} | got: ${got})`);
        fail++;
      }
    };

    console.log('\n## Claim A: /$batch returns 404 on a REST service');
    const post = await fetch(`${base}/$batch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: '{}',
    });
    const batchGet = await fetch(`${base}/$batch`);
    check('$batch POST -> 404', '404', String(post.status));
    check('$batch GET  -> 404', '404', String(batchGet.status));

    console.log('\n## Claim B: collection GET honours all eight OData query options');
    check('$top=2', '"ID":1', await get('?$top=2'));
    check('$top=2 excludes 3rd', 'ok', (await get('?$top=2')).includes('Gamma') ? 'HASGAMMA' : 'ok');
    check('$skip=1 drops Alpha', 'Beta', await get('?$skip=1'));
    check('$filter stock gt 15', 'Beta', await get('?$filter=stock%20gt%2015'));
    check('$filter excludes Alpha', 'ok', (await get('?$filter=stock%20gt%2015')).includes('Alpha') ? 'HAS' : 'ok');
    check('$select=title', '"title":"Alpha"', await get('?$select=title'));
    check('$orderby=stock desc', '"title":"Gamma"', await get('?$orderby=stock%20desc'));
    check('$count=true', '"count":3', await get('?$count=true'));
    check('$search=Alpha', 'Alpha', await get('?$search=Alpha'));
    check('$expand=author', '"name":"Austen"', await get('?$expand=author'));
    check('$expand nested $select', '"author":{"name"', await get('?$expand=author($select=name)&$top=1'));
    check('combined filter+ob+top', '"title":"Gamma"', await get('?$filter=stock%20gt%205&$orderby=stock%20desc&$top=2'));

    console.log(`\n## result: ${pass} passed, ${fail} failed  (cds-dk 10.x, runtime via npx cds)`);
    process.exitCode = fail === 0 ? 0 : 1;
  } finally {
    await cleanup();
  }
}

main();

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Compiler generates $batch request for REST service

2 participants