sourceafBedSheet::HttpSession.fan

using afIoc::Inject
using afIoc::Registry
using afIoc::IocErr
using afConcurrent::LocalRef
using web::WebSession

** (Service) - An injectable 'const' version of [WebSession]`web::WebSession`.
** 
** Provides a name/value map associated with a specific browser *connection* to the web server.
** A cookie (with the name 'fanws') is used to track which session is made available to the request.
** 
** All values stored in the session must be either immutable or serialisable. 
** Note that immutable objects give better performance.  
** 
** For scalable applications, the session should be used sparingly; house cleaned regularly and not used as a dumping ground. 
** 
** Flash 
** -----
** Whereas normal session values are persisted indefinitely, flash vales only exist until the end of the next request.
** After that, they are removed from the session.
** 
** A common usage is to store message values before a redirect (usually after a form post).
** When the following page is rendered, the message is retrieved and displayed.
** After which the message is automatically discarded from the session. 
** 
** *(Flash: A-ah - Saviour of the Universe!)*  
const mixin HttpSession {
    
    ** Get the unique id used to identify this session.
    ** 
    ** Calling this method **will** create a session if it does not exist.
    ** 
    ** @see `web::WebSession.id`
    abstract Str id()

    ** Returns 'true' if the session map is empty. 
    ** 
    ** Calling this method does not create a session if it does not exist.
    abstract Bool isEmpty()

    ** Returns 'true' if the session map contains the given key. 
    ** 
    ** Calling this method does not create a session if it does not exist.
    abstract Bool containsKey(Str key)
    
    ** Returns session value or def if not defined.
    ** 
    ** Calling this method does not create a session if it does not exist.
    ** 
    ** @see `web::WebSession.get`
    @Operator
    abstract Obj? get(Str name, Obj? def := null)

    ** Convenience for 'map.getOrAdd(name, valFunc)'.
    ** 
    ** Calling this **will** create a session if it doesn't already exist.
    abstract Obj? getOrAdd(Str key, |Str->Obj?| valFunc)
    
    ** Sets a session value - which must be immutable or serialisable.
    ** 
    ** Calling this method **will** create a session if it does not exist.
    ** 
    ** @see `web::WebSession.set`
    @Operator 
    abstract Void set(Str name, Obj? val)
    
    ** Convenience for 'map.remove(name)'.
    ** 
    ** Calling this method does not create a session if it does not exist.
    ** 
    ** @see `web::WebSession.remove`
    abstract Obj? remove(Str name)

    ** The application name/value pairs which are persisted between HTTP requests. 
    ** Returns an empty map if a session does not exist.
    ** 
    ** Calling this method does not create a session if it does not exist.
    ** 
    ** The returned map is *READ ONLY*. 
    abstract Str:Obj? val()
    
    @NoDoc @Deprecated { msg="Use 'val()' instead" }
    virtual Str:Obj? map() { val }

    ** Delete this web session which clears both the user agent cookie and the server side session 
    ** instance. This method must be called before the WebRes is committed otherwise the server side 
    ** instance is cleared, but the user agent cookie will remain uncleared.
    ** 
    ** Calling this method does not create a session if it does not exist.
    ** 
    ** @see `web::WebSession.delete`
    abstract Void delete()
    
    ** Returns 'true' if a session exists. 
    ** 
    ** Wisp does not offer a sure-fire method of checking if a session actually exists or not.
    ** So in essence, all this does is check for the existence of a 'fanws' wisp session cookie.
    ** It makes no assurances that the associated ID is valid or if the session has expired or not.
    ** 
    ** Calling this method does not create a session if it does not exist.
    abstract Bool exists()
    
    ** A map whose name/value pairs are persisted *only* until the end of the user's **next** HTTP request. 
    ** (Note that actually they're persisted until the next request in which 'flash()' is called again.)
    ** 
    ** The returned map is *READ ONLY*. 
    abstract Str:Obj? flash()

    ** Sets the given value in the *flash*. 
    ** The key/value pair will be persisted until the end of the user's *next* request.
    ** 
    ** Values must be immutable or serialisable.
    ** 
    ** Calling this method **will** create a session if it does not exist.
    abstract Void flashSet(Str key, Obj? val)

    ** Removes the key/value pair from *flash* and returns the value. 
    ** If the key was not mapped then returns 'null'.
    ** 
    ** Calling this method does not create a session if it does not exist.
    abstract Obj? flashRemove(Str key)
    
    ** Adds an event handler that gets called as soon as a session is created.
    ** 
    ** Callbacks may be mutable, do not need to be cleaned up, but should be added at the start of *every* HTTP request. 
    ** 
    ** Note that due to limitations of the underlying 'web::WebSession' class, BedSheet must store a token "afBedSheet.exists" 
    ** value in the session to ensure 'onCreate()' is called at the appropriate time.
    abstract Void onCreate(|HttpSession| fn)

    internal abstract Void _finalSession()
}

internal const class HttpSessionImpl : HttpSession {
    private static  const Str:Obj?          emptyRoMap  := Str:Obj?[:].toImmutable
    @Inject private const |->RequestState|  reqStateFunc
    @Inject private const LocalRef          reqStateRef
    @Inject private const HttpCookies       httpCookies
    @Inject private const LocalRef          existsRef
    
    new make(|This|in) { in(this) } 

    override Str id() {
        session.id
    }

    override Bool isEmpty() {
        if (!exists)
            return true
        
        sessionMap  := reqState.mutableSessionState
        if (sessionMap.size > 0)
            return false
        
        isEmpty := true
        session.each { isEmpty = false }
        return isEmpty
    }

    override Bool containsKey(Str key) {
        if (!exists)
            return false

        sessionMap  := reqState.mutableSessionState
        if (sessionMap.containsKey(key))
            return true
        
        containsKey := false
        session.each |v, k| { if (k == key) containsKey = true }
        return containsKey
    }
    
    override Obj? get(Str name, Obj? def := null) {
        if (!exists)
            return def

        sessionMap  := reqState.mutableSessionState
        if (sessionMap.containsKey(name))
            return sessionMap[name]

        val := session.get(name, def)
        if (val is SessionValue) {
            rawVal := SessionValue.deserialise(val)
            sessionMap[name] = rawVal
            val = rawVal 
        }
        return val
    }

    override Str:Obj? val() {
        if (!exists) 
            return emptyRoMap

        sessionMap  := reqState.mutableSessionState
        map         := reqState.mutableSessionState.dup
        
        session.each |val, key| {
            if (!map.containsKey(key)) {
                if (val is SessionValue) {
                    rawVal := SessionValue.deserialise(val)
                    map[key] = rawVal
                    sessionMap[key] = rawVal
                } else
                    map[key] = val
            }
        } 
        return map.ro
    }

    override Obj? getOrAdd(Str name, |Str->Obj?| valFunc) {
        if (containsKey(name))
            return get(name)
        val := valFunc.call(name)
        set(name, val)
        return val
    }
    
    override Void set(Str name, Obj? val) {
        // attempt serialisation just so we can fail fast and grab the users attention
        sessVal     := SessionValue.serialise(val)
        if (isMutable(val))
            // let mutable maps and lists stay mutable until the end of the request
            reqState.mutableSessionState[name] = val
        else
            reqState.mutableSessionState.remove(name)
        // always create the session on demand - when we expect it to (and before the response is committed)
        session.set(name, sessVal)
    }
    
    override Obj? remove(Str name) {
        if (exists) {
            val1 := reqState.mutableSessionState.remove(name)
            val2 := session.get(name) 
            session.remove(name)    // session.remove returns Void - see http://fantom.org/forum/topic/2672
            return val1 ?: val2
        }
        return null
    }
    
    override Void delete() {
        if (exists) {
            reqState.mutableSessionState.clear
            reqState.mutableSessionState = null
            session.delete
            existsRef.val = false
        }
    }

    override Bool exists() {
        // this gets called a *lot* and each time we manually compile cookie lists just to check if it's empty!
        // so we do a little dirty cashing
        if (existsRef.isMapped)
            return existsRef.val
        
        // make sure the request scope exists so we can further interrogate the session objs 
        try reqState()
        catch (IocErr ie)
            return false

        // note this session support only for WISP web server
        cookieName := Env.cur.config(WebSession#.pod, "sessionCookieName", "fanws") 
        exists := httpCookies[cookieName] != null
        // note - I could also just check for the existence of 'Actor.locals["web.session"]' 
        // but that's another, more in-depth, wisp implementation detail

        if (exists)
            // don't save 'false' values, so we still re-evaluate next time round
            existsRef.val = true
        return exists
    }
    
    override Str:Obj? flash() {
        _initFlash
        
        oldFlashMap := reqState.flashOldMap
        newFlashMap := get("afBedSheet.flash")

        map := Str:Obj?[:] { it.caseInsensitive = true }
        if (oldFlashMap != null)
            map.setAll(oldFlashMap)
        if (newFlashMap != null)
            map.setAll(newFlashMap)

        return map.ro
    }
    
    override Void flashSet(Str key, Obj? val) {
        _initFlash
        newFlashMap := ([Str:Obj?]?) get("afBedSheet.flash")
        if (newFlashMap == null) {
            newFlashMap = Str:Obj?[:]
            set("afBedSheet.flash", newFlashMap)
        }
        newFlashMap[key] = val
    }

    override Obj? flashRemove(Str key) {
        _initFlash
        
        oldFlashMap := reqState.flashOldMap
        newFlashMap := ([Str:Obj?]?) get("afBedSheet.flash")

        val1 := null
        if (oldFlashMap != null) {
            if (oldFlashMap.isRO)
                oldFlashMap = oldFlashMap.rw
            val1 = oldFlashMap.remove(key)
        }

        val2 := null
        if (newFlashMap != null)
            val2 = newFlashMap.remove(key)
        
        return val2 ?: val1
    }

    override Void _finalSession() {
        sessionMap := reqState.mutableSessionState
        if (sessionMap != null && sessionMap.size > 0) {
            session := session
            sessionMap.each |v, k| {
                session[k] = SessionValue.serialise(v)
            }
            sessionMap.clear
        }
        reqState.mutableSessionState = null
    }
    
    override Void onCreate(|HttpSession| fn) {
        reqState.addSessionCreateFn(fn)
    }
    
    // if this is called *every* request (as it was in BedSheet 1.5.8) then 
    // the Session is loaded (from database?) on *every* request, including for the many asset requests
    // getting the user to call 'flash()' when they want to clear it is a happy compromise
    private Void _initFlash() {
        if (reqState.flashInitialised) return

        // grab the old value...
        reqState.flashOldMap = get("afBedSheet.flash")

        // ... and delete it
        remove("afBedSheet.flash")
        
        reqState.flashInitialised = true
    }
    
    private RequestState reqState() {
        if (reqStateRef.isMapped)
            return reqStateRef.val
        try return reqStateRef.val = reqStateFunc()
        catch (IocErr ie)
            throw IocErr("Request scope is not available")
    }

    ** Route all session requests through here so we can trap when it gets created
    private WebSession session() {
        session := reqState.webReq.session
        exists  := session["afBedSheet.exists"] != null
        existed := session["afBedSheet.exists"] == true
        session["afBedSheet.exists"] = existed
        if (!exists)
            reqState.fireSessionCreate(this)
        session["afBedSheet.exists"] = true
        return session
    }

    private static Bool isMutable(Obj? val) {
        val != null && !val.isImmutable
    }
}

// Wraps an object value, serialising it if it's not immutable
@NoDoc  // for Bounce
const class SessionValue {
    const Str   objStr
    
    private new make(|This| f) { f(this) }
    
    static Obj? serialise(Obj? val) {
        if (val == null)
            return null
        if (val.isImmutable)
            return val
        
        if (val is Map || val is List || val is Buf || !val.typeof.hasFacet(Serializable#)) {
            try {
                objActual := val.toImmutable
                return objActual
            } catch (NotImmutableErr err) { /* try serialisation */ }
        }

        if (!val.typeof.hasFacet(Serializable#))
            throw BedSheetErr("Session values should be immutable (preferably) or serialisable: ${val.typeof.qname} - ${val}")

        // do the serialisation
        return SessionValue {
            it.objStr = Buf().writeObj(val).flip.readAllStr
        }
    }
    
    static Obj? deserialise(Obj? val) {
        val is SessionValue ? ((SessionValue) val).val : val
    }
    
    Obj? val() {
        objStr.toBuf.readObj
    }
    
    override Str toStr() {
        // pretend to be the real object when debugging 
        val?.toStr ?: "null"
    }
}