Skip to content

Commit

Permalink
Merge pull request #71 from bouwe77/etags
Browse files Browse the repository at this point in the history
Etags
  • Loading branch information
bouwe77 authored Aug 15, 2024
2 parents 5479912 + 3d0707f commit 6773fc4
Show file tree
Hide file tree
Showing 15 changed files with 528 additions and 133 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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 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

Because Temba uses Express under the hood, you can create an Express router, and configure it as a `customRouter`:
Expand Down Expand Up @@ -439,6 +454,7 @@ const config = {
connectionString: 'mongodb://localhost:27017/myDatabase',
customRouter: router,
delay: 500,
etags: true,
port: 4321,
requestInterceptor: {
get: ({ resource, id }) => {
Expand Down Expand Up @@ -487,6 +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](#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) | `[]` |
Expand Down
271 changes: 143 additions & 128 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type Config = {
port: number
schemas: ConfiguredSchemas | null
allowDeleteCollection: boolean
etags: boolean
}

export type ConfigKey = keyof Config
Expand All @@ -31,6 +32,7 @@ export type RouterConfig = Pick<
| 'responseBodyInterceptor'
| 'returnNullFields'
| 'allowDeleteCollection'
| 'etags'
>

export type UserConfig = {
Expand All @@ -47,6 +49,7 @@ export type UserConfig = {
port?: number
schemas?: ConfiguredSchemas
allowDeleteCollection?: boolean
etags?: boolean
}

const defaultConfig: Config = {
Expand All @@ -64,6 +67,7 @@ const defaultConfig: Config = {
port: 3000,
schemas: null,
allowDeleteCollection: false,
etags: false,
}

export const initConfig = (userConfig?: UserConfig): Config => {
Expand Down Expand Up @@ -161,6 +165,10 @@ export const initConfig = (userConfig?: UserConfig): Config => {
config.allowDeleteCollection = userConfig.allowDeleteCollection
}

if (!isUndefined(userConfig.etags)) {
config.etags = userConfig.etags
}

return config
}

Expand Down
10 changes: 10 additions & 0 deletions src/etags/etags.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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'))

Expand Down
39 changes: 39 additions & 0 deletions src/requestHandlers/delete.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,6 +9,7 @@ export const createDeleteRoutes = (
queries: Queries,
allowDeleteCollection: boolean,
requestInterceptor: RequestInterceptor | null,
etags: boolean,
) => {
const handleDelete = async (req: DeleteRequest) => {
try {
Expand All @@ -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)
}

Expand Down
20 changes: 16 additions & 4 deletions src/requestHandlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ export const getRequestHandler = (
schemas: CompiledSchemas,
routerConfig: RouterConfig,
) => {
const { requestInterceptor, responseBodyInterceptor, returnNullFields, allowDeleteCollection } =
routerConfig
const {
requestInterceptor,
responseBodyInterceptor,
returnNullFields,
allowDeleteCollection,
etags,
} = routerConfig

const handleGet = createGetRoutes(
queries,
Expand All @@ -25,16 +30,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,
Expand Down
14 changes: 14 additions & 0 deletions src/requestHandlers/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/requestHandlers/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/requestHandlers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type RequestInfo = {
host: string | null
protocol: string | null
method: string
etag: string | null
}

export type ErrorResponse = {
Expand All @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions src/resourceRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const convertToPutRequest = (requestInfo: RequestInfo) => {
id: requestInfo.id!,
resource: requestInfo.resource,
body: requestInfo.body ?? {},
etag: requestInfo.etag ?? null,
} satisfies PutRequest
}

Expand All @@ -75,6 +76,7 @@ const convertToDeleteRequest = (requestInfo: RequestInfo) => {
return {
id: requestInfo.id,
resource: requestInfo.resource,
etag: requestInfo.etag ?? null,
} satisfies DeleteRequest
}

Expand All @@ -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,
Expand All @@ -105,6 +108,7 @@ export const createResourceRouter = (
host,
protocol,
method: req.method,
etag,
} satisfies RequestInfo
}

Expand Down
Loading

0 comments on commit 6773fc4

Please sign in to comment.