diff --git a/adex/package.json b/adex/package.json index 60ff7d9..bfad76c 100644 --- a/adex/package.json +++ b/adex/package.json @@ -102,6 +102,6 @@ "peerDependencies": { "@preact/preset-vite": ">=2.8.2", "adex-adapter-node": ">=0.0.15", - "preact": "^10.22.0" + "preact": "^10.26.9" } } diff --git a/adex/runtime/handler.js b/adex/runtime/handler.js index bd14e8f..39973c6 100644 --- a/adex/runtime/handler.js +++ b/adex/runtime/handler.js @@ -14,6 +14,31 @@ import { routes as pageRoutes } from '~routes' const html = String.raw +function getMethodHandler(module, method) { + const supportedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + + // Check for method-specific export (case-insensitive) + for (const supportedMethod of supportedMethods) { + if (method.toUpperCase() === supportedMethod) { + if (module[supportedMethod] || module[supportedMethod.toLowerCase()]) { + return module[supportedMethod] || module[supportedMethod.toLowerCase()] + } + } + } + + // Fall back to default export + if (module.default) { + return module.default + } + + // Return 405 handler if neither exists + return (req, res) => { + res.statusCode = 405 + res.setHeader('Allow', supportedMethods.join(', ')) + res.end('Method Not Allowed') + } +} + export async function handler(req, res) { res.statusCode = 200 @@ -38,8 +63,7 @@ export async function handler(req, res) { } await emitToHooked(CONSTANTS.apiCall, modifiableContext) return { - serverHandler: - 'default' in module ? module.default : (_, res) => res.end(), + serverHandler: getMethodHandler(module, req.method || 'GET'), } } return { diff --git a/adex/src/http.d.ts b/adex/src/http.d.ts index 71429ff..d02c27b 100644 --- a/adex/src/http.d.ts +++ b/adex/src/http.d.ts @@ -19,5 +19,22 @@ export type ServerResponse = HTTPServerResponse & { internalServerError: (message?: string) => void } +export type APIHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise + +// Support for method-specific exports +export interface APIModule { + // Method-specific handlers + GET?: APIHandler + POST?: APIHandler + PUT?: APIHandler + PATCH?: APIHandler + DELETE?: APIHandler + OPTIONS?: APIHandler + HEAD?: APIHandler + + // Legacy default export support + default?: APIHandler +} + export function prepareRequest(req: IncomingMessage): void export function prepareResponse(res: ServerResponse): void diff --git a/adex/tests/integration-method-handlers.spec.js b/adex/tests/integration-method-handlers.spec.js new file mode 100644 index 0000000..1bef733 --- /dev/null +++ b/adex/tests/integration-method-handlers.spec.js @@ -0,0 +1,247 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' + +// Test with isolated handler function to avoid dependency issues +function createMethodHandlerTests() { + + function getMethodHandler(module, method) { + const supportedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + + // Check for method-specific export (case-insensitive) + for (const supportedMethod of supportedMethods) { + if (method.toUpperCase() === supportedMethod) { + if (module[supportedMethod] || module[supportedMethod.toLowerCase()]) { + return module[supportedMethod] || module[supportedMethod.toLowerCase()] + } + } + } + + // Fall back to default export + if (module.default) { + return module.default + } + + // Return 405 handler if neither exists + return (req, res) => { + res.statusCode = 405 + res.setHeader('Allow', supportedMethods.join(', ')) + res.end('Method Not Allowed') + } + } + + // Simulate API handler modules + const testModules = { + getOnly: { + GET: (req, res) => { + res.statusCode = 200 + res.end('GET handler') + } + }, + + postOnly: { + POST: (req, res) => { + res.statusCode = 201 + res.end('POST handler') + } + }, + + multipleMethod: { + GET: (req, res) => { + res.statusCode = 200 + res.end('GET method') + }, + POST: (req, res) => { + res.statusCode = 201 + res.end('POST method') + }, + PUT: (req, res) => { + res.statusCode = 200 + res.end('PUT method') + } + }, + + mixedCase: { + get: (req, res) => { // lowercase + res.statusCode = 200 + res.end('lowercase get') + }, + POST: (req, res) => { // uppercase + res.statusCode = 201 + res.end('uppercase POST') + } + }, + + defaultFallback: { + GET: (req, res) => { + res.statusCode = 200 + res.end('GET specific') + }, + default: (req, res) => { + res.statusCode = 200 + res.end('default handler') + } + }, + + onlyDefault: { + default: (req, res) => { + res.statusCode = 200 + res.end('only default') + } + }, + + empty: { + // No handlers + } + } + + class MockResponse { + constructor() { + this.statusCode = 200 + this.headers = {} + this.body = '' + } + + setHeader(name, value) { + this.headers[name] = value + } + + end(data) { + if (data) this.body = data + } + } + + return { getMethodHandler, testModules, MockResponse } +} + +describe('Method-specific handler integration tests', () => { + const { getMethodHandler, testModules, MockResponse } = createMethodHandlerTests() + + it('should handle GET request with GET export', () => { + const handler = getMethodHandler(testModules.getOnly, 'GET') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.body, 'GET handler') + }) + + it('should handle POST request with POST export', () => { + const handler = getMethodHandler(testModules.postOnly, 'POST') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 201) + assert.strictEqual(res.body, 'POST handler') + }) + + it('should return 405 for unsupported method', () => { + const handler = getMethodHandler(testModules.getOnly, 'POST') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 405) + assert.strictEqual(res.headers['Allow'], 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD') + assert.strictEqual(res.body, 'Method Not Allowed') + }) + + it('should handle multiple methods correctly', () => { + const methods = ['GET', 'POST', 'PUT'] + const expectedBodies = ['GET method', 'POST method', 'PUT method'] + const expectedCodes = [200, 201, 200] + + methods.forEach((method, i) => { + const handler = getMethodHandler(testModules.multipleMethod, method) + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, expectedCodes[i]) + assert.strictEqual(res.body, expectedBodies[i]) + }) + }) + + it('should handle case-insensitive method matching', () => { + // Test lowercase export with uppercase method + const getHandler = getMethodHandler(testModules.mixedCase, 'GET') + const getRes = new MockResponse() + getHandler({}, getRes) + + assert.strictEqual(getRes.statusCode, 200) + assert.strictEqual(getRes.body, 'lowercase get') + + // Test uppercase export with lowercase method + const postHandler = getMethodHandler(testModules.mixedCase, 'post') + const postRes = new MockResponse() + postHandler({}, postRes) + + assert.strictEqual(postRes.statusCode, 201) + assert.strictEqual(postRes.body, 'uppercase POST') + }) + + it('should prefer method-specific over default', () => { + const handler = getMethodHandler(testModules.defaultFallback, 'GET') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.body, 'GET specific') + }) + + it('should fall back to default when method not available', () => { + const handler = getMethodHandler(testModules.defaultFallback, 'POST') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.body, 'default handler') + }) + + it('should use default when only default is available', () => { + const handler = getMethodHandler(testModules.onlyDefault, 'GET') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.body, 'only default') + }) + + it('should return 405 when no handlers available', () => { + const handler = getMethodHandler(testModules.empty, 'GET') + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 405) + assert.strictEqual(res.headers['Allow'], 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD') + assert.strictEqual(res.body, 'Method Not Allowed') + }) + + it('should support all HTTP methods', () => { + const allMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + + // Create a module with all methods + const allMethodsModule = {} + allMethods.forEach(method => { + allMethodsModule[method] = (req, res) => { + res.statusCode = 200 + res.end(`${method} handler`) + } + }) + + allMethods.forEach(method => { + const handler = getMethodHandler(allMethodsModule, method) + const res = new MockResponse() + + handler({}, res) + + assert.strictEqual(res.statusCode, 200) + assert.strictEqual(res.body, `${method} handler`) + }) + }) +}) \ No newline at end of file diff --git a/adex/tests/method-specific-handlers.spec.js b/adex/tests/method-specific-handlers.spec.js new file mode 100644 index 0000000..70457c6 --- /dev/null +++ b/adex/tests/method-specific-handlers.spec.js @@ -0,0 +1,123 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert' + +// Test the method dispatch logic separately +function getMethodHandler(module, method) { + const supportedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + + // Check for method-specific export (case-insensitive) + for (const supportedMethod of supportedMethods) { + if (method.toUpperCase() === supportedMethod) { + if (module[supportedMethod] || module[supportedMethod.toLowerCase()]) { + return module[supportedMethod] || module[supportedMethod.toLowerCase()] + } + } + } + + // Fall back to default export + if (module.default) { + return module.default + } + + // Return 405 handler if neither exists + return (req, res) => { + res.statusCode = 405 + res.setHeader('Allow', supportedMethods.join(', ')) + res.end('Method Not Allowed') + } +} + +describe('Method-specific API handler dispatch logic', () => { + + it('should use GET method-specific export', () => { + const module = { + GET: (req, res) => 'GET handler', + POST: (req, res) => 'POST handler' + } + + const handler = getMethodHandler(module, 'GET') + assert.strictEqual(handler.name, module.GET.name) + }) + + it('should use POST method-specific export', () => { + const module = { + GET: (req, res) => 'GET handler', + POST: (req, res) => 'POST handler' + } + + const handler = getMethodHandler(module, 'POST') + assert.strictEqual(handler.name, module.POST.name) + }) + + it('should handle case-insensitive method matching', () => { + const module = { + put: (req, res) => 'PUT handler' // lowercase export + } + + const handler = getMethodHandler(module, 'PUT') + assert.strictEqual(handler, module.put) + }) + + it('should fall back to default export when method export not found', () => { + const module = { + default: (req, res) => 'Default handler' + } + + const handler = getMethodHandler(module, 'GET') + assert.strictEqual(handler, module.default) + }) + + it('should prefer method-specific export over default', () => { + const module = { + GET: (req, res) => 'GET specific', + default: (req, res) => 'Default handler' + } + + const handler = getMethodHandler(module, 'GET') + assert.strictEqual(handler, module.GET) + }) + + it('should fall back to default when method not found but default exists', () => { + const module = { + GET: (req, res) => 'GET handler', + default: (req, res) => 'Default handler' + } + + const handler = getMethodHandler(module, 'DELETE') + assert.strictEqual(handler, module.default) + }) + + it('should return 405 handler when neither method nor default export exists', () => { + const module = { + // No handlers + } + + // Mock response object + const res = { + statusCode: 200, + headers: {}, + setHeader(name, value) { this.headers[name] = value }, + end(data) { this.endData = data } + } + + const handler = getMethodHandler(module, 'GET') + handler({}, res) + + assert.strictEqual(res.statusCode, 405) + assert.strictEqual(res.headers['Allow'], 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD') + assert.strictEqual(res.endData, 'Method Not Allowed') + }) + + it('should support all HTTP methods', () => { + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + + methods.forEach(method => { + const module = { + [method]: () => `${method} handler` + } + + const handler = getMethodHandler(module, method) + assert.strictEqual(handler, module[method]) + }) + }) +}) \ No newline at end of file