sourcecamAxonPlugin::Client.fan

//
// Copyright (c) 2009, SkyFoundry LLC
// All Rights Reserved
//
// History:
//   22 Aug 09  Brian Frank  Creation
//

using web

**
** Client manages a network connection to a haystack server.
**
class Client
{
  ** Haystack version of the server (set in make upon connection)
  Str version := "1.0"

//////////////////////////////////////////////////////////////////////////
// Construction
//////////////////////////////////////////////////////////////////////////

  **
  ** Open with URI of project such as "http://host/api/myProj/".
  ** Throw IOErr for network/connection error or `AuthErr` if
  ** credentials are not authenticated.
  **
  static Client open(Uri uri, Str username, Str password)
  {
    // normalize and check URI
    uri = uri.plusSlash
    if (uri.scheme != "http") throw ArgErr("Only http: URIs supported: $uri")

    // authenticate with server
    cookie := ApiAuth(uri, username, password).auth

    // we're in
    return make(uri, username, cookie)
  }

  private new make(Uri uri, Str username, Str cookie)
  {
    this.uri = uri
    this.username = username
    this.cookie = cookie
    try
    {
      dict := about
      version = dict["haystackVersion"] ?: "1.0"
    }
    catch(Err e)
    {
      /*IF about fails then it's probably 1.0 barring unexpected issue. Not an ideal version test.*/
    }
  }

//////////////////////////////////////////////////////////////////////////
// Identity
//////////////////////////////////////////////////////////////////////////

  **
  ** URI of project path such as "http://host/api/myProj/".
  ** This URI always ends in a trailing slash.
  **
  const Uri uri

  ** Username used for authentication
  const Str username

  **
  ** Return uri.toStr
  **
  override Str toStr() { uri.toStr }

//////////////////////////////////////////////////////////////////////////
// Requests
//////////////////////////////////////////////////////////////////////////

  **
  ** Call "about" operation to query server summary info.
  **
  Dict about()
  {
    call("about", Etc.makeEmptyGrid).first
  }

  **
  ** Call "read" operation to read a record by its identifier.  If the
  ** record is not found then return null or raise UnknownRecException
  ** based on checked flag.  Raise `CallErr` if server returns error grid.
  ** Also see [Rest API]`docSkySpark::Ops#read`.
  **
  Dict? readById(Ref id, Bool checked := true)
  {
    req := Etc.makeListGrid(null, "id", null, [id])
    res := call("read", req)
    if (!res.isEmpty && res.first.has("id")) return res.first
    if (checked) throw UnknownRecErr(id.toCode)
    return null
  }

  **
  ** Call "read" operation to read a list of records by their identifiers.
  ** Return a grid where each row of the grid maps to the respective
  ** id list (indexes line up).  If checked is true and any one of the
  ** ids cannot be resolved then raise UnknownRecErr for first id not
  ** resolved.  If checked is false, then each id not found has a row
  ** where every cell is null.  Raise `CallErr` if server returns error
  ** grid.  Also see [Rest API]`docSkySpark::Ops#read`.
  **
  Grid readByIds(Ref[] ids, Bool checked := true)
  {
    req := Etc.makeListGrid(null, "id", null, ids)
    res := call("read", req)
    if (checked) res.each |r, i| { if (r.missing("id")) throw UnknownRecErr(ids[i].toStr) }
    return res
  }

  **
  ** Call "read" operation to read a record that matches the given filter.
  ** If there is more than one record, then it is undefined which one is
  ** returned.  If there are no matches then return null or raise
  ** UnknownRecException based on checked flag.  Raise `CallErr` if server
  ** returns error grid.  Also see [Rest API]`docSkySpark::Ops#read`.
  **
  Dict? read(Str filter, Bool checked := true)
  {
    req := Etc.makeListsGrid(null, ["filter", "limit"], null, [[filter, Number.one]])
    res := call("read", req)
    if (!res.isEmpty) return res.first
    if (checked) throw UnknownRecErr(filter)
    return null
  }

  **
  ** Call "read" operation to read a record all recs which match the
  ** given filter.  Raise `CallErr` if server returns error grid.
  ** Also see [Rest API]`docSkySpark::Ops#read`.
  **
  Grid readAll(Str filter)
  {
    req := Etc.makeListGrid(null, "filter", null, [filter])
    return call("read", req)
  }

  **
  ** Evaluate an Axon expression and return results as Grid.
  ** Raise `CallErr` if server returns error grid.
  ** Also see [Rest API]`docSkySpark::Ops#eval`.
  **
  Grid eval(Str expr)
  {
    call("eval", Etc.makeListGrid(null, "expr", null, [expr]))
  }

  **
  ** Evaluate a list of expressions.  The req parameter must be
  ** 'Str[]' of Axon expressions or a correctly formatted `Grid`
  ** with 'expr' column.
  **
  ** A separate grid is returned for each row in the request.  If checked
  ** is false, then this call does *not* automatically check for error
  ** grids - client code must individual check each grid for partial
  ** failures using `Grid.isErr`.  If checked is true and one of the
  ** requests failed, then raise `CallErr` for first failure.
  **
  ** Also see [Rest API]`docSkySpark::Ops#evalAll`.
  **
  Grid[] evalAll(Obj req, Bool checked := true)
  {
    // construct grid request
    reqGrid := req as Grid
    if (reqGrid == null)
    {
      if (req isnot List) throw ArgErr("Expected Grid or Str[]")
      reqGrid = Etc.makeListGrid(null, "expr", null, req)
    }

    // make request and parse response
    reqStr := ZincWriter.gridToStr(reqGrid, version)
    resStr := doCall("evalAll", reqStr)
    res := ZincReader(resStr.in).readGrids

    // check for errors
    if (checked) res.each |g| { if (g.isErr) throw CallErr(g) }
    return res
  }

  **
  ** Commit a set of diffs.  The req parameter must be a grid
  ** with a "commit" tag in the grid.meta.  The rows are the
  ** items to commit.  Return result as Grid or or raise `CallErr`
  ** if server returns error grid.
  **
  ** Also see [Rest API]`docSkySpark::Ops#commit`.
  **
  ** Examples:
  **   // add new record
  **   tags := ["site":Marker.val, "dis":"Example Site"])
  **   toCommit := Etc.makeDictGrid(["commit":"add"], tags)
  **   client.commit(toCommit)
  **
  **   // update dis tag
  **   changes := ["id": orig->id, "mod":orig->mod, "dis": "New dis"]
  **   toCommit := Etc.makeDictGrid(["commit":"update"], changes)
  **   client.commit(toCommit)
  **
  Grid commit(Grid req)
  {
    if (req.meta.missing("commit")) throw ArgErr("Must specified grid.meta commit tag")
    return call("commit", req)
  }

  **
  ** Call the given REST operation with its request grid and
  ** return the response grid.  If req is null, then an empty
  ** grid used for request.  If the checked flag is true and server
  ** returns an error grid, then raise `CallErr`, otherwise return
  ** the grid itself.
  **
  Grid call(Str op, Grid? req := null, Bool checked := true)
  {
    if (req == null) req = Etc.makeEmptyGrid
    Str reqStr := ZincWriter.gridToStr(req, version)
    Str resStr := doCall(op, reqStr)
    Grid res   := ZincReader(resStr.in).readGrid
    if (checked && res.isErr) throw CallErr(res)
    return res
  }

  private Str doCall(Str op, Str req)
  {
    c := WebClient(this.uri + op.toUri)
    c.reqHeaders["Cookie"] = cookie
    c.postStr(req)
    return c.resIn.readAllStr
  }

//////////////////////////////////////////////////////////////////////////
// Main
//////////////////////////////////////////////////////////////////////////

  // set ApiAuth.debug = true to dump authentication cycle
  @NoDoc static Void main(Str[] args)
  {
    if (args.size < 3) { echo("usage: <uri> <user> <pass>"); return }
    c := Client.open(args[0].toUri, args[1], args[2])
    a := c.about
    echo("\nPing successful: $c.uri\n")
    Etc.dictNames(a).sort.each |n| { echo("$n: ".padr(18) + a[n]) }
  }

//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////

  private const Str cookie
}

**************************************************************************
** ApiAuth
**************************************************************************

**
** ApiAuth is used to authenticate a user/password combination using
** the REST API authentication mechanism.
**
@NoDoc
class ApiAuth
{
  **
  ** Construct with a protected URI space such as "/api/{proj}"
  **
  new make(Uri uri, Str user, Str pass)
  {
    this.uri  = uri
    this.user = user
    this.pass = pass
  }

  **
  ** Authenticate the username, password against the URI.  If successful
  ** then return the session cookie.  If authentication failed then throw
  ** AuthErr.
  **
  Str auth()
  {
    readAuthUri
    if (cookie != null) return cookie
    readAuthInfo
    computeDigest
    authenticate
    return cookie
  }

  private Void readAuthUri()
  {
    // make request to URI with redirects disabled
    c := WebClient(uri.plus(`about`))
    c.followRedirects = false
    send(c, false, null)

    // 4xx or 5xx
    if (c.resCode % 100 >= 4) throw IOErr("HTTP error code: $c.resCode")

    // if client returned 200, then it is not running with security
    if (c.resCode == 200) { this.cookie = "fanws:test"; return }

    // get URI from required header
    this.authUri = c.resHeaders["Folio-Auth-Api-Uri"]   ?: throw AuthErr("Missing 'Folio-Auth-Api-Uri' header [$c.resCode]")
  }

  private Void readAuthInfo()
  {
    c := WebClient(uri + authUri.toUri  + `?$user`)
    response := send(c, true, null)
    this.authInfo = parseAuthProps(response)
  }

  private Void computeDigest()
  {
    this.nonce = authInfo["nonce"] ?: throw AuthErr("Missing 'nonce' in auth info")
    this.salt  = authInfo["userSalt"] ?: throw AuthErr("Missing 'userSalt' in auth info")

    // compute salted hmac
    hmac := Buf().print("$user:$salt").hmac("SHA-1", pass.toBuf).toBase64

    // now compute login digest using nonce
    this.digest = "${hmac}:${nonce}".toBuf.toDigest("SHA-1").toBase64
  }

  private Void authenticate()
  {
    // post back to auth URI
    c := WebClient(uri + authUri.toUri  + `?$user`)
    response := send(c, true, "nonce:$nonce\ndigest:$digest")

    if (c.resCode != 200) throw AuthErr("Authentication failed")

    info := parseAuthProps(response)
    this.cookie = info["cookie"] ?: throw AuthErr("Missing 'cookie'")
  }

  private static Str:Str parseAuthProps(Str text)
  {
    map := Str:Str[:]
    text.splitLines.each |line|
    {
      line = line.trim
      if (line.isEmpty) return
      colon := line.index(":")
      map[line[0..<colon].trim] = line[colon+1..-1].trim
    }
    return map
  }

  private Str? send(WebClient c, Bool get, Str? post)
  {
    try
    {
      // if posting, translate to body buffer and get web client setup
      Buf? body
      if (post != null)
      {
        body = Buf().print(post).flip
        c.reqMethod = "POST"
        c.reqHeaders["Content-Type"] = "text/plain; charset=utf-8"
        c.reqHeaders["Content-Length"] = body.size.toStr
      }

      // debug dump request
      if (debug)
      {
        method := post == null ? "GET" : "POST"
        echo("$method $c.reqUri.relToAuth HTTP/1.1")
        echo("Host: $c.reqUri.host")
        c.reqHeaders.each |v, k| { echo("$k: $v") }
        echo
        if (post != null) { echo(post); echo }
      }

      // make request
      if (post == null)
      {
        c.writeReq.readRes
      }
      else
      {
        c.writeReq
        c.reqOut.writeBuf(body).close
        c.readRes
      }

      // read response
      response := get && c.resCode == 200 ? c.resIn.readAllStr : null

      // debug dump response
      if (debug)
      {
        echo("HTTP/1.1 $c.resCode $c.resPhrase")
        c.resHeaders.each |v, k| { echo("$k: $v") }
        echo
        if (response != null) { echo(response); echo }
      }

      return response
    }
    finally c.close
  }

  private static const Bool debug := false

  private const Uri uri            // constructor
  private const Str user           // constructor
  private const Str pass           // constructor
  private Str? authUri             // readAuthApiUri
  private Str:Str authInfo := [:]  // readAuthApiInfo
  private Str? nonce               // computeDigest
  private Str? salt                // computeDigest
  private Str? digest              // computeDigest
  private Str? cookie              // authenticate
}