From 7e4b62112f3e010cad6b99c001aad4afb13b5db6 Mon Sep 17 00:00:00 2001 From: Bouwe Date: Thu, 15 Aug 2024 21:41:20 +0100 Subject: [PATCH 1/5] chore: docs for etags support --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 449b4ec..b866701 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,21 @@ If you don't return anything, the response body will be sent as-is. The `responseBodyInterceptor` will only be called when the response was successful, i.e. a `200 OK` status code. +### Caching and consistency with Etags + +To optimize `GET` requests, and only send JSON over the wire when it changed, you can configure to enable Etags. Etags also prevent so-called mid-air collisions, where a client tries to update en item that has been updated by another client in the meantime: + +```js +const config = { + etags: true, +} +const server = create(config) +``` + +After enabling etags, every `GET` request will return an `etag` response header, which clients can send as a `If-None-Match` header with every subsequent `GET` request. Only if the resource changed in the meantime the server will return the new JSON, and otherwise it will return a `304 Not Modified` response with an empty response body. + +For updating or deleting items with a `PUT`, `PATCH`, or `DELETE`, after enabling etags, these requests are required to provide a `If-Match` header with the etag. Only if the etag represents the latest version of the resource the update is made, otherwise the server responds with a `412 Precondition Failed` status code. + ### Custom router Because Temba uses Express under the hood, you can create an Express router, and configure it as a `customRouter`: @@ -440,6 +455,7 @@ const config = { connectionString: 'mongodb://localhost:27017/myDatabase', customRouter: router, delay: 500, + etags: true, port: 4321, requestInterceptor: { get: ({ resource, id }) => { @@ -489,6 +505,7 @@ These are all the possible settings: | `connectionString` | See [Data persistency](#data-persistency) | `null` | | `customRouter` | See [Custom router](#custom-router) | `null` | | `delay` | The delay, in milliseconds, after processing the request before sending the response. | `0` | +| `etags` | See [Caching and consistency with Etags] | `false` | | `port` | The port your Temba server listens on | `3000` | | `requestInterceptor` | See [Request validation or mutation](#request-validation-or-mutation) | `noop` | | `resources` | See [Allowing specific resources only](#allowing-specific-resources-only) | `[]` | From 0deed3691cb7ecbf2d710c4d17635ed25948450b Mon Sep 17 00:00:00 2001 From: Bouwe Date: Thu, 15 Aug 2024 21:42:57 +0100 Subject: [PATCH 2/5] tests: etags --- test/integration/etag.test.ts | 241 ++++++++++++++++++++++++++++++++ test/integration/helpers.ts | 7 + test/unit/config/config.test.ts | 5 + 3 files changed, 253 insertions(+) create mode 100644 test/integration/etag.test.ts create mode 100644 test/integration/helpers.ts diff --git a/test/integration/etag.test.ts b/test/integration/etag.test.ts new file mode 100644 index 0000000..3402a7a --- /dev/null +++ b/test/integration/etag.test.ts @@ -0,0 +1,241 @@ +import { test, expect } from 'vitest' +import request from 'supertest' +import type { UserConfig } from '../../src/config' +import createServer from './createServer' +import { expectSuccess } from './helpers' + +/* + Tests etag behavior when configured. +*/ + +test('GET does not return an etag header by default', async () => { + const tembaServer = createServer() + const response = await request(tembaServer).get('/') + + expect(response.headers['etag']).toBeUndefined() + expect(response.statusCode).toEqual(200) +}) + +test('GET returns an etag header when configured', async () => { + const tembaServer = createServer({ etags: true } satisfies UserConfig) + const response = await request(tembaServer).get('/') + + expect(response.headers['etag']).toBeDefined() + expect(response.statusCode).toEqual(200) +}) + +test('GET only returns a different etag if the resource changed', async () => { + const tembaServer = createServer({ etags: true } satisfies UserConfig) + + // Create a resource + const postResponse = await request(tembaServer).post('/stuff').send({ name: 'item 1' }) + expectSuccess(postResponse) + const id = postResponse.body.id + + // Get the created resource + const getResponse = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse) + const etag1 = getResponse.headers['etag'] + + // Get the created resource again + const getResponse2 = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse2) + const etag2 = getResponse2.headers['etag'] + + // The etags should be the same + expect(etag1).toEqual(etag2) + + // Update the resource + const putResponse = await request(tembaServer) + .put('/stuff/' + id) + .send({ name: 'item 2' }) + .set('If-Match', etag1) + expectSuccess(putResponse) + + // Get the resource again + const getResponse3 = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse3) + const etag3 = getResponse3.headers['etag'] + + // The etags should be different + expect(etag1).not.toEqual(etag3) +}) + +test('GET with If-None-Match returns 304 Not Modified if etag is the same', async () => { + const tembaServer = createServer({ etags: true } satisfies UserConfig) + + // Create a resource + const postResponse = await request(tembaServer).post('/stuff').send({ name: 'item 1' }) + expectSuccess(postResponse) + const id = postResponse.body.id + + // Get the created resource + const getResponse = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse) + const etag = getResponse.headers['etag'] + + // Get the resource again with the etag + const getResponse2 = await request(tembaServer) + .get('/stuff/' + id) + .set('If-None-Match', etag) + expect(getResponse2.statusCode).toEqual(304) + + // Update the resource + const putResponse = await request(tembaServer) + .put('/stuff/' + id) + .send({ name: 'item 2' }) + .set('If-Match', etag) + expectSuccess(putResponse) + + // Get the resource again with the etag from the GET before the update + const getResponse3 = await request(tembaServer) + .get('/stuff/' + id) + .set('If-None-Match', etag) + expectSuccess(getResponse3) + expect(getResponse3.statusCode).toEqual(200) + expect(getResponse3.body.name).toEqual('item 2') +}) + +test('PUT requires an If-Match header with an up to date etag', async () => { + const tembaServer = createServer({ etags: true } satisfies UserConfig) + + // Create a resource + const postResponse = await request(tembaServer).post('/stuff').send({ name: 'item 1' }) + expectSuccess(postResponse) + const id = postResponse.body.id + + // Get the created resource + const getResponse = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse) + const etag = getResponse.headers['etag'] + + // Try to update the resource without an If-Match header, which is not allowed + const putResponse = await request(tembaServer) + .put('/stuff/' + id) + .send({ name: 'item 2' }) + expect(putResponse.statusCode).toEqual(412) + + // Now try to update the resource with the etag, which should work + const putResponse2 = await request(tembaServer) + .put('/stuff/' + id) + .send({ name: 'item 3' }) + .set('If-Match', etag) + expectSuccess(putResponse2) + + // Try to update the resource again, but now with the old etag, which is also not allowed + const putResponse3 = await request(tembaServer) + .put('/stuff/' + id) + .send({ name: 'item 4' }) + .set('If-Match', etag) + expect(putResponse3.statusCode).toEqual(412) + + // If we get the resource again, it should have a new etag + const getResponse2 = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse2) + const etag2 = getResponse2.headers['etag'] + expect(etag).not.toEqual(etag2) +}) + +test('PATCH requires an If-Match header with an up to date etag', async () => { + const tembaServer = createServer({ etags: true } satisfies UserConfig) + + // Create a resource + const postResponse = await request(tembaServer).post('/stuff').send({ name: 'item 1' }) + expectSuccess(postResponse) + const id = postResponse.body.id + + // Get the created resource + const getResponse = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse) + const etag = getResponse.headers['etag'] + + // Try to update the resource without an If-Match header, which is not allowed + const patchResponse = await request(tembaServer) + .patch('/stuff/' + id) + .send({ name: 'item 2' }) + expect(patchResponse.statusCode).toEqual(412) + + // Now try to update the resource with the etag, which should work + const patchResponse2 = await request(tembaServer) + .patch('/stuff/' + id) + .send({ name: 'item 3' }) + .set('If-Match', etag) + expectSuccess(patchResponse2) + + // Try to update the resource again, but now with the old etag, which is also not allowed + const patchResponse3 = await request(tembaServer) + .patch('/stuff/' + id) + .send({ name: 'item 4' }) + .set('If-Match', etag) + expect(patchResponse3.statusCode).toEqual(412) + + // If we get the resource again, it should have a new etag + const getResponse2 = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse2) + const etag2 = getResponse2.headers['etag'] + expect(etag).not.toEqual(etag2) +}) + +test.only('DELETE an item requires an If-Match header with an up to date etag', async () => { + const tembaServer = createServer({ etags: true } satisfies UserConfig) + + // Create a resource + const postResponse = await request(tembaServer).post('/stuff').send({ name: 'item 1' }) + expectSuccess(postResponse) + const id = postResponse.body.id + + // Get the created resource + const getResponse = await request(tembaServer).get('/stuff/' + id) + expectSuccess(getResponse) + const etag = getResponse.headers['etag'] + + // Try to delete the resource without an If-Match header, which is not allowed + const deleteResponse = await request(tembaServer).delete('/stuff/' + id) + expect(deleteResponse.statusCode).toEqual(412) + + // Now try to delete the resource with the etag, which should work + const deleteResponse2 = await request(tembaServer) + .delete('/stuff/' + id) + .set('If-Match', etag) + expectSuccess(deleteResponse2) + + // Try to delete the resource again, which is fine because it's already gone + const deleteResponse3 = await request(tembaServer) + .delete('/stuff/' + id) + .set('If-Match', etag) + expect(deleteResponse3.statusCode).toEqual(204) +}) + +test('DELETE a collection requires an If-Match header with an up to date etag', async () => { + const tembaServer = createServer({ + etags: true, + allowDeleteCollection: true, + } satisfies UserConfig) + + // Create a resource + const postResponse = await request(tembaServer).post('/stuff').send({ name: 'item 1' }) + expectSuccess(postResponse) + + // GET the collection + const getResponse = await request(tembaServer).get('/stuff') + expectSuccess(getResponse) + const etag = getResponse.headers['etag'] + + // Try to delete the collection without an If-Match header, which is not allowed + const deleteResponse = await request(tembaServer).delete('/stuff') + expect(deleteResponse.statusCode).toEqual(412) + + // Now try to delete the collection with the etag, which should work + const deleteResponse2 = await request(tembaServer).delete('/stuff').set('If-Match', etag) + expectSuccess(deleteResponse2) + + // Try to delete the resource again, but now with the old etag, which is also not allowed + const deleteResponse3 = await request(tembaServer).delete('/stuff').set('If-Match', etag) + expect(deleteResponse3.statusCode).toEqual(412) + + // If we get the collection again, it should have a new etag + const getResponse2 = await request(tembaServer).get('/stuff') + expectSuccess(getResponse2) + const etag2 = getResponse2.headers['etag'] + expect(etag).not.toEqual(etag2) +}) diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts new file mode 100644 index 0000000..ec0953b --- /dev/null +++ b/test/integration/helpers.ts @@ -0,0 +1,7 @@ +import { expect } from 'vitest' +import { Response } from 'supertest' + +export const expectSuccess = (response: Response) => { + expect(response.statusCode).toBeGreaterThanOrEqual(200) + expect(response.statusCode).toBeLessThan(300) +} diff --git a/test/unit/config/config.test.ts b/test/unit/config/config.test.ts index 569d87d..e0e1b6d 100644 --- a/test/unit/config/config.test.ts +++ b/test/unit/config/config.test.ts @@ -19,6 +19,7 @@ const defaultConfig: Config = { port: 3000, schemas: null, allowDeleteCollection: false, + etags: false, } test('No config returns default config', () => { @@ -45,6 +46,7 @@ test('No config returns default config', () => { expect(initializedConfig.port).toBe(defaultConfig.port) expect(initializedConfig.schemas).toBe(defaultConfig.schemas) expect(initializedConfig.allowDeleteCollection).toBe(defaultConfig.allowDeleteCollection) + expect(initializedConfig.etags).toBe(defaultConfig.etags) }) test('Full user config overrides all defaults', () => { @@ -98,6 +100,7 @@ test('Full user config overrides all defaults', () => { }, }, allowDeleteCollection: true, + etags: true, }) expect(config.resources).toEqual(['movies']) @@ -119,6 +122,7 @@ test('Full user config overrides all defaults', () => { expect(config.port).toBe(3001) expect(config.schemas).not.toBeNull() expect(config.allowDeleteCollection).toBe(true) + expect(config.etags).toBe(true) }) test('Partial user config applies those, but leaves the rest at default', () => { @@ -145,4 +149,5 @@ test('Partial user config applies those, but leaves the rest at default', () => expect(config.port).toBe(defaultConfig.port) expect(config.schemas).toBe(defaultConfig.schemas) expect(config.allowDeleteCollection).toBe(defaultConfig.allowDeleteCollection) + expect(config.etags).toBe(defaultConfig.etags) }) From a23c199c6ee2d7d3463aca481a5125008721a4ea Mon Sep 17 00:00:00 2001 From: Bouwe Date: Thu, 15 Aug 2024 21:43:18 +0100 Subject: [PATCH 3/5] feat: etags support --- package-lock.json | 267 ++++++++++++++++++---------------- package.json | 2 + src/config/index.ts | 8 + src/etags/etags.ts | 10 ++ src/index.ts | 4 + src/requestHandlers/delete.ts | 39 +++++ src/requestHandlers/index.ts | 12 +- src/requestHandlers/patch.ts | 14 ++ src/requestHandlers/put.ts | 14 ++ src/requestHandlers/types.ts | 3 + src/resourceRouter.ts | 4 + 11 files changed, 249 insertions(+), 128 deletions(-) create mode 100644 src/etags/etags.ts diff --git a/package-lock.json b/package-lock.json index f5793ae..d0ba554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,14 @@ "connect-pause": "^0.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", + "etag": "^1.8.1", "express": "^4.18.3", "lowdb": "^7.0.1", "morgan": "^1.10.0" }, "devDependencies": { "@types/cors": "^2.8.17", + "@types/etag": "^1.8.3", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/supertest": "^6.0.2", @@ -57,9 +59,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -73,9 +75,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -89,9 +91,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -105,9 +107,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -121,9 +123,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -137,9 +139,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -153,9 +155,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -169,9 +171,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -185,9 +187,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -201,9 +203,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -217,9 +219,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -233,9 +235,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -249,9 +251,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -265,9 +267,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -281,9 +283,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -297,9 +299,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -313,9 +315,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -329,9 +331,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -345,9 +347,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -361,9 +363,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -377,9 +379,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -393,9 +395,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -409,9 +411,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -906,6 +908,15 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/etag": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@types/etag/-/etag-1.8.3.tgz", + "integrity": "sha512-QYHv9Yeh1ZYSMPQOoxY4XC4F1r+xRUiAriB303F4G6uBsT3KKX60DjiogvVv+2VISVDuJhcIzMdbjT+Bm938QQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -1554,12 +1565,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1781,9 +1792,9 @@ } }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -2017,9 +2028,9 @@ } }, "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -2029,29 +2040,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escape-html": { @@ -2310,16 +2321,16 @@ } }, "node_modules/express": { - "version": "4.18.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz", - "integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -2436,9 +2447,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -3580,9 +3591,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picoid": { @@ -3629,9 +3640,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "dev": true, "funding": [ { @@ -3649,8 +3660,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4148,9 +4159,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -4550,14 +4561,14 @@ } }, "node_modules/vite": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.6.tgz", - "integrity": "sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", + "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", "dev": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.35", - "rollup": "^4.2.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.40", + "rollup": "^4.13.0" }, "bin": { "vite": "bin/vite.js" @@ -4576,6 +4587,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -4593,6 +4605,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, diff --git a/package.json b/package.json index 9c9e0f6..287023c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "license": "ISC", "devDependencies": { "@types/cors": "^2.8.17", + "@types/etag": "^1.8.3", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/supertest": "^6.0.2", @@ -40,6 +41,7 @@ "connect-pause": "^0.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", + "etag": "^1.8.1", "express": "^4.18.3", "lowdb": "^7.0.1", "morgan": "^1.10.0" diff --git a/src/config/index.ts b/src/config/index.ts index 4953e86..87fd90c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -19,6 +19,7 @@ export type Config = { port: number schemas: ConfiguredSchemas | null allowDeleteCollection: boolean + etags: boolean } export type ConfigKey = keyof Config @@ -33,6 +34,7 @@ export type RouterConfig = Pick< | 'responseBodyInterceptor' | 'returnNullFields' | 'allowDeleteCollection' + | 'etags' > export type UserConfig = { @@ -50,6 +52,7 @@ export type UserConfig = { port?: number schemas?: ConfiguredSchemas allowDeleteCollection?: boolean + etags?: boolean } const defaultConfig: Config = { @@ -68,6 +71,7 @@ const defaultConfig: Config = { port: 3000, schemas: null, allowDeleteCollection: false, + etags: false, } export const initConfig = (userConfig?: UserConfig): Config => { @@ -169,6 +173,10 @@ export const initConfig = (userConfig?: UserConfig): Config => { config.allowDeleteCollection = userConfig.allowDeleteCollection } + if (!isUndefined(userConfig.etags)) { + config.etags = userConfig.etags + } + return config } diff --git a/src/etags/etags.ts b/src/etags/etags.ts new file mode 100644 index 0000000..8fb924e --- /dev/null +++ b/src/etags/etags.ts @@ -0,0 +1,10 @@ +import etagFromExpress, { type StatsLike } from 'etag' + +// Temba uses generating etags both for generating etags and for comparing etags. +// Express only supports generating them, so to be sure we use the same algorithm for both, +// we use the etag module for both by wrapping it here. +// This means we also override the default Express etag function, but that's with the same algorithm. + +export const etag = (entity: string | Buffer | StatsLike) => { + return etagFromExpress(entity, { weak: true }) +} diff --git a/src/index.ts b/src/index.ts index 565fb72..844259d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ import { createResourceRouter } from './resourceRouter' import { TembaError as TembaErrorInternal } from './requestInterceptor/TembaError' import { createAuthMiddleware, isAuthEnabled } from './auth/auth' import { initLogger } from './log/logger' +import { etag } from './etags/etags' +import type { StatsLike } from 'etag' // Route for handling not allowed methods. const handleMethodNotAllowed = (_: Request, res: Response) => { @@ -32,6 +34,8 @@ const createServer = (userConfig?: UserConfig) => { const app = express() app.use(json()) + app.set('etag', config.etags ? (entity: string | Buffer | StatsLike) => etag(entity) : false) + // Add HTTP request logging. if (logLevel === 'debug') app.use(morgan('tiny')) diff --git a/src/requestHandlers/delete.ts b/src/requestHandlers/delete.ts index 55c5850..0fabb17 100644 --- a/src/requestHandlers/delete.ts +++ b/src/requestHandlers/delete.ts @@ -1,5 +1,6 @@ import { TembaError } from '..' import type { Queries } from '../data/types' +import { etag } from '../etags/etags' import { interceptDeleteRequest } from '../requestInterceptor/interceptRequest' import type { RequestInterceptor } from '../requestInterceptor/types' import type { DeleteRequest } from './types' @@ -8,6 +9,7 @@ export const createDeleteRoutes = ( queries: Queries, allowDeleteCollection: boolean, requestInterceptor: RequestInterceptor | null, + etags: boolean, ) => { const handleDelete = async (req: DeleteRequest) => { try { @@ -27,12 +29,49 @@ export const createDeleteRoutes = ( if (id) { const item = await queries.getById(resource, id) if (item) { + if (etags) { + const itemEtag = etag(JSON.stringify(item)) + if (req.etag !== itemEtag) { + return { + status: 412, + body: { + message: 'Precondition failed', + }, + } + } + } + await queries.deleteById(resource, id) + } else { + // Even when deleting a non existing item, we still need an etag. + // The client needs to do a GET to determine it, after which it finds out the item is gone. + if (etags && !req.etag) { + return { + status: 412, + body: { + message: 'Precondition failed', + }, + } + } } } else { if (!allowDeleteCollection) { return { status: 405 } } + + if (etags) { + const items = await queries.getAll(resource) + const etagValue = etag(JSON.stringify(items)) + if (req.etag !== etagValue) { + return { + status: 412, + body: { + message: 'Precondition failed', + }, + } + } + } + await queries.deleteAll(resource) } diff --git a/src/requestHandlers/index.ts b/src/requestHandlers/index.ts index 6db15c4..8b6580b 100644 --- a/src/requestHandlers/index.ts +++ b/src/requestHandlers/index.ts @@ -19,6 +19,7 @@ export const getRequestHandler = ( responseBodyInterceptor, returnNullFields, allowDeleteCollection, + etags, } = routerConfig const handleGet = createGetRoutes( @@ -31,16 +32,23 @@ export const getRequestHandler = ( const handlePost = createPostRoutes(queries, requestInterceptor, returnNullFields, schemas.post) - const handlePut = createPutRoutes(queries, requestInterceptor, returnNullFields, schemas.put) + const handlePut = createPutRoutes( + queries, + requestInterceptor, + returnNullFields, + schemas.put, + etags, + ) const handlePatch = createPatchRoutes( queries, requestInterceptor, returnNullFields, schemas.patch, + etags, ) - const handleDelete = createDeleteRoutes(queries, allowDeleteCollection, requestInterceptor) + const handleDelete = createDeleteRoutes(queries, allowDeleteCollection, requestInterceptor, etags) return { handleGet, diff --git a/src/requestHandlers/patch.ts b/src/requestHandlers/patch.ts index f149089..e117f60 100644 --- a/src/requestHandlers/patch.ts +++ b/src/requestHandlers/patch.ts @@ -6,12 +6,14 @@ import type { PatchRequest } from './types' import type { Queries } from '../data/types' import type { RequestInterceptor } from '../requestInterceptor/types' import { TembaError } from '../requestInterceptor/TembaError' +import { etag } from '../etags/etags' export const createPatchRoutes = ( queries: Queries, requestInterceptor: RequestInterceptor | null, returnNullFields: boolean, schemas: ValidateFunctionPerResource | null, + etags: boolean, ) => { const handlePatch = async (req: PatchRequest) => { try { @@ -44,6 +46,18 @@ export const createPatchRoutes = ( }, } + if (etags) { + const itemEtag = etag(JSON.stringify(item)) + if (req.etag !== itemEtag) { + return { + status: 412, + body: { + message: 'Precondition failed', + }, + } + } + } + item = { ...item, ...(body2 as object), id } const updatedItem = await queries.update(resource, item) diff --git a/src/requestHandlers/put.ts b/src/requestHandlers/put.ts index 86043e6..f9c6854 100644 --- a/src/requestHandlers/put.ts +++ b/src/requestHandlers/put.ts @@ -6,12 +6,14 @@ import type { PutRequest } from './types' import type { Queries } from '../data/types' import type { RequestInterceptor } from '../requestInterceptor/types' import { TembaError } from '../requestInterceptor/TembaError' +import { etag } from '../etags/etags' export const createPutRoutes = ( queries: Queries, requestInterceptor: RequestInterceptor | null, returnNullFields: boolean, schemas: ValidateFunctionPerResource | null, + etags: boolean, ) => { const handlePut = async (req: PutRequest) => { try { @@ -44,6 +46,18 @@ export const createPutRoutes = ( }, } + if (etags) { + const itemEtag = etag(JSON.stringify(item)) + if (req.etag !== itemEtag) { + return { + status: 412, + body: { + message: 'Precondition failed', + }, + } + } + } + item = { ...(body2 as object), id } const replacedItem = await queries.replace(resource, item) diff --git a/src/requestHandlers/types.ts b/src/requestHandlers/types.ts index e6f17ad..a85adf5 100644 --- a/src/requestHandlers/types.ts +++ b/src/requestHandlers/types.ts @@ -10,6 +10,7 @@ export type RequestInfo = { host: string | null protocol: string | null method: string + etag: string | null } export type ErrorResponse = { @@ -35,12 +36,14 @@ export type PostRequest = TembaRequest & { export type PutRequest = TembaRequest & { id: string body: unknown + etag: string | null } export type PatchRequest = PutRequest export type DeleteRequest = TembaRequest & { id: string | null + etag: string | null } export type TembaResponse = { diff --git a/src/resourceRouter.ts b/src/resourceRouter.ts index e3747e5..be30631 100644 --- a/src/resourceRouter.ts +++ b/src/resourceRouter.ts @@ -66,6 +66,7 @@ const convertToPutRequest = (requestInfo: RequestInfo) => { id: requestInfo.id!, resource: requestInfo.resource, body: requestInfo.body ?? {}, + etag: requestInfo.etag ?? null, } satisfies PutRequest } @@ -75,6 +76,7 @@ const convertToDeleteRequest = (requestInfo: RequestInfo) => { return { id: requestInfo.id, resource: requestInfo.resource, + etag: requestInfo.etag ?? null, } satisfies DeleteRequest } @@ -97,6 +99,7 @@ export const createResourceRouter = ( const host = req.get('host') || null const protocol = host ? req.protocol : null + const etag = req.headers['if-match'] ?? null return { id: urlInfo.id, @@ -105,6 +108,7 @@ export const createResourceRouter = ( host, protocol, method: req.method, + etag, } satisfies RequestInfo } From 76eb3763b9246f19033a180a58d0111968e76b79 Mon Sep 17 00:00:00 2001 From: Bouwe Date: Thu, 15 Aug 2024 22:22:42 +0100 Subject: [PATCH 4/5] docs: misc etags fixes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 91d2638..7f4385d 100644 --- a/README.md +++ b/README.md @@ -375,9 +375,9 @@ const config = { const server = create(config) ``` -After enabling etags, every `GET` request will return an `etag` response header, which clients can send as a `If-None-Match` header with every subsequent `GET` request. Only if the resource changed in the meantime the server will return the new JSON, and otherwise it will return a `304 Not Modified` response with an empty response body. +After enabling etags, every `GET` request will return an `etag` response header, which clients can (optionally) send as an `If-None-Match` header with every subsequent `GET` request. Only if the resource changed in the meantime the server will return the new JSON, and otherwise it will return a `304 Not Modified` response with an empty response body. -For updating or deleting items with a `PUT`, `PATCH`, or `DELETE`, after enabling etags, these requests are required to provide a `If-Match` header with the etag. Only if the etag represents the latest version of the resource the update is made, otherwise the server responds with a `412 Precondition Failed` status code. +For updating or deleting items with a `PUT`, `PATCH`, or `DELETE`, after enabling etags, these requests are _required_ to provide an `If-Match` header with the etag. Only if the etag represents the latest version of the resource the update is made, otherwise the server responds with a `412 Precondition Failed` status code. ### Custom router @@ -503,7 +503,7 @@ These are all the possible settings: | `connectionString` | See [Data persistency](#data-persistency) | `null` | | `customRouter` | See [Custom router](#custom-router) | `null` | | `delay` | The delay, in milliseconds, after processing the request before sending the response. | `0` | -| `etags` | See [Caching and consistency with Etags] | `false` | +| `etags` | See [Caching and consistency with Etags](#caching-and-consistency-with-etags) | `false` | | `port` | The port your Temba server listens on | `3000` | | `requestInterceptor` | See [Request validation or mutation](#request-validation-or-mutation) | `noop` | | `resources` | See [Allowing specific resources only](#allowing-specific-resources-only) | `[]` | From 3d0707fa850a7e47a9f72ca254b10a05403e4c7f Mon Sep 17 00:00:00 2001 From: Bouwe Date: Thu, 15 Aug 2024 22:23:07 +0100 Subject: [PATCH 5/5] 0.33.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3491a1a..9bc035b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "temba", - "version": "0.32.0", + "version": "0.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "temba", - "version": "0.32.0", + "version": "0.33.0", "license": "ISC", "dependencies": { "@rakered/mongo": "^1.6.0", diff --git a/package.json b/package.json index 0b9125b..1eb5158 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "temba", - "version": "0.32.0", + "version": "0.33.0", "description": "Get a simple REST API with zero coding in less than 30 seconds (seriously).", "type": "module", "main": "dist/src/index.js",