Search code examples
scalareactivemongoplay-reactivemongo

BSONDocument to JsObject and override BSONDateTimeFormat


I use ReactiveMongo 0.11.11 for Play 2.5 and want to convert a BSONDocument to a JsObject.

For most BSON data types (String, Int...) the defaults are perfectly fine to let the library do the job. For BSON type DateTime (BSONDateTime) the value of the JSON property does not give me the format I need.

The JSON value for a Date is a JsObject with property name $date and a UNIX timestamp in milliseconds as its value:

{
    "something": {
        "$date": 1462288846873
    }
}

The JSON I want is a String representation of the Date like this:

{
    "something": "2016-05-03T15:20:46.873Z"
}

Unfortunately I don't know how to override the default behaviour without rewriting everything or changing code in the library itself.

This is where I think it happens (source code):

val partialWrites: PartialFunction[BSONValue, JsValue] = {
    case dt: BSONDateTime => Json.obj("$date" -> dt.value)
}

My version would have to look like this:

val partialWrites: PartialFunction[BSONValue, JsValue] = {
    case dt: BSONDateTime =>
        JsString(Instant.ofEpochMilli(dt.value).toString)
}

Is it possible to override this bit?

I have created an experiment...

import java.time.Instant
import play.api.libs.json._
import reactivemongo.bson._
import reactivemongo.play.json.BSONFormats.BSONDocumentFormat

object Experiment {

    // Original document (usually coming from the database)
    val bson = BSONDocument(
        "something" -> BSONDateTime(1462288846873L) // equals "2016-05-03T15:20:46.873Z"
    )

    // Reader: converts from BSONDateTime to JsString
    implicit object BSONDateTimeToJsStringReader extends BSONReader[BSONDateTime, JsString] {
        def read(bsonDate: BSONDateTime): JsString = {
            JsString(Instant.ofEpochMilli(bsonDate.value).toString)
        }
    }

    // Reader: converts from BSONDateTime to JsValue
    implicit object BSONDateTimeToJsValueReader extends BSONReader[BSONDateTime, JsValue] {
        def read(bsonDate: BSONDateTime): JsValue = {
            JsString(Instant.ofEpochMilli(bsonDate.value).toString)
        }
    }

    // Read and print specific property "something" using the `BSONReader`s above
    def printJsDate = {
        val jsStr: JsString = bson.getAs[JsString]("something").get
        println(jsStr) // "2016-05-03T15:20:46.873Z"

        val jsVal: JsValue = bson.getAs[JsValue]("something").get
        println(jsVal) // "2016-05-03T15:20:46.873Z"
    }

    // Use ReactiveMongo's default format to convert a BSONDocument into a JsObject
    def printAsJsonDefault = {
        val json: JsObject = BSONDocumentFormat.writes(bson).as[JsObject]
        println(json) // {"something":{"$date":1462288846873}}
        // What I want: {"something":"2016-05-03T15:20:46.873Z"}
    }

}

I'd like to note that the BSONDateTime conversion to JsValue should always work when I convert a BSONDocument to JsObject, not only when I manually pick a specific known property. This means the property "something" in my example could have any name and also appear in a sub-document.

BTW: In case you wonder, I generally work with BSON collections in my Play project, but I don't think it makes a difference in this case anyway.

Edit

I've tried providing a Writes[BSONDateTime], but unfortunately it's not being used and I still get the same result as before. Code:

import java.time.Instant
import play.api.libs.json._
import reactivemongo.bson.{BSONDocument, BSONDateTime}

object MyImplicits {
    implicit val dateWrites = Writes[BSONDateTime] (bsonDate =>
        JsString(Instant.ofEpochMilli(bsonDate.value).toString)
    )

    // I've tried this too:
//  implicit val dateWrites = new Writes[BSONDateTime] {
//      def writes(bsonDate: BSONDateTime) = JsString(Instant.ofEpochMilli(bsonDate.value).toString)
//  }
}

object Experiment {
    // Original document (usually coming from the database)
    val bson = BSONDocument("something" -> BSONDateTime(1462288846873L))

    // Use ReactiveMongo's default format to convert a BSONDocument into a JsObject
    def printAsJson = {
        import reactivemongo.play.json.BSONFormats.BSONDocumentFormat
        import MyImplicits.dateWrites // import is ignored

        val json: JsObject = BSONDocumentFormat.writes(bson).as[JsObject]
        //val json: JsValue = Json.toJson(bson) // I've tried this too
        println(json) // {"something":{"$date":1462288846873}}
    }
}

Solution

  • As for any type, the BSON value are converted to Play JSON using instances of Writes[T].

    There you needs to provide in the implicit scope your own Writes[BSONDateTime].

    import reactivemongo.bson._
    import play.api.libs.json._
    
    object MyImplicits {
      implicit val dateWrites = Writes[BSONDateTime] { date =>
        ???
      }
    
      def jsonDoc(doc: BSONDocument) = 
        JsObject(bson.elements.map(elem => elem._1 -> myJson(elem._2)))
    
      implicit val docWrites = OWrites[BSONDocument](jsonDoc)
    
      def myJson(value: BSONValue): JsValue = value match {
        case BSONDateTime(value) = ???
        case doc @ BSONDocument(_) => jsonDoc(doc)
        case bson => BSONFormats.toJSON(bson)
      }
    }
    
    /* where needed */ import MyImplicits.dateWrites