The Path to Generic Endpoints using Shapeless

 
Scala eXchange, December 2017
Maria Livia Chiorean
 

Me

@MariaLiviaCh

The Guardian

Content API

Composer

Atom Tools

Content Atoms

  • Structured data
  • Reusable content with its own lifecycle
  • Simultaneous updates

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eleifend condimentum metus, at pretium orci sagittis et. Cras euismod justo ut tellus venenatis, et fermentum lectus feugiat. Nam pellentesque massa ac nulla semper ultricies. In eleifend tempor cursus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eleifend condimentum metus, at pretium orci sagittis et.

 Cras euismod justo ut tellus venenatis, et fermentum lectus feugiat. Nam pellentesque massa ac nulla semper ultricies. In eleifend tempor cursus.

Atom Workshop

  • Currently we have > 10 types of atoms
  • Common components
  • Shared services
  • Built on Scala, Play and React

Generic API Endpoint

PATCH /api/:atomType/:id/:path
val atomData = MediaAtom(
     url = "gu.com",
     metadata = Some(Metadata( 
       commentsEnabled = Some(true), 
       channelId = Some("happy-friday"))))

val atomData = MediaAtom(
     title = "Tech time video",
     metadata = Some(Metadata( 
       commentsEnabled = Some(true), 
       channelId = Some("Scala"))))
/api/media/some-id/metadata.channelId
body: "Scala"

Atom Workshop Architecture

Thrift models

Backend

Frontend

JSON

Thrift + Scrooge

namespace * contentatom.qanda
namespace java com.gu.contentatom.thrift.atom.qanda
#@namespace scala com.gu.contentatom.thrift.atom.qanda

include "../shared.thrift"
include "storyquestions.thrift"

struct QAndAItem {
  1: optional string title
  2: required string body
}

struct QAndAAtom {
  1: optional string typeLabel
  3: optional shared.Image eventImage
  4: required QAndAItem item
  5: optional storyquestions.Question question
}
/**
 * Generated by Scrooge
 *   version: 4.16.0
 *   rev: 0201cac9fdd6188248d42da91fd14c87744cc4a5
 *   built at: 20170421-124523
 */
package com.gu.contentatom.thrift.atom.qanda

import com.twitter.scrooge.{
  HasThriftStructCodec3,
  LazyTProtocol,
  TFieldBlob,
  ThriftException,
  ThriftStruct,
  ThriftStructCodec3,
  ThriftStructFieldInfo,
  ThriftStructMetaData,
  ThriftUtil
}
import org.apache.thrift.protocol._
import org.apache.thrift.transport.{TMemoryBuffer, TTransport}
import java.nio.ByteBuffer
import java.util.Arrays
import scala.collection.immutable.{Map => immutable$Map}
import scala.collection.mutable.Builder
import scala.collection.mutable.{
  ArrayBuffer => mutable$ArrayBuffer, Buffer => mutable$Buffer,
  HashMap => mutable$HashMap, HashSet => mutable$HashSet}
import scala.collection.{Map, Set}


object QAndAAtom extends ThriftStructCodec3[QAndAAtom] {
  private val NoPassthroughFields = immutable$Map.empty[Short, TFieldBlob]
  val Struct = new TStruct("QAndAAtom")
  val TypeLabelField = new TField("typeLabel", TType.STRING, 1)
  val TypeLabelFieldManifest = implicitly[Manifest[String]]
  val EventImageField = new TField("eventImage", TType.STRUCT, 3)
  val EventImageFieldManifest = implicitly[Manifest[com.gu.contentatom.thrift.Image]]
  val ItemField = new TField("item", TType.STRUCT, 4)
  val ItemFieldManifest = implicitly[Manifest[com.gu.contentatom.thrift.atom.qanda.QAndAItem]]
  val QuestionField = new TField("question", TType.STRUCT, 5)
  val QuestionFieldManifest = implicitly[Manifest[com.gu.contentatom.thrift.atom.storyquestions.Question]]

  /**
   * Field information in declaration order.
   */
  lazy val fieldInfos: scala.List[ThriftStructFieldInfo] = scala.List[ThriftStructFieldInfo](
    new ThriftStructFieldInfo(
      TypeLabelField,
      true,
      false,
      TypeLabelFieldManifest,
      _root_.scala.None,
      _root_.scala.None,
      immutable$Map.empty[String, String],
      immutable$Map.empty[String, String],
      None
    ),
    new ThriftStructFieldInfo(
      EventImageField,
      true,
      false,
      EventImageFieldManifest,
      _root_.scala.None,
      _root_.scala.None,
      immutable$Map.empty[String, String],
      immutable$Map.empty[String, String],
      None
    ),
    new ThriftStructFieldInfo(
      ItemField,
      false,
      true,
      ItemFieldManifest,
      _root_.scala.None,
      _root_.scala.None,
      immutable$Map.empty[String, String],
      immutable$Map.empty[String, String],
      None
    ),
    new ThriftStructFieldInfo(
      QuestionField,
      true,
      false,
      QuestionFieldManifest,
      _root_.scala.None,
      _root_.scala.None,
      immutable$Map.empty[String, String],
      immutable$Map.empty[String, String],
      None
    )
  )

  lazy val structAnnotations: immutable$Map[String, String] =
    immutable$Map.empty[String, String]

  /**
   * Checks that all required fields are non-null.
   */
  def validate(_item: QAndAAtom): Unit = {
    if (_item.item == null) throw new TProtocolException("Required field item cannot be null")
  }

  def withoutPassthroughFields(original: QAndAAtom): QAndAAtom =
    new Immutable(
      typeLabel =
        {
          val field = original.typeLabel
          field.map { field =>
            field
          }
        },
      eventImage =
        {
          val field = original.eventImage
          field.map { field =>
            com.gu.contentatom.thrift.Image.withoutPassthroughFields(field)
          }
        },
      item =
        {
          val field = original.item
          com.gu.contentatom.thrift.atom.qanda.QAndAItem.withoutPassthroughFields(field)
        },
      question =
        {
          val field = original.question
          field.map { field =>
            com.gu.contentatom.thrift.atom.storyquestions.Question.withoutPassthroughFields(field)
          }
        }
    )

  override def encode(_item: QAndAAtom, _oproto: TProtocol): Unit = {
    _item.write(_oproto)
  }

  private[this] def lazyDecode(_iprot: LazyTProtocol): QAndAAtom = {

    var typeLabelOffset: Int = -1
    var eventImage: Option[com.gu.contentatom.thrift.Image] = None
    var item: com.gu.contentatom.thrift.atom.qanda.QAndAItem = null
    var _got_item = false
    var question: Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = None

    var _passthroughFields: Builder[(Short, TFieldBlob), immutable$Map[Short, TFieldBlob]] = null
    var _done = false
    val _start_offset = _iprot.offset

    _iprot.readStructBegin()
    while (!_done) {
      val _field = _iprot.readFieldBegin()
      if (_field.`type` == TType.STOP) {
        _done = true
      } else {
        _field.id match {
          case 1 =>
            _field.`type` match {
              case TType.STRING =>
                typeLabelOffset = _iprot.offsetSkipString
    
              case _actualType =>
                val _expectedType = TType.STRING
                throw new TProtocolException(
                  "Received wrong type for field 'typeLabel' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case 3 =>
            _field.`type` match {
              case TType.STRUCT =>
    
                eventImage = Some(readEventImageValue(_iprot))
              case _actualType =>
                val _expectedType = TType.STRUCT
                throw new TProtocolException(
                  "Received wrong type for field 'eventImage' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case 4 =>
            _field.`type` match {
              case TType.STRUCT =>
    
                item = readItemValue(_iprot)
                _got_item = true
              case _actualType =>
                val _expectedType = TType.STRUCT
                throw new TProtocolException(
                  "Received wrong type for field 'item' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case 5 =>
            _field.`type` match {
              case TType.STRUCT =>
    
                question = Some(readQuestionValue(_iprot))
              case _actualType =>
                val _expectedType = TType.STRUCT
                throw new TProtocolException(
                  "Received wrong type for field 'question' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case _ =>
            if (_passthroughFields == null)
              _passthroughFields = immutable$Map.newBuilder[Short, TFieldBlob]
            _passthroughFields += (_field.id -> TFieldBlob.read(_field, _iprot))
        }
        _iprot.readFieldEnd()
      }
    }
    _iprot.readStructEnd()

    if (!_got_item) throw new TProtocolException("Required field 'item' was not found in serialized data for struct QAndAAtom")
    new LazyImmutable(
      _iprot,
      _iprot.buffer,
      _start_offset,
      _iprot.offset,
      typeLabelOffset,
      eventImage,
      item,
      question,
      if (_passthroughFields == null)
        NoPassthroughFields
      else
        _passthroughFields.result()
    )
  }
trait QAndAAtom
  extends ThriftStruct
  with _root_.scala.Product4[Option[String], Option[com.gu.contentatom.thrift.Image], com.gu.contentatom.thrift.atom.qanda.QAndAItem, Option[com.gu.contentatom.thrift.atom.storyquestions.Question]]
  with HasThriftStructCodec3[QAndAAtom]
  with java.io.Serializable
{
  import QAndAAtom._

  def typeLabel: _root_.scala.Option[String]
  def eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image]
  def item: com.gu.contentatom.thrift.atom.qanda.QAndAItem
  def question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question]

  def _passthroughFields: immutable$Map[Short, TFieldBlob] = immutable$Map.empty

  def _1 = typeLabel
  def _2 = eventImage
  def _3 = item
  def _4 = question

  def toTuple: _root_.scala.Tuple4[Option[String], Option[com.gu.contentatom.thrift.Image], com.gu.contentatom.thrift.atom.qanda.QAndAItem, Option[com.gu.contentatom.thrift.atom.storyquestions.Question]] = {
    (
      typeLabel,
      eventImage,
      item,
      question
    )
  }


  /**
   * Gets a field value encoded as a binary blob using TCompactProtocol.  If the specified field
   * is present in the passthrough map, that value is returned.  Otherwise, if the specified field
   * is known and not optional and set to None, then the field is serialized and returned.
   */
  def getFieldBlob(_fieldId: Short): _root_.scala.Option[TFieldBlob] = {
    lazy val _buff = new TMemoryBuffer(32)
    lazy val _oprot = new TCompactProtocol(_buff)
    _passthroughFields.get(_fieldId) match {
      case blob: _root_.scala.Some[TFieldBlob] => blob
      case _root_.scala.None => {
        val _fieldOpt: _root_.scala.Option[TField] =
          _fieldId match {
            case 1 =>
              if (typeLabel.isDefined) {
                writeTypeLabelValue(typeLabel.get, _oprot)
                _root_.scala.Some(QAndAAtom.TypeLabelField)
              } else {
                _root_.scala.None
              }
            case 3 =>
              if (eventImage.isDefined) {
                writeEventImageValue(eventImage.get, _oprot)
                _root_.scala.Some(QAndAAtom.EventImageField)
              } else {
                _root_.scala.None
              }
            case 4 =>
              if (item ne null) {
                writeItemValue(item, _oprot)
                _root_.scala.Some(QAndAAtom.ItemField)
              } else {
                _root_.scala.None
              }
            case 5 =>
              if (question.isDefined) {
                writeQuestionValue(question.get, _oprot)
                _root_.scala.Some(QAndAAtom.QuestionField)
              } else {
                _root_.scala.None
              }
            case _ => _root_.scala.None
          }
        _fieldOpt match {
          case _root_.scala.Some(_field) =>
            val _data = Arrays.copyOfRange(_buff.getArray, 0, _buff.length)
            _root_.scala.Some(TFieldBlob(_field, _data))
          case _root_.scala.None =>
            _root_.scala.None
        }
      }
    }
  }

  /**
   * Collects TCompactProtocol-encoded field values according to `getFieldBlob` into a map.
   */
  def getFieldBlobs(ids: TraversableOnce[Short]): immutable$Map[Short, TFieldBlob] =
    (ids flatMap { id => getFieldBlob(id) map { id -> _ } }).toMap

  /**
   * Sets a field using a TCompactProtocol-encoded binary blob.  If the field is a known
   * field, the blob is decoded and the field is set to the decoded value.  If the field
   * is unknown and passthrough fields are enabled, then the blob will be stored in
   * _passthroughFields.
   */
  def setField(_blob: TFieldBlob): QAndAAtom = {
    var typeLabel: _root_.scala.Option[String] = this.typeLabel
    var eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = this.eventImage
    var item: com.gu.contentatom.thrift.atom.qanda.QAndAItem = this.item
    var question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = this.question
    var _passthroughFields = this._passthroughFields
    _blob.id match {
      case 1 =>
        typeLabel = _root_.scala.Some(readTypeLabelValue(_blob.read))
      case 3 =>
        eventImage = _root_.scala.Some(readEventImageValue(_blob.read))
      case 4 =>
        item = readItemValue(_blob.read)
      case 5 =>
        question = _root_.scala.Some(readQuestionValue(_blob.read))
      case _ => _passthroughFields += (_blob.id -> _blob)
    }
    new Immutable(
      typeLabel,
      eventImage,
      item,
      question,
      _passthroughFields
    )
  }

  /**
   * If the specified field is optional, it is set to None.  Otherwise, if the field is
   * known, it is reverted to its default value; if the field is unknown, it is removed
   * from the passthroughFields map, if present.
   */
  def unsetField(_fieldId: Short): QAndAAtom = {
    var typeLabel: _root_.scala.Option[String] = this.typeLabel
    var eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = this.eventImage
    var item: com.gu.contentatom.thrift.atom.qanda.QAndAItem = this.item
    var question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = this.question

    _fieldId match {
      case 1 =>
        typeLabel = _root_.scala.None
      case 3 =>
        eventImage = _root_.scala.None
      case 4 =>
        item = null
      case 5 =>
        question = _root_.scala.None
      case _ =>
    }
    new Immutable(
      typeLabel,
      eventImage,
      item,
      question,
      _passthroughFields - _fieldId
    )
  }

  /**
   * If the specified field is optional, it is set to None.  Otherwise, if the field is
   * known, it is reverted to its default value; if the field is unknown, it is removed
   * from the passthroughFields map, if present.
   */
  def unsetTypeLabel: QAndAAtom = unsetField(1)

  def unsetEventImage: QAndAAtom = unsetField(3)

  def unsetItem: QAndAAtom = unsetField(4)

  def unsetQuestion: QAndAAtom = unsetField(5)


  override def write(_oprot: TProtocol): Unit = {
    QAndAAtom.validate(this)
    _oprot.writeStructBegin(Struct)
    if (typeLabel.isDefined) writeTypeLabelField(typeLabel.get, _oprot)
    if (eventImage.isDefined) writeEventImageField(eventImage.get, _oprot)
    if (item ne null) writeItemField(item, _oprot)
    if (question.isDefined) writeQuestionField(question.get, _oprot)
    if (_passthroughFields.nonEmpty) {
      _passthroughFields.values.foreach { _.write(_oprot) }
    }
    _oprot.writeFieldStop()
    _oprot.writeStructEnd()
  }

  def copy(
    typeLabel: _root_.scala.Option[String] = this.typeLabel,
    eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = this.eventImage,
    item: com.gu.contentatom.thrift.atom.qanda.QAndAItem = this.item,
    question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = this.question,
    _passthroughFields: immutable$Map[Short, TFieldBlob] = this._passthroughFields
  ): QAndAAtom =
    new Immutable(
      typeLabel,
      eventImage,
      item,
      question,
      _passthroughFields
    )

  override def canEqual(other: Any): Boolean = other.isInstanceOf[QAndAAtom]

  private def _equals(x: QAndAAtom, y: QAndAAtom): Boolean =
      x.productArity == y.productArity &&
      x.productIterator.sameElements(y.productIterator)

  override def equals(other: Any): Boolean =
    canEqual(other) &&
      _equals(this, other.asInstanceOf[QAndAAtom]) &&
      _passthroughFields == other.asInstanceOf[QAndAAtom]._passthroughFields

  override def hashCode: Int = _root_.scala.runtime.ScalaRunTime._hashCode(this)

  override def toString: String = _root_.scala.runtime.ScalaRunTime._toString(this)


  override def productArity: Int = 4

  override def productElement(n: Int): Any = n match {
    case 0 => this.typeLabel
    case 1 => this.eventImage
    case 2 => this.item
    case 3 => this.question
    case _ => throw new IndexOutOfBoundsException(n.toString)
  }

  override def productPrefix: String = "QAndAAtom"

  def _codec: ThriftStructCodec3[QAndAAtom] = QAndAAtom
}
  override def decode(_iprot: TProtocol): QAndAAtom =
    _iprot match {
      case i: LazyTProtocol => lazyDecode(i)
      case i => eagerDecode(i)
    }

  private[this] def eagerDecode(_iprot: TProtocol): QAndAAtom = {
    var typeLabel: _root_.scala.Option[String] = _root_.scala.None
    var eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = _root_.scala.None
    var item: com.gu.contentatom.thrift.atom.qanda.QAndAItem = null
    var _got_item = false
    var question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = _root_.scala.None
    var _passthroughFields: Builder[(Short, TFieldBlob), immutable$Map[Short, TFieldBlob]] = null
    var _done = false

    _iprot.readStructBegin()
    while (!_done) {
      val _field = _iprot.readFieldBegin()
      if (_field.`type` == TType.STOP) {
        _done = true
      } else {
        _field.id match {
          case 1 =>
            _field.`type` match {
              case TType.STRING =>
                typeLabel = _root_.scala.Some(readTypeLabelValue(_iprot))
              case _actualType =>
                val _expectedType = TType.STRING
                throw new TProtocolException(
                  "Received wrong type for field 'typeLabel' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case 3 =>
            _field.`type` match {
              case TType.STRUCT =>
                eventImage = _root_.scala.Some(readEventImageValue(_iprot))
              case _actualType =>
                val _expectedType = TType.STRUCT
                throw new TProtocolException(
                  "Received wrong type for field 'eventImage' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case 4 =>
            _field.`type` match {
              case TType.STRUCT =>
                item = readItemValue(_iprot)
                _got_item = true
              case _actualType =>
                val _expectedType = TType.STRUCT
                throw new TProtocolException(
                  "Received wrong type for field 'item' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case 5 =>
            _field.`type` match {
              case TType.STRUCT =>
                question = _root_.scala.Some(readQuestionValue(_iprot))
              case _actualType =>
                val _expectedType = TType.STRUCT
                throw new TProtocolException(
                  "Received wrong type for field 'question' (expected=%s, actual=%s).".format(
                    ttypeToString(_expectedType),
                    ttypeToString(_actualType)
                  )
                )
            }
          case _ =>
            if (_passthroughFields == null)
              _passthroughFields = immutable$Map.newBuilder[Short, TFieldBlob]
            _passthroughFields += (_field.id -> TFieldBlob.read(_field, _iprot))
        }
        _iprot.readFieldEnd()
      }
    }
    _iprot.readStructEnd()

    if (!_got_item) throw new TProtocolException("Required field 'item' was not found in serialized data for struct QAndAAtom")
    new Immutable(
      typeLabel,
      eventImage,
      item,
      question,
      if (_passthroughFields == null)
        NoPassthroughFields
      else
        _passthroughFields.result()
    )
  }

  def apply(
    typeLabel: _root_.scala.Option[String] = _root_.scala.None,
    eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = _root_.scala.None,
    item: com.gu.contentatom.thrift.atom.qanda.QAndAItem,
    question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = _root_.scala.None
  ): QAndAAtom =
    new Immutable(
      typeLabel,
      eventImage,
      item,
      question
    )

  def unapply(_item: QAndAAtom): _root_.scala.Option[_root_.scala.Tuple4[Option[String], Option[com.gu.contentatom.thrift.Image], com.gu.contentatom.thrift.atom.qanda.QAndAItem, Option[com.gu.contentatom.thrift.atom.storyquestions.Question]]] = _root_.scala.Some(_item.toTuple)


  @inline private def readTypeLabelValue(_iprot: TProtocol): String = {
    _iprot.readString()
  }

  @inline private def writeTypeLabelField(typeLabel_item: String, _oprot: TProtocol): Unit = {
    _oprot.writeFieldBegin(TypeLabelField)
    writeTypeLabelValue(typeLabel_item, _oprot)
    _oprot.writeFieldEnd()
  }

  @inline private def writeTypeLabelValue(typeLabel_item: String, _oprot: TProtocol): Unit = {
    _oprot.writeString(typeLabel_item)
  }

  @inline private def readEventImageValue(_iprot: TProtocol): com.gu.contentatom.thrift.Image = {
    com.gu.contentatom.thrift.Image.decode(_iprot)
  }

  @inline private def writeEventImageField(eventImage_item: com.gu.contentatom.thrift.Image, _oprot: TProtocol): Unit = {
    _oprot.writeFieldBegin(EventImageField)
    writeEventImageValue(eventImage_item, _oprot)
    _oprot.writeFieldEnd()
  }

  @inline private def writeEventImageValue(eventImage_item: com.gu.contentatom.thrift.Image, _oprot: TProtocol): Unit = {
    eventImage_item.write(_oprot)
  }

  @inline private def readItemValue(_iprot: TProtocol): com.gu.contentatom.thrift.atom.qanda.QAndAItem = {
    com.gu.contentatom.thrift.atom.qanda.QAndAItem.decode(_iprot)
  }

  @inline private def writeItemField(item_item: com.gu.contentatom.thrift.atom.qanda.QAndAItem, _oprot: TProtocol): Unit = {
    _oprot.writeFieldBegin(ItemField)
    writeItemValue(item_item, _oprot)
    _oprot.writeFieldEnd()
  }

  @inline private def writeItemValue(item_item: com.gu.contentatom.thrift.atom.qanda.QAndAItem, _oprot: TProtocol): Unit = {
    item_item.write(_oprot)
  }

  @inline private def readQuestionValue(_iprot: TProtocol): com.gu.contentatom.thrift.atom.storyquestions.Question = {
    com.gu.contentatom.thrift.atom.storyquestions.Question.decode(_iprot)
  }

  @inline private def writeQuestionField(question_item: com.gu.contentatom.thrift.atom.storyquestions.Question, _oprot: TProtocol): Unit = {
    _oprot.writeFieldBegin(QuestionField)
    writeQuestionValue(question_item, _oprot)
    _oprot.writeFieldEnd()
  }

  @inline private def writeQuestionValue(question_item: com.gu.contentatom.thrift.atom.storyquestions.Question, _oprot: TProtocol): Unit = {
    question_item.write(_oprot)
  }


  object Immutable extends ThriftStructCodec3[QAndAAtom] {
    override def encode(_item: QAndAAtom, _oproto: TProtocol): Unit = { _item.write(_oproto) }
    override def decode(_iprot: TProtocol): QAndAAtom = QAndAAtom.decode(_iprot)
    override lazy val metaData: ThriftStructMetaData[QAndAAtom] = QAndAAtom.metaData
  }

  /**
   * The default read-only implementation of QAndAAtom.  You typically should not need to
   * directly reference this class; instead, use the QAndAAtom.apply method to construct
   * new instances.
   */
  class Immutable(
      val typeLabel: _root_.scala.Option[String],
      val eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image],
      val item: com.gu.contentatom.thrift.atom.qanda.QAndAItem,
      val question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question],
      override val _passthroughFields: immutable$Map[Short, TFieldBlob])
    extends QAndAAtom {
    def this(
      typeLabel: _root_.scala.Option[String] = _root_.scala.None,
      eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = _root_.scala.None,
      item: com.gu.contentatom.thrift.atom.qanda.QAndAItem,
      question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = _root_.scala.None
    ) = this(
      typeLabel,
      eventImage,
      item,
      question,
      Map.empty
    )
  }

  /**
   * This is another Immutable, this however keeps strings as lazy values that are lazily decoded from the backing
   * array byte on read.
   */
  private[this] class LazyImmutable(
      _proto: LazyTProtocol,
      _buf: Array[Byte],
      _start_offset: Int,
      _end_offset: Int,
      typeLabelOffset: Int,
      val eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image],
      val item: com.gu.contentatom.thrift.atom.qanda.QAndAItem,
      val question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question],
      override val _passthroughFields: immutable$Map[Short, TFieldBlob])
    extends QAndAAtom {

    override def write(_oprot: TProtocol): Unit = {
      _oprot match {
        case i: LazyTProtocol => i.writeRaw(_buf, _start_offset, _end_offset - _start_offset)
        case _ => super.write(_oprot)
      }
    }

    lazy val typeLabel: _root_.scala.Option[String] =
      if (typeLabelOffset == -1)
        None
      else {
        Some(_proto.decodeString(_buf, typeLabelOffset))
      }

    /**
     * Override the super hash code to make it a lazy val rather than def.
     *
     * Calculating the hash code can be expensive, caching it where possible
     * can provide significant performance wins. (Key in a hash map for instance)
     * Usually not safe since the normal constructor will accept a mutable map or
     * set as an arg
     * Here however we control how the class is generated from serialized data.
     * With the class private and the contract that we throw away our mutable references
     * having the hash code lazy here is safe.
     */
    override lazy val hashCode = super.hashCode
  }

  /**
   * This Proxy trait allows you to extend the QAndAAtom trait with additional state or
   * behavior and implement the read-only methods from QAndAAtom using an underlying
   * instance.
   */
  trait Proxy extends QAndAAtom {
    protected def _underlying_QAndAAtom: QAndAAtom
    override def typeLabel: _root_.scala.Option[String] = _underlying_QAndAAtom.typeLabel
    override def eventImage: _root_.scala.Option[com.gu.contentatom.thrift.Image] = _underlying_QAndAAtom.eventImage
    override def item: com.gu.contentatom.thrift.atom.qanda.QAndAItem = _underlying_QAndAAtom.item
    override def question: _root_.scala.Option[com.gu.contentatom.thrift.atom.storyquestions.Question] = _underlying_QAndAAtom.question
    override def _passthroughFields = _underlying_QAndAAtom._passthroughFields
  }
}

Shapeless

  • Library for generic programming in Scala
  • Leverages the similarities between types
case class Employee(name: String,         age: Int, manager: Boolean)​
case class IceCream(name: String, numCherries: Int,  inCone: Boolean)​
String :: Int :: Boolean :: HNil

HList

Generic[IceCream].to(iceCream)
Generic[Employee].to(employee)

First approach

case class Atom(
    id: String, 
    atomType: String, 
    title: String, 
    data: AtomData)

sealed trait AtomData
object AtomData {
  case class Media(media: MediaAtom) extends AtomData
  case class Explainer(explainer: ExplainerAtom) extends AtomData
}

case class MediaAtom(
    url: String, 
    metadata: Option[Metadata])
case class ExplainerAtom(
    headline: String, 
    body: String)

case class Metadata(
    commentsEnabled: Option[Boolean], 
    channelId: Option[String])

First approach

val genericRepr = LabelledGeneric[Atom].to(mediaAtom) 
// "123" :: "media" :: "Scala fun" :: Media(MediaAtom(...)) :: HNil
PATCH /api/media/123/title
body: "Updated title"
val updatedGeneric = genericRepr + (Symbol("title") ->> "Updated title")) 
// "123" :: "media" :: "Updated title" :: Media(MediaAtom(...)) :: HNil
val mediaAtom = Atom(
    id = "123", 
    atomType = "media", 
    title = "Scala fun", 
    data = AtomData.Media(MediaAtom(
        url = "gu.com",
        metadata = Some(Metadata( 
            commentsEnabled = Some(true), 
            channelId = Some("programming"))))))

First approach

val genericRepr = LabelledGeneric[Atom].to(mediaAtom) 
// "123" :: "media" :: "Scala fun" :: Media(MediaAtom(...)) :: HNil
PATCH /api/media/123/data.metadata.channelId
body: "Scala"
val updatedGeneric2 = genericRepr + (Symbol(nestedField) ->> "Updated field"))
// error: Expression scala.Symbol.apply(ScalaFiddle.this.nestedField) does 
// not evaluate to a constant or a stable reference value
val nestedField = "data.metadata.channelId"
val mediaAtom = Atom(
    id = "123", 
    atomType = "media", 
    title = "Scala fun", 
    data = AtomData.Media(MediaAtom(
        url = "gu.com",
        metadata = Some(Metadata( 
            commentsEnabled = Some(true), 
            channelId = Some("programming"))))))

Second approach

Nested Map[String, Any]

Update the field in the map

HList

HList

Type Classes

trait Greetings[A] {
  def message(creature: A): String
}

object Greetings {
    implicit def humanMessage: Greetings[Human] = 
        (human: Human) => s"${human.name} says hello."
    
    implicit def catMessage: Greetings[Cat] = 
        (cat: Cat) => s"${cat.name} says meow."

    implicit class GreetingsOps[A](creature: A) {
        def greet(implicit greetings: Greetings[A]) = {
          greetings.message(creature)
        }
    }
}

Human("Maria").greet 
// "Maria says hello."
Cat("Mistoffelees").greet 
// "Mistoffelees says meow."
Dog("Pollicle").greet 
// error: could not find implicit value for parameter message


Case class => Map[String,Any]

trait ToMapRec[L <: HList] {
  def apply(l: L): Map[String, Any]
}
"1234567" :: Some("media") :: "Scala fun" :: Media(MediaAtom(...)) :: HNil

...

// Implicits:

HNil
FieldType[K, Option[H]] :: T
FieldType[K, Seq[H]] :: T
FieldType[K, H] :: T // Where the head can be recursively converted
FieldType[K, H] :: T // Where the head can NOT be recursively converted (low priority)

Case class => Map[String,Any]

trait ToMapRec[L <: HList] {
  def apply(hlist: L): Map[String, Any]
}

...

implicit def hconsToMapRecOption[K <: Symbol, V, H <: HList, T <: HList]
(implicit
 wit: Witness.Aux[K],
 gen: LabelledGeneric.Aux[V, H],
 tmrT: Lazy[ToMapRec[T]],
 tmrH: Lazy[ToMapRec[H]]
): ToMapRec[FieldType[K, Option[V]] :: T] = ???

...

Case class => Map[String,Any]

trait ToMapRec[L <: HList] {
  def apply(hlist: L): Map[String, Any]
}

...

implicit def hconsToMapRecOption[K <: Symbol, V, H <: HList, T <: HList]
(implicit
 wit: Witness.Aux[K],
 gen: LabelledGeneric.Aux[V, H],
 tmrT: Lazy[ToMapRec[T]],
 tmrH: Lazy[ToMapRec[H]]
): ToMapRec[FieldType[K, Option[V]] :: T] = 

    (hlist: FieldType[K, Option[V]] :: T) =>

        tmrT.value(hlist.tail) + 
        (wit.value.name -> hlist.head.map(value => tmrH.value(gen.to(value))))

Case class => Map[String,Any]

...

  implicit class ToMapRecOps[A](val a: A) extends AnyVal {
    def toMap[L <: HList]
    (implicit
     gen: LabelledGeneric.Aux[A, L],
     tmr: Lazy[ToMapRec[L]]
    ): Map[String, Any] = tmr.value(gen.to(a))
  }

val atomDataMap = atomData.toMap
val atomDataMap = Map(
  "metadata" -> Some(Map(
    "commentsEnabled" -> Some(false), 
    "channelId" -> Some("programming"))), 
  "url" -> "gu.com")
[error] could not find implicit value for parameter 
gen: shapeless.LabelledGeneric.Aux[com.gu.contentatom.thrift.AtomData,L]

Fixing Thrift

def helperLabelledGeneric[A, I <: A, L <: HList]
(implicit gen: LabelledGeneric.Aux[I, L])
: LabelledGeneric.Aux[A, L] =
    new LabelledGeneric[A] {
      override type Repr = L
      override def to(t: A): Repr = gen.to(t.asInstanceOf[I])
      override def from(r: Repr): A = gen.from(r)
    }
implicit def atomDataLabelledGeneric[L <: HList]
(implicit gen: LabelledGeneric.Aux[AtomData.Immutable, L])
: LabelledGeneric.Aux[AtomData, L] =
    helperLabelledGeneric[AtomData, AtomData.Immutable, L]

...

Map[String,Any] => Case class

// Implicits:

HNil
FieldType[K, Option[H]] :: T
FieldType[K, Seq[H]] :: T
FieldType[K, H] :: T // Where the head can be recursively converted
FieldType[K, H] :: T // Where the head can NOT be recursively converted (low priority)
trait FromMap[L <: HList] {
    def apply(m: Map[String, Any]): Option[L]
}

Map[String,Any] => Case class

trait FromMap[L <: HList] {
    def apply(m: Map[String, Any]): Option[L]
}
...

implicit def hconsFromMapOption[K <: Symbol, V, R <: HList, T <: HList]
(implicit
 witness: Witness.Aux[K],
 gen: LabelledGeneric.Aux[V, R],
 fromMapH: Lazy[FromMap[R]],
 fromMapT: Lazy[FromMap[T]]
): FromMap[FieldType[K, Option[V]] :: T] = (m: Map[String, Any]) =>
  m(witness.value.name) match {
    case Some(v) =>
      for {
        r <- Typeable[Option[Map[String, Any]]].cast(Some(v))
        h <- r.map(fromMapH.value(_))
        t <- fromMapT.value(m)
      } yield field[K](h.map(gen.from)) :: t
    case None =>
      for {
        t <- fromMapT.value(m)
      } yield field[K](None) :: t
  }

...

Map[String,Any] => Case class

...

implicit class FromMapOps[A] {
    def from[R <: HList](m: Map[String, Any])
    (implicit gen: LabelledGeneric.Aux[A, R],
     fromMap: Lazy[FromMap[R]]
    ): Option[A] = fromMap.value(m).map(gen.from)
}

val atom: Atom = to[Atom.Immutable].from(updatedAtom)

Final approach

Json + Circe

Circe

PATCH /api/media/some-id
body:
{
    "data": {
        "metadata": {
            "channelId": "Scala"
        }
    }
}
val atomJson = atom.toJson
atomJson.deepMerge(bodyJson)

Bonus: Magnolia

import magnolia._
import scala.language.experimental.macros

trait ToMapMagnolia[T] {
  def apply(arg: T) : Any
}

object ToMapMagnolia {
  type Typeclass[T] = ToMapMagnolia[T]

  def combine[T](caseClass: CaseClass[Typeclass, T]): Typeclass[T] = 
    (instance: T) => caseClass.parameters.map(param => 
        param.label -> {param.typeclass(param.dereference(instance))}).toMap

  def dispatch[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = 
    (instance: T) => sealedTrait.dispatch(instance)(subType => 
        subType.typeclass.apply(subType.cast(instance)))

  implicit def baseCase[T <: AnyVal]: ToMapMagnolia[T] = (x: T) => x
  implicit def stringCase: ToMapMagnolia[String] = x => x

  implicit def optCase[T](implicit tm: Typeclass[T]): Typeclass[Option[T]] = _.map(tm.apply)
  implicit def seqCase[T](implicit tm: Typeclass[T]): Typeclass[Seq[T]] = _.map(tm.apply)
  
  implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T]

  implicit class ToMapOps[A](a: A) {
    def toMapMagnolia(implicit tm: ToMapMagnolia[A]): Any = tm(a)
  }
}

Summary

Thank you.

The Path to Generic Endpoints using Shapeless

By Maria Livia

The Path to Generic Endpoints using Shapeless

Scala eXchange

  • 1,928