Merge pull request #2 from noordstar/2-transfer-tools

Task 2: Adding configurations
pull/4/head
BramvdnHeuvel 2023-12-19 00:13:05 +01:00 committed by GitHub
commit 2e6d07bc42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1941 additions and 4 deletions

View File

@ -4,10 +4,29 @@
"summary": "Matrix SDK for instant communication. Unstable beta version for testing only.",
"license": "EUPL-1.1",
"version": "1.0.0",
"exposed-modules": [
"Matrix",
"Matrix.Settings",
"Internal.Config.Default",
"Internal.Config.Leaks",
"Internal.Config.Text",
"Internal.Tools.Decode",
"Internal.Tools.Encode",
"Internal.Tools.Hashdict",
"Internal.Tools.Iddict",
"Internal.Tools.Timestamp",
"Internal.Tools.VersionControl",
"Internal.Values.Context",
"Internal.Values.Envelope",
"Internal.Values.Vault",
"Types"
],
"elm-version": "0.19.0 <= v < 0.20.0",
"exposed-modules": [ "Matrix" ],
"dependencies": {
"elm/core": "1.0.0 <= v < 2.0.0"
"elm/core": "1.0.0 <= v < 2.0.0",
"elm/json": "1.0.0 <= v < 2.0.0",
"elm/time": "1.0.0 <= v < 2.0.0",
"miniBill/elm-fast-dict": "1.0.0 <= v < 2.0.0"
},
"test-dependencies": {}
}

View File

@ -0,0 +1,54 @@
module Internal.Config.Default exposing
( currentVersion, deviceName
, syncTime
)
{-| This module hosts all default settings and configurations that the Vault
will assume until overriden by the user.
## Version management
@docs currentVersion, deviceName
## Communication config
@docs syncTime
-}
{-| The version that is being communicated to the user
-}
currentVersion : String
currentVersion =
"beta 1.0.0"
{-| The default device name that is being communicated with the Matrix API.
This is mostly useful for users who are logged in with multiple sessions.
-}
deviceName : String
deviceName =
"Elm SDK (" ++ currentVersion ++ ")"
{-| Whenever the Matrix API has nothing new to report, the Elm SDK is kept on
hold until something new happens. The `syncTime` indicates a timeout to how long
the Elm SDK tolerates being held on hold.
- A high value is good because it significantly reduces traffic between the
user and the homeserver.
- A low value is good because it reduces the risk of
the connection ending abruptly or unexpectedly.
Nowadays, most libraries use 30 seconds as the standard, as does the Elm SDK.
The value is in miliseconds, so it is set at 30,000.
-}
syncTime : Int
syncTime =
30 * 1000

View File

@ -0,0 +1,60 @@
module Internal.Config.Leaks exposing (accessToken, baseUrl, transaction, versions)
{-|
# Leaks module
The Elm compiler is quite picky when it comes to handling edge cases, which may
occasionally result in requiring us to insert values in impossible states.
This module offers placeholders for those times. The placeholder values are
intentionally called "leaks", because they should be used carefully: a wrongful
implementation might cause unexpected behaviour, vulnerabilities or even
security risks!
You should not use this module unless you know what you're doing. That is:
- By exclusively using leaking values in opaque types so a user cannot
accidentally reach an impossible state
- By exclusively using leaking values in cases where the compiler is the only
reason that the leaking value needs to be used
- By exclusively using leaking values if there is no way to circumvent the
compiler with a reasonable method.
One such example would be to turn an `Maybe Int` into an `Int` if you already
know 100% sure that the value isn't `Nothing`.
Just 5 |> Maybe.withDefault Leaks.number
@docs accessToken, baseUrl, transaction, versions
-}
{-| Placeholder access token.
-}
accessToken : String
accessToken =
"elm-sdk-placeholder-access-token-leaks"
{-| Placeholder base URL.
-}
baseUrl : String
baseUrl =
"elm-sdk-placeholder-baseurl-leaks.example.org"
{-| Placeholder transaction id.
-}
transaction : String
transaction =
"elm-sdk-placeholder-transaction-leaks"
{-| Placeholder versions list.
-}
versions : List String
versions =
[ "elm-sdk-placeholder-versions-leaks" ]

View File

@ -0,0 +1,116 @@
module Internal.Config.Text exposing
( versionsFoundLocally, versionsReceived, versionsFailedToDecode
, accessTokenFoundLocally, accessTokenExpired, accessTokenInvalid
, unsupportedVersionForEndpoint
)
{-| Throughout the Elm SDK, there are lots of pieces of text being used for
various purposes. Some of these are:
- To log what is happening during an API call.
- To fail with custom decoder errors.
- To describe custom values in a human readable format.
All magic values of text are gathered in this module, to form a monolithic
source of text. This allows people to learn more about the Elm SDK, and it
offers room for future translations.
Optionally, developers can even consider taking the values of some of these
variables to interpret them automatically when they appear as logs on the other
side. This could be used to automatically detect when the Vault is failing to
authenticate, for example, so that a new login screen can be shown. **WARNING:**
This is a risky feature, keep in mind that even a patch update might break this!
You should only do this if you know what you're doing.
## API Versions
Messages sent as API logs while the Elm SDK is figuring out how modern the
homeserver is and how it can best communicate.
@docs versionsFoundLocally, versionsReceived, versionsFailedToDecode
## API Authentication
Messages sent as API logs during the authentication phase of the API
interaction.
@docs accessTokenFoundLocally, accessTokenExpired, accessTokenInvalid
offers room for translation, re-wording and refactors.
## API miscellaneous messages
Messages sent as API logs during communication with the API.
@docs unsupportedVersionForEndpoint
-}
{-| Logs when the Matrix API returns that an access token is no longer valid.
-}
accessTokenExpired : String
accessTokenExpired =
"Matrix API reports access token as no longer valid"
{-| Logs when the Vault has an access token that is still (locally) considered
valid.
-}
accessTokenFoundLocally : String
accessTokenFoundLocally =
"Found locally cached access token"
{-| Logs when the Matrix API rejects an access token without explicitly
mentioning a reason.
-}
accessTokenInvalid : String
accessTokenInvalid =
"Matrix API rejected access token as invalid"
{-| The Matrix homeserver can specify how it wishes to communicate, and the Elm
SDK aims to communicate accordingly. This may fail in some scenarios, however,
in which case it will throw this error.
Most of the time, the error is caused by one of two options:
1. The homeserver is very archaic and does not (yet) support API endpoints that
are nowadays considered mature.
2. The homeserver is much more modern than the Elm SDK and either uses
exclusively API endpoints that the Elm SDK doesn't (yet) support, or it uses
spec versions that aren't considered "official" Matrix spec versions and
were designed by a third party.
-}
unsupportedVersionForEndpoint : String
unsupportedVersionForEndpoint =
"This Matrix homeserver and the Elm SDK do not share a common spec version for this endpoint"
{-| Occasionally, the Matrix homeserver fails to communicate how it is best
communicated with. Most of the time, this means that the homeserver is somehow
unreachable or some gateway error has occured.
-}
versionsFailedToDecode : String
versionsFailedToDecode =
"Matrix API returned an invalid version list"
{-| Logs when the Vault remembers how to communicate with the Matrix homeserver.
-}
versionsFoundLocally : String
versionsFoundLocally =
"Found locally cached version list"
{-| Logs when the Matrix API has returned how to best communicate with them.
-}
versionsReceived : String
versionsReceived =
"Matrix API returned a version list"

View File

@ -0,0 +1,155 @@
module Internal.Tools.Decode exposing
( opField, opFieldWithDefault
, map9, map10, map11
)
{-|
# Decode module
This module contains helper functions that help decode JSON.
## Optional field decoders
@docs opField, opFieldWithDefault
## Extended map functions
@docs map9, map10, map11
-}
import Json.Decode as D
{-| Add an optional field decoder. If the field exists, the decoder will fail
if the field doesn't decode properly.
This decoder standard out from `D.maybe <| D.field fieldName decoder` because
that will decode into a `Nothing` if the `decoder` fails. This function will
only decode into a `Nothing` if the field doesn't exist, and will fail if
`decoder` fails.
The function also returns Nothing if the field exists but it is null.
-}
opField : String -> D.Decoder a -> D.Decoder (Maybe a)
opField fieldName decoder =
D.value
|> D.field fieldName
|> D.maybe
|> D.andThen
(\v ->
case v of
Just _ ->
D.oneOf
[ D.null Nothing
, D.map Just decoder
]
|> D.field fieldName
Nothing ->
D.succeed Nothing
)
{-| Add an optional field decoder. If the field is not given, the decoder will
return a default value. If the field exists, the decoder will fail if the field
doesn't decode properly.
-}
opFieldWithDefault : String -> a -> D.Decoder a -> D.Decoder a
opFieldWithDefault fieldName default decoder =
opField fieldName decoder |> D.map (Maybe.withDefault default)
{-| Try 9 decoders and combine the result.
-}
map9 :
(a -> b -> c -> d -> e -> f -> g -> h -> i -> value)
-> D.Decoder a
-> D.Decoder b
-> D.Decoder c
-> D.Decoder d
-> D.Decoder e
-> D.Decoder f
-> D.Decoder g
-> D.Decoder h
-> D.Decoder i
-> D.Decoder value
map9 func da db dc dd de df dg dh di =
D.map8
(\a b c d e f g ( h, i ) ->
func a b c d e f g h i
)
da
db
dc
dd
de
df
dg
(D.map2 Tuple.pair dh di)
{-| Try 10 decoders and combine the result.
-}
map10 :
(a -> b -> c -> d -> e -> f -> g -> h -> i -> j -> value)
-> D.Decoder a
-> D.Decoder b
-> D.Decoder c
-> D.Decoder d
-> D.Decoder e
-> D.Decoder f
-> D.Decoder g
-> D.Decoder h
-> D.Decoder i
-> D.Decoder j
-> D.Decoder value
map10 func da db dc dd de df dg dh di dj =
D.map8
(\a b c d e f ( g, h ) ( i, j ) ->
func a b c d e f g h i j
)
da
db
dc
dd
de
df
(D.map2 Tuple.pair dg dh)
(D.map2 Tuple.pair di dj)
{-| Try 11 decoders and combine the result.
-}
map11 :
(a -> b -> c -> d -> e -> f -> g -> h -> i -> j -> k -> value)
-> D.Decoder a
-> D.Decoder b
-> D.Decoder c
-> D.Decoder d
-> D.Decoder e
-> D.Decoder f
-> D.Decoder g
-> D.Decoder h
-> D.Decoder i
-> D.Decoder j
-> D.Decoder k
-> D.Decoder value
map11 func da db dc dd de df dg dh di dj dk =
D.map8
(\a b c d e ( f, g ) ( h, i ) ( j, k ) ->
func a b c d e f g h i j k
)
da
db
dc
dd
de
(D.map2 Tuple.pair df dg)
(D.map2 Tuple.pair dh di)
(D.map2 Tuple.pair dj dk)

View File

@ -0,0 +1,52 @@
module Internal.Tools.Encode exposing (maybeObject)
{-|
# Encode module
This module contains helper functions that help decode JSON.
# Optional body object
@docs maybeObject
-}
import Json.Encode as E
{-| Create a body object based on optionally provided values.
In other words, the following two variables create the same JSON value:
value1 : Json.Encode.Value
value1 =
maybeObject
[ ( "name", Just (Json.Encode.string "Alice") )
, ( "age", Nothing )
, ( "height", Just (Json.Encode.float 1.61) )
, ( "weight", Nothing )
]
value2 : Json.Encode.Value
value2 =
Json.Encode.object
[ ( "name", Json.Encode.string "Alice" )
, ( "height", Json.Encode.float 1.61 )
]
-}
maybeObject : List ( String, Maybe E.Value ) -> E.Value
maybeObject =
List.filterMap
(\( name, value ) ->
case value of
Just v ->
Just ( name, v )
_ ->
Nothing
)
>> E.object

View File

@ -0,0 +1,266 @@
module Internal.Tools.Hashdict exposing
( Hashdict
, empty, singleton, insert, remove, removeKey
, isEmpty, member, memberKey, get, size
, keys, values, toList, fromList
, rehash, union
, encode, decoder, softDecoder
)
{-| This module abstracts the `Dict` type with one function that assigns a
unique identifier for each value based on a function that assigns each value.
This allows you to store values based on an externally defined identifier.
## Dictionaries
@docs Hashdict
## Build
@docs empty, singleton, insert, remove, removeKey
## Query
@docs isEmpty, member, memberKey, get, size
## Lists
@docs keys, values, toList, fromList
## Transform
@docs rehash, union
## JSON coders
@docs encode, decoder, softDecoder
-}
import FastDict as Dict exposing (Dict)
import Json.Decode as D
import Json.Encode as E
{-| A dictionary of keys and values where each key is defined by its value. For
example, this can be useful when every user is identifiable by their username:
import Hashdict exposing (Hashdict)
users : Hashdict User
users =
Hashdict.fromList .name
[ User "Alice" 28 1.65
, User "Bob" 19 1.82
, User "Chuck" 33 1.75
]
type alias User =
{ name : String
, age : Int
, height : Float
}
In the example listed above, the users are stored by their username, which means
that all you need to know is the value "Alice" to retrieve all the information
about them. Additionally, you do not need to specify a key to insert the values.
-}
type Hashdict a
= Hashdict
{ hash : a -> String
, values : Dict String a
}
{-| Decode a hashdict from a JSON value. To create a hashdict, you are expected
to insert a hash function. If the hash function doesn't properly hash the values
as expected, the decoder will fail to decode the hashdict.
-}
decoder : (a -> String) -> D.Decoder a -> D.Decoder (Hashdict a)
decoder f xDecoder =
D.keyValuePairs xDecoder
|> D.andThen
(\items ->
if List.all (\( hash, value ) -> f value == hash) items then
items
|> Dict.fromList
|> (\d -> { hash = f, values = d })
|> Hashdict
|> D.succeed
else
D.fail "Hash function fails to properly hash all values"
)
{-| Create an empty hashdict.
-}
empty : (a -> String) -> Hashdict a
empty hash =
Hashdict { hash = hash, values = Dict.empty }
{-| Encode a Hashdict into a JSON value. Keep in mind that an Elm function
cannot be universally converted to JSON, so it is up to you to preserve that
hash function!
-}
encode : (a -> E.Value) -> Hashdict a -> E.Value
encode encodeX (Hashdict h) =
h.values
|> Dict.toList
|> List.map (Tuple.mapSecond encodeX)
|> E.object
{-| Convert an association list into a hashdict.
-}
fromList : (a -> String) -> List a -> Hashdict a
fromList hash xs =
Hashdict
{ hash = hash
, values =
xs
|> List.map (\x -> ( hash x, x ))
|> Dict.fromList
}
{-| Get the value associated with a hash. If the hash is not found, return
`Nothing`. This is useful when you are not sure if a hash will be in the
hashdict.
-}
get : String -> Hashdict a -> Maybe a
get k (Hashdict h) =
Dict.get k h.values
{-| Insert a value into a hashdict. The key is automatically generated by the
hash function. If the function generates a collision, it replaces the existing
value in the hashdict.
-}
insert : a -> Hashdict a -> Hashdict a
insert v (Hashdict h) =
Hashdict { h | values = Dict.insert (h.hash v) v h.values }
{-| Determine if a hashdict is empty.
-}
isEmpty : Hashdict a -> Bool
isEmpty (Hashdict h) =
Dict.isEmpty h.values
{-| Get all of the hashes in a hashdict, sorted from lowest to highest.
-}
keys : Hashdict a -> List String
keys (Hashdict h) =
Dict.keys h.values
{-| Determine if a value's hash is in a hashdict.
-}
member : a -> Hashdict a -> Bool
member value (Hashdict h) =
Dict.member (h.hash value) h.values
{-| Determine if a hash is in a hashdict.
-}
memberKey : String -> Hashdict a -> Bool
memberKey key (Hashdict h) =
Dict.member key h.values
{-| Remap a hashdict using a new hashing algorithm.
-}
rehash : (a -> String) -> Hashdict a -> Hashdict a
rehash f (Hashdict h) =
Hashdict
{ hash = f
, values =
h.values
|> Dict.values
|> List.map (\v -> ( f v, v ))
|> Dict.fromList
}
{-| Remove a value from a hashdict. If the value's hash is found, the key-value
pair is removed. If the value's hash is not found, no changes are made.
hdict |> Hashdict.remove (User "Alice" 19 1.82)
-}
remove : a -> Hashdict a -> Hashdict a
remove v (Hashdict h) =
Hashdict { h | values = Dict.remove (h.hash v) h.values }
{-| Remove a key from a hashdict. If the key is not found, no changes are made.
hdict |> Hashdict.removeKey "Alice"
-}
removeKey : String -> Hashdict a -> Hashdict a
removeKey k (Hashdict h) =
Hashdict { h | values = Dict.remove k h.values }
{-| Create a hashdict with a single key-value pair.
-}
singleton : (a -> String) -> a -> Hashdict a
singleton f v =
empty f |> insert v
{-| Determine the number of values in a hashdict.
-}
size : Hashdict a -> Int
size (Hashdict h) =
Dict.size h.values
{-| Decode a hashdict from a JSON value. If you cannot deduce the originally
used hash function, (or if you simply do not care) you can use this function to
decode and rehash the Hashdict using your new hash function.
-}
softDecoder : (a -> String) -> D.Decoder a -> D.Decoder (Hashdict a)
softDecoder f xDecoder =
D.keyValuePairs xDecoder
|> D.map (List.map Tuple.second >> fromList f)
{-| Convert a hashdict into an association list of key-value pairs, sorted by
keys.
-}
toList : Hashdict a -> List ( String, a )
toList (Hashdict h) =
Dict.toList h.values
{-| Combine two hashdicts under the hash function of the first. If there is a
collision, preference is given to the first hashdict.
-}
union : Hashdict a -> Hashdict a -> Hashdict a
union (Hashdict h1) hd2 =
case rehash h1.hash hd2 of
Hashdict h2 ->
Hashdict
{ hash = h1.hash
, values = Dict.union h1.values h2.values
}
{-| Get all values stored in the hashdict, in the order of their keys.
-}
values : Hashdict a -> List a
values (Hashdict h) =
Dict.values h.values

View File

@ -0,0 +1,197 @@
module Internal.Tools.Iddict exposing
( Iddict
, empty, singleton, insert, map, remove
, isEmpty, member, get, size
, keys, values
, encode, decoder
)
{-| The id-dict is a data type that lets us store values in a dictionary using
unique identifiers. This can be used as a dictionary where the keys do not
matter.
The benefit of the iddict is that it generates the keys FOR you. This way, you
do not need to generate identifiers yourself.
## Id-dict
@docs Iddict
## Build
@docs empty, singleton, insert, map, remove
## Query
@docs isEmpty, member, get, size
## Lists
@docs keys, values
## JSON coders
@docs encode, decoder
-}
import FastDict as Dict exposing (Dict)
import Json.Decode as D
import Json.Encode as E
{-| The Iddict data type.
-}
type Iddict a
= Iddict
{ cursor : Int
, dict : Dict Int a
}
{-| Decode an id-dict from a JSON value.
-}
decoder : D.Decoder a -> D.Decoder (Iddict a)
decoder xDecoder =
D.map2
(\c pairs ->
let
dict : Dict Int a
dict =
pairs
|> List.filterMap
(\( k, v ) ->
k
|> String.toInt
|> Maybe.map (\n -> ( n, v ))
)
|> Dict.fromList
in
Iddict
{ cursor =
Dict.keys dict
-- Larger than all values in the list
|> List.map ((+) 1)
|> List.maximum
|> Maybe.withDefault 0
|> max (Dict.size dict)
-- At least the dict size
|> max c
-- At least the given value
, dict = dict
}
)
(D.field "cursor" D.int)
(D.field "dict" <| D.keyValuePairs xDecoder)
{-| Create an empty id-dict.
-}
empty : Iddict a
empty =
Iddict
{ cursor = 0
, dict = Dict.empty
}
{-| Encode an id-dict to a JSON value.
-}
encode : (a -> E.Value) -> Iddict a -> E.Value
encode encodeX (Iddict d) =
E.object
[ ( "cursor", E.int d.cursor )
, ( "dict"
, d.dict
|> Dict.toCoreDict
|> E.dict String.fromInt encodeX
)
]
{-| Get a value from the id-dict using its key.
-}
get : Int -> Iddict a -> Maybe a
get k (Iddict { dict }) =
Dict.get k dict
{-| Insert a new value into the id-dict. Given that the id-dict generates its
key, the function returns both the updated id-dict as the newly generated key.
x = empty |> insert "hello" -- ( 0, <Iddict with value "hello"> )
case x of
( _, iddict ) ->
get 0 iddict -- Just "hello"
-}
insert : a -> Iddict a -> ( Int, Iddict a )
insert v (Iddict d) =
( d.cursor
, Iddict { cursor = d.cursor + 1, dict = Dict.insert d.cursor v d.dict }
)
{-| Determine if an id-dict is empty.
-}
isEmpty : Iddict a -> Bool
isEmpty (Iddict d) =
Dict.isEmpty d.dict
{-| Get all of the keys from the id-dict, sorted from lowest to highest.
-}
keys : Iddict a -> List Int
keys (Iddict { dict }) =
Dict.keys dict
{-| Map an existing value at a given key, if it exists. If it does not exist,
the operation does nothing.
-}
map : Int -> (a -> a) -> Iddict a -> Iddict a
map k f (Iddict d) =
Iddict { d | dict = Dict.update k (Maybe.map f) d.dict }
{-| Determine if a key is in an id-dict.
-}
member : Int -> Iddict a -> Bool
member k (Iddict d) =
k < d.cursor && Dict.member k d.dict
{-| Remove a key-value pair from the id-dict. If the key is not found, no
changes are made.
-}
remove : Int -> Iddict a -> Iddict a
remove k (Iddict d) =
Iddict { d | dict = Dict.remove k d.dict }
{-| Create an id-dict with a single value.
-}
singleton : a -> ( Int, Iddict a )
singleton v =
insert v empty
{-| Determine the number of key-value pairs in the id-dict.
-}
size : Iddict a -> Int
size (Iddict d) =
Dict.size d.dict
{-| Get all of the values from an id-dict, in the order of their keys.
-}
values : Iddict a -> List a
values (Iddict { dict }) =
Dict.values dict

View File

@ -0,0 +1,43 @@
module Internal.Tools.Timestamp exposing
( Timestamp
, encode, decoder
)
{-| The Timestamp module is a simplification of the Timestamp as delivered by
elm/time. This module offers ways to work with the timestamp in meaningful ways.
## Timestamp
@docs Timestamp
## JSON coders
@docs encode, decoder
-}
import Json.Decode as D
import Json.Encode as E
import Time
{-| The Timestamp data type representing a moment in time.
-}
type alias Timestamp =
Time.Posix
{-| Encode a timestamp into a JSON value.
-}
encode : Timestamp -> E.Value
encode =
Time.posixToMillis >> E.int
{-| Decode a timestamp from a JSON value.
-}
decoder : D.Decoder Timestamp
decoder =
D.map Time.millisToPosix D.int

View File

@ -0,0 +1,365 @@
module Internal.Tools.VersionControl exposing
( VersionControl, withBottomLayer
, sameForVersion, MiddleLayer, addMiddleLayer
, isSupported, toDict, fromVersion, mostRecentFromVersionList, fromVersionList
)
{-|
# Version Control module
This module helps you maintain different functions based on their version.
Not every Matrix homeserver is the same. Some keep up with the latest Matrix
specifications, while others stay behind because they have to support legacy
projects who do not support new API endpoints (yet). The Elm SDK aims to support
as many homeserver versions as possible - at the same time.
Support for legacy versions can be difficult! The Elm SDK expects one way of
getting information, and translating every Matrix spec(ification) version to it
can take time. But what if a new Matrix spec version adds a new feature? Do we
need to re-translate every single version to accomodate any future updates?
The VersionControl helps define different API rules for different spec versions
in an easy way. The VersionControl module puts all the versions in a linear
timeline. (Because, you know, updates are usually newer versions of older
versions.) This way, you can define different behaviour while still having only
one input, one output.
The module can be best described as a layered version type.
|----------------------------------------------|
| VersionControl |
| input output |
| | ^ |
|---------------------- | -------------- | ----|
| |
|---------------------- | -------------- | ----|
| MiddleLayer v3 | | |
| [---> current ---] |
| | | |
| downcast upcast |
| | ^ |
|---------------------- | -------------- | ----|
| |
|---------------------- | -------------- | ----|
| MiddleLayer v2 | | |
| [---> current ---] |
| | | |
| downcast upcast |
| | ^ |
|---------------------- | -------------- | ----|
| |
|---------------------- | -------------- | ----|
| BottomLayer v1 | | |
| \---> current ---/ |
| |
|----------------------------------------------|
This method means you only need to write one downcast, one current and one
upcast whenever you introduce a new version. In other words, you can instantly
update all functions without having to write every version!
The VersionControl keeps tracks the version order. This way, you can either get
the VersionControl type to render the function for the most recent supported
version, or you can choose for yourself which version you prefer to use.
## Building a VersionControl
To build a VersionControl type, one must start with the bottom layer and start
building up to newer versions with middle layers.
### Create
@docs VersionControl, withBottomLayer
### Expand
@docs sameForVersion, MiddleLayer, addMiddleLayer
## Getting functions
Once you've successfully built the VersionControl type, there's a variety of
ways in which you can find an appropriate function.
@docs isSupported, toDict, fromVersion, mostRecentFromVersionList, fromVersionList
-}
import Dict exposing (Dict)
{-| The VersionControl layer is the layer on top that keeps track of all the
available versions. It is usually defined with a bottom layer and a few layers
on top.
-}
type VersionControl input output
= VersionControl
{ latestVersion : input -> output
, order : List String
, versions : Dict String (input -> output)
}
{-| The middle layer is placed between a VersionControl and a BottomLayer to
support a new function for a new version. The abbreviations stand for the
following:
- `cin` means **current in**. It is the Middle Layer's input.
- `cout` means **current out**. It is the Middle Layer's output.
- `din` means **downcast in**. It is the Bottom Layer's input.
- `dout` means **downcast out**. It is the Bottom Layer's output.
As a result, we have the following model to explain the MiddleLayer:
|----------------------------------------------|
| VersionControl |
| input output |
| | ^ |
|---------------------- | -------------- | ----|
[cin] [cout]
|---------------------- | -------------- | ----|
| MiddleLayer | | |
| [---> current ---] |
| | | |
| downcast upcast |
| | ^ |
|---------------------- | -------------- | ----|
[din] [dout]
|---------------------- | -------------- | ----|
| BottomLayer | | |
| \---> current ---/ |
| |
|----------------------------------------------|
To sew a MiddleLayer type, we need the `downcast` and `upcast` functions to
translate the `cin` and `cout` to meaningful values `din` and `dout` for the
BottomLayer function.
Usually, this means transforming the data. For example, say our BottomLayer
still has an old version where people had just one name, and our MiddleLayer
version has two fields: a first and last name.
type alias NewUser =
{ firstName : String, lastName : String, age : Int }
type alias OldUser =
{ name : String, age : Int }
An appropriate downcasting function could then something like the following:
downcast : NewUser -> OldUser
downcast user =
{ name = user.firstName ++ " " ++ user.lastName, age = user.age }
-}
type alias MiddleLayer cin cout din dout =
{ current : cin -> cout
, downcast : cin -> din
, upcast : dout -> cout
, version : String
}
{-| Add a MiddleLayer to the VersionControl, effectively updating all old
functions with a downcast and upcast to deal with the inputs and outputs of all
functions at the same time.
For example, using the `NewUser` and `OldUser` types, one could create the
following example to get the user's names:
vc : VersionControl NewUser String
vc =
withBottomLayer
{ current = .name
, version = "v1"
}
|> sameForVersion "v2"
|> sameForVersion "v3"
|> sameForVersion "v4"
|> sameForVersion "v5"
|> sameForVersion "v6"
|> addMiddleLayer
{ downcast = \user -> { name = user.firstName ++ " " ++ user.lastName, age = user.age }
, current = \user -> user.firstName ++ " " ++ user.lastName
, upcast = identity
, version = "v7"
}
Effectively, even though versions `v1` through `v6` still require an `OldUser`
type as an input, all functions have now been updated to the new standard of
getting a `NewUser` as an input thanks to the `downcast` function.
-}
addMiddleLayer : MiddleLayer cin cout din dout -> VersionControl din dout -> VersionControl cin cout
addMiddleLayer { current, downcast, upcast, version } (VersionControl d) =
VersionControl
{ latestVersion = current
, order = version :: d.order
, versions =
d.versions
|> Dict.map (\_ f -> downcast >> f >> upcast)
|> Dict.insert version current
}
{-| Get the function that corresponds with a given version. Returns `Nothing` if
the version has never been inserted into the VersionControl type.
-}
fromVersion : String -> VersionControl a b -> Maybe (a -> b)
fromVersion version (VersionControl { versions }) =
Dict.get version versions
{-| Provided a list of versions, this function will provide a list of compatible versions to you in your preferred order.
If you just care about getting the most recent function, you will be better off using `mostRecentFromVersionList`,
but this function can help if you care about knowing which Matrix spec version you're using.
-}
fromVersionList : List String -> VersionControl a b -> List ( String, a -> b )
fromVersionList versionList vc =
List.filterMap
(\version ->
vc
|> fromVersion version
|> Maybe.map (\f -> ( version, f ))
)
versionList
{-| Determine if a version is supported by the VersionControl.
vc : VersionControl NewUser String
vc =
withBottomLayer
{ current = .name
, version = "v1"
}
|> sameForVersion "v2"
|> sameForVersion "v3"
|> sameForVersion "v4"
isSupported "v3" vc -- True
isSupported "v9" vc -- False
-}
isSupported : String -> VersionControl a b -> Bool
isSupported version (VersionControl d) =
Dict.member version d.versions
{-| Get the most recent event based on a list of versions. Returns `Nothing` if
the list is empty, or if none of the versions are supported.
vc : VersionControl a b
vc =
withBottomLayer
{ current = foo
, version = "v1"
}
|> sameForVersion "v2"
|> sameForVersion "v3"
|> sameForVersion "v4"
|> sameForVersion "v5"
|> sameForVersion "v6"
-- This returns the function for v6 because that is the most recent version
-- in the provided version list
mostRecentFromVersionList [ "v5", "v3", "v7", "v6", "v8" ] vc
-}
mostRecentFromVersionList : List String -> VersionControl a b -> Maybe (a -> b)
mostRecentFromVersionList versionList ((VersionControl { order }) as vc) =
order
|> List.filter (\o -> List.member o versionList)
|> List.filterMap (\v -> fromVersion v vc)
|> List.head
{-| Not every version overhauls every interaction. For this reason, many version
functions are identical to their previous functions.
This function adds a new version to the VersionControl and tells it that the
version uses the same function as the previous version.
vc : VersionControl User String
vc =
withBottomLayer
{ current = .name
, version = "v1"
}
|> sameForVersion "v2"
|> sameForVersion "v3"
|> sameForVersion "v4"
|> sameForVersion "v5"
|> sameForVersion "v6"
The example above lists the function `.name` for versions `v1` through `v6`.
-}
sameForVersion : String -> VersionControl a b -> VersionControl a b
sameForVersion version (VersionControl data) =
VersionControl
{ data
| order = version :: data.order
, versions = Dict.insert version data.latestVersion data.versions
}
{-| Get a dict of all available functions.
vc : VersionControl NewUser String
vc =
withBottomLayer
{ current = .name
, version = "v1"
}
|> sameForVersion "v2"
|> sameForVersion "v3"
|> sameForVersion "v4"
|> toDict
-- Dict.fromList
-- [ ( "v1", <internal> )
-- , ( "v2", <internal> )
-- , ( "v3", <internal> )
-- , ( "v4", <internal> )
-- ]
-}
toDict : VersionControl a b -> Dict String (a -> b)
toDict (VersionControl d) =
d.versions
{-| You cannot create an empty VersionControl layer, you must always start with a BottomLayer
and then stack MiddleLayer types on top until you've reached the version that you're happy with.
vc : VersionControl User String
vc =
withBottomLayer
{ current = .name
, version = "v1"
}
type alias User =
{ name : String, age : Int }
-}
withBottomLayer : { current : input -> output, version : String } -> VersionControl input output
withBottomLayer { current, version } =
VersionControl
{ latestVersion = current
, order = List.singleton version
, versions = Dict.singleton version current
}

View File

@ -0,0 +1,196 @@
module Internal.Values.Context exposing
( Context, init, encode, decoder
, APIContext, apiFormat
, setAccessToken, getAccessToken
, setBaseUrl, getBaseUrl
, setTransaction, getTransaction
, setVersions, getVersions
)
{-| The Context is the set of variables that the user (mostly) cannot control.
The Context contains tokens, values and other bits that the Vault receives from
the Matrix API.
## Context
@docs Context, init, encode, decoder
## APIContext
Once the API starts needing information, that's when we use the APIContext type
to build the right environment for the API communication to work with.
@docs APIContext, apiFormat
Once the APIContext is ready, there's helper functions for each piece of
information that can be inserted.
### Access token
@docs setAccessToken, getAccessToken
### Base URL
@docs setBaseUrl, getBaseUrl
### Transaction id
@docs setTransaction, getTransaction
### Versions
@docs setVersions, getVersions
-}
import Internal.Config.Leaks as L
import Internal.Tools.Decode as D
import Internal.Tools.Encode as E
import Json.Decode as D
import Json.Encode as E
{-| The Context type stores all the information in the Vault. This data type is
static and hence can be passed on easily.
-}
type alias Context =
{ accessToken : Maybe String
, baseUrl : Maybe String
, password : Maybe String
, refreshToken : Maybe String
, username : Maybe String
, transaction : Maybe String
, versions : Maybe (List String)
}
{-| The APIContext is a separate type that uses a phantom type to trick the
compiler into requiring values to be present. This data type is used to gather
the right variables (like an access token) before accessing the Matrix API.
-}
type APIContext ph
= APIContext
{ accessToken : String
, baseUrl : String
, context : Context
, transaction : String
, versions : List String
}
{-| Create an unformatted APIContext type.
-}
apiFormat : Context -> APIContext {}
apiFormat context =
APIContext
{ accessToken = context.accessToken |> Maybe.withDefault L.accessToken
, baseUrl = context.baseUrl |> Maybe.withDefault L.baseUrl
, context = context
, transaction = context.transaction |> Maybe.withDefault L.transaction
, versions = context.versions |> Maybe.withDefault L.versions
}
{-| Decode a Context type from a JSON value.
-}
decoder : D.Decoder Context
decoder =
D.map7 Context
(D.opField "accessToken" D.string)
(D.opField "baseUrl" D.string)
(D.opField "password" D.string)
(D.opField "refreshToken" D.string)
(D.opField "username" D.string)
(D.opField "transaction" D.string)
(D.opField "versions" (D.list D.string))
{-| Encode a Context type into a JSON value.
-}
encode : Context -> E.Value
encode context =
E.maybeObject
[ ( "accessToken", Maybe.map E.string context.accessToken )
, ( "baseUrl", Maybe.map E.string context.baseUrl )
, ( "password", Maybe.map E.string context.password )
, ( "refreshToken", Maybe.map E.string context.refreshToken )
, ( "username", Maybe.map E.string context.username )
, ( "transaction", Maybe.map E.string context.transaction )
, ( "versions", Maybe.map (E.list E.string) context.versions )
]
{-| A basic, untouched version of the Context, containing no information.
-}
init : Context
init =
{ accessToken = Nothing
, baseUrl = Nothing
, refreshToken = Nothing
, password = Nothing
, username = Nothing
, transaction = Nothing
, versions = Nothing
}
{-| Get an inserted access token.
-}
getAccessToken : APIContext { a | accessToken : () } -> String
getAccessToken (APIContext c) =
c.accessToken
{-| Insert an access token into the APIContext.
-}
setAccessToken : String -> APIContext a -> APIContext { a | accessToken : () }
setAccessToken value (APIContext c) =
APIContext { c | accessToken = value }
{-| Get an inserted base URL.
-}
getBaseUrl : APIContext { a | baseUrl : () } -> String
getBaseUrl (APIContext c) =
c.baseUrl
{-| Insert a base URL into the APIContext.
-}
setBaseUrl : String -> APIContext a -> APIContext { a | baseUrl : () }
setBaseUrl value (APIContext c) =
APIContext { c | baseUrl = value }
{-| Get an inserted transaction id.
-}
getTransaction : APIContext { a | transaction : () } -> String
getTransaction (APIContext c) =
c.transaction
{-| Insert a transaction id into the APIContext.
-}
setTransaction : String -> APIContext a -> APIContext { a | transaction : () }
setTransaction value (APIContext c) =
APIContext { c | transaction = value }
{-| Get an inserted versions list.
-}
getVersions : APIContext { a | versions : () } -> List String
getVersions (APIContext c) =
c.versions
{-| Insert a versions list into the APIContext.
-}
setVersions : List String -> APIContext a -> APIContext { a | versions : () }
setVersions value (APIContext c) =
APIContext { c | versions = value }

View File

@ -0,0 +1,301 @@
module Internal.Values.Envelope exposing
( Envelope, init
, map, mapMaybe, mapList
, Settings, mapSettings, extractSettings
, mapContext
, getContent, extract
, encode, decoder
)
{-| The Envelope module wraps existing data types with lots of values and
settings that can be adjusted manually.
## Create
@docs Envelope, init
## Manipulate
@docs map, mapMaybe, mapList
## Settings
@docs Settings, mapSettings, extractSettings
## Context
@docs mapContext
## Extract
@docs getContent, extract
## JSON coders
@docs encode, decoder
-}
import Internal.Config.Default as Default
import Internal.Tools.Decode as D
import Internal.Tools.Encode as E
import Internal.Values.Context as Context exposing (Context)
import Json.Decode as D
import Json.Encode as E
{-| There are lots of different data types in the Elm SDK, and many of them
need the same values. The Envelope type wraps settings, tokens and values around
each data type so they can all enjoy those values without needing to explicitly
define them in their type.
-}
type Envelope a
= Envelope
{ content : a
, context : Context
, settings : Settings
}
{-| Custom settings that can be manipulated by the user. These serve as a
configuration for how the Elm SDK should behave.
Custom settings are always part of the Envelope, allowing all functions to
behave under the user's preferred settings.
-}
type alias Settings =
{ currentVersion : String
, deviceName : String
, syncTime : Int
}
{-| Decode an enveloped type from a JSON value. The decoder also imports any
potential tokens, values and settings included in the JSON.
-}
decoder : D.Decoder a -> D.Decoder (Envelope a)
decoder xDecoder =
D.map3 (\a b c -> Envelope { content = a, context = b, settings = c })
(D.field "content" xDecoder)
(D.field "context" Context.decoder)
(D.field "settings" decoderSettings)
{-| Decode settings from a JSON value.
-}
decoderSettings : D.Decoder Settings
decoderSettings =
D.map3 Settings
(D.opFieldWithDefault "currentVersion" Default.currentVersion D.string)
(D.opFieldWithDefault "deviceName" Default.deviceName D.string)
(D.opFieldWithDefault "syncTime" Default.syncTime D.int)
{-| Encode an enveloped type into a JSON value. The function encodes all
non-standard settings, tokens and values.
-}
encode : (a -> E.Value) -> Envelope a -> E.Value
encode encodeX (Envelope data) =
E.object
[ ( "content", encodeX data.content )
, ( "context", Context.encode data.context )
, ( "settings", encodeSettings data.settings )
, ( "version", E.string Default.currentVersion )
]
{-| Encode the settings into a JSON value.
-}
encodeSettings : Settings -> E.Value
encodeSettings settings =
let
differentFrom : b -> b -> Maybe b
differentFrom defaultValue currentValue =
if currentValue == defaultValue then
Nothing
else
Just currentValue
in
E.maybeObject
[ ( "currentVersion"
, settings.currentVersion
|> differentFrom Default.currentVersion
|> Maybe.map E.string
)
, ( "deviceName"
, settings.deviceName
|> differentFrom Default.deviceName
|> Maybe.map E.string
)
, ( "syncTime"
, settings.syncTime
|> differentFrom Default.syncTime
|> Maybe.map E.int
)
]
{-| Map a function, then get its content. This is useful for getting information
from a data type inside an Envelope.
type alias User =
{ name : String, age : Int }
getName : Envelope User -> String
getName =
Envelope.extract .name
-}
extract : (a -> b) -> Envelope a -> b
extract f (Envelope data) =
f data.content
{-| Map a function on the settings, effectively getting data that way.
This can be helpful if you have a UI that displays custom settings to a user.
-}
extractSettings : (Settings -> b) -> Envelope a -> b
extractSettings f (Envelope data) =
f data.settings
{-| Get the original item that is stored inside an Envelope.
Make sure that you're only using this if you're interested in the actual value!
If you'd like to get the content, run a function on it, and put it back in an
Envelope, consider using [map](#map) instead.
-}
getContent : Envelope a -> a
getContent =
extract identity
{-| Create a new enveloped data type. All settings are set to default values
from the [Internal.Config.Default](Internal-Config-Default) module.
-}
init : a -> Envelope a
init x =
Envelope
{ content = x
, context = Context.init
, settings =
{ currentVersion = Default.currentVersion
, deviceName = Default.deviceName
, syncTime = Default.syncTime
}
}
{-| Map a function on the content of the Envelope.
type alias User =
{ name : String, age : Int }
getName : Envelope User -> Envelope String
getName =
Envelope.map .name
-}
map : (a -> b) -> Envelope a -> Envelope b
map f (Envelope data) =
Envelope
{ content = f data.content
, context = data.context
, settings = data.settings
}
{-| Update the Context in the Envelope.
-}
mapContext : (Context -> Context) -> Envelope a -> Envelope a
mapContext f (Envelope data) =
Envelope
{ content = data.content
, context = f data.context
, settings = data.settings
}
{-| Map the contents of a function, where the result is wrapped in a `List`
type. This can be useful when you are mapping to a list of individual values
that you would all like to see enveloped.
type alias User =
{ name : String, age : Int }
type alias Company =
{ name : String, employees : List User }
getEmployees : Envelope Company -> List (Envelope User)
getEmployees envelope =
mapList .employees envelope
-}
mapList : (a -> List b) -> Envelope a -> List (Envelope b)
mapList f =
map f >> toList
{-| Map the contents of a function, where the result is wrapped in a `Maybe`
type. This can be useful when you are not guaranteed to find the value you're
looking for.
type alias User =
{ name : String, age : Int }
type alias UserDatabase =
List User
getFirstUser : Envelope UserDatabase -> Maybe (Envelope User)
getFirstUser envelope =
mapMaybe List.head envelope
-}
mapMaybe : (a -> Maybe b) -> Envelope a -> Maybe (Envelope b)
mapMaybe f =
map f >> toMaybe
{-| Update the settings in the Envelope.
setDeviceName : String -> Envelope a -> Envelope a
setDeviceName name envelope =
mapSettings
(\settings ->
{ settings | deviceName = name }
)
envelope
-}
mapSettings : (Settings -> Settings) -> Envelope a -> Envelope a
mapSettings f (Envelope data) =
Envelope
{ content = data.content
, context = data.context
, settings = f data.settings
}
toList : Envelope (List a) -> List (Envelope a)
toList (Envelope data) =
List.map
(\content -> map (always content) (Envelope data))
data.content
toMaybe : Envelope (Maybe a) -> Maybe (Envelope a)
toMaybe (Envelope data) =
Maybe.map
(\content -> map (always content) (Envelope data))
data.content

View File

@ -0,0 +1,15 @@
module Internal.Values.Vault exposing (Vault)
{-| This module hosts the Vault module.
@docs Vault
-}
import Internal.Values.Envelope as Envelope
{-| This is the Vault type.
-}
type alias Vault =
Envelope.Envelope {}

View File

@ -19,11 +19,13 @@ support a monolithic public registry. (:
-}
import Types
{-| The Vault type stores all relevant information about the Matrix API.
It currently supports no functionality and it will just stay here - for fun.
-}
type Vault
= Vault
type alias Vault =
Types.Vault

71
src/Matrix/Settings.elm Normal file
View File

@ -0,0 +1,71 @@
module Matrix.Settings exposing
( getDeviceName, setDeviceName
, getSyncTime, setSyncTime
)
{-| The Matrix Vault has lots of configurable variables that you rarely want to
interact with. Usually, you configure these variables only when creating a new
Vault, or when a user explicitly changes one of their preferred settings.
## Device name
The default device name that is being communicated with the Matrix API.
This is mostly useful for users who are logged in with multiple sessions. They
will see device names like "Element for Android" or "Element on iOS". For the
Elm SDK, they will by default see the Elm SDK with its version included. If you
are writing a custom client, however, you are free to change this to something
more meaningful to the user.
@docs getDeviceName, setDeviceName
## Sync time
Whenever the Matrix API has nothing new to report, the Elm SDK is kept on
hold until something new happens. The `syncTime` indicates a timeout to how long
the Elm SDK tolerates being held on hold.
- A high value is good because it significantly reduces traffic between the
user and the homeserver.
- A low value is good because it reduces the risk of
the connection ending abruptly or unexpectedly.
Nowadays, most libraries use 30 seconds as the standard, as does the Elm SDK.
The value is in miliseconds, so it is set at 30,000.
@docs getSyncTime, setSyncTime
-}
import Internal.Values.Envelope as Envelope
import Types exposing (Vault(..))
{-| Determine the device name.
-}
getDeviceName : Vault -> String
getDeviceName (Vault vault) =
Envelope.extractSettings .deviceName vault
{-| Override the device name.
-}
setDeviceName : String -> Vault -> Vault
setDeviceName name (Vault vault) =
Vault <| Envelope.mapSettings (\s -> { s | deviceName = name }) vault
{-| Determine the sync timeout value.
-}
getSyncTime : Vault -> Int
getSyncTime (Vault vault) =
Envelope.extractSettings .syncTime vault
{-| Override the sync timeout value.
-}
setSyncTime : Int -> Vault -> Vault
setSyncTime time (Vault vault) =
Vault <| Envelope.mapSettings (\s -> { s | syncTime = time }) vault

25
src/Types.elm Normal file
View File

@ -0,0 +1,25 @@
module Types exposing (Vault(..))
{-| The Elm SDK uses a lot of records and values that are easy to manipulate.
Yet, the [Elm design guidelines](https://package.elm-lang.org/help/design-guidelines#keep-tags-and-record-constructors-secret)
highly recommend using opaque types in order to avoid breaking everyone's code
in a future major release.
This module forms as a protective layer between the internal modules and the
exposed modules, hiding all exposed types behind opaque types so the user cannot
access their content directly.
The opaque types are placed in a central module so all exposed modules can
safely access all exposed data types without risking to create circular imports.
@docs Vault
-}
import Internal.Values.Vault as Vault
{-| Opaque type for Matrix Vault
-}
type Vault
= Vault Vault.Vault