openvibers/openvibe.trade Commits
Move Trade's error helpers onto openvibe-sdk/service (#3)
Changes
@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},@@@ -1181,9 +1181,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.25.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",-- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",++ "version": "0.26.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",++ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==","license": "MIT","engines": {"node": ">=20"@@@ -29,7 +29,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},@@@ -2,64 +2,26 @@/*** Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),-- * and the small request helpers every router shares.++ * and the small request helpers every router shares. Built from openvibe-sdk/service (docs/service.md,++ * the Blog row) so every service answers the same way; the exports stay put so no call site moves.*/--const express = require('express');--const contracts = require('openvibe-contracts');--const cache = require('openvibe-shared/cache-policy');++const svc = require('openvibe-sdk/service');--/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */--class ApiError extends Error {-- constructor(status, code, detail, extra) {-- super(detail || code);-- this.name = 'ApiError';-- this.status = status;-- this.code = code;-- this.extra = extra || null;-- }--}++/** A refusal with a stable problem code (e.g. 404 'instrument.not_found'). */++const ApiError = svc.createServiceError('ApiError');++++const o = { name: 'Trade API', publishing: true, ServiceError: ApiError };/** Publishing package errors carry { status, code } too. */--function asApiError(err) {-- if (err instanceof ApiError) return err;-- if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {-- const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;-- return new ApiError(err.status, err.code, err.message, extra);-- }-- if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {-- return new ApiError(422, 'request.invalid', err.message);-- }-- return null;--}++const asApiError = (err) => svc.asServiceError(err, o);/** Wrap a JSON handler: its return value is the body; errors become problems. */--function run(fn, status = 200) {-- return async (req, res) => {-- try {-- const out = await fn(req, res);-- if (out === undefined || res.headersSent) return;-- res.status(typeof status === 'function' ? status(out) : status).json(out);-- } catch (err) {-- if (res.headersSent) return;-- const e = asApiError(err);-- if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });-- console.error('[Trade API]', err && err.stack ? err.stack : err);-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });-- }-- };--}++const run = (fn, status) => svc.run(fn, status, o);--const jsonParser = express.json({ limit: '512kb' });--/** JSON body parser whose syntax errors are problems too. */--function jsonBody(req, res, next) {-- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));--}++/** JSON body parser whose failures are problems too: malformed 400, over the limit 413. */++const jsonBody = svc.jsonBody();/** Private, per-viewer responses: never stored by a shared cache, never indexed. */--function privateNoStore(res) {-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- res.vary('Cookie');-- res.vary('Authorization');--}++const privateNoStore = svc.privateNoStore;module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * server/http/errors.js now sits on openvibe-sdk/service (plan T1): the exports stay put, so these pin++ * the behaviour the hand-rolled helpers promised. The shared JSON body parser answers 413++ * request.too_large for a body over the limit (the hand-rolled one folded that into 400++ * request.invalid_json), malformed JSON is still 400 request.invalid_json, and an ApiError carrying++ * `extra` still answers its status/code/detail with extra spread into the problem body.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { ApiError, asApiError, run } = require('../server/http/errors');++++(async () => {++ const t = await boot();++ const svc = t.network.serviceToken('tools', ['trade.watchlist.create']);++++ await check('a JSON body over 512 kB is 413 request.too_large, not 400 request.invalid_json', async () => {++ const r = await t.get('/api/v1/watchlists', { as: svc, json: { name: 'Big', padding: 'y'.repeat(600 * 1024) } });++ assert.strictEqual(r.status, 413, r.text);++ assert.strictEqual(r.json().code, 'request.too_large');++ });++++ await check('a malformed JSON body is still 400 request.invalid_json', async () => {++ const r = await t.get('/api/v1/watchlists', { as: svc, body: '{"name":', headers: { 'content-type': 'application/json' } });++ assert.strictEqual(r.status, 400, r.text);++ assert.strictEqual(r.json().code, 'request.invalid_json');++ });++++ await check('an ApiError with extra answers its status/code/detail and spreads extra', async () => {++ const err = new ApiError(409, 'watchlist.revision_conflict', 'The watchlist changed', { expected: 3, current: 4 });++ assert.strictEqual(asApiError(err), err, 'a branded ApiError maps to itself');++++ const res = { statusCode: 0, headers: {}, body: '', headersSent: false, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; } };++ await run(async () => { throw err; })({}, res);++++ assert.strictEqual(res.statusCode, 409);++ const b = JSON.parse(res.body);++ assert.strictEqual(b.status, 409);++ assert.strictEqual(b.code, 'watchlist.revision_conflict');++ assert.strictEqual(b.detail, 'The watchlist changed');++ assert.deepStrictEqual({ expected: b.expected, current: b.current }, { expected: 3, current: 4 });++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1181,9 +1181,9 @@
@@ -1181,9 +1181,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.25.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
+ "version": "0.26.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
+ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -2,64 +2,26 @@
@@ -2,64 +2,26 @@
/**
/**
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
- * and the small request helpers every router shares.
+ * and the small request helpers every router shares. Built from openvibe-sdk/service (docs/service.md,
+ * the Blog row) so every service answers the same way; the exports stay put so no call site moves.
*/
*/
-const express = require('express');
-const contracts = require('openvibe-contracts');
-const cache = require('openvibe-shared/cache-policy');
+const svc = require('openvibe-sdk/service');
-/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */
-class ApiError extends Error {
- constructor(status, code, detail, extra) {
- super(detail || code);
- this.name = 'ApiError';
- this.status = status;
- this.code = code;
- this.extra = extra || null;
- }
-}
+/** A refusal with a stable problem code (e.g. 404 'instrument.not_found'). */
+const ApiError = svc.createServiceError('ApiError');
+
+const o = { name: 'Trade API', publishing: true, ServiceError: ApiError };
/** Publishing package errors carry { status, code } too. */
/** Publishing package errors carry { status, code } too. */
-function asApiError(err) {
- if (err instanceof ApiError) return err;
- if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {
- const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;
- return new ApiError(err.status, err.code, err.message, extra);
- }
- if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {
- return new ApiError(422, 'request.invalid', err.message);
- }
- return null;
-}
+const asApiError = (err) => svc.asServiceError(err, o);
/** Wrap a JSON handler: its return value is the body; errors become problems. */
/** Wrap a JSON handler: its return value is the body; errors become problems. */
-function run(fn, status = 200) {
- return async (req, res) => {
- try {
- const out = await fn(req, res);
- if (out === undefined || res.headersSent) return;
- res.status(typeof status === 'function' ? status(out) : status).json(out);
- } catch (err) {
- if (res.headersSent) return;
- const e = asApiError(err);
- if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
- console.error('[Trade API]', err && err.stack ? err.stack : err);
- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
- }
- };
-}
+const run = (fn, status) => svc.run(fn, status, o);
-const jsonParser = express.json({ limit: '512kb' });
-/** JSON body parser whose syntax errors are problems too. */
-function jsonBody(req, res, next) {
- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
-}
+/** JSON body parser whose failures are problems too: malformed 400, over the limit 413. */
+const jsonBody = svc.jsonBody();
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
-function privateNoStore(res) {
- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- res.vary('Cookie');
- res.vary('Authorization');
-}
+const privateNoStore = svc.privateNoStore;
module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };
module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * server/http/errors.js now sits on openvibe-sdk/service (plan T1): the exports stay put, so these pin
+ * the behaviour the hand-rolled helpers promised. The shared JSON body parser answers 413
+ * request.too_large for a body over the limit (the hand-rolled one folded that into 400
+ * request.invalid_json), malformed JSON is still 400 request.invalid_json, and an ApiError carrying
+ * `extra` still answers its status/code/detail with extra spread into the problem body.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { ApiError, asApiError, run } = require('../server/http/errors');
+
+(async () => {
+ const t = await boot();
+ const svc = t.network.serviceToken('tools', ['trade.watchlist.create']);
+
+ await check('a JSON body over 512 kB is 413 request.too_large, not 400 request.invalid_json', async () => {
+ const r = await t.get('/api/v1/watchlists', { as: svc, json: { name: 'Big', padding: 'y'.repeat(600 * 1024) } });
+ assert.strictEqual(r.status, 413, r.text);
+ assert.strictEqual(r.json().code, 'request.too_large');
+ });
+
+ await check('a malformed JSON body is still 400 request.invalid_json', async () => {
+ const r = await t.get('/api/v1/watchlists', { as: svc, body: '{"name":', headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(r.status, 400, r.text);
+ assert.strictEqual(r.json().code, 'request.invalid_json');
+ });
+
+ await check('an ApiError with extra answers its status/code/detail and spreads extra', async () => {
+ const err = new ApiError(409, 'watchlist.revision_conflict', 'The watchlist changed', { expected: 3, current: 4 });
+ assert.strictEqual(asApiError(err), err, 'a branded ApiError maps to itself');
+
+ const res = { statusCode: 0, headers: {}, body: '', headersSent: false, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; } };
+ await run(async () => { throw err; })({}, res);
+
+ assert.strictEqual(res.statusCode, 409);
+ const b = JSON.parse(res.body);
+ assert.strictEqual(b.status, 409);
+ assert.strictEqual(b.code, 'watchlist.revision_conflict');
+ assert.strictEqual(b.detail, 'The watchlist changed');
+ assert.deepStrictEqual({ expected: b.expected, current: b.current }, { expected: 3, current: 4 });
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });