Skip to content
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

feat/implement local storage persistence - WF-93 #687

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/ui/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
AbstractTemplate,
Component,
ComponentMap,
InstancePath,
InstancePath, LocalStorageRemoveItemEvent, LocalStorageSetItemEvent,
MailItem,
UserFunction,
} from "@/writerTypes";
Expand Down Expand Up @@ -59,6 +59,20 @@ export function generateCore() {
addMailSubscription("pageChange", (pageKey: string) => {
setActivePageFromKey(pageKey);
});
addMailSubscription(
"localStorageSetItem",
(event: LocalStorageSetItemEvent) => {
localStorage.setItem(event.key, event.value);
},
);

addMailSubscription(
"localStorageRemoveItem",
(event: LocalStorageRemoveItemEvent) => {
localStorage.removeItem(event.key);
},
);

sendKeepAliveMessage();
if (mode.value != "edit") return;
}
Expand All @@ -69,6 +83,12 @@ export function generateCore() {
* @returns
*/
async function initSession() {
const localStorageItems = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
localStorageItems[key] = localStorage.getItem(key);
}

const response = await fetch("./api/init", {
method: "post",
cache: "no-store",
Expand All @@ -77,6 +97,7 @@ export function generateCore() {
},
body: JSON.stringify({
proposedSessionId: sessionId,
localStorage: localStorageItems,
}),
});
const initData = await response.json();
Expand Down
9 changes: 9 additions & 0 deletions src/ui/src/writerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,12 @@ export type AbstractTemplate = {
};

export type TemplateMap = Record<string, VueComponent>;

export type LocalStorageSetItemEvent = {
key: string;
value: string;
};

export type LocalStorageRemoveItemEvent = {
key: string;
};
3 changes: 1 addition & 2 deletions src/writer/app_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def _handle_session_init(self, payload: InitSessionRequestPayload) -> InitSessio

session = writer.session_manager.get_session(payload.proposedSessionId, restore_initial_mail=True)
if session is None:
session = writer.session_manager.get_new_session(payload.cookies, payload.headers, payload.proposedSessionId)
session = writer.session_manager.get_new_session(payload.cookies, payload.headers, payload.localStorage, payload.proposedSessionId)

if session is None:
raise MessageHandlingException("Session rejected.")
Expand Down Expand Up @@ -177,7 +177,6 @@ def _handle_event(self, session: WriterSession, event: WriterEvent) -> EventResp
import traceback as tb

result = session.event_handler.handle(event)

mutations = {}

try:
Expand Down
51 changes: 48 additions & 3 deletions src/writer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,14 @@ class WriterSession:
Represents a session.
"""

def __init__(self, session_id: str, cookies: Optional[Dict[str, str]], headers: Optional[Dict[str, str]]) -> None:
def __init__(self, session_id: str, cookies: Optional[Dict[str, str]], headers: Optional[Dict[str, str]], local_storage: Optional[Dict[str, Any]]) -> None:
if local_storage is None:
local_storage = {}

self.session_id = session_id
self.cookies = cookies
self.headers = headers
self.local_storage = local_storage
self.last_active_timestamp: int = int(time.time())
new_state = WriterState.get_new()
new_state.user_state.mutated = set()
Expand Down Expand Up @@ -1078,6 +1082,46 @@ def call_frontend_function(self, module_key: str, function_name: str, args: List
"args": args
})

def local_storage_set_item(self, key: str, value: str) -> None:
"""
Saves a value to the browser's local storage.

>>> state.local_storage_set_item("my_key", "value")

The value must be a string. If it is another type, it will be converted to a character string without serialization.
The browser's local storage values as text.

It is recommended to serialize the data upstream, for example in JSON.

>>> state.local_storage_set_item("my_key", json.dumps({"value": 1}))
"""
if not isinstance(value, str):
value = str(value)

self.add_mail("localStorageSetItem", {
"key": key,
"value": value
})

_session = get_session()
assert _session is not None, "local_storage_set_item must be used within a user request."
_session.local_storage[key] = value

def local_storage_remove_item(self, key: str) -> None:
"""
Removes a value from the browser's local storage.

>>> state.local_storage_remove_item("my_key")
"""
self.add_mail("localStorageRemoveItem", {
"key": key
})

_session = get_session()
assert _session is not None, "local_storage_remove_item must be used within a user request."
if key in _session.local_storage:
del _session.local_storage[key]

class MiddlewareExecutor():
"""
A MiddlewareExecutor executes middleware in a controlled context. It allows writer framework
Expand Down Expand Up @@ -1520,7 +1564,7 @@ def _check_proposed_session_id(self, proposed_session_id: Optional[str]) -> bool
return True
return False

def get_new_session(self, cookies: Optional[Dict] = None, headers: Optional[Dict] = None, proposed_session_id: Optional[str] = None) -> Optional[WriterSession]:
def get_new_session(self, cookies: Optional[Dict] = None, headers: Optional[Dict] = None, local_storage: Optional[Dict[str, Any]] = None, proposed_session_id: Optional[str] = None) -> Optional[WriterSession]:
if not self._check_proposed_session_id(proposed_session_id):
return None
if not self._verify_before_new_session(cookies, headers):
Expand All @@ -1530,7 +1574,7 @@ def get_new_session(self, cookies: Optional[Dict] = None, headers: Optional[Dict
new_id = self._generate_session_id()
else:
new_id = proposed_session_id
new_session = WriterSession(new_id, cookies, headers)
new_session = WriterSession(new_id, cookies, headers, local_storage)
self.sessions[new_id] = new_session
return new_session

Expand Down Expand Up @@ -2040,6 +2084,7 @@ def _event_handler_session_info() -> Dict[str, Any]:
session_info['cookies'] = current_session.cookies
session_info['headers'] = current_session.headers
session_info['userinfo'] = current_session.userinfo or {}
session_info['local_storage'] = current_session.local_storage or {}

return session_info

Expand Down
3 changes: 2 additions & 1 deletion src/writer/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,8 @@ async def init(initBody: InitRequestBody, request: Request, response: Response)
app_response = await app_runner.init_session(InitSessionRequestPayload(
cookies=dict(request.cookies),
headers=dict(request.headers),
proposedSessionId=initBody.proposedSessionId
proposedSessionId=initBody.proposedSessionId,
localStorage=initBody.localStorage,
))

status = app_response.status
Expand Down
2 changes: 2 additions & 0 deletions src/writer/ss_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class AbstractTemplate(BaseModel):

class InitRequestBody(BaseModel):
proposedSessionId: Optional[str] = None
localStorage: Optional[Dict[str, Any]] = None


class InitResponseBody(BaseModel):
Expand Down Expand Up @@ -83,6 +84,7 @@ class AppProcessServerRequest(BaseModel):
class InitSessionRequestPayload(BaseModel):
cookies: Optional[Dict[str, str]] = None
headers: Optional[Dict[str, str]] = None
localStorage: Optional[Dict[str, Any]] = None
proposedSessionId: Optional[str] = None

class InitSessionRequest(AppProcessServerRequest):
Expand Down
24 changes: 23 additions & 1 deletion tests/backend/fixtures/app_runner_fixtures.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import contextlib
from typing import Optional

from writer.app_runner import AppRunner
from writer.ss_types import InitSessionRequestPayload
from writer.core import session_manager, use_request_context
from writer.ss_types import AppProcessServerRequest, InitSessionRequestPayload

FIXED_SESSION_ID = "0000000000000000000000000000000000000000000000000000000000000000" # Compliant session number

Expand Down Expand Up @@ -32,3 +34,23 @@ async def init_app_session(app_runner: AppRunner,
result = await app_runner.init_session(init_session_payload)

return result.payload.model_dump().get("sessionId")


@contextlib.contextmanager
def within_message_request(session_id=FIXED_SESSION_ID, request: AppProcessServerRequest=None):
"""
This fixture starts a session and emulates the context of an event message.

>>> with within_message_request():
>>> _session = core.get_session()
>>> _session.local_storage['key'] = "value"

:param session_id: the session identifier
:param request: the request to create
"""
if request is None:
request = AppProcessServerRequest(type="event")

session_manager.get_new_session(proposed_session_id=session_id)
with use_request_context(session_id=session_id, request=request):
yield
40 changes: 38 additions & 2 deletions tests/backend/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import pyarrow as pa
import pytest
import writer as wf
from writer import audit_and_fix, wf_project
from writer import audit_and_fix, core, wf_project
from writer.core import (
BytesWrapper,
EventDeserialiser,
Expand All @@ -31,6 +31,7 @@
)
from writer.ss_types import WriterEvent

from backend.fixtures import app_runner_fixtures
from tests.backend import test_app_dir
from tests.backend.fixtures import (
writer_fixtures,
Expand Down Expand Up @@ -155,7 +156,7 @@ def test_apply_mutation_marker(self) -> None:
'+name': 'Robert',
'+state\\.with\\.dots': None,
'+utfࠀ': 23,
'+a\.b': 3
r'+a\.b': 3
}

self.sp_simple_dict.apply_mutation_marker()
Expand Down Expand Up @@ -751,6 +752,40 @@ def test_unpickable_members(self) -> None:
json.dumps(cloned.user_state.to_dict())
json.dumps(cloned.mail)

def test_local_storage_set_item_should_update_backend_localstorage(self):
"""
Tests that setting an item in the local storage updates the backend local storage
"""
with app_runner_fixtures.within_message_request():
# Assign
_state = WriterState({"a": 1, "b": 2})

# Acts
_state.local_storage_set_item("tab", "tab1")

# Assert
_session = core.get_session()
assert _session.local_storage['tab'] == "tab1"
assert _state.mail[0].get("type") == "localStorageSetItem"
assert _state.mail[0].get("payload") == {"key": "tab", "value": "tab1"}

def test_local_storage_set_item_should_set_value_as_string(self):
"""
Tests that using local_storage_set_item forces the value to be a string.
"""
with app_runner_fixtures.within_message_request():
# Assign
_state = WriterState({"a": 1, "b": 2})

# Acts
_state.local_storage_set_item("tab", 0)

# Assert
_session = core.get_session()
assert _session.local_storage['tab'] == "0"
assert _state.mail[0].get("type") == "localStorageSetItem"
assert _state.mail[0].get("payload") == {"key": "tab", "value": "0"}


class TestEventDeserialiser:

Expand Down Expand Up @@ -1222,6 +1257,7 @@ def test_get_new_session_proposed(self) -> None:
self.sm.get_new_session(
{"testCookie": "yes"},
{"origin": "example.com"},
{},
self.proposed_session_id
)
self.sm.get_session(self.proposed_session_id)
Expand Down
Loading