From 1abb01eb619025db40d9916ecb4dc9795f3b5448 Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sat, 21 Feb 2026 17:08:22 +0000 Subject: [PATCH 1/7] Breaking: Extract AbstractApiUtils methods to individual utility files (fixes #71) Move all five static methods (httpMethodToAction, httpMethodToDBFunction, argsFromReq, generateApiMetadata, stringifyValues) into lib/utils/ as standalone functions. AbstractApiUtils retained with deprecated delegation stubs for backwards compatibility. Internal consumer AbstractApiModule updated to import directly from utils. Co-Authored-By: Claude Opus 4.6 --- index.js | 1 + lib/AbstractApiModule.js | 13 +- lib/AbstractApiUtils.js | 154 +++------------------ lib/utils.js | 5 + lib/utils/argsFromReq.js | 17 +++ lib/utils/generateApiMetadata.js | 69 +++++++++ lib/utils/httpMethodToAction.js | 19 +++ lib/utils/httpMethodToDBFunction.js | 15 ++ lib/utils/stringifyValues.js | 18 +++ package-lock.json | 8 +- package.json | 2 +- tests/utils-argsFromReq.spec.js | 42 ++++++ tests/utils-generateApiMetadata.spec.js | 113 +++++++++++++++ tests/utils-httpMethodToAction.spec.js | 27 ++++ tests/utils-httpMethodToDBFunction.spec.js | 30 ++++ tests/utils-stringifyValues.spec.js | 67 +++++++++ 16 files changed, 453 insertions(+), 147 deletions(-) create mode 100644 lib/utils.js create mode 100644 lib/utils/argsFromReq.js create mode 100644 lib/utils/generateApiMetadata.js create mode 100644 lib/utils/httpMethodToAction.js create mode 100644 lib/utils/httpMethodToDBFunction.js create mode 100644 lib/utils/stringifyValues.js create mode 100644 tests/utils-argsFromReq.spec.js create mode 100644 tests/utils-generateApiMetadata.spec.js create mode 100644 tests/utils-httpMethodToAction.spec.js create mode 100644 tests/utils-httpMethodToDBFunction.spec.js create mode 100644 tests/utils-stringifyValues.spec.js diff --git a/index.js b/index.js index 734618d7..fbf0b670 100644 --- a/index.js +++ b/index.js @@ -5,3 +5,4 @@ export { default as AbstractApiModule } from './lib/AbstractApiModule.js' export { default as AbstractApiUtils } from './lib/AbstractApiUtils.js' export { default } from './lib/AbstractApiModule.js' +export { argsFromReq, generateApiMetadata, httpMethodToAction, httpMethodToDBFunction, stringifyValues } from './lib/utils.js' diff --git a/lib/AbstractApiModule.js b/lib/AbstractApiModule.js index c2bb994f..4103009a 100644 --- a/lib/AbstractApiModule.js +++ b/lib/AbstractApiModule.js @@ -1,6 +1,9 @@ import _ from 'lodash' import { AbstractModule, Hook } from 'adapt-authoring-core' -import ApiUtils from './AbstractApiUtils.js' +import { argsFromReq } from './utils/argsFromReq.js' +import { generateApiMetadata } from './utils/generateApiMetadata.js' +import { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' +import { stringifyValues } from './utils/stringifyValues.js' import DataCache from './DataCache.js' /** * Abstract module for creating APIs @@ -177,7 +180,7 @@ class AbstractApiModule extends AbstractModule { return this.log('error', 'Must set API root before calling useDefaultConfig function') } /** @ignore */ this.routes = this.DEFAULT_ROUTES - ApiUtils.generateApiMetadata(this) + generateApiMetadata(this) } /** @@ -349,7 +352,7 @@ class AbstractApiModule extends AbstractModule { requestHandler () { const requestHandler = async (req, res, next) => { const method = req.method.toLowerCase() - const func = this[ApiUtils.httpMethodToDBFunction(method)] + const func = this[httpMethodToDBFunction(method)] if (!func) { return next(this.app.errors.HTTP_METHOD_NOT_SUPPORTED.setData({ method })) } @@ -361,7 +364,7 @@ class AbstractApiModule extends AbstractModule { if (preCheck) { await this.checkAccess(req, req.apiData.query) } - data = await func.apply(this, ApiUtils.argsFromReq(req)) + data = await func.apply(this, argsFromReq(req)) if (postCheck) { data = await this.checkAccess(req, data) } @@ -654,7 +657,7 @@ class AbstractApiModule extends AbstractModule { if (options.invokePreHook !== false) await this.preUpdateHook.invoke(originalDoc, formattedData.$set, options, mongoOptions) formattedData.$set = await this.validate(options.schemaName, { - ...ApiUtils.stringifyValues(originalDoc), + ...stringifyValues(originalDoc), ...formattedData.$set }, options) diff --git a/lib/AbstractApiUtils.js b/lib/AbstractApiUtils.js index d107ed33..954c3417 100644 --- a/lib/AbstractApiUtils.js +++ b/lib/AbstractApiUtils.js @@ -1,145 +1,25 @@ +import { argsFromReq } from './utils/argsFromReq.js' +import { generateApiMetadata } from './utils/generateApiMetadata.js' +import { httpMethodToAction } from './utils/httpMethodToAction.js' +import { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' +import { stringifyValues } from './utils/stringifyValues.js' + /** * Utilities for APIs * @memberof api + * @deprecated Use named imports from 'adapt-authoring-api' instead */ class AbstractApiUtils { - /** - * Converts HTTP methods to a corresponding 'action' for use in auth - * @param {String} method The HTTP method - * @return {String} - */ - static httpMethodToAction (method) { - switch (method.toLowerCase()) { - case 'get': - return 'read' - case 'post': - case 'put': - case 'patch': - case 'delete': - return 'write' - default: - return '' - } - } - - /** - * Converts HTTP methods to a corresponding database function - * @param {String} method The HTTP method - * @return {String} - */ - static httpMethodToDBFunction (method) { - switch (method.toLowerCase()) { - case 'post': return 'insert' - case 'get': return 'find' - case 'put': case 'patch': return 'update' - case 'delete': return 'delete' - default: return '' - } - } - - /** - * Generates a list of arguments to be passed to the MongoDBModule from a request object - * @param {external:ExpressRequest} req - * @return {Array<*>} - */ - static argsFromReq (req) { - const opts = { schemaName: req.apiData.schemaName, collectionName: req.apiData.collectionName } - switch (req.method) { - case 'GET': case 'DELETE': - return [req.apiData.query, opts] - case 'POST': - return [req.apiData.data, opts] - case 'PUT': case 'PATCH': - return [req.apiData.query, req.apiData.data, opts] - } - } - - /** - * Generates REST API metadata and stores on route config - * @param {AbstractApiModule} instance The current AbstractApiModule instance - */ - static generateApiMetadata (instance) { - const getData = isList => { - const $ref = { $ref: `#/components/schemas/${instance.schemaName}` } - return { - description: `The ${instance.schemaName} data`, - content: { 'application/json': { schema: isList ? { type: 'array', items: $ref } : $ref } } - } - } - const queryParams = [ - { - name: 'limit', - in: 'query', - description: `How many results should be returned Default value is ${instance.app.config.get('adapt-authoring-api.defaultPageSize')} (max value is ${instance.app.config.get('adapt-authoring-api.maxPageSize')})` - }, - { - name: 'page', - in: 'query', - description: 'The page of results to return (determined from the limit value)' - } - ] - const verbMap = { - put: 'Replace', - get: 'Retrieve', - patch: 'Update', - delete: 'Delete', - post: 'Insert' - } - instance.routes.forEach(r => { - r.meta = {} - Object.keys(r.handlers).forEach(method => { - let summary, parameters, requestBody, responses - switch (r.route) { - case '/': - if (method === 'post') { - summary = `${verbMap.post} a new ${instance.schemaName} document` - requestBody = getData() - responses = { 201: getData() } - } else { - summary = `${verbMap.get} all ${instance.collectionName} documents` - parameters = queryParams - responses = { 200: getData(true) } - } - break - - case '/:_id': - summary = `${verbMap[method]} an existing ${instance.schemaName} document` - requestBody = method === 'put' || method === 'patch' ? getData() : method === 'delete' ? undefined : {} - responses = { [method === 'delete' ? 204 : 200]: getData() } - break - - case '/query': - summary = `Query all ${instance.collectionName}` - parameters = queryParams - responses = { 200: getData(true) } - break - - case '/schema': - summary = `Retrieve ${instance.schemaName} schema` - break - } - r.meta[method] = { summary, parameters, requestBody, responses } - }) - }) - } - - /** - * Clones an object and converts any Dates and ObjectIds to Strings - * @param {Object} data - * @returns A clone object with stringified ObjectIds - */ - static stringifyValues (data) { - return Object.entries(data).reduce((cloned, [key, val]) => { - const type = val?.constructor?.name - cloned[key] = - type === 'Date' || type === 'ObjectId' - ? val.toString() - : type === 'Array' || type === 'Object' - ? this.stringifyValues(val) - : val - return cloned - }, Array.isArray(data) ? [] : {}) - } + /** @deprecated Use httpMethodToAction() directly */ + static httpMethodToAction (method) { return httpMethodToAction(method) } + /** @deprecated Use httpMethodToDBFunction() directly */ + static httpMethodToDBFunction (method) { return httpMethodToDBFunction(method) } + /** @deprecated Use argsFromReq() directly */ + static argsFromReq (req) { return argsFromReq(req) } + /** @deprecated Use generateApiMetadata() directly */ + static generateApiMetadata (instance) { return generateApiMetadata(instance) } + /** @deprecated Use stringifyValues() directly */ + static stringifyValues (data) { return stringifyValues(data) } } export default AbstractApiUtils diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..a070658a --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,5 @@ +export { argsFromReq } from './utils/argsFromReq.js' +export { generateApiMetadata } from './utils/generateApiMetadata.js' +export { httpMethodToAction } from './utils/httpMethodToAction.js' +export { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' +export { stringifyValues } from './utils/stringifyValues.js' diff --git a/lib/utils/argsFromReq.js b/lib/utils/argsFromReq.js new file mode 100644 index 00000000..26ba57c2 --- /dev/null +++ b/lib/utils/argsFromReq.js @@ -0,0 +1,17 @@ +/** + * Generates a list of arguments to be passed to the MongoDBModule from a request object + * @param {external:ExpressRequest} req + * @return {Array<*>} + * @memberof api + */ +export function argsFromReq (req) { + const opts = { schemaName: req.apiData.schemaName, collectionName: req.apiData.collectionName } + switch (req.method) { + case 'GET': case 'DELETE': + return [req.apiData.query, opts] + case 'POST': + return [req.apiData.data, opts] + case 'PUT': case 'PATCH': + return [req.apiData.query, req.apiData.data, opts] + } +} diff --git a/lib/utils/generateApiMetadata.js b/lib/utils/generateApiMetadata.js new file mode 100644 index 00000000..51470c87 --- /dev/null +++ b/lib/utils/generateApiMetadata.js @@ -0,0 +1,69 @@ +/** + * Generates REST API metadata and stores on route config + * @param {AbstractApiModule} instance The current AbstractApiModule instance + * @memberof api + */ +export function generateApiMetadata (instance) { + const getData = isList => { + const $ref = { $ref: `#/components/schemas/${instance.schemaName}` } + return { + description: `The ${instance.schemaName} data`, + content: { 'application/json': { schema: isList ? { type: 'array', items: $ref } : $ref } } + } + } + const queryParams = [ + { + name: 'limit', + in: 'query', + description: `How many results should be returned Default value is ${instance.app.config.get('adapt-authoring-api.defaultPageSize')} (max value is ${instance.app.config.get('adapt-authoring-api.maxPageSize')})` + }, + { + name: 'page', + in: 'query', + description: 'The page of results to return (determined from the limit value)' + } + ] + const verbMap = { + put: 'Replace', + get: 'Retrieve', + patch: 'Update', + delete: 'Delete', + post: 'Insert' + } + instance.routes.forEach(r => { + r.meta = {} + Object.keys(r.handlers).forEach(method => { + let summary, parameters, requestBody, responses + switch (r.route) { + case '/': + if (method === 'post') { + summary = `${verbMap.post} a new ${instance.schemaName} document` + requestBody = getData() + responses = { 201: getData() } + } else { + summary = `${verbMap.get} all ${instance.collectionName} documents` + parameters = queryParams + responses = { 200: getData(true) } + } + break + + case '/:_id': + summary = `${verbMap[method]} an existing ${instance.schemaName} document` + requestBody = method === 'put' || method === 'patch' ? getData() : method === 'delete' ? undefined : {} + responses = { [method === 'delete' ? 204 : 200]: getData() } + break + + case '/query': + summary = `Query all ${instance.collectionName}` + parameters = queryParams + responses = { 200: getData(true) } + break + + case '/schema': + summary = `Retrieve ${instance.schemaName} schema` + break + } + r.meta[method] = { summary, parameters, requestBody, responses } + }) + }) +} diff --git a/lib/utils/httpMethodToAction.js b/lib/utils/httpMethodToAction.js new file mode 100644 index 00000000..dc45b26b --- /dev/null +++ b/lib/utils/httpMethodToAction.js @@ -0,0 +1,19 @@ +/** + * Converts HTTP methods to a corresponding 'action' for use in auth + * @param {String} method The HTTP method + * @return {String} + * @memberof api + */ +export function httpMethodToAction (method) { + switch (method.toLowerCase()) { + case 'get': + return 'read' + case 'post': + case 'put': + case 'patch': + case 'delete': + return 'write' + default: + return '' + } +} diff --git a/lib/utils/httpMethodToDBFunction.js b/lib/utils/httpMethodToDBFunction.js new file mode 100644 index 00000000..38bfc576 --- /dev/null +++ b/lib/utils/httpMethodToDBFunction.js @@ -0,0 +1,15 @@ +/** + * Converts HTTP methods to a corresponding database function + * @param {String} method The HTTP method + * @return {String} + * @memberof api + */ +export function httpMethodToDBFunction (method) { + switch (method.toLowerCase()) { + case 'post': return 'insert' + case 'get': return 'find' + case 'put': case 'patch': return 'update' + case 'delete': return 'delete' + default: return '' + } +} diff --git a/lib/utils/stringifyValues.js b/lib/utils/stringifyValues.js new file mode 100644 index 00000000..779a6daf --- /dev/null +++ b/lib/utils/stringifyValues.js @@ -0,0 +1,18 @@ +/** + * Clones an object and converts any Dates and ObjectIds to Strings + * @param {Object} data + * @returns A clone object with stringified ObjectIds + * @memberof api + */ +export function stringifyValues (data) { + return Object.entries(data).reduce((cloned, [key, val]) => { + const type = val?.constructor?.name + cloned[key] = + type === 'Date' || type === 'ObjectId' + ? val.toString() + : type === 'Array' || type === 'Object' + ? stringifyValues(val) + : val + return cloned + }, Array.isArray(data) ? [] : {}) +} diff --git a/package-lock.json b/package-lock.json index b96521e8..d87af75e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.3.2", "license": "GPL-3.0", "dependencies": { - "adapt-authoring-core": "^1.7.0", + "adapt-authoring-core": "^2.0.0", "lodash": "^4.17.21" }, "devDependencies": { @@ -1156,9 +1156,9 @@ } }, "node_modules/adapt-authoring-core": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/adapt-authoring-core/-/adapt-authoring-core-1.7.0.tgz", - "integrity": "sha512-Lh35JIKpzCsJKc6mmCvC1tJABvup76lulK+eKJRxi3+AhFr/apvN5DW4my/nbmG2YF3R5ig1t/wegS5eRn3/Aw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/adapt-authoring-core/-/adapt-authoring-core-2.0.0.tgz", + "integrity": "sha512-q+XVpSzo3PI+mfKyem2jaOtHGUNq+M09348Qd3y9+ut58bQ3PaAM9CzGQV+s43cgmfCBCPe5bdkmo8jZ/9iZSA==", "license": "GPL-3.0", "dependencies": { "fs-extra": "11.3.3", diff --git a/package.json b/package.json index 2e70c002..09f2af89 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "node --test 'tests/**/*.spec.js'" }, "dependencies": { - "adapt-authoring-core": "^1.7.0", + "adapt-authoring-core": "^2.0.0", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/tests/utils-argsFromReq.spec.js b/tests/utils-argsFromReq.spec.js new file mode 100644 index 00000000..19322ff6 --- /dev/null +++ b/tests/utils-argsFromReq.spec.js @@ -0,0 +1,42 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { argsFromReq } from '../lib/utils/argsFromReq.js' + +describe('argsFromReq()', () => { + const baseApiData = { + query: { _id: '123' }, + data: { name: 'test' }, + schemaName: 'testSchema', + collectionName: 'testCollection' + } + const expectedOpts = { schemaName: 'testSchema', collectionName: 'testCollection' } + + it('should return [query, opts] for GET', () => { + const result = argsFromReq({ method: 'GET', apiData: baseApiData }) + assert.deepEqual(result, [baseApiData.query, expectedOpts]) + }) + + it('should return [query, opts] for DELETE', () => { + const result = argsFromReq({ method: 'DELETE', apiData: baseApiData }) + assert.deepEqual(result, [baseApiData.query, expectedOpts]) + }) + + it('should return [data, opts] for POST', () => { + const result = argsFromReq({ method: 'POST', apiData: baseApiData }) + assert.deepEqual(result, [baseApiData.data, expectedOpts]) + }) + + it('should return [query, data, opts] for PUT', () => { + const result = argsFromReq({ method: 'PUT', apiData: baseApiData }) + assert.deepEqual(result, [baseApiData.query, baseApiData.data, expectedOpts]) + }) + + it('should return [query, data, opts] for PATCH', () => { + const result = argsFromReq({ method: 'PATCH', apiData: baseApiData }) + assert.deepEqual(result, [baseApiData.query, baseApiData.data, expectedOpts]) + }) + + it('should return undefined for unknown methods', () => { + assert.equal(argsFromReq({ method: 'OPTIONS', apiData: baseApiData }), undefined) + }) +}) diff --git a/tests/utils-generateApiMetadata.spec.js b/tests/utils-generateApiMetadata.spec.js new file mode 100644 index 00000000..9e5073e6 --- /dev/null +++ b/tests/utils-generateApiMetadata.spec.js @@ -0,0 +1,113 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { generateApiMetadata } from '../lib/utils/generateApiMetadata.js' + +describe('generateApiMetadata()', () => { + function createInstance (routes) { + return { + schemaName: 'TestSchema', + collectionName: 'testcollection', + app: { + config: { + get: (key) => { + const map = { + 'adapt-authoring-api.defaultPageSize': 50, + 'adapt-authoring-api.maxPageSize': 200 + } + return map[key] + } + } + }, + routes: routes || [] + } + } + + it('should set meta on each route', () => { + const instance = createInstance([ + { route: '/', handlers: { post: () => {}, get: () => {} } } + ]) + generateApiMetadata(instance) + assert.ok(instance.routes[0].meta) + assert.ok(instance.routes[0].meta.post) + assert.ok(instance.routes[0].meta.get) + }) + + it('should generate correct summary for POST /', () => { + const instance = createInstance([ + { route: '/', handlers: { post: () => {} } } + ]) + generateApiMetadata(instance) + assert.equal(instance.routes[0].meta.post.summary, 'Insert a new TestSchema document') + }) + + it('should generate correct summary for GET /', () => { + const instance = createInstance([ + { route: '/', handlers: { get: () => {} } } + ]) + generateApiMetadata(instance) + assert.equal(instance.routes[0].meta.get.summary, 'Retrieve all testcollection documents') + }) + + it('should include query parameters for GET /', () => { + const instance = createInstance([ + { route: '/', handlers: { get: () => {} } } + ]) + generateApiMetadata(instance) + const params = instance.routes[0].meta.get.parameters + assert.ok(Array.isArray(params)) + assert.equal(params.length, 2) + assert.equal(params[0].name, 'limit') + assert.equal(params[1].name, 'page') + }) + + it('should generate correct summary for /:_id routes', () => { + const instance = createInstance([ + { route: '/:_id', handlers: { get: () => {}, put: () => {}, patch: () => {}, delete: () => {} } } + ]) + generateApiMetadata(instance) + assert.equal(instance.routes[0].meta.get.summary, 'Retrieve an existing TestSchema document') + assert.equal(instance.routes[0].meta.put.summary, 'Replace an existing TestSchema document') + assert.equal(instance.routes[0].meta.patch.summary, 'Update an existing TestSchema document') + assert.equal(instance.routes[0].meta.delete.summary, 'Delete an existing TestSchema document') + }) + + it('should set 201 response for POST and 204 for DELETE', () => { + const instance = createInstance([ + { route: '/', handlers: { post: () => {} } }, + { route: '/:_id', handlers: { delete: () => {} } } + ]) + generateApiMetadata(instance) + assert.ok(instance.routes[0].meta.post.responses[201]) + assert.ok(instance.routes[1].meta.delete.responses[204]) + }) + + it('should generate correct summary for /query route', () => { + const instance = createInstance([ + { route: '/query', handlers: { post: () => {} } } + ]) + generateApiMetadata(instance) + assert.equal(instance.routes[0].meta.post.summary, 'Query all testcollection') + }) + + it('should generate correct summary for /schema route', () => { + const instance = createInstance([ + { route: '/schema', handlers: { get: () => {} } } + ]) + generateApiMetadata(instance) + assert.equal(instance.routes[0].meta.get.summary, 'Retrieve TestSchema schema') + }) + + it('should handle multiple routes', () => { + const instance = createInstance([ + { route: '/', handlers: { post: () => {}, get: () => {} } }, + { route: '/:_id', handlers: { get: () => {}, delete: () => {} } }, + { route: '/query', handlers: { post: () => {} } }, + { route: '/schema', handlers: { get: () => {} } } + ]) + generateApiMetadata(instance) + assert.equal(Object.keys(instance.routes[0].meta).length, 2) + assert.equal(Object.keys(instance.routes[1].meta).length, 2) + assert.equal(Object.keys(instance.routes[2].meta).length, 1) + assert.equal(Object.keys(instance.routes[3].meta).length, 1) + }) +}) diff --git a/tests/utils-httpMethodToAction.spec.js b/tests/utils-httpMethodToAction.spec.js new file mode 100644 index 00000000..89756bc1 --- /dev/null +++ b/tests/utils-httpMethodToAction.spec.js @@ -0,0 +1,27 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { httpMethodToAction } from '../lib/utils/httpMethodToAction.js' + +describe('httpMethodToAction()', () => { + it('should return "read" for GET', () => { + assert.equal(httpMethodToAction('get'), 'read') + }) + + it('should be case-insensitive', () => { + assert.equal(httpMethodToAction('GET'), 'read') + assert.equal(httpMethodToAction('Get'), 'read') + }) + + const writeMethods = ['post', 'put', 'patch', 'delete'] + writeMethods.forEach(method => { + it(`should return "write" for ${method.toUpperCase()}`, () => { + assert.equal(httpMethodToAction(method), 'write') + assert.equal(httpMethodToAction(method.toUpperCase()), 'write') + }) + }) + + it('should return empty string for unknown methods', () => { + assert.equal(httpMethodToAction('options'), '') + assert.equal(httpMethodToAction('head'), '') + }) +}) diff --git a/tests/utils-httpMethodToDBFunction.spec.js b/tests/utils-httpMethodToDBFunction.spec.js new file mode 100644 index 00000000..5547acf1 --- /dev/null +++ b/tests/utils-httpMethodToDBFunction.spec.js @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { httpMethodToDBFunction } from '../lib/utils/httpMethodToDBFunction.js' + +describe('httpMethodToDBFunction()', () => { + const cases = [ + { method: 'post', expected: 'insert' }, + { method: 'get', expected: 'find' }, + { method: 'put', expected: 'update' }, + { method: 'patch', expected: 'update' }, + { method: 'delete', expected: 'delete' } + ] + + cases.forEach(({ method, expected }) => { + it(`should return "${expected}" for ${method.toUpperCase()}`, () => { + assert.equal(httpMethodToDBFunction(method), expected) + }) + }) + + it('should be case-insensitive', () => { + assert.equal(httpMethodToDBFunction('POST'), 'insert') + assert.equal(httpMethodToDBFunction('Get'), 'find') + assert.equal(httpMethodToDBFunction('DELETE'), 'delete') + }) + + it('should return empty string for unknown methods', () => { + assert.equal(httpMethodToDBFunction('options'), '') + assert.equal(httpMethodToDBFunction('head'), '') + }) +}) diff --git a/tests/utils-stringifyValues.spec.js b/tests/utils-stringifyValues.spec.js new file mode 100644 index 00000000..14ba74bc --- /dev/null +++ b/tests/utils-stringifyValues.spec.js @@ -0,0 +1,67 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { stringifyValues } from '../lib/utils/stringifyValues.js' + +describe('stringifyValues()', () => { + it('should pass through plain values unchanged', () => { + const data = { a: 'hello', b: 42, c: true, d: null } + assert.deepEqual(stringifyValues(data), data) + }) + + it('should convert Date values to strings', () => { + const date = new Date('2025-01-01T00:00:00.000Z') + const result = stringifyValues({ date }) + assert.equal(typeof result.date, 'string') + assert.equal(result.date, date.toString()) + }) + + it('should convert ObjectId-like values to strings', () => { + class ObjectId { + constructor (id) { this.id = id } + toString () { return this.id } + } + const data = { _id: new ObjectId('abc123') } + const result = stringifyValues(data) + assert.equal(typeof result._id, 'string') + assert.equal(result._id, 'abc123') + }) + + it('should recursively process nested objects', () => { + const date = new Date('2025-01-01') + const data = { nested: { value: 'test', date } } + const result = stringifyValues(data) + assert.equal(typeof result.nested, 'object') + assert.equal(typeof result.nested.date, 'string') + assert.equal(result.nested.value, 'test') + }) + + it('should recursively process arrays', () => { + const date = new Date('2025-01-01') + const data = { items: [date, 'text', 42] } + const result = stringifyValues(data) + assert.ok(Array.isArray(result.items)) + assert.equal(typeof result.items[0], 'string') + assert.equal(result.items[1], 'text') + assert.equal(result.items[2], 42) + }) + + it('should return an array when input is an array', () => { + const result = stringifyValues([{ a: 1 }, { b: 2 }]) + assert.ok(Array.isArray(result)) + assert.equal(result.length, 2) + }) + + it('should not mutate the original data', () => { + const date = new Date('2025-01-01') + const data = { date } + stringifyValues(data) + assert.ok(data.date instanceof Date) + }) + + it('should handle deeply nested structures', () => { + const date = new Date('2025-01-01') + const data = { a: { b: { c: { date } } } } + const result = stringifyValues(data) + assert.equal(typeof result.a.b.c.date, 'string') + }) +}) From a509471b6da5e22bebb100f0204652bc8c8d157b Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sun, 22 Feb 2026 00:06:15 +0000 Subject: [PATCH 2/7] Refactor: use barrel import for utility functions Co-Authored-By: Claude Opus 4.6 --- lib/AbstractApiModule.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/AbstractApiModule.js b/lib/AbstractApiModule.js index 4103009a..689ba532 100644 --- a/lib/AbstractApiModule.js +++ b/lib/AbstractApiModule.js @@ -1,9 +1,6 @@ import _ from 'lodash' import { AbstractModule, Hook } from 'adapt-authoring-core' -import { argsFromReq } from './utils/argsFromReq.js' -import { generateApiMetadata } from './utils/generateApiMetadata.js' -import { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' -import { stringifyValues } from './utils/stringifyValues.js' +import { argsFromReq, generateApiMetadata, httpMethodToDBFunction, stringifyValues } from './utils.js' import DataCache from './DataCache.js' /** * Abstract module for creating APIs From ea1418334ecf1a6b1b7d56153917022d0cea9c5e Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sun, 22 Feb 2026 01:01:55 +0000 Subject: [PATCH 3/7] Breaking: remove public exports for internal-only utility functions argsFromReq and httpMethodToDBFunction are only used internally by AbstractApiModule and have no external consumers. Remove them from the package exports. generateApiMetadata, httpMethodToAction and stringifyValues are retained as they have external consumers. Co-Authored-By: Claude Opus 4.6 --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index fbf0b670..8754357c 100644 --- a/index.js +++ b/index.js @@ -5,4 +5,4 @@ export { default as AbstractApiModule } from './lib/AbstractApiModule.js' export { default as AbstractApiUtils } from './lib/AbstractApiUtils.js' export { default } from './lib/AbstractApiModule.js' -export { argsFromReq, generateApiMetadata, httpMethodToAction, httpMethodToDBFunction, stringifyValues } from './lib/utils.js' +export { generateApiMetadata, httpMethodToAction, stringifyValues } from './lib/utils.js' From c9902b76bf77f8a0190dc4643beb9418305f3416 Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sun, 22 Feb 2026 01:10:09 +0000 Subject: [PATCH 4/7] Breaking: also remove httpMethodToAction from public exports httpMethodToAction is only used internally with no external consumers. Co-Authored-By: Claude Opus 4.6 --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 8754357c..85be9b71 100644 --- a/index.js +++ b/index.js @@ -5,4 +5,4 @@ export { default as AbstractApiModule } from './lib/AbstractApiModule.js' export { default as AbstractApiUtils } from './lib/AbstractApiUtils.js' export { default } from './lib/AbstractApiModule.js' -export { generateApiMetadata, httpMethodToAction, stringifyValues } from './lib/utils.js' +export { generateApiMetadata, stringifyValues } from './lib/utils.js' From da2a12e63e5879d81fdb841879385a063507ae3a Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sun, 22 Feb 2026 18:27:07 +0000 Subject: [PATCH 5/7] Breaking: move stringifyValues to core stringifyValues is a generic pure function with no API-specific logic. A deprecated re-export from core is kept for backwards compatibility. Co-Authored-By: Claude Opus 4.6 --- index.js | 4 +- lib/AbstractApiModule.js | 4 +- lib/AbstractApiUtils.js | 2 +- lib/utils.js | 1 - lib/utils/stringifyValues.js | 18 -------- tests/utils-stringifyValues.spec.js | 67 ----------------------------- 6 files changed, 6 insertions(+), 90 deletions(-) delete mode 100644 lib/utils/stringifyValues.js delete mode 100644 tests/utils-stringifyValues.spec.js diff --git a/index.js b/index.js index 85be9b71..bfe5bac4 100644 --- a/index.js +++ b/index.js @@ -5,4 +5,6 @@ export { default as AbstractApiModule } from './lib/AbstractApiModule.js' export { default as AbstractApiUtils } from './lib/AbstractApiUtils.js' export { default } from './lib/AbstractApiModule.js' -export { generateApiMetadata, stringifyValues } from './lib/utils.js' +export { generateApiMetadata } from './lib/utils.js' +/** @deprecated Use named import { stringifyValues } from 'adapt-authoring-core' instead */ +export { stringifyValues } from 'adapt-authoring-core' diff --git a/lib/AbstractApiModule.js b/lib/AbstractApiModule.js index 689ba532..5bcdd436 100644 --- a/lib/AbstractApiModule.js +++ b/lib/AbstractApiModule.js @@ -1,6 +1,6 @@ import _ from 'lodash' -import { AbstractModule, Hook } from 'adapt-authoring-core' -import { argsFromReq, generateApiMetadata, httpMethodToDBFunction, stringifyValues } from './utils.js' +import { AbstractModule, Hook, stringifyValues } from 'adapt-authoring-core' +import { argsFromReq, generateApiMetadata, httpMethodToDBFunction } from './utils.js' import DataCache from './DataCache.js' /** * Abstract module for creating APIs diff --git a/lib/AbstractApiUtils.js b/lib/AbstractApiUtils.js index 954c3417..efa98b08 100644 --- a/lib/AbstractApiUtils.js +++ b/lib/AbstractApiUtils.js @@ -2,7 +2,7 @@ import { argsFromReq } from './utils/argsFromReq.js' import { generateApiMetadata } from './utils/generateApiMetadata.js' import { httpMethodToAction } from './utils/httpMethodToAction.js' import { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' -import { stringifyValues } from './utils/stringifyValues.js' +import { stringifyValues } from 'adapt-authoring-core' /** * Utilities for APIs diff --git a/lib/utils.js b/lib/utils.js index a070658a..70f4af46 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,4 +2,3 @@ export { argsFromReq } from './utils/argsFromReq.js' export { generateApiMetadata } from './utils/generateApiMetadata.js' export { httpMethodToAction } from './utils/httpMethodToAction.js' export { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' -export { stringifyValues } from './utils/stringifyValues.js' diff --git a/lib/utils/stringifyValues.js b/lib/utils/stringifyValues.js deleted file mode 100644 index 779a6daf..00000000 --- a/lib/utils/stringifyValues.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Clones an object and converts any Dates and ObjectIds to Strings - * @param {Object} data - * @returns A clone object with stringified ObjectIds - * @memberof api - */ -export function stringifyValues (data) { - return Object.entries(data).reduce((cloned, [key, val]) => { - const type = val?.constructor?.name - cloned[key] = - type === 'Date' || type === 'ObjectId' - ? val.toString() - : type === 'Array' || type === 'Object' - ? stringifyValues(val) - : val - return cloned - }, Array.isArray(data) ? [] : {}) -} diff --git a/tests/utils-stringifyValues.spec.js b/tests/utils-stringifyValues.spec.js deleted file mode 100644 index 14ba74bc..00000000 --- a/tests/utils-stringifyValues.spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { stringifyValues } from '../lib/utils/stringifyValues.js' - -describe('stringifyValues()', () => { - it('should pass through plain values unchanged', () => { - const data = { a: 'hello', b: 42, c: true, d: null } - assert.deepEqual(stringifyValues(data), data) - }) - - it('should convert Date values to strings', () => { - const date = new Date('2025-01-01T00:00:00.000Z') - const result = stringifyValues({ date }) - assert.equal(typeof result.date, 'string') - assert.equal(result.date, date.toString()) - }) - - it('should convert ObjectId-like values to strings', () => { - class ObjectId { - constructor (id) { this.id = id } - toString () { return this.id } - } - const data = { _id: new ObjectId('abc123') } - const result = stringifyValues(data) - assert.equal(typeof result._id, 'string') - assert.equal(result._id, 'abc123') - }) - - it('should recursively process nested objects', () => { - const date = new Date('2025-01-01') - const data = { nested: { value: 'test', date } } - const result = stringifyValues(data) - assert.equal(typeof result.nested, 'object') - assert.equal(typeof result.nested.date, 'string') - assert.equal(result.nested.value, 'test') - }) - - it('should recursively process arrays', () => { - const date = new Date('2025-01-01') - const data = { items: [date, 'text', 42] } - const result = stringifyValues(data) - assert.ok(Array.isArray(result.items)) - assert.equal(typeof result.items[0], 'string') - assert.equal(result.items[1], 'text') - assert.equal(result.items[2], 42) - }) - - it('should return an array when input is an array', () => { - const result = stringifyValues([{ a: 1 }, { b: 2 }]) - assert.ok(Array.isArray(result)) - assert.equal(result.length, 2) - }) - - it('should not mutate the original data', () => { - const date = new Date('2025-01-01') - const data = { date } - stringifyValues(data) - assert.ok(data.date instanceof Date) - }) - - it('should handle deeply nested structures', () => { - const date = new Date('2025-01-01') - const data = { a: { b: { c: { date } } } } - const result = stringifyValues(data) - assert.equal(typeof result.a.b.c.date, 'string') - }) -}) From 4159d16d6894cd5771e8bac7ad548a7996196302 Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sun, 22 Feb 2026 18:30:20 +0000 Subject: [PATCH 6/7] Breaking: remove stringifyValues stub from AbstractApiUtils No longer needed now that stringifyValues has moved to core. Co-Authored-By: Claude Opus 4.6 --- lib/AbstractApiUtils.js | 4 ---- tests/AbstractApiUtils.spec.js | 38 ---------------------------------- 2 files changed, 42 deletions(-) diff --git a/lib/AbstractApiUtils.js b/lib/AbstractApiUtils.js index efa98b08..3f1c48cf 100644 --- a/lib/AbstractApiUtils.js +++ b/lib/AbstractApiUtils.js @@ -2,8 +2,6 @@ import { argsFromReq } from './utils/argsFromReq.js' import { generateApiMetadata } from './utils/generateApiMetadata.js' import { httpMethodToAction } from './utils/httpMethodToAction.js' import { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' -import { stringifyValues } from 'adapt-authoring-core' - /** * Utilities for APIs * @memberof api @@ -18,8 +16,6 @@ class AbstractApiUtils { static argsFromReq (req) { return argsFromReq(req) } /** @deprecated Use generateApiMetadata() directly */ static generateApiMetadata (instance) { return generateApiMetadata(instance) } - /** @deprecated Use stringifyValues() directly */ - static stringifyValues (data) { return stringifyValues(data) } } export default AbstractApiUtils diff --git a/tests/AbstractApiUtils.spec.js b/tests/AbstractApiUtils.spec.js index 9587d134..51419e9e 100644 --- a/tests/AbstractApiUtils.spec.js +++ b/tests/AbstractApiUtils.spec.js @@ -98,42 +98,4 @@ describe('AbstractApiUtils', () => { assert.equal(AbstractApiUtils.argsFromReq(req), undefined) }) }) - - describe('.stringifyValues()', () => { - it('should pass through plain values unchanged', () => { - const data = { a: 'hello', b: 42, c: true, d: null } - const result = AbstractApiUtils.stringifyValues(data) - assert.deepEqual(result, data) - }) - - it('should convert Date values to strings', () => { - const date = new Date('2025-01-01T00:00:00.000Z') - const result = AbstractApiUtils.stringifyValues({ date }) - assert.equal(typeof result.date, 'string') - assert.equal(result.date, date.toString()) - }) - - it('should recursively process nested objects', () => { - const data = { nested: { value: 'test', date: new Date('2025-01-01') } } - const result = AbstractApiUtils.stringifyValues(data) - assert.equal(typeof result.nested, 'object') - assert.equal(typeof result.nested.date, 'string') - }) - - it('should recursively process arrays', () => { - const date = new Date('2025-01-01') - const data = { items: [date, 'text', 42] } - const result = AbstractApiUtils.stringifyValues(data) - assert.ok(Array.isArray(result.items)) - assert.equal(typeof result.items[0], 'string') - assert.equal(result.items[1], 'text') - assert.equal(result.items[2], 42) - }) - - it('should return an array when input is an array', () => { - const result = AbstractApiUtils.stringifyValues([{ a: 1 }, { b: 2 }]) - assert.ok(Array.isArray(result)) - assert.equal(result.length, 2) - }) - }) }) From 62a45cea32773d51df9d60ec513d976cb23c4f02 Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Sun, 22 Feb 2026 18:45:50 +0000 Subject: [PATCH 7/7] Breaking: remove AbstractApiUtils, make generateApiMetadata an instance method generateApiMetadata is now an instance method on AbstractApiModule. AbstractApiUtils has no remaining consumers and is deleted. generateApiMetadata remains as an internal utility function for testability. Co-Authored-By: Claude Opus 4.6 --- index.js | 2 - lib/AbstractApiModule.js | 7 +++ lib/AbstractApiUtils.js | 21 ------- tests/AbstractApiUtils.spec.js | 101 --------------------------------- 4 files changed, 7 insertions(+), 124 deletions(-) delete mode 100644 lib/AbstractApiUtils.js delete mode 100644 tests/AbstractApiUtils.spec.js diff --git a/index.js b/index.js index bfe5bac4..071e0d08 100644 --- a/index.js +++ b/index.js @@ -3,8 +3,6 @@ * @namespace api */ export { default as AbstractApiModule } from './lib/AbstractApiModule.js' -export { default as AbstractApiUtils } from './lib/AbstractApiUtils.js' export { default } from './lib/AbstractApiModule.js' -export { generateApiMetadata } from './lib/utils.js' /** @deprecated Use named import { stringifyValues } from 'adapt-authoring-core' instead */ export { stringifyValues } from 'adapt-authoring-core' diff --git a/lib/AbstractApiModule.js b/lib/AbstractApiModule.js index 5bcdd436..f1262745 100644 --- a/lib/AbstractApiModule.js +++ b/lib/AbstractApiModule.js @@ -177,6 +177,13 @@ class AbstractApiModule extends AbstractModule { return this.log('error', 'Must set API root before calling useDefaultConfig function') } /** @ignore */ this.routes = this.DEFAULT_ROUTES + this.generateApiMetadata() + } + + /** + * Generates REST API metadata and stores on route config + */ + generateApiMetadata () { generateApiMetadata(this) } diff --git a/lib/AbstractApiUtils.js b/lib/AbstractApiUtils.js deleted file mode 100644 index 3f1c48cf..00000000 --- a/lib/AbstractApiUtils.js +++ /dev/null @@ -1,21 +0,0 @@ -import { argsFromReq } from './utils/argsFromReq.js' -import { generateApiMetadata } from './utils/generateApiMetadata.js' -import { httpMethodToAction } from './utils/httpMethodToAction.js' -import { httpMethodToDBFunction } from './utils/httpMethodToDBFunction.js' -/** - * Utilities for APIs - * @memberof api - * @deprecated Use named imports from 'adapt-authoring-api' instead - */ -class AbstractApiUtils { - /** @deprecated Use httpMethodToAction() directly */ - static httpMethodToAction (method) { return httpMethodToAction(method) } - /** @deprecated Use httpMethodToDBFunction() directly */ - static httpMethodToDBFunction (method) { return httpMethodToDBFunction(method) } - /** @deprecated Use argsFromReq() directly */ - static argsFromReq (req) { return argsFromReq(req) } - /** @deprecated Use generateApiMetadata() directly */ - static generateApiMetadata (instance) { return generateApiMetadata(instance) } -} - -export default AbstractApiUtils diff --git a/tests/AbstractApiUtils.spec.js b/tests/AbstractApiUtils.spec.js deleted file mode 100644 index 51419e9e..00000000 --- a/tests/AbstractApiUtils.spec.js +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import AbstractApiUtils from '../lib/AbstractApiUtils.js' - -describe('AbstractApiUtils', () => { - describe('.httpMethodToAction()', () => { - it('should return "read" for GET', () => { - assert.equal(AbstractApiUtils.httpMethodToAction('get'), 'read') - assert.equal(AbstractApiUtils.httpMethodToAction('GET'), 'read') - }) - - const writeMethods = ['post', 'put', 'patch', 'delete'] - writeMethods.forEach(method => { - it(`should return "write" for ${method.toUpperCase()}`, () => { - assert.equal(AbstractApiUtils.httpMethodToAction(method), 'write') - assert.equal(AbstractApiUtils.httpMethodToAction(method.toUpperCase()), 'write') - }) - }) - - it('should return empty string for unknown methods', () => { - assert.equal(AbstractApiUtils.httpMethodToAction('options'), '') - assert.equal(AbstractApiUtils.httpMethodToAction('head'), '') - }) - }) - - describe('.httpMethodToDBFunction()', () => { - const cases = [ - { method: 'post', expected: 'insert' }, - { method: 'get', expected: 'find' }, - { method: 'put', expected: 'update' }, - { method: 'patch', expected: 'update' }, - { method: 'delete', expected: 'delete' } - ] - cases.forEach(({ method, expected }) => { - it(`should return "${expected}" for ${method.toUpperCase()}`, () => { - assert.equal(AbstractApiUtils.httpMethodToDBFunction(method), expected) - }) - }) - - it('should be case-insensitive', () => { - assert.equal(AbstractApiUtils.httpMethodToDBFunction('POST'), 'insert') - assert.equal(AbstractApiUtils.httpMethodToDBFunction('Get'), 'find') - }) - - it('should return empty string for unknown methods', () => { - assert.equal(AbstractApiUtils.httpMethodToDBFunction('options'), '') - }) - }) - - describe('.argsFromReq()', () => { - const baseApiData = { - query: { _id: '123' }, - data: { name: 'test' }, - schemaName: 'testSchema', - collectionName: 'testCollection' - } - - it('should return [query, opts] for GET', () => { - const req = { method: 'GET', apiData: baseApiData } - const result = AbstractApiUtils.argsFromReq(req) - assert.deepEqual(result[0], baseApiData.query) - assert.deepEqual(result[1], { schemaName: 'testSchema', collectionName: 'testCollection' }) - assert.equal(result.length, 2) - }) - - it('should return [query, opts] for DELETE', () => { - const req = { method: 'DELETE', apiData: baseApiData } - const result = AbstractApiUtils.argsFromReq(req) - assert.deepEqual(result[0], baseApiData.query) - assert.equal(result.length, 2) - }) - - it('should return [data, opts] for POST', () => { - const req = { method: 'POST', apiData: baseApiData } - const result = AbstractApiUtils.argsFromReq(req) - assert.deepEqual(result[0], baseApiData.data) - assert.equal(result.length, 2) - }) - - it('should return [query, data, opts] for PUT', () => { - const req = { method: 'PUT', apiData: baseApiData } - const result = AbstractApiUtils.argsFromReq(req) - assert.deepEqual(result[0], baseApiData.query) - assert.deepEqual(result[1], baseApiData.data) - assert.equal(result.length, 3) - }) - - it('should return [query, data, opts] for PATCH', () => { - const req = { method: 'PATCH', apiData: baseApiData } - const result = AbstractApiUtils.argsFromReq(req) - assert.deepEqual(result[0], baseApiData.query) - assert.deepEqual(result[1], baseApiData.data) - assert.equal(result.length, 3) - }) - - it('should return undefined for unknown methods', () => { - const req = { method: 'OPTIONS', apiData: baseApiData } - assert.equal(AbstractApiUtils.argsFromReq(req), undefined) - }) - }) -})