Skip to content

feat: Support Multiple session stores #277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 36 commits into
base: master
Choose a base branch
from

Conversation

ElijahAhianyo
Copy link
Contributor

@ElijahAhianyo ElijahAhianyo commented Apr 6, 2025

This Pr lays the foundation for supporting multiple session stores. Currently, there are 4 supported store types:
Memory, Cache, File and DB(implementation to be added in a follow up PR).

Memory

This is the default store which stores the session data in a thread-safe hashmap. It is suitable for development environments

Toml Example

...
[middlewares.session.store]
type = "memory"

Config Example

use cot::config::{MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};
...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::Memory)
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

File Store

The file store persists sessions in a specified directory as files on a file system.

Toml Example

...
[middlewares.session.store]
type = "file"
path = "/path/to/session/storage"

Config Example

use std::path::PathBuf;
use cot::config::{MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};

...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::File{ path : PathBuf::from("/path/to/dir")})
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

Cache Store

The cache store uses a configured cache backed to persist data. This PR ships with support for a Redis backend. Support for other cache backends will be added in follow up PRs.
The cache store is gated behind the cache feature, while the redis implementation is gated behind the redis feature. To use the redis cache store, you will have to enable both.

Toml Example

...
[middlewares.session.store]
type = "cache"
uri = "redis://localhost:6379"

Config Example

use cot::config::{CacheUrl, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};

...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::Cache{ uri : CacheUri::from("redis://localhost:6379")})
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

DB store

The DB store uses Cot's ORM to persist session data, Its implementation details will be added in a follow up PR.

@github-actions github-actions bot added the C-lib Crate: cot (main library crate) label Apr 6, 2025
Copy link

codecov bot commented Apr 6, 2025

Codecov Report

Attention: Patch coverage is 90.33816% with 60 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cot/src/session/store/file.rs 86.45% 13 Missing and 13 partials ⚠️
cot/src/session/store/redis.rs 84.10% 10 Missing and 14 partials ⚠️
cot/src/config.rs 94.80% 8 Missing ⚠️
cot/src/middleware.rs 90.90% 2 Missing ⚠️
Flag Coverage Δ
rust 88.35% <90.33%> (+0.14%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
cot/src/session.rs 82.35% <ø> (ø)
cot/src/session/store.rs 100.00% <100.00%> (ø)
cot/src/session/store/memory.rs 100.00% <100.00%> (ø)
cot/src/middleware.rs 82.60% <90.90%> (+0.33%) ⬆️
cot/src/config.rs 95.73% <94.80%> (+4.38%) ⬆️
cot/src/session/store/redis.rs 84.10% <84.10%> (ø)
cot/src/session/store/file.rs 86.45% <86.45%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from f8e50cd to 3c6dbd6 Compare April 24, 2025 13:33
@ElijahAhianyo
Copy link
Contributor Author

@m4tx @seqre this PR is not fully ready, but I'd love some initial feedback if this design is heading in the right direction or makes any sense to you at all.

Example usages

No session storage specified(using the admin example)

when the session_store is not provided, it defaults to the MemoryStore in the config

struct AdminProject;

impl Project for AdminProject {
   ...
    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {

        Ok(ProjectConfig::builder()
            .debug(true)
            .database(
                DatabaseConfig::builder()
                    .url("sqlite://db.sqlite3?mode=rwc")
                    .build(),
            )
            .auth_backend(AuthBackendConfig::Database)
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .build())
                    .build(),
            )
            .build())
    }

    ...
}

Session storage provided using MemoryStore provided by Cot

struct AdminProject;

impl Project for AdminProject {
   ...
    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {

        Ok(ProjectConfig::builder()
            .debug(true)
            .database(
                DatabaseConfig::builder()
                    .url("sqlite://db.sqlite3?mode=rwc")
                    .build(),
            )
            .auth_backend(AuthBackendConfig::Database)
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .session_store(Arc::new(MemoryStore::default()))
                        .build())
                    .build(),
            )
            .build())
    }

    ...
}

(Also haven't played around custom SessionStores yet)

@m4tx
Copy link
Member

m4tx commented Apr 25, 2025

@ElijahAhianyo I think we should do this similarly to the Auth backend, i.e. the ProjectConfig should only include a serializable enum of possible choices for the Session store (Memory Store, ORM, Redis, etc., possibly with some inner configs as well), and the Bootstrapper should initialize a proper Session Store backend by calling a method (called session_store?) on the Project trait. The idea is that ProjectConfig must be serializable to/from a TOML file, and if you want to override the default behavior, you can do it by overriding a method in the Project trait. See the following snippets:

cot/cot/src/project.rs

Lines 335 to 351 in 70995f0

fn auth_backend(&self, context: &AuthBackendContext) -> Arc<dyn AuthBackend> {
#[expect(trivial_casts)] // cast to Arc<dyn AuthBackend>
match &context.config().auth_backend {
AuthBackendConfig::None => Arc::new(NoAuthBackend) as Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
AuthBackendConfig::Database => Arc::new(DatabaseUserBackend::new(
context
.try_database()
.expect(
"Database missing when constructing database auth backend. \
Make sure the database config is set up correctly or disable \
authentication in the config.",
)
.clone(),
)) as Arc<dyn AuthBackend>,
}
}

cot/cot/src/project.rs

Lines 1240 to 1241 in 70995f0

let auth_backend = self.project.auth_backend(&self.context);
let context = self.context.with_auth(auth_backend);

It's not ideal (mainly because it inflates the number of methods in the Project trait), but it's the best solution that we could come up with, allowing the users to use the "common" options easily, but not limiting them in what they can do. However, if you have ideas for a better design, I'm happy to discuss them.

@ElijahAhianyo
Copy link
Contributor Author

@ElijahAhianyo I think we should do this similarly to the Auth backend, i.e. the ProjectConfig should only include a serializable enum of possible choices for the Session store (Memory Store, ORM, Redis, etc., possibly with some inner configs as well), and the Bootstrapper should initialize a proper Session Store backend by calling a method (called session_store?) on the Project trait. The idea is that ProjectConfig must be serializable to/from a TOML file, and if you want to override the default behavior, you can do it by overriding a method in the Project trait. See the following snippets:

cot/cot/src/project.rs

Lines 335 to 351 in 70995f0

fn auth_backend(&self, context: &AuthBackendContext) -> Arc<dyn AuthBackend> {
#[expect(trivial_casts)] // cast to Arc<dyn AuthBackend>
match &context.config().auth_backend {
AuthBackendConfig::None => Arc::new(NoAuthBackend) as Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
AuthBackendConfig::Database => Arc::new(DatabaseUserBackend::new(
context
.try_database()
.expect(
"Database missing when constructing database auth backend. \
Make sure the database config is set up correctly or disable \
authentication in the config.",
)
.clone(),
)) as Arc<dyn AuthBackend>,
}
}

cot/cot/src/project.rs

Lines 1240 to 1241 in 70995f0

let auth_backend = self.project.auth_backend(&self.context);
let context = self.context.with_auth(auth_backend);

It's not ideal (mainly because it inflates the number of methods in the Project trait), but it's the best solution that we could come up with, allowing the users to use the "common" options easily, but not limiting them in what they can do. However, if you have ideas for a better design, I'm happy to discuss them.

Based on your suggestion, I did some research and now have a clearer picture. My plan is to start by sketching out what the TOML file might look like, then work my way down into implementation details.

I looked at how other frameworks handle this and identified two main patterns we canexplore:

1 a. Self-contained store config

You declare the store type and provide any necessary connection details (or file paths, in the case of file storage):

secret_key = "{{ dev_secret_key }}"

[database]
url = "sqlite://db.sqlite3?mode=rwc"

[auth_backend]
type = "database"

[middlewares]
live_reload.enabled = true

[middlewares.session]
secure = false

[middlewares.session.store]
type = "redis"
connection = "redis://"

1 b Per-store subsection

The approach in 1.a could get quite noisy(or maybe not) if different store types have some configs specific to them.

secret_key = "{{ dev_secret_key }}"

[database]
url = "sqlite://db.sqlite3?mode=rwc"

[auth_backend]
type = "database"

[middlewares]
live_reload.enabled = true

[middlewares.session]
secure = false

[middlewares.session.store]
type = "file"

[middlewares.session.store.file]
dir = "/tmp/"

2. Named connections

The first pattern forces you to re-declare connection details even if you’ve already defined them under [databases] or [caches](currently not implemented). It also assumes a single DB/cache backend. We may need to structurally change the API to support this:

secret_key = "{{ dev_secret_key }}"

[databases]
default = "postgres://…"

[caches]
redis_main = "redis://…"

[middlewares.session]
store      = "redis"
connection = "cache.redis_main"  # or, alternatively, use a separate `connection_source` field instead of dotted notation

Option 1 should be easy to implement given our current design. However, with option 2, do we have any plans to support multiple DBs or caches?

@m4tx let me also know if you've got other opinions on what the TOML should look like.

@m4tx
Copy link
Member

m4tx commented Apr 30, 2025

@ElijahAhianyo

do we have any plans to support multiple DBs or caches?

Ah, that's a good question. Definitely yes, but certainly not in the near future.

After some consideration, I think it would make sense to have some sort of hybrid between 1a and 2. I can imagine the following typical use cases for the session backends:

  1. in-memory store, file store - for development or super low traffic websites where performance and reliability don't matter
  2. database (using Cot's ORM) - for development or low/medium traffic websites that need simplicity and reliability, but don't have high enough traffic to require caching
  3. cache (Redis, MongoDB, etc.) - for high traffic websites that need caching for other stuff anyway

If we want to store sessions externally, we probably should already have an API for that. This doesn't apply for in-memory or file stores (because they're mainly development-focused anyway), but we already have a way to define a DB connection in the config (for use with the Cot's ORM) and the framework user shouldn't need to provide the same credentials twice, but the session store should rather use the Cot's ORM directly. The same should be true for Redis/Mongo/whatever cache – we don't have any Cache API at the moment, but I think we should at some point. And when we have the Cache API, we should just be able to use it to implement session stores.

So I think we could try to translate these use cases into Config files:

  1. In-memory/file backend
[middlewares.session.store]
type = "memory"
[middlewares.session.store]
type = "file"
path = "sessions.db"
  1. Database backend
[middlewares.session.store]
type = "database"  # will just use Cot's ORM
  1. Redis

For now, something like so should be good enough:

[middlewares.session.store]
type = "redis"
url = "redis://localhost:6379"

But, when Cot gets a dedicated Cache API with its own connection settings, the above can be just changed to:

[middlewares.session.store]
type = "cache"  # similarly to "database", this just means we'll use Cot's Cache API
name = "cache1"  # optional; only needed if more than one cache is defined

Do you think the above makes sense?

Obviously, implementing a Cache API is out of scope for this PR, but if you'd like to do it as well, you're more than welcome to.

@ElijahAhianyo
Copy link
Contributor Author

@ElijahAhianyo

do we have any plans to support multiple DBs or caches?

Ah, that's a good question. Definitely yes, but certainly not in the near future.

After some consideration, I think it would make sense to have some sort of hybrid between 1a and 2. I can imagine the following typical use cases for the session backends:

  1. in-memory store, file store - for development or super low traffic websites where performance and reliability don't matter
  2. database (using Cot's ORM) - for development or low/medium traffic websites that need simplicity and reliability, but don't have high enough traffic to require caching
  3. cache (Redis, MongoDB, etc.) - for high traffic websites that need caching for other stuff anyway

If we want to store sessions externally, we probably should already have an API for that. This doesn't apply for in-memory or file stores (because they're mainly development-focused anyway), but we already have a way to define a DB connection in the config (for use with the Cot's ORM) and the framework user shouldn't need to provide the same credentials twice, but the session store should rather use the Cot's ORM directly. The same should be true for Redis/Mongo/whatever cache – we don't have any Cache API at the moment, but I think we should at some point. And when we have the Cache API, we should just be able to use it to implement session stores.

So I think we could try to translate these use cases into Config files:

  1. In-memory/file backend
[middlewares.session.store]
type = "memory"
[middlewares.session.store]
type = "file"
path = "sessions.db"
  1. Database backend
[middlewares.session.store]
type = "database"  # will just use Cot's ORM
  1. Redis

For now, something like so should be good enough:

[middlewares.session.store]
type = "redis"
url = "redis://localhost:6379"

But, when Cot gets a dedicated Cache API with its own connection settings, the above can be just changed to:

[middlewares.session.store]
type = "cache"  # similarly to "database", this just means we'll use Cot's Cache API
name = "cache1"  # optional; only needed if more than one cache is defined

Do you think the above makes sense?

Obviously, implementing a Cache API is out of scope for this PR, but if you'd like to do it as well, you're more than welcome to.

@m4tx Yes, that makes sense. I can look into having a Cache API(we can create an issue to track this), in a separate PR—almost async with this one. Would you still want that designed with a singular backend in mind for now, and support multi later?

@m4tx m4tx mentioned this pull request Apr 30, 2025
@m4tx
Copy link
Member

m4tx commented Apr 30, 2025

@ElijahAhianyo actually, I think cases 1 and 3 from my answer can be merged together – there's nothing stopping us from implementing in-memory and file-backed caching. This would leave us with only two session backends that we would need to implement - database and cache.

I've created an issue to track the Cache API: #308.

Would you still want that designed with a singular backend in mind for now, and support multi later?

I think having just one backend is 100% fine for now - we can work on this to add support for multiple ones later, as this doesn't sound like an everyday use case anyways.

@ElijahAhianyo
Copy link
Contributor Author

ElijahAhianyo commented May 1, 2025

@ElijahAhianyo actually, I think cases 1 and 3 from my answer can be merged together – there's nothing stopping us from implementing in-memory and file-backed caching. This would leave us with only two session backends that we would need to implement - database and cache.

I've created an issue to track the Cache API: #308.

Would you still want that designed with a singular backend in mind for now, and support multi later?

I think having just one backend is 100% fine for now - we can work on this to add support for multiple ones later, as this doesn't sound like an everyday use case anyways.

@m4tx I see, just so we're on the same page on what the TOML file should look like in the context of this PR, If we have two(database and cache) store variants. How do we differentiate what cache type(redis, file, memcache, etc) it is from the TOML file?
Would you rather

  1. Ask the user for more help by introducing another config knob to identify what cache type they want:
[middlewares.session.store]
type = "cache"
cache_type = "redis" # very verbose
url = "redis://localhost:6379"
  1. Do some implicit gymnastics using the url/path provided to determine which cache the user wants:
[middlewares.session.store]
type = "cache"
url = "redis://localhost:6379" # will be infered as redis store from the uri
  1. Any other option?

@m4tx
Copy link
Member

m4tx commented May 1, 2025

@ElijahAhianyo I think option number 2 should be good enough, as it doesn't duplicate the data in the config, and it will be consistent with the ORM behavior. When creating the Cache API, we just need to make sure it's possible to register new cache backends (for instance, the cache backend may need to provide all the URL schemas it supports, and our code would just try to match the URL to all registered backends).

By the way, just to be sure, this is fine for now:

[middlewares.session.store]
type = "cache"
url = "redis://localhost:6379"

But eventually we'll want to have something closer to this (when we have the Cache API):

[cache]
URL = "redis://localhost:6379"

[middlewares.session.store]
type = "cache"

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from 1c7763d to fe3713b Compare May 13, 2025 13:03
@github-actions github-actions bot added the A-ci Area: CI (Continuous Integration) label May 15, 2025
@github-actions github-actions bot added the C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) label May 15, 2025
},
#[cfg(feature = "db")]
Self::Database => {
unimplemented!();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up moving the DB implementation into another PR since there are some nuances to it; The DB option requires a migration model and generated migration, which I haven't gotten to the bottom of

@ElijahAhianyo ElijahAhianyo changed the title [WIP]Support Multiple session stores feat:Support Multiple session stores May 26, 2025
@ElijahAhianyo ElijahAhianyo marked this pull request as ready for review May 26, 2025 10:26
@ElijahAhianyo ElijahAhianyo changed the title feat:Support Multiple session stores feat: Support Multiple session stores May 26, 2025
@ElijahAhianyo
Copy link
Contributor Author

ElijahAhianyo commented May 26, 2025

@m4tx @seqre This should somewhat be ready for review, Let me know what your initial thoughts are

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ci Area: CI (Continuous Integration) C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) C-lib Crate: cot (main library crate)
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants