Conversation
$batch path for REST services
tim-sh
left a comment
There was a problem hiding this comment.
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).
|
Fair observation. I adjusted the output accordingly. |
tim-sh
left a comment
There was a problem hiding this comment.
$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();
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