Json requester

use courier = "courier"
use "json"
use ssl = "ssl/net"

interface tag JsonRequesterResultReceiver
  """
  Receives the result of a JSON API request: either a parsed JSON response
  on success, or status/body/message details on failure.
  """
  be success(json: JsonNav)
  be failure(status: U16, response_body: String, message: String)

actor JsonRequester is courier.HTTPClientConnectionActor
  """
  Issues an HTTP request that expects a JSON response. Supports GET (200),
  POST (201), and PATCH (200) methods. GET requests follow 301/307 redirects
  automatically. On success, the response body is parsed as JSON and delivered
  to the receiver; on failure, the receiver gets the status code, raw response
  body, and an error message.
  """
  var _http: courier.HTTPClientConnection = courier.HTTPClientConnection.none()
  var _collector: courier.ResponseCollector = courier.ResponseCollector
  let _creds: Credentials
  let _receiver: JsonRequesterResultReceiver
  let _method: courier.Method
  let _expected_status: U16
  let _body: (String | None)
  var _request_path: String = ""
  var _redirected: Bool = false
  var _status: U16 = 0

  new get(creds: Credentials,
    url: String,
    receiver: JsonRequesterResultReceiver)
  =>
    """
    Issues an HTTP GET request expecting a 200 response with a JSON body.
    """
    _creds = creds
    _receiver = receiver
    _method = courier.GET
    _expected_status = 200
    _body = None
    _connect(url)

  new post(creds: Credentials,
    url: String,
    body: String,
    receiver: JsonRequesterResultReceiver)
  =>
    """
    Issues an HTTP POST request expecting a 201 response with a JSON body.
    """
    _creds = creds
    _receiver = receiver
    _method = courier.POST
    _expected_status = 201
    _body = body
    _connect(url)

  new patch(creds: Credentials,
    url: String,
    body: String,
    receiver: JsonRequesterResultReceiver)
  =>
    """
    Issues an HTTP PATCH request expecting a 200 response with a JSON body.
    """
    _creds = creds
    _receiver = receiver
    _method = courier.PATCH
    _expected_status = 200
    _body = body
    _connect(url)

  fun ref _connect(url: String) =>
    match courier.URL.parse(url)
    | let parsed: courier.ParsedURL =>
      _request_path = parsed.request_path()
      let ctx = match _creds.ssl_ctx
      | let c: ssl.SSLContext val => c
      | None => SSLContextFactory()
      end
      let config = courier.ClientConnectionConfig
      _http = courier.HTTPClientConnection.ssl(
        _creds.auth, ctx, parsed.host, parsed.port,
        this, config)
    | let _: courier.URLParseError =>
      _fail("Unable to parse URL: " + url)
    end

  fun ref _http_client_connection(): courier.HTTPClientConnection =>
    _http

  fun ref on_connected() =>
    let hdrs = recover trn courier.Headers end
    hdrs.set("User-Agent", "Pony GitHub Rest API Client")
    hdrs.set("Accept", "application/vnd.github.v3+json")
    match _creds.token
    | let t: String =>
      (let n, let v) = courier.BearerAuth(t)
      hdrs.set(n, v)
    end
    match _body
    | let b: String =>
      hdrs.set("Content-Length", b.size().string())
    end
    let request = courier.HTTPRequest(
      _method,
      _request_path,
      consume hdrs,
      match _body
      | let b: String => b.array()
      | None => None
      end)
    _http.send_request(request)

  fun ref on_response(response: courier.Response val) =>
    _status = response.status
    if (_method is courier.GET)
      and ((_status == 301) or (_status == 307))
    then
      match response.headers.get("location")
      | let loc: String =>
        _redirected = true
        _http.close()
        JsonRequester.get(_creds, loc, _receiver)
        return
      end
    end
    _collector = courier.ResponseCollector
    _collector.set_response(response)

  fun ref on_body_chunk(data: Array[U8] val) =>
    _collector.add_chunk(data)

  fun ref on_response_complete() =>
    if _redirected then return end
    _http.close()
    try
      let response = _collector.build()?
      if _status == _expected_status then
        match \exhaustive\ courier.ResponseJSON(response)
        | let json: JsonValue =>
          _receiver.success(JsonNav(json))
        | let _: JsonParseError =>
          _receiver.failure(_status, "", "Failed to parse response")
        end
      else
        let body_str = String.from_array(response.body)
        _receiver.failure(_status, consume body_str, "")
      end
    else
      _receiver.failure(0, "", "Failed to build response")
    end

  fun ref on_connection_failure(reason: courier.ConnectionFailureReason) =>
    let msg = match \exhaustive\ reason
    | courier.ConnectionFailedDNS => "DNS resolution failed"
    | courier.ConnectionFailedTCP => "Unable to connect"
    | courier.ConnectionFailedSSL => "SSL handshake failed"
    | courier.ConnectionFailedTimeout => "Connection timed out"
    end
    _receiver.failure(0, "", consume msg)

  fun ref on_parse_error(err: courier.ParseError) =>
    _http.close()
    _receiver.failure(0, "", "HTTP parse error")

  be _fail(message: String) =>
    _receiver.failure(0, "", message)