Skip to content

Commit

Permalink
feat: add dynamoDB driver for storing sessions
Browse files Browse the repository at this point in the history
feat: Add DynamoDB as a Bundled Store for Sessions
  • Loading branch information
thetutlage authored Sep 23, 2024
2 parents 21fc8d2 + 545c57a commit f9a2f7b
Show file tree
Hide file tree
Showing 10 changed files with 564 additions and 34 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,29 @@ jobs:
--health-retries 5
ports:
- 6379:6379
dynamodb-local:
image: amazon/dynamodb-local
ports:
- 8000:8000
steps:
- uses: actions/checkout@v2
- name: Create DynamoDB Table
env:
AWS_ACCESS_KEY_ID: accessKeyId
AWS_SECRET_ACCESS_KEY: secretAccessKey
AWS_DEFAULT_REGION: us-east-1
run: |
aws dynamodb create-table --endpoint-url http://localhost:8000 \
--table-name Session \
--key-schema AttributeName=key,KeyType=HASH \
--attribute-definitions AttributeName=key,AttributeType=S \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \
&& aws dynamodb create-table --endpoint-url http://localhost:8000 \
--table-name CustomKeySession \
--key-schema AttributeName=sessionId,KeyType=HASH \
--attribute-definitions AttributeName=sessionId,AttributeType=S \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
Expand Down Expand Up @@ -59,3 +80,4 @@ jobs:
run: npm test
env:
NO_REDIS: true
NO_DYNAMODB: true
31 changes: 31 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,34 @@ services:
- 8001:8001
environment:
- REDIS_URI=redis://redis:6379

dynamodb-local:
image: amazon/dynamodb-local
ports:
- 8000:8000
command: '-jar DynamoDBLocal.jar -inMemory -sharedDb'
working_dir: /home/dynamodblocal
healthcheck:
test:
[
'CMD-SHELL',
'[ "$(curl -s -o /dev/null -I -w ''%{http_code}'' http://localhost:8000)" == "400" ]',
]
interval: 10s
timeout: 10s
retries: 10

dynamodb-local-setup:
depends_on:
dynamodb-local:
condition: service_healthy
image: amazon/aws-cli
volumes:
- './schemas:/tmp/dynamo'
environment:
AWS_ACCESS_KEY_ID: 'accessKeyId'
AWS_SECRET_ACCESS_KEY: 'secretAccessKey'
AWS_REGION: 'us-east-1'
entrypoint:
- bash
command: '-c "for f in /tmp/dynamo/*.json; do aws dynamodb create-table --endpoint-url "http://dynamodb-local:8000" --cli-input-json file://"$${f#./}"; done"'
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"@adonisjs/prettier-config": "^1.3.0",
"@adonisjs/redis": "^9.1.0",
"@adonisjs/tsconfig": "^1.3.0",
"@aws-sdk/client-dynamodb": "^3.651.1",
"@aws-sdk/util-dynamodb": "^3.651.1",
"@japa/api-client": "^2.0.3",
"@japa/assert": "^3.0.0",
"@japa/browser-client": "^2.0.3",
Expand Down
19 changes: 19 additions & 0 deletions schemas/custom-key-sessions-table.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"TableName": "CustomKeySession",
"KeySchema": [
{
"AttributeName": "sessionId",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "sessionId",
"AttributeType": "S"
}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5
}
}
19 changes: 19 additions & 0 deletions schemas/sessions-table.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"TableName": "Session",
"KeySchema": [
{
"AttributeName": "key",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "key",
"AttributeType": "S"
}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5
}
}
13 changes: 13 additions & 0 deletions src/define_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
FileStoreConfig,
RedisStoreConfig,
SessionStoreFactory,
DynamoDBStoreConfig,
} from './types.js'

/**
Expand Down Expand Up @@ -121,6 +122,7 @@ export const stores: {
file: (config: FileStoreConfig) => ConfigProvider<SessionStoreFactory>
redis: (config: RedisStoreConfig) => ConfigProvider<SessionStoreFactory>
cookie: () => ConfigProvider<SessionStoreFactory>
dynamodb: (config: DynamoDBStoreConfig) => ConfigProvider<SessionStoreFactory>
} = {
file: (config) => {
return configProvider.create(async () => {
Expand Down Expand Up @@ -148,4 +150,15 @@ export const stores: {
}
})
},
dynamodb: (config) => {
return configProvider.create(async () => {
const { DynamoDBStore } = await import('./stores/dynamodb.js')
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb')
const client = new DynamoDBClient(config?.clientConfig ?? {})

return (_, sessionConfig: SessionConfig) => {
return new DynamoDBStore(client, config.tableName, sessionConfig.age, config.keyAttribute)
}
})
},
}
154 changes: 154 additions & 0 deletions src/stores/dynamodb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @adonisjs/session
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import {
DynamoDBClient,
GetItemCommand,
PutItemCommand,
DeleteItemCommand,
UpdateItemCommand,
} from '@aws-sdk/client-dynamodb'
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'
import string from '@poppinss/utils/string'
import { MessageBuilder } from '@adonisjs/core/helpers'

import debug from '../debug.js'
import type { SessionStoreContract, SessionData } from '../types.js'

/**
* DynamoDB store to read/write session to DynamoDB
*/
export class DynamoDBStore implements SessionStoreContract {
#client: DynamoDBClient
#tableName: string = 'Session'
#keyAttribute: string = 'key'
#valueAttribute: string = 'value'
#expiresAtAttribute: string = 'expires_at'
#ttlSeconds: number

constructor(
client: DynamoDBClient,
tableName: string = this.#tableName,
age: string | number,
keyAttribute: string | undefined = this.#keyAttribute
) {
this.#client = client
this.#tableName = tableName
this.#keyAttribute = keyAttribute
this.#ttlSeconds = string.seconds.parse(age)
debug('initiating dynamodb store')
}

/**
* Returns session data. A new item will be created if it's
* missing.
*/
async read(sessionId: string): Promise<SessionData | null> {
debug('dynamodb store: reading session data %s', sessionId)

const command = new GetItemCommand({
TableName: this.#tableName,
Key: marshall({ [this.#keyAttribute]: sessionId }),
})

const response = await this.#client.send(command)
if (!response.Item) {
return null
}

if (!response.Item[this.#valueAttribute]) {
return null
}

const item = unmarshall(response.Item)
const contents = item[this.#valueAttribute] as string
const expiration = item[this.#expiresAtAttribute] as number

/**
* Check if the item has been expired and return null (if expired)
*/
if (Date.now() > expiration) {
return null
}

/**
* Verify contents with the session id and return them as an object. The verify
* method can fail when the contents is not JSON.
*/
try {
return new MessageBuilder().verify<SessionData>(contents, sessionId)
} catch {
return null
}
}

/**
* Write session values to DynamoDB
*/
async write(sessionId: string, values: Object): Promise<void> {
debug('dynamodb store: writing session data %s, %O', sessionId, values)

const message = new MessageBuilder().build(values, undefined, sessionId)
const item = marshall({
[this.#keyAttribute]: sessionId,
[this.#valueAttribute]: message,
[this.#expiresAtAttribute]: Date.now() + this.#ttlSeconds * 1000,
})

const command = new PutItemCommand({
TableName: this.#tableName,
Item: item,
ConditionExpression: 'attribute_not_exists(#key) OR #expires_at > :now',
ExpressionAttributeNames: {
'#key': this.#keyAttribute,
'#expires_at': this.#expiresAtAttribute,
},
ExpressionAttributeValues: marshall({
':now': Date.now(),
}),
})

await this.#client.send(command)
}

/**
* Cleanup session item by removing it
*/
async destroy(sessionId: string): Promise<void> {
debug('dynamodb store: destroying session data %s', sessionId)

const command = new DeleteItemCommand({
TableName: this.#tableName,
Key: marshall({ [this.#keyAttribute]: sessionId }),
})

await this.#client.send(command)
}

/**
* Updates the value expiry
*/
async touch(sessionId: string): Promise<void> {
debug('dynamodb store: touching session data %s', sessionId)

const command = new UpdateItemCommand({
TableName: this.#tableName,
Key: marshall({ [this.#keyAttribute]: sessionId }),
UpdateExpression: 'SET #expires_at = :expires_at',
ExpressionAttributeNames: {
'#expires_at': this.#expiresAtAttribute,
},
ExpressionAttributeValues: marshall({
':expires_at': Date.now() + this.#ttlSeconds * 1000,
}),
})

await this.#client.send(command)
}
}
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { HttpContext } from '@adonisjs/core/http'
import { RedisConnections } from '@adonisjs/redis/types'
import type { CookieOptions } from '@adonisjs/core/types/http'
import type { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'

/**
* The values allowed by the `session.put` method
Expand Down Expand Up @@ -102,6 +103,15 @@ export type RedisStoreConfig = {
connection: keyof RedisConnections
}

/**
* Configuration used by the dynamodb store.
*/
export type DynamoDBStoreConfig = {
clientConfig?: DynamoDBClientConfig
tableName?: string
keyAttribute?: string
}

/**
* Factory function to instantiate session store
*/
Expand Down
Loading

0 comments on commit f9a2f7b

Please sign in to comment.