commit 0186e62c10e7accc76595e533d3df037e18459e0 Author: Mikko Ahlroth Date: Thu Sep 14 21:18:49 2023 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3bed461 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mikko Ahlroth + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f7aa95 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# varasto + +[![Package Version](https://img.shields.io/hexpm/v/varasto)](https://hex.pm/packages/varasto) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/varasto/) + +Typed access to the [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API), i.e. LocalStorage and SessionStorage. + +This package is only for the JavaScript target. It depends on [plinth](https://hexdocs.pm/plinth/index.html), which implements the lower level (string only) Web Storage API access. Thus, if you only intend to read and write string values, you may use plinth directly. + +## Usage + +Varasto uses JSON as the storage format. To use it, you will need to construct a `TypedStorage` value that encapsulates a reader and a writer to convert the value to and from `gleam/json.Json`. + +An example where the data type is a list of integers: + +```gleam +import gleam/dynamic +import gleam/json +import plinth/javascript/storage +import varasto + +// Reader that decodes a Dynamic to a list of integers +fn int_list_reader() { + dynamic.list(dynamic.int) +} + +// A writer that converts a list of integers to a Json value +fn int_list_writer() { + fn(val: List(Int)) { json.array(val, json.int) } +} + +// First use plinth to get the low level storage +let assert Ok(local) = storage.local() + +// Construct TypedStorage with the reader and writer +let s = varasto.new(local, int_list_reader(), int_list_writer()) + +// Set a value +varasto.set(s, "Foo", [1, 2, 3]) + +// Returns Ok([1, 2, 3]) +varasto.get(s, "Foo") +``` + +## Installation + +This package can be added to your Gleam project: + +```sh +gleam add varasto +``` + +and its documentation can be found at . diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..0d480ae --- /dev/null +++ b/gleam.toml @@ -0,0 +1,20 @@ +name = "varasto" +version = "1.0.0" +description = "Typed access to LocalStorage/SessionStorage in Gleam" +target = "javascript" +gleam = ">= 0.30.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +licences = ["MIT"] +repository = { type = "gitlab", user = "Nicd", repo = "varasto" } +links = [] + +[dependencies] +gleam_stdlib = "~> 0.30" +plinth = ">= 0.1.2" +gleam_json = "~> 0.6" + +[dev-dependencies] +gleeunit = "~> 0.10" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..bb5e660 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,17 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" }, + { name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" }, + { name = "gleam_stdlib", version = "0.30.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "8D8BF3790AA31176B1E1C0B517DD74C86DA8235CF3389EA02043EE4FD82AE3DC" }, + { name = "gleeunit", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "1397E5C4AC4108769EE979939AC39BF7870659C5AFB714630DEEEE16B8272AD5" }, + { name = "plinth", version = "0.1.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "E40A48FAA3AB9410803AB937BE620692D86B7ABB46459A83E8C674B82CFFD05B" }, + { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, +] + +[requirements] +gleam_json = { version = "~> 0.6" } +gleam_stdlib = { version = "~> 0.30" } +gleeunit = { version = "~> 0.10" } +plinth = { version = ">= 0.1.2" } diff --git a/src/varasto.gleam b/src/varasto.gleam new file mode 100644 index 0000000..fa5a899 --- /dev/null +++ b/src/varasto.gleam @@ -0,0 +1,62 @@ +//// Typed access to the Web Storage API. + +import gleam/result +import gleam/dynamic.{Dynamic} +import gleam/json.{Json} +import plinth/javascript/storage as plinth_storage + +/// An error that occurs when reading. +pub type ReadError { + /// A value was not found with the given key. + NotFound + /// The found value could not be decoded. + DecodeError(err: json.DecodeError) +} + +pub opaque type TypedStorage(a) { + TypedStorage( + raw_storage: plinth_storage.Storage, + reader: fn(Dynamic) -> Result(a, List(dynamic.DecodeError)), + writer: fn(a) -> Json, + ) +} + +/// Create a new `TypedStorage`. +pub fn new( + raw_storage: plinth_storage.Storage, + reader: fn(Dynamic) -> Result(a, List(dynamic.DecodeError)), + writer: fn(a) -> Json, +) -> TypedStorage(a) { + TypedStorage(raw_storage: raw_storage, reader: reader, writer: writer) +} + +/// Get a value from the storage. +pub fn get(storage: TypedStorage(a), key: String) -> Result(a, ReadError) { + use str <- result.try( + plinth_storage.get_item(storage.raw_storage, key) + |> result.replace_error(NotFound), + ) + json.decode(str, storage.reader) + |> result.map_error(DecodeError) +} + +/// Set a value in the storage. +pub fn set(storage: TypedStorage(a), key: String, value: a) -> Result(Nil, Nil) { + let encoded = + value + |> storage.writer() + |> json.to_string() + plinth_storage.set_item(storage.raw_storage, key, encoded) +} + +/// Remove a value from the storage. +pub fn remove(storage: TypedStorage(a), key: String) -> Nil { + plinth_storage.remove_item(storage.raw_storage, key) +} + +/// Clear the whole storage. +/// +/// NOTE! This will clear the whole storage, not just values you have set. +pub fn clear(storage: TypedStorage(a)) -> Nil { + plinth_storage.clear(storage.raw_storage) +} diff --git a/test/storage_test_ffi.mjs b/test/storage_test_ffi.mjs new file mode 100644 index 0000000..d3d126b --- /dev/null +++ b/test/storage_test_ffi.mjs @@ -0,0 +1,90 @@ +const ITEM_LIMIT = 5; + +class Storage { + #items; + + constructor() { + this.#items = new Map(); + } + + get length() { + return this.#items.size; + } + + key(index) { + let i = 0; + for (const k of this.#items.keys()) { + if (i === index) { + return k; + } + + ++i; + } + + return null; + } + + getItem(k) { + return this.#items.get(k) || null; + } + + setItem(k, v) { + if (this.#items.size === ITEM_LIMIT) { + throw new Error("Full!"); + } + + this.#items.set(k, v); + } + + removeItem(k) { + this.#items.delete(k); + } + + clear() { + this.#items.clear(); + } +} + +function getter(type) { + return new Storage(); +} + +export function runWithMockStorage(callback) { + const oldStorage = globalThis.Storage; + const oldLocal = globalThis.localStorage; + const oldSession = globalThis.sessionStorage; + + Object.defineProperty(globalThis, "Storage", { + value: Storage, + configurable: true, + }); + Object.defineProperty(globalThis, "localStorage", { + get() { + return getter("localStorage"); + }, + configurable: true, + }); + Object.defineProperty(globalThis, "sessionStorage", { + get() { + return getter("sessionStorage"); + }, + configurable: true, + }); + + try { + callback(); + } finally { + Object.defineProperty(globalThis, "Storage", { + value: oldStorage, + configurable: true, + }); + Object.defineProperty(globalThis, "localStorage", { + value: oldLocal, + configurable: true, + }); + Object.defineProperty(globalThis, "sessionStorage", { + value: oldSession, + configurable: true, + }); + } +} diff --git a/test/varasto_test.gleam b/test/varasto_test.gleam new file mode 100644 index 0000000..4794db4 --- /dev/null +++ b/test/varasto_test.gleam @@ -0,0 +1,72 @@ +import gleam/dynamic +import gleam/json +import gleeunit +import gleeunit/should +import plinth/javascript/storage +import varasto + +pub fn main() { + gleeunit.main() +} + +pub fn get_set_test() { + use <- run() + let assert Ok(local) = storage.local() + let s = varasto.new(local, int_list_reader(), int_list_writer()) + should.be_ok(varasto.set(s, "Foo", [1, 2, 3])) + should.equal(varasto.get(s, "Foo"), Ok([1, 2, 3])) +} + +pub fn set_limit_test() { + use <- run() + let assert Ok(local) = storage.local() + let s = varasto.new(local, str_reader(), str_writer()) + should.be_ok(varasto.set(s, "Foo1", "Bar")) + should.be_ok(varasto.set(s, "Foo2", "Bar")) + should.be_ok(varasto.set(s, "Foo3", "Bar")) + should.be_ok(varasto.set(s, "Foo4", "Bar")) + should.be_ok(varasto.set(s, "Foo5", "Bar")) + should.be_error(varasto.set(s, "Foo6", "Bar")) +} + +pub fn remove_test() { + use <- run() + let assert Ok(local) = storage.local() + let s = varasto.new(local, str_reader(), str_writer()) + should.equal(varasto.remove(s, "not here"), Nil) + should.be_ok(varasto.set(s, "Foo", "Bar")) + should.equal(varasto.remove(s, "Foo"), Nil) + should.be_error(varasto.get(s, "Foo")) +} + +pub fn clear_test() { + use <- run() + let assert Ok(session) = storage.session() + let s = varasto.new(session, str_reader(), str_writer()) + should.be_ok(varasto.set(s, "Foo1", "Bar")) + should.be_ok(varasto.set(s, "Foo2", "Bar")) + should.be_ok(varasto.set(s, "Foo3", "Bar")) + should.be_ok(varasto.set(s, "Foo4", "Bar")) + should.be_ok(varasto.set(s, "Foo5", "Bar")) + varasto.clear(s) + should.equal(storage.length(session), 0) +} + +fn int_list_reader() { + dynamic.list(dynamic.int) +} + +fn int_list_writer() { + fn(val: List(Int)) { json.array(val, json.int) } +} + +fn str_reader() { + dynamic.string +} + +fn str_writer() { + fn(val: String) { json.string(val) } +} + +@external(javascript, "./storage_test_ffi.mjs", "runWithMockStorage") +fn run(callback: fn() -> a) -> Nil