sourceafBounce::BedTerminator.fan

using afIoc::Registry
using afIocConfig
using afButter
using afButter::HttpResponseHeaders as ButtHead
using afBedSheet::BedSheetConfigIds
using afBedSheet::HttpResponseHeaders
using afBedSheet::MiddlewarePipeline
using afBedSheet::SessionValue
using web::Cookie
using web::WebOutStream
using web::WebMod
using web::WebReq
using web::WebRes
using web::WebSession
using web::WebUtil
using inet::IpAddr
using inet::SocketOptions
using inet::TcpSocket
using concurrent::Actor

** A 'Butter' terminator that makes requests against a given `BedServer`.
class BedTerminator : ButterMiddleware {
    
    // this is weird place to hold the session - but it's needed by WebReq
    internal BounceWebSession   _session

    ** The 'BedServer' this terminator makes calls against.
    BedServer bedServer

    ** Create a BedTerminator attached to the given 'BedServer'
    new make(BedServer bedServer) {
        this.bedServer  = bedServer
        this._session   = BounceWebSession()
    }

    override ButterResponse sendRequest(Butter butter, ButterRequest req) {
        // sometimes we can't avoid abs urls
        if (req.url.isRel == false && req.url.host == "localhost")
            req.url = req.url.relToAuth
        if (req.url.isRel == false)
            throw Err("Request URIs for Bed App testing should only be a path, e.g. `/index` vs `${req.url}`")
        if (req.url.isPathAbs == false)
            throw Err("Request URIs for Bed App testing should start with a slash, e.g. `/index` vs `${req.url}`")

        // set the Host (as configured in BedSheet), if it's not been already
        if (req.headers.host == null) {
            confSrc := (ConfigSource) bedServer.serviceById(ConfigSource#.qname)
            bsHost  := (Uri) confSrc.get(BedSheetConfigIds.host, Uri#)
            req.headers.host = HttpTerminator.normaliseHost(bsHost)
        }

        // set the Content-Length, if it's not been already
        if (req.headers.contentLength == null && req.method != "GET") {
            req.headers.contentLength = req.body.buf?.size ?: 0
        }

        try {
            bounceWebRes := BounceWebRes()
            
            Actor.locals["web.req"] = toWebReq(req, _session)
            Actor.locals["web.res"] = bounceWebRes

            pipeline := (MiddlewarePipeline) bedServer.serviceById(MiddlewarePipeline#.qname)
            bedServer.registry.rootScope.createChild("httpRequest") {
                pipeline.service
            }
            
            return bounceWebRes.toButterResponse

        } finally {
            ((WebReq) Actor.locals["web.req"]).stash.clear
            Actor.locals.remove("web.req")
            Actor.locals.remove("web.res")
            Actor.locals.remove("web.session")
        }       
    }
    
    internal WebReq toWebReq(ButterRequest req, WebSession session) {
        return BounceWebReq {
            it.version  = req.version
            it.method   = req.method
            it.uri      = req.url
            it.headers  = req.headers.val
            it.session  = session
            it.reqBodyBuf = req.body.buf?.seek(0) ?: Buf()
        }
    }
}



internal class BounceWebReq : WebReq {
    private static const WebMod webMod := BounceDefaultMod() 
    
             Buf reqBodyBuf
    override WebMod mod                     := webMod
    override IpAddr remoteAddr()            { IpAddr("127.0.0.1") }
    override Int remotePort()               { 80 }
    override SocketOptions  socketOptions() { TcpSocket().options }
    override TcpSocket      socket()        { TcpSocket() }
    override Bool           isGet()         { method == "GET" }
    override Bool           isPost()        { method == "POST" }
    
    override Version    version
    override Str        method
    override Uri        uri
    override Str:Str    headers
    override WebSession session {
        get {
            // Wisp creates the session cookie as soon as the WebSession is returned
            ((BounceWebSession) &session).create
            return &session
        }
    }
    override InStream   in() {
        if (reqBodyBuf.size == 0)
            throw Err("Attempt to access WebReq.in with no content")
        return reqBodyBuf.in
    }
    
    new make(|This|in) { in(this) }
}



** Adapted from WispReq to mimic the same uncommitted behaviour 
internal class BounceWebRes : WebRes {
    private Buf             buf
    private WebOutStream    webOut

    new make() {
        this.buf        = Buf() 
        this.webOut     = WebOutStream(buf.out)
        this.headers    = Str:Str[:] { it.caseInsensitive = true }
        this.cookies    = [,]
    }

    override TcpSocket upgrade(Int statusCode := 101) {
        throw UnsupportedErr()
    }
    
    override Int statusCode := 200 {
        set {
            checkUncommitted
            &statusCode = it
        }
    }

    override Str:Str headers {
        get { checkUncommitted; return &headers }
    }

    override Cookie[] cookies {
        get { checkUncommitted; return &cookies }
    }

    override Bool isCommitted := false { private set }

    override WebOutStream out() {
        commit
        return webOut
    }

    override Void redirect(Uri uri, Int statusCode := 303) {
        checkUncommitted
        this.statusCode = statusCode
        headers["Location"] = uri.encode
        headers["Content-Length"] = "0"
        commit
        done
    }

    override Void sendErr(Int statusCode, Str? msg := null) {
        // write message to buffer
        buf := Buf()
        bufOut := WebOutStream(buf.out)
        bufOut.docType
        bufOut.html
        bufOut.head.title.w("$statusCode ${statusMsg[statusCode]}").titleEnd.headEnd
        bufOut.body
        bufOut.h1.w(statusMsg[statusCode]).h1End
        if (msg != null) bufOut.w(msg).nl
        bufOut.bodyEnd
        bufOut.htmlEnd

        // write response
        checkUncommitted
        this.statusCode = statusCode
        headers["Content-Type"] = "text/html; charset=UTF-8"
        headers["Content-Length"] = buf.size.toStr
        this.out.writeBuf(buf.flip)
        done
    }

    override Bool isDone := false { private set }

    override Void done() { isDone = true }

    internal Void checkUncommitted() {
        if (isCommitted) throw Err("WebRes already committed")
    }

    internal Void commit() {
        if (isCommitted) return
        isCommitted = true
    }

    internal Void close() {
        commit
        webOut.close
    }

    internal ButterResponse toButterResponse() {
        myStatusCode := &statusCode
        myCookies    := &cookies
        myHeaders    := ButtHead {
            keyVals  := it.convertMap(&headers)
            myCookies.each |cookie| {
                keyVals.add(KeyVal("Set-Cookie", cookie.toStr))
            }
            it.keyVals = keyVals
        }
        res := ButterResponse(myStatusCode, myHeaders, buf)
        return res
    }
}



** I know HttpSession wraps data up in SessionValues - we do it here too so that direct users of
** WebSession don't need to know it exists - as used when setting session data directly, 
** e.g. setting a logged in user
internal class BounceWebSession : WebSession {
    
    Bool exists
    
    override Str id {
        get {
            val := findSessionCookie?.val ?: "???"
            // try to retain original unquoted session cookie ID for SleepSafe
            return val.getSafe(0) == '"' && val.getSafe(-1) == '"' ? WebUtil.fromQuotedStr(val) : val
        }
        set { }
    }
    
    override Str:Obj? map {
        get {
            map := Str:Obj?[:]
            &map.each |val, key| {
                map[key] = val is SessionValue ? ((SessionValue) val).val : val
            } 
            return map
        }
    }
    
    new make() { this.map = Str:Obj?[:] }

    override Void delete() {
        &map.clear

        // follow wisp behaviour
        if (exists) {
            webRes := (WebRes?) Actor.locals["web.res"]
            webRes?.cookies?.add(Cookie(cookieName, id) { maxAge=0sec })
        }
        
        exists = false
    }
    
    override Void each(|Obj?, Str| f) {
        &map.each(f)
    }
    
    @Operator
    override Obj? get(Str name, Obj? def := null) {
        val := &map.get(name, def)
        // flash is an internal BedSheet thing, as used by BedSheet, so always return the raw value
        if (name == "afBedSheet.flash") return val
        return val is SessionValue ? ((SessionValue) val).val : val
    }
    
    @Operator
    override Void set(Str name, Obj? val) {
        if (val is SessionValue)
            &map.set(name, val)
        else
            &map.set(name, SessionValue.serialise(val))
    }
    
    override Void remove(Str name) {
        &map.remove(name)
    }
    
    override Str toStr() {
        "id=${id}, ${map}"
    }

    Void create() {
        if (exists) return

        exists = true

        webReq := (WebReq?)     Actor.locals["web.req"]
        webRes := (WebRes?)     Actor.locals["web.res"]
        webSes := (WebSession?) Actor.locals["web.session"]
        
        if (webReq == null || webRes == null)
            return

        // note we're now committed to recovering or creating a session
        if (findSessionCookie == null)
            webRes.cookies.add(Cookie(cookieName, Int.random.toHex.upper))

        // this is what Wisp does - bounce / bedsheet doesn't need it
        Actor.locals["web.session"] = this
    }
    
    Cookie? findSessionCookie() {
        webReq := (WebReq?) Actor.locals["web.req"]
        reqStr := webReq?.cookies?.get(cookieName)
        reqCok := reqStr == null ? null : Cookie(cookieName, reqStr)
        webRes := (WebRes?) Actor.locals["web.res"]
        resCok := webRes?.cookies?.find { it.name == cookieName }
        return reqCok ?: resCok
    }
    
    private once Str cookieName() {
        Env.cur.config(WebSession#.pod, "sessionCookieName", "fanws")
    }
}



internal const class BounceDefaultMod : WebMod { }