Handling POST Requests and Processing JSON

Source   Edit  

Goal: Learn how to handle POST requests and process incoming JSON data.

Source code: chapter3/src/dashboard.nim

In a real-life application, you often need to receive data from clients, not just serve static content. Our dashboard needs to receive status reports from other services.

Let's update our server to handle POST requests containing JSON data and store these reports in memory:

#ANCHOR: import
import chronos/apps/http/httpserver
import std/[json, tables]
#ANCHOR_END: import

#ANCHOR: handler_closure
proc handler(reports: TableRef[string, string]): HttpProcessCallback2 =
  proc(
      reqfence: RequestFence
  ): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} =
#ANCHOR_END: handler_closure
    let request = reqfence.valueOr:
      return defaultResponse()

    try:
      case request.uri.path
      of "/":
        await request.respond(Http200, "Welcome to the Status Dashboard!")

      #ANCHOR: status_get
      of "/status":
        var output = "Current Service Status:\n"
        if reports.len == 0:
          output.add("- No reports available.")
        else:
          for name, status in reports:
            output.add("- " & name & ": " & status & "\n")
        await request.respond(Http200, output)
      #ANCHOR_END: status_get

      #ANCHOR: report_post
      of "/report":
        if request.meth != MethodPost:
          return await request.respond(Http405, "Method Not Allowed")

        let
          body = await request.getBody()
          data =
            try:
              parseJson(bytesToString(body))
            except CatchableError:
              return await request.respond(Http400, "Invalid JSON.")
          name =
            try:
              data["name"].getStr()
            except KeyError:
              return await request.respond(Http400, "Missing 'name' field.")
          status =
            try:
              data["status"].getStr()
            except KeyError:
              return await request.respond(Http400, "Missing 'status' field.")

        reports[name] = status
        echo "Received report: " & name & " is " & status

        await request.respond(Http200, "Report received.")
      #ANCHOR_END: report_post
      else:
        await request.respond(Http404, "Page not found.")
    except HttpError as exc:
      defaultResponse(exc)

proc main() {.async: (raises: [TransportAddressError, CancelledError]).} =
  #ANCHOR: reports_table
  var reports = newTable[string, string]()

  let
    address = initTAddress("127.0.0.1:8080")
    server = HttpServerRef.new(address, handler(reports)).valueOr:
      echo "Unable to start HTTP server: " & error
      return

  server.start()
  echo "HTTP server running on http://127.0.0.1:8080"
  #ANCHOR_END: reports_table

  try:
    await server.join()
  finally:
    await server.stop()
    await server.closeWait()

when isMainModule:
  waitFor main()

To test this version, run it with nimble run and use a tool like curl to send a POST request:

$ curl -X POST -H "Content-Type: application/json" -d '{"name": "google.com", "status": "UP"}' http://127.0.0.1:8080/report

Then, visit 127.0.0.1:8080 in your browser to see the updated status.

Handling POST Requests

Note: The HTTP protocol divides each request and response into a header and a body. The header contains metadata like the request method and path, while the body contains the actual content — the JSON payload in our case. This is true for both requests and responses.
proc handler(reports: TableRef[string, string]): HttpProcessCallback2 =
  proc(
      reqfence: RequestFence
  ): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} =

The first change you'll notice is that we wrapped our handler proc with another function that returns the actual handler (of type httpserver: HttpProcessCallback2). This is done to enable passing an input param reports that we'll use to store the statuses.

In the handler, we added logic for the /report path:

      of "/report":
        if request.meth != MethodPost:
          return await request.respond(Http405, "Method Not Allowed")

        let
          body = await request.getBody()
          data =
            try:
              parseJson(bytesToString(body))
            except CatchableError:
              return await request.respond(Http400, "Invalid JSON.")
          name =
            try:
              data["name"].getStr()
            except KeyError:
              return await request.respond(Http400, "Missing 'name' field.")
          status =
            try:
              data["status"].getStr()
            except KeyError:
              return await request.respond(Http400, "Missing 'status' field.")

        reports[name] = status
        echo "Received report: " & name & " is " & status

        await request.respond(Http200, "Report received.")
      
  1. We check if the request method is MethodPost.
  2. We use httpserver: getBody(HttpRequestRef) to asynchronously read the entire request body.
  3. body is an array of bytes, so we need to convert it to a string before we can parse it. To do that, we use httpcommon: bytesToString(openArray[byte]) function from chronos/apps/http/httpcommon.
  4. We use Nim's std/json library to parse the body as JSON. We wrap this in a try-except block to handle parsing errors. We want to catch all parsing errors at this point, so it's a rare case where catching generic CatchableError is fine.
  5. We extract the relevant fields and store them in our table. We use a separate try-except block to catch KeyError if the fields are missing.
Note: When dealing with JSON from clients, we must assume it can be malformed or missing fields. We handle these cases by catching parsing errors and KeyError exceptions, returning an appropriate HTTP 400 Bad Request status.

Generating Response

Finally, for the /status path, we now generate a dynamic string based on the data in our table:

      of "/status":
        var output = "Current Service Status:\n"
        if reports.len == 0:
          output.add("- No reports available.")
        else:
          for name, status in reports:
            output.add("- " & name & ": " & status & "\n")
        await request.respond(Http200, output)
      

Storing Data in Memory

We use an in-memory TableRef to store our status reports.

  var reports = newTable[string, string]()

  let
    address = initTAddress("127.0.0.1:8080")
    server = HttpServerRef.new(address, handler(reports)).valueOr:
      echo "Unable to start HTTP server: " & error
      return

  server.start()
  echo "HTTP server running on http://127.0.0.1:8080"
  

We pass reports to the handler generating function to generate a handler that would store statuses to it.

Note: In a real app you would store your persistent data in a database of key-value storage. In this tutorial, we use a Table for simplicity's sake.