Skip to content

Commit

Permalink
implemented DynamoDBSessionInterface and tests. (#214)
Browse files Browse the repository at this point in the history
  • Loading branch information
Lxstr authored Mar 24, 2024
2 parents 9d328e2 + 5a3413b commit f8f5ad1
Show file tree
Hide file tree
Showing 16 changed files with 272 additions and 10 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ jobs:
image: mongo
ports:
- 27017:27017
dynamodb:
image: amazon/dynamodb-local
ports:
- 8000:8000
steps:
- uses: actions/checkout@v4
- uses: supercharge/redis-github-action@1.5.0
Expand Down
7 changes: 5 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
0.7.2 - 2024-03-21
0.8.0
------------------

Added
~~~~~~~
- Add DynamoDB session interface (`#214 <https://github.com/pallets-eco/flask-session/pull/214>`_).

Fixed
~~~~~
- Include prematurely removed ``cachelib`` dependency. Will be removed in 1.0.0 to be an optional dependency (`#223 <https://github.com/pallets-eco/flask-session/issues/223>`_).
- Note 0.7.1 was not released due to a publishing error.


0.7.0 - 2024-03-18
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Contributors

- [necat1](https://github.com/necat1)
- [nebolax](https://github.com/nebolax)
- [Taragolis](https://github.com/Taragolis)
- [Lxstr](https://github.com/Lxstr)
Expand Down
16 changes: 12 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
version: '3.8'

services:
dynamodb-local:
image: "amazon/dynamodb-local:latest"
container_name: dynamodb-local
ports:
- "8000:8000"
environment:
- AWS_ACCESS_KEY_ID=dummy
- AWS_SECRET_ACCESS_KEY=dummy
- AWS_DEFAULT_REGION=us-west-2

mongo:
image: mongo:latest
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- "27017:27017"
volumes:
Expand All @@ -26,4 +33,5 @@ services:
volumes:
postgres_data:
mongo_data:
redis_data:
redis_data:
dynamodb_data:
3 changes: 2 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ Anything documented here is part of the public API that Flask-Session provides,
.. autoclass:: flask_session.filesystem.FileSystemSessionInterface
.. autoclass:: flask_session.cachelib.CacheLibSessionInterface
.. autoclass:: flask_session.mongodb.MongoDBSessionInterface
.. autoclass:: flask_session.sqlalchemy.SqlAlchemySessionInterface
.. autoclass:: flask_session.sqlalchemy.SqlAlchemySessionInterface
.. autoclass:: flask_session.dynamodb.DynamoDBSessionInterface
15 changes: 15 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ SqlAlchemy

Default: ``None``

Dynamodb
~~~~~~~~~~~~~~~~~~~~~~~

.. py:data:: SESSION_DYNAMODB
A ``boto3.resource`` instance.
Default: Instance connected to ``'localhost:8000'``
.. py:data:: SESSION_DYNAMODB_TABLE_NAME
The name of the table you want to use.
Default: ``'Sessions'``
.. deprecated:: 0.7.0

``SESSION_FILE_DIR``, ``SESSION_FILE_THRESHOLD``, ``SESSION_FILE_MODE``. Use ``SESSION_CACHELIB`` instead.
Expand Down
2 changes: 2 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Flask-Session has an increasing number of directly supported storage and client
- pymongo_
* - SQL Alchemy
- flask-sqlalchemy_
* - DynamoDB
- boto3_

Other libraries may work if they use the same commands as the ones listed above.

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,7 @@ dev-dependencies = [
"sphinx>=7.1.2",
"furo>=2024.1.29",
"sphinx-favicon>=1.0.1",
"boto3>=1.34.68",
"mypy_boto3_dynamodb>=1.34.67",
"pymemcache>=4.0.0",
]
2 changes: 2 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ redis
pymemcache
Flask-SQLAlchemy
pymongo
boto3
mypy_boto3_dynamodb

4 changes: 3 additions & 1 deletion requirements/docs.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ redis
cachelib
pymongo
flask_sqlalchemy
pymemcache
pymemcache
boto3
mypy_boto3_dynamodb
26 changes: 24 additions & 2 deletions requirements/docs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ beautifulsoup4==4.12.3
# via furo
blinker==1.7.0
# via flask
boto3==1.34.69
# via -r requirements/docs.in
botocore==1.34.69
# via
# boto3
# s3transfer
cachelib==0.12.0
# via -r requirements/docs.in
certifi==2023.5.7
Expand Down Expand Up @@ -40,10 +46,16 @@ jinja2==3.1.2
# via
# flask
# sphinx
jmespath==1.0.1
# via
# boto3
# botocore
markupsafe==2.1.2
# via
# jinja2
# werkzeug
mypy-boto3-dynamodb==1.34.67
# via -r requirements/docs.in
packaging==23.1
# via sphinx
pygments==2.15.1
Expand All @@ -54,10 +66,16 @@ pymemcache==4.0.0
# via -r requirements/docs.in
pymongo==4.6.2
# via -r requirements/docs.in
python-dateutil==2.9.0.post0
# via botocore
redis==5.0.1
# via -r requirements/docs.in
requests==2.30.0
# via sphinx
s3transfer==0.10.1
# via boto3
six==1.16.0
# via python-dateutil
snowballstemmer==2.2.0
# via sphinx
soupsieve==2.5
Expand Down Expand Up @@ -87,8 +105,12 @@ sphinxcontrib-serializinghtml==1.1.5
sqlalchemy==2.0.27
# via flask-sqlalchemy
typing-extensions==4.10.0
# via sqlalchemy
# via
# mypy-boto3-dynamodb
# sqlalchemy
urllib3==2.0.2
# via requests
# via
# botocore
# requests
werkzeug==3.0.1
# via flask
15 changes: 15 additions & 0 deletions src/flask_session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ def _get_interface(self, app):
"SESSION_CLEANUP_N_REQUESTS", Defaults.SESSION_CLEANUP_N_REQUESTS
)

# DynamoDB settings
SESSION_DYNAMODB = config.get("SESSION_DYNAMODB", Defaults.SESSION_DYNAMODB)
SESSION_DYNAMODB_TABLE = config.get(
"SESSION_DYNAMODB_TABLE", Defaults.SESSION_DYNAMODB_TABLE
)

common_params = {
"app": app,
"key_prefix": SESSION_KEY_PREFIX,
Expand Down Expand Up @@ -165,6 +171,15 @@ def _get_interface(self, app):
bind_key=SESSION_SQLALCHEMY_BIND_KEY,
cleanup_n_requests=SESSION_CLEANUP_N_REQUESTS,
)
elif SESSION_TYPE == "dynamodb":
from .dynamodb import DynamoDBSessionInterface

session_interface = DynamoDBSessionInterface(
**common_params,
client=SESSION_DYNAMODB,
table_name=SESSION_DYNAMODB_TABLE,
)

else:
raise ValueError(f"Unrecognized value for SESSION_TYPE: {SESSION_TYPE}")

Expand Down
4 changes: 4 additions & 0 deletions src/flask_session/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ class Defaults:
SESSION_SQLALCHEMY_SEQUENCE = None
SESSION_SQLALCHEMY_SCHEMA = None
SESSION_SQLALCHEMY_BIND_KEY = None

# DynamoDB settings
SESSION_DYNAMODB = None
SESSION_DYNAMODB_TABLE = "Sessions"
1 change: 1 addition & 0 deletions src/flask_session/dynamodb/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .dynamodb import DynamoDBSession, DynamoDBSessionInterface # noqa: F401
126 changes: 126 additions & 0 deletions src/flask_session/dynamodb/dynamodb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import warnings
from datetime import datetime
from datetime import timedelta as TimeDelta
from decimal import Decimal
from typing import Optional

import boto3
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource
from flask import Flask
from itsdangerous import want_bytes

from ..base import ServerSideSession, ServerSideSessionInterface
from ..defaults import Defaults


class DynamoDBSession(ServerSideSession):
pass


class DynamoDBSessionInterface(ServerSideSessionInterface):
"""A Session interface that uses dynamodb as backend. (`boto3` required)
:param client: A ``DynamoDBServiceResource`` instance.
:param key_prefix: A prefix that is added to all DynamoDB store keys.
:param use_signer: Whether to sign the session id cookie or not.
:param permanent: Whether to use permanent session or not.
:param sid_length: The length of the generated session id in bytes.
:param table_name: DynamoDB table name to store the session.
.. versionadded:: 0.6
The `sid_length` parameter was added.
.. versionadded:: 0.2
The `use_signer` parameter was added.
"""

session_class = DynamoDBSession

def __init__(
self,
app: Flask,
client: Optional[DynamoDBServiceResource] = Defaults.SESSION_DYNAMODB,
key_prefix: str = Defaults.SESSION_KEY_PREFIX,
use_signer: bool = Defaults.SESSION_USE_SIGNER,
permanent: bool = Defaults.SESSION_PERMANENT,
sid_length: int = Defaults.SESSION_ID_LENGTH,
serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT,
table_name: str = Defaults.SESSION_DYNAMODB_TABLE,
):

if client is None:
warnings.warn(
"No valid DynamoDBServiceResource instance provided, attempting to create a new instance on localhost:8000.",
RuntimeWarning,
stacklevel=1,
)
client = boto3.resource(
"dynamodb",
endpoint_url="http://localhost:8000",
region_name="us-west-2",
aws_access_key_id="dummy",
aws_secret_access_key="dummy",
)

try:
client.create_table(
AttributeDefinitions=[
{"AttributeName": "id", "AttributeType": "S"},
],
TableName=table_name,
KeySchema=[
{"AttributeName": "id", "KeyType": "HASH"},
],
BillingMode="PAY_PER_REQUEST",
)
client.meta.client.get_waiter("table_exists").wait(TableName=table_name)
client.meta.client.update_time_to_live(
TableName=self.table_name,
TimeToLiveSpecification={
"Enabled": True,
"AttributeName": "expiration",
},
)
except (AttributeError, client.meta.client.exceptions.ResourceInUseException):
# TTL already exists, or table already exists
pass

self.client = client
self.store = client.Table(table_name)
super().__init__(
app,
key_prefix,
use_signer,
permanent,
sid_length,
serialization_format,
)

def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
# Get the saved session (document) from the database
document = self.store.get_item(Key={"id": store_id}).get("Item")
if document:
serialized_session_data = want_bytes(document.get("val").value)
return self.serializer.decode(serialized_session_data)
return None

def _delete_session(self, store_id: str) -> None:
self.store.delete_item(Key={"id": store_id})

def _upsert_session(
self, session_lifetime: TimeDelta, session: ServerSideSession, store_id: str
) -> None:
storage_expiration_datetime = datetime.utcnow() + session_lifetime
# Serialize the session data
serialized_session_data = self.serializer.encode(session)

self.store.update_item(
Key={
"id": store_id,
},
UpdateExpression="SET val = :value, expiration = :exp",
ExpressionAttributeValues={
":value": serialized_session_data,
":exp": Decimal(storage_expiration_datetime.timestamp()),
},
)
Loading

0 comments on commit f8f5ad1

Please sign in to comment.