Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

many changes to make more flexible and consistent

  • Loading branch information...
commit 7d8ba565808210df2c07222851656cb741d3bd5c 1 parent d8fedee
Jeremy Cloud authored
View
1  .gitignore
@@ -4,3 +4,4 @@ project/target
project/plugins/project/
project/plugins/src_managed/
target
+streamyj.tmproj
View
47 README.md
@@ -19,11 +19,11 @@ on specific parsed items.
It's easiest to show with an example
val s = new Streamy("""{"bar":1}""")
- s.obj {
- case FieldName("bar") => println(s.readField())
+ s readObject {
+ case "bar" => println(s.readScalar())
}
-In this case, we call obj() on the Streamy object to tell the parser to read the current object.
+In this case, we call readObject() on the Streamy object to tell the parser to read the current object.
The case statements tell Streamy to print the current field value when the field name is "bar".
Simple enough.
@@ -31,11 +31,9 @@ A slightly more complex example
var baz = 0L
val s = new Streamy("""{"bar":{"baz":1}}""")
- s.obj {
- case FieldName("bar") => {
- s.obj {
- case FieldName("baz") => baz = s.readLongField()
- }
+ s readObject {
+ case "bar" => s readObject {
+ case "baz" => baz = s.readLong()
}
}
@@ -43,38 +41,21 @@ Here we have nested handlers. When the parser encounters the "bar" field, we te
to read another object. In this nested object, when you see the baz field, assign our
baz var whatever the value of the baz field is in JSON.
-Parsing arrays is also handled.
+Parsing arrays is also handled. Instead of field names when reading an object,
+the readArray takes a function that takes the current index in the array.
val s = new Streamy("""{"bar":[1,2,3]}""")
- s.obj {
- case FieldName("bar") => {
- s.arr {
- case ValueLong(l) => println(l)
- }
+ s readObject {
+ case "bar" => s readArray {
+ idx => println(idx + ": " + s.readLong())
}
}
Parsing methods can also be defined and used without the {} syntax
- val printfield:Streamy.ParseFunc = {
- case FieldName(s) => println("hit field " + s)
+ val printfield: Streamy.ObjectParseFunc = {
+ case s => println("hit field " + s)
}
val s = new Streamy("""{"bar":1, "baz":2}""")
- s.obj(printfield)
+ s.readObject(printfield)
-Lastly, if the helpers fail you always have access to the underlying Json reader,
-as well as the current Streamy token
-
- val s = new Streamy("""{"bar": 1, "baz": {"baz2": 3}}""")
- s.obj {
- case FieldName("bar") => println("bar is " + s.readLongField())
- case FieldName("baz") => {
- println(s.next())
- s.token match {
- case StartObject => println("I'm at an object start")
- case _ => println("I'm not at an object start")
- }
- s.readObject(s.eat)
- }
- }
-
View
4 project/build.properties
@@ -3,6 +3,6 @@
project.organization=com.twitter
project.name=streamyj
sbt.version=0.7.4
-project.version=0.1-SNAPSHOT
-build.scala.versions=2.8.0 2.7.7
+project.version=0.2-SNAPSHOT
+build.scala.versions=2.8.1 2.7.7
project.initialize=false
View
2  project/build/StreamyJ.scala
@@ -7,7 +7,7 @@ class StreamyJProject(info: ProjectInfo) extends StandardProject(info) with Subv
val objenesis = "org.objenesis" % "objenesis" % "1.1"
val specs = buildScalaVersion match {
case "2.7.7" => "org.scala-tools.testing" % "specs" % "1.6.2.1" % "test"
- case _ => "org.scala-tools.testing" %% "specs" % "1.6.5" % "test"
+ case _ => "org.scala-tools.testing" %% "specs" % "1.6.6" % "test"
}
override def disableCrossPaths = false
View
2  project/plugins/Plugins.scala
@@ -2,5 +2,5 @@ import sbt._
class Plugins(info: ProjectInfo) extends PluginDefinition(info) {
val twitterMaven = "twitter.com" at "http://maven.twttr.com/"
- val defaultProject = "com.twitter" % "standard-project" % "0.7.10"
+ val defaultProject = "com.twitter" % "standard-project" % "0.7.17"
}
View
553 src/main/scala/com/twitter/streamyj/Streamy.scala
@@ -1,54 +1,27 @@
package com.twitter.streamyj
+import java.io.{File, Reader, StringWriter}
import org.codehaus.jackson._
import org.codehaus.jackson.JsonToken._
-
-/**
- * The base case class for a JsonToken
- */
-abstract sealed case class StreamyToken()
-/** maps to JsonToken.START_ARRAY */
-case object StartArray extends StreamyToken
-/** maps to JsonToken.END_ARRAY */
-case object EndArray extends StreamyToken
-/** maps to JsonToken.START_OBJECT */
-case object StartObject extends StreamyToken
-/** maps to JsonToken.END_OBJECT */
-case object EndObject extends StreamyToken
-/** maps to JsonToken.FIELD_NAME */
-case class FieldName(name: String) extends StreamyToken
-/** maps to JsonToken.NOT_AVAILABLE */
-case object NotAvailable extends StreamyToken
-/** maps to JsonToken.VALUE_FALSE */
-case object ValueFalse extends StreamyToken
-/** maps to JsonToken.VALUE_TRUE */
-case object ValueTrue extends StreamyToken
-/** maps to JsonToken.VALUE_NULL */
-case object ValueNull extends StreamyToken
-/** A base class for long, double, and string fields */
-abstract case class ValueScalar(value: Any) extends StreamyToken
-/** maps to JsonToken.VALUE_NUMBER_INT */
-case class ValueLong(override val value: Long) extends ValueScalar(value)
-/** maps to JsonToken.VALUE_NUMBER_FLOAT */
-case class ValueDouble(override val value: Double) extends ValueScalar(value)
-/** maps to JsonToken.VALUE_STRING */
-case class ValueString(override val value: String) extends ValueScalar(value)
-
+import scala.annotation.tailrec
/**
* Just store the PartialFunction type for parse functions so
* it can be referenced by user implementations (easily)
*/
object Streamy {
- /**
- * This is the partial function parse methods need
- */
- type ParseFunc = PartialFunction[StreamyToken, Unit]
+ type ObjectParseFunc = PartialFunction[String, Unit]
+ type ArrayParseFunc = Function[Int, Unit]
+
/**
* Jackson's Json parser factory
*/
val factory = new JsonFactory()
- def createParser(s: String) = factory.createJsonParser(s)
+
+ def apply(source: String): Streamy = apply(factory.createJsonParser(source))
+ def apply(reader: Reader): Streamy = apply(factory.createJsonParser(reader))
+ def apply(file: File): Streamy = apply(factory.createJsonParser(file))
+ def apply(parser: JsonParser): Streamy = new Streamy(parser)
}
/**
@@ -58,215 +31,449 @@ object Streamy {
* Scala-idiomatic manner. A quick example:
*
*/
-class Streamy(s: String) {
+class Streamy(parser: JsonParser) {
+ import Streamy.ObjectParseFunc
+ import Streamy.ArrayParseFunc
+
+ private var currentToken: StreamyToken = NotAvailable
+ private var peekedToken: StreamyToken = NotAvailable
/**
- * Pull in Streamy.ParseFunc just for brevity
+ * Advances the parser and sets the current token.
+ * @throws JsonParseException if the are no more tokens
*/
- type ParseFunc = Streamy.ParseFunc
+ def next(): StreamyToken = {
+ peekedToken match {
+ case NotAvailable =>
+ currentToken = StreamToken(parser)
+ case token =>
+ currentToken = token
+ peekedToken = NotAvailable
+ }
+ currentToken
+ }
+
/**
- * the underlying json parser
+ * Gets the current token, which is the last token returned by next()
*/
- val reader = Streamy.createParser(s)
+ def current = currentToken
+
/**
- * Store the current token (it's useful while parsing)
+ * Looks at the next token without consuming it.
*/
- var token:StreamyToken = null
+ def peek(): StreamyToken = {
+ peekedToken match {
+ case NotAvailable =>
+ peekedToken = StreamToken(parser)
+ case _ =>
+ }
+ peekedToken
+ }
+
+// def read[T](fn: PartialFunction[Token,T]): Option[T] = {}
/**
- * A mapping of JsonToken constants to StreamyToken instances.
- * This allows (more elegant) pattern matching parsers
+ * Gets the location of the current token
*/
- def tokenToCaseClass(token: JsonToken) = token match {
- case START_ARRAY => StartArray
- case END_ARRAY => EndArray
- case START_OBJECT => StartObject
- case END_OBJECT => EndObject
- case FIELD_NAME => FieldName(reader.getCurrentName)
- case NOT_AVAILABLE => NotAvailable
- case VALUE_FALSE => ValueFalse
- case VALUE_TRUE => ValueTrue
- case VALUE_NULL => ValueNull
- case VALUE_NUMBER_FLOAT => ValueDouble(reader.getDoubleValue())
- case VALUE_NUMBER_INT => ValueLong(reader.getLongValue())
- case VALUE_STRING => ValueString(reader.getText())
- case _ => NotAvailable
+ def location = parser.getCurrentLocation
+
+ /**
+ * alias for readObject(fn)
+ */
+ def \(fn: ObjectParseFunc) = readObject(fn)
+
+ /**
+ * An alias for readArray.
+ */
+ def arr(fn: ArrayParseFunc) = readArray(fn)
+
+ /**
+ * Matches the start of an object value, but without reading the object
+ * body, or throws an exception if the next token is not an StartObject.
+ */
+ def startObject() {
+ next() match {
+ case StartObject => // goo
+ case token => unexpected(token, "object")
+ }
}
/**
- * A parse function that "eats" objects or arrays
+ * Matches the start of an object value, but without reading the object
+ * body, or "null", or throws an exception if the next token is not an StartObject.
+ * @return true if starting an object, false if null
*/
- val eat:ParseFunc = {
- case FieldName(s) => null; // noop
- case ValueFalse => null; // noop
- case ValueTrue => null; // noop
- case ValueScalar(s) => null; //noop
+ def startObjectOption(): Boolean = {
+ next() match {
+ case ValueNull => false
+ case StartObject => true
+ case token => unexpected(token, "object")
+ }
}
+ /**
+ * Reads an object from open-curly to close-curly. Any field name not
+ * recognized by the given PartialFunction will be skipped.
+ * If the PartialFunction matches a field name, it MUST either fully
+ * read the corresponding value or skip it. Not doing so will leave
+ * the parser in an unpredictable state.
+ */
+ def readObject(fn: ObjectParseFunc) {
+ startObject()
+ readObjectBody(fn)
+ }
/**
- * alias for startObject(fn)
+ * Reads an object from open-curly to close-curly. Any field name not
+ * recognized by the given PartialFunction will be skipped.
+ * If the PartialFunction matches a field name, it MUST either fully
+ * read the corresponding value or skip it. Not doing so will leave
+ * the parser in an unpredictable state.
*/
- def \(fn: ParseFunc) = {
- startObject(fn)
+ def readObjectOption(fn: ObjectParseFunc): Boolean = {
+ startObjectOption() && { readObjectBody(fn); true }
}
/**
- * alias for startObject(fn)
+ * Reads the body of the object, up to the close-curly. Any field name not
+ * recognized by the given PartialFunction will be skipped.
+ * The result of calling this is undefined if not already in an object.
+ * If the PartialFunction matches a field name, it MUST either fully
+ * read the corresponding value or skip it. Not doing so will leave
+ * the parser in an unpredictable state.
*/
- def obj(fn: ParseFunc) = {
- startObject(fn)
+ def readObjectBody(fn: ObjectParseFunc) {
+ @tailrec def loop() {
+ next() match {
+ case EndObject => // done
+ case FieldName(name) =>
+ if (fn.isDefinedAt(name)) fn(name) else skipNext()
+ loop()
+ case token => unexpected(token, "field name")
+ }
+ }
+ loop()
}
/**
- * An alias for startArray.
- * Applies the supplied function to the current
- * JSON array. Note that the current token should
- * be the start of the array (either an opening bracket or null)
+ * Reads an object using an accumulator.
+ * If the PartialFunction matches a field name, it MUST either fully
+ * read the corresponding value or skip it. Not doing so will leave
+ * the parser in an unpredictable state.
*/
- def arr(fn: ParseFunc) = {
- startArray(fn)
+ def foldObject[T](start: T)(fn: PartialFunction[(T,String), T]): T = {
+ startObject()
+ foldObjectBody(start)(fn)
}
/**
- * Advances the parser and sets the current token
+ * Reads an object using an accumulator.
+ * If the PartialFunction matches a field name, it MUST either fully
+ * read the corresponding value or skip it. Not doing so will leave
+ * the parser in an unpredictable state.
*/
- def next():StreamyToken = {
- token = tokenToCaseClass(reader.nextToken())
- token
+ def foldObjectBody[T](start: T)(fn: PartialFunction[(T,String), T]): T = {
+ @tailrec def loop(accum: T): T = {
+ next() match {
+ case EndObject => accum
+ case FieldName(name) =>
+ val tup = (accum, name)
+ if (fn.isDefinedAt(tup))
+ loop(fn(tup))
+ else {
+ skipNext()
+ loop(accum)
+ }
+ case token => unexpected(token, "field name")
+ }
+ }
+ loop(start)
}
/**
- * applies the supplied function to the current
- * JSON object. Note that the current token should
- * be the start of the object (either a curly brace or null).
- * After the first token is read this passes control to
- * readObject.
+ * Matches the start of an array value, but without reading the array
+ * members, or throws an exception if the next token is not a StartArray.
*/
- def startObject(fn: ParseFunc):Unit = {
+ def startArray() {
next() match {
- case token if fn.isDefinedAt(token) => {
- fn(token)
- }
- case ValueNull => return
- case EndObject => return
- case _ => //noop
+ case StartArray => // good
+ case token => unexpected(token, "array")
}
- readObject(fn)
}
/**
- * Continues reading an object until it is closed.
- * If the passed in function is defined at the current token
- * it is called. Otherwise the following actions are taken
- * <ul>
- * <li>NotAvailable: return. The JSON stream has ended. Shouldn't happen</li>
- * <li>EndObject: return. The JSON object has ended.</li>
- * <li>StartObject: call readObject with the eat() handler. Just consumes
- * tokens from the embedded object</li>
- * <li>StartArray: call readArray with the eat() handler. Just consumes
- * tokens from the embedded array</li>
- * <li>Anything else: noop</li>
+ * Matches the start of an array value, but without reading the array
+ * members, or "null", or throws an exception if the next token is not a StartArray.
+ * @return true if starting an array, false if null.
*/
- def readObject(fn: ParseFunc):Unit = {
+ def startArrayOption(): Boolean = {
next() match {
- case token if fn.isDefinedAt(token) => {
- fn(token)
- }
- case NotAvailable => return
- case EndObject => return
- case StartArray => startArray(eat)
- case StartObject => startObject(eat)
- case _ => //noop
+ case ValueNull => false
+ case StartArray => true
+ case token => unexpected(token, "array")
}
- readObject(fn)
}
/**
* applies the supplied function to the current
* JSON array. Note that the current token should
* be the start of the array (either an opening bracket or null)
+ * The given function MUST either fully read the corresponding value
+ * or skip it. Not doing so will leave the parser in an unpredictable state.
*/
- def startArray(fn: ParseFunc):Unit = {
- next() match {
- case token if fn.isDefinedAt(token) => {
- fn(token)
+ def readArray(fn: ArrayParseFunc) {
+ startArray()
+ readArrayBody(fn)
+ }
+
+ /**
+ * applies the supplied function to the current
+ * JSON array. Note that the current token should
+ * be the start of the array (either an opening bracket or null)
+ * The given function MUST either fully read the corresponding value
+ * or skip it. Not doing so will leave the parser in an unpredictable state.
+ */
+ def readArrayOption(fn: ArrayParseFunc): Boolean = {
+ startArrayOption() && { readArrayBody(fn); true }
+ }
+
+ /**
+ * Reads the body of an array upto the close-bracket.
+ * The given function MUST either fully read the corresponding value
+ * or skip it. Not doing so will leave the parser in an unpredictable state.
+ */
+ def readArrayBody(fn: ArrayParseFunc) {
+ @tailrec def loop(index: Int) {
+ if (peek() == EndArray) {
+ next() // skip ]
+ } else {
+ fn(index)
+ loop(index + 1)
}
- case ValueNull => return
- case EndArray => return
- case _ => //noop
}
- readArray(fn)
- }
-
- /*
- * Continues reading an array until it is closed.
- * If the passed in function is defined at the current token
- * it is called. Otherwise the following actions are taken
- * <ul>
- * <li>NotAvailable: return. The JSON stream has ended. Shouldn't happen</li>
- * <li>EndArray: return. The JSON array has ended.</li>
- * <li>StartObjoct: call readObject with the eat() handler. Just consumes
- * tokens from the embedded object</li>
- * <li>StartArray: call readArray with the eat() handler. Just consumes
- * tokens from the embedded array</li>
- * <li>Anything else: noop</li>
- */
- def readArray(fn: ParseFunc):Unit = {
- next () match {
- case token if fn.isDefinedAt(token) => {
- fn(token)
+ loop(0)
+ }
+
+ /**
+ * Reads an array using an accumulator.
+ * The given function MUST either fully read the corresponding value
+ * or skip it. Not doing so will leave the parser in an unpredictable state.
+ */
+ def foldArray[T](start: T)(fn: (T,Int) => T): T = {
+ startArray()
+ foldArrayBody(start)(fn)
+ }
+
+ /**
+ * Reads an array using an accumulator.
+ * The given function MUST either fully read the corresponding value
+ * or skip it. Not doing so will leave the parser in an unpredictable state.
+ */
+ def foldArrayBody[T](start: T)(fn: (T,Int) => T): T = {
+ @tailrec def loop(accum: T, index: Int): T = {
+ if (peek() == EndArray) {
+ next() // skip ]
+ accum
+ } else {
+ loop(fn(accum, index), index + 1)
}
- case NotAvailable => return
- case EndArray => return
- case StartArray => startArray(eat)
- case StartObject => startObject(eat)
- case _ => //noop
}
- readArray(fn)
+ loop(start, 0)
+ }
+
+ /**
+ * If the next value is "null", it is read and returned. Otherwise,
+ * nothing happens.
+ */
+ def readNullOption(): Option[Null] = peek() match {
+ case ValueNull => next(); Some(null)
+ case _ => None
}
/**
* reads a field of type Any
*/
- def readField() = {
- next() match {
- case ValueScalar(rv) => rv
- case _ => throw new IllegalArgumentException("tried to read a non-scalar field as a string")
- }
+ def readScalar(): Any = next() match {
+ case scalar: ValueScalar => scalar.value
+ case token => unexpected(token, "scalar")
}
/**
- * reads a string field. Throws an
- * IllegalArgumentException if the current value isn't a string
+ * reads a string value. Throws a
+ * JsonParseException if the current value isn't a string
*/
- def readStringField() = {
- next() match {
- case ValueScalar(rv) => rv.toString
- case _ => throw new IllegalArgumentException("tried to read a non-scalar field as a string")
+ def readString(): String = next() match {
+ case scalar: ValueScalar => scalar.value.toString
+ case token => unexpected(token, "string (or any scalar)")
+ }
+
+ /**
+ * reads a string value or null. Throws a
+ * JsonParseException if the current value isn't a string
+ */
+ def readStringOption(): Option[String] = next() match {
+ case ValueNull => None
+ case scalar: ValueScalar => Some(scalar.value.toString)
+ case token => unexpected(token, "string (or any scalar)")
+ }
+
+ /**
+ * reads a boolean value. throws a JsonParseException if
+ * the current value isn't a boolean.
+ */
+ def readBoolean(): Boolean = next() match {
+ case ValueBoolean(rv) => rv
+ case token => unexpected(token, "boolean")
+ }
+
+ /**
+ * reads a boolean value or null. throws a JsonParseException if
+ * the current value isn't a boolean.
+ */
+ def readBooleanOption(): Option[Boolean] = next() match {
+ case ValueNull => None
+ case ValueBoolean(rv) => Some(rv)
+ case token => unexpected(token, "boolean")
+ }
+
+ /**
+ * reads a long value. Throws a
+ * JsonParseException if the current value isn't a long
+ */
+ def readLong(): Long = next() match {
+ case ValueLong(rv) => rv
+ case token => unexpected(token, "integer")
+ }
+
+ /**
+ * reads a long value or null. Throws a
+ * JsonParseException if the current value isn't a long
+ */
+ def readLongOption(): Option[Long] = next() match {
+ case ValueNull => None
+ case ValueLong(rv) => Some(rv)
+ case token => unexpected(token, "integer")
+ }
+
+ /**
+ * reads an int value. Throws a
+ * JsonParseException if the current value isn't a long
+ */
+ def readInt(): Int = next() match {
+ case ValueLong(rv) => rv.toInt
+ case token => unexpected(token, "integer")
+ }
+
+ /**
+ * reads an int value or null. Throws a
+ * JsonParseException if the current value isn't a long
+ */
+ def readIntOption(): Option[Int] = next() match {
+ case ValueNull => None
+ case ValueLong(rv) => Some(rv.toInt)
+ case token => unexpected(token, "integer")
+ }
+
+ /**
+ * reads a double value. Throws a
+ * JsonParseException if the current value isn't a double or a long
+ */
+ def readDouble(): Double = next() match {
+ case ValueDouble(rv) => rv
+ case ValueLong(rv) => rv
+ case token => unexpected(token, "double")
+ }
+
+ /**
+ * reads a double value or null. Throws a
+ * JsonParseException if the current value isn't a double or a long
+ */
+ def readDoubleOption(): Option[Double] = next() match {
+ case ValueNull => None
+ case ValueDouble(rv) => Some(rv)
+ case ValueLong(rv) => Some(rv)
+ case token => unexpected(token, "double")
+ }
+
+ /**
+ * Reads the entire next value as JSON encoded string.
+ */
+ def readNextAsJsonString() = {
+ val buf = new StringWriter
+ val out = Streamy.factory.createJsonGenerator(buf)
+ def copyNext() {
+ next() match {
+ case ValueNull =>
+ out.writeNull()
+ case ValueBoolean(v) =>
+ out.writeBoolean(v)
+ case ValueLong(v) =>
+ out.writeNumber(v)
+ case ValueDouble(v) =>
+ out.writeNumber(v)
+ case ValueString(v) =>
+ out.writeString(v)
+ case StartObject =>
+ out.writeStartObject()
+ readObjectBody {
+ case name =>
+ out.writeFieldName(name)
+ copyNext()
+ }
+ out.writeEndObject()
+ case StartArray =>
+ out.writeStartArray()
+ readArrayBody(_ => copyNext())
+ out.writeEndArray()
+ case token => unexpected(token, "something something")
+ }
}
+ copyNext()
+ out.flush()
+ buf.toString
}
/**
- * reads a long field. Throws an
- * IllegalArgumentException if the current value isn't a long
+ * Skips past the next value (not just the next token), fast-forwarding to
+ * an object or array end if at an object or array start.
*/
- def readLongField() = {
- next() match {
- case ValueLong(rv) => rv
- case _ => throw new IllegalArgumentException("tried to read a non-int field as a long")
+ def skipNext() {
+ next()
+ skipCurrent()
+ }
+
+ /**
+ * Skips past the current value (not just the current token), fast-forwarding to
+ * an object or array end if at an object or array start.
+ */
+ def skipCurrent() {
+ current match {
+ case StartObject => skipToObjectEnd()
+ case StartArray => skipToArrayEnd()
+ case _ => // nothing to do
}
}
/**
- * reads a double field. Throws an
- * IllegalArgumentException if the current value isn't a double or a long
+ * Fast-forwards to the first EndObject at the same level or a higher
+ * (less-nested) level.
*/
- def readDoubleField(): Double = {
- next() match {
- case ValueDouble(rv) => rv
- case ValueLong(rv) => rv
- case _ => throw new IllegalArgumentException("tried to read a non-numeric field as a double")
+ def skipToObjectEnd() {
+ while (next() != EndObject) skipCurrent()
+ }
+
+ /**
+ * Fast-forwards to the first array end at the same level or a higher
+ * (less-nested) level.
+ */
+ def skipToArrayEnd() {
+ while (next() != EndArray) skipCurrent()
+ }
+
+ def unexpected(token: StreamyToken, expecting: String) = {
+ token match {
+ case NotAvailable => throw new JsonParseException("Unexpected end of input", location)
+ case _ => throw new JsonParseException("Expecting " + expecting + ", found " + token, location)
}
}
}
View
55 src/main/scala/com/twitter/streamyj/StreamyToken.scala
@@ -0,0 +1,55 @@
+package com.twitter.streamyj
+
+import org.codehaus.jackson._
+import org.codehaus.jackson.JsonToken._
+
+/**
+ * The base case class for a JsonToken
+ */
+abstract sealed trait StreamyToken
+/** maps to JsonToken.START_ARRAY */
+case object StartArray extends StreamyToken
+/** maps to JsonToken.END_ARRAY */
+case object EndArray extends StreamyToken
+/** maps to JsonToken.START_OBJECT */
+case object StartObject extends StreamyToken
+/** maps to JsonToken.END_OBJECT */
+case object EndObject extends StreamyToken
+/** maps to JsonToken.FIELD_NAME */
+case class FieldName(name: String) extends StreamyToken
+/** maps to JsonToken.NOT_AVAILABLE */
+case object NotAvailable extends StreamyToken
+/** maps to JsonToken.VALUE_NULL */
+case object ValueNull extends StreamyToken
+/** A base class for long, double, and string fields */
+abstract class ValueScalar extends StreamyToken {
+ def value: Any
+}
+/** maps to JsonToken.VALUE_FALSE and VALUE_TRUE */
+case class ValueBoolean(val value: Boolean) extends ValueScalar
+/** maps to JsonToken.VALUE_NUMBER_INT */
+case class ValueLong(val value: Long) extends ValueScalar
+/** maps to JsonToken.VALUE_NUMBER_FLOAT */
+case class ValueDouble(val value: Double) extends ValueScalar
+/** maps to JsonToken.VALUE_STRING */
+case class ValueString(val value: String) extends ValueScalar
+
+object StreamToken {
+ def apply(parser: JsonParser) = {
+ parser.nextToken() match {
+ case START_ARRAY => StartArray
+ case END_ARRAY => EndArray
+ case START_OBJECT => StartObject
+ case END_OBJECT => EndObject
+ case FIELD_NAME => FieldName(parser.getCurrentName)
+ case NOT_AVAILABLE => NotAvailable
+ case VALUE_FALSE => ValueBoolean(false)
+ case VALUE_TRUE => ValueBoolean(true)
+ case VALUE_NULL => ValueNull
+ case VALUE_NUMBER_FLOAT => ValueDouble(parser.getDoubleValue())
+ case VALUE_NUMBER_INT => ValueLong(parser.getLongValue())
+ case VALUE_STRING => ValueString(parser.getText())
+ case _ => NotAvailable
+ }
+ }
+}
View
26 src/main/scala/com/twitter/streamyj/StreamyUnpacker.scala
@@ -150,10 +150,9 @@ class StreamyUnpacker {
case ValueLong(x) => field.set(obj, coerce(field.getName, x, field.getType))
case ValueDouble(x) => field.set(obj, coerce(field.getName, x, field.getType))
case ValueString(x) => field.set(obj, coerce(field.getName, x, field.getType))
- case ValueFalse => field.set(obj, coerce(field.getName, false, field.getType))
- case ValueTrue => field.set(obj, coerce(field.getName, true, field.getType))
+ case ValueBoolean(x) => field.set(obj, coerce(field.getName, x, field.getType))
case StartArray => field.set(obj, coerce(field.getName, getArray(streamy, field.getType), field.getType))
- case StartObject => field.set(obj, unpackObject(streamy, field.getType))
+ case StartObject => field.set(obj, unpackObject(streamy, field.getType, true))
case ValueNull => field.set(obj, null)
case x =>
throw new JsonUnpackingException("Unexpected token: " + x)
@@ -171,8 +170,7 @@ class StreamyUnpacker {
case ValueLong(x) => list += x
case ValueDouble(x) => list += x
case ValueString(x) => list += x
- case ValueFalse => list += false
- case ValueTrue => list += true
+ case ValueBoolean(x) => list += x
case ValueNull => list += null
case StartArray =>
if (!cls.isArray) {
@@ -183,7 +181,7 @@ class StreamyUnpacker {
if (!cls.isArray) {
throw new JsonUnpackingException("Can't unpack lists of objects due to type erasure (try arrays)")
}
- list += unpackObject(streamy, cls.getComponentType)
+ list += unpackObject(streamy, cls.getComponentType, true)
case x =>
throw new JsonUnpackingException("Unexpected token: " + x)
}
@@ -191,12 +189,17 @@ class StreamyUnpacker {
}
@throws(classOf[JsonProcessingException])
- def unpackObject[T](streamy: Streamy, cls: Class[T]): T = {
+ def unpackObject[T](streamy: Streamy, cls: Class[T]): T = unpackObject(streamy, cls, false)
+
+ @throws(classOf[JsonProcessingException])
+ def unpackObject[T](streamy: Streamy, cls: Class[T], inObject: Boolean): T = {
val (obj, fields) = makeObject(cls)
val seenFields = new mutable.ListBuffer[String]
- streamy.obj {
- case FieldName(name) =>
+ if (!inObject) streamy.startObject()
+
+ streamy.readObjectBody {
+ case name =>
fields.find { _.getName == name } match {
case None =>
if (!ignoreExtraFields) {
@@ -232,6 +235,7 @@ class StreamyUnpacker {
}
object StreamyUnpacker {
- def apply[T](s: String)(implicit manifest: Manifest[T]): T =
- new StreamyUnpacker().unpackObject(new Streamy(s), manifest.erasure.asInstanceOf[Class[T]])
+ def apply[T](s: String)(implicit manifest: Manifest[T]): T = {
+ new StreamyUnpacker().unpackObject(Streamy(s), manifest.erasure.asInstanceOf[Class[T]])
+ }
}
View
457 src/test/scala/com/twitter/streamyj/StreamySpec.scala
@@ -5,325 +5,230 @@ import org.codehaus.jackson._
import org.specs._
import scala.collection.mutable.ListBuffer
-case class Geo(latitude: Double, longitude: Double)
-case class Place(x1: Double, y1: Double, x2: Double, y2: Double)
-
-case class AnnotationAttribute(name: String, value: String) {
- val trackKeys = buildTrackKeys()
+object StreamySpec extends Specification {
+ "Streamy" should {
+ "handle scalar values" in {
+ val s = Streamy("true false 123456789 3.1415927 \"hello world\"")
+ s.readBoolean() must beTrue
+ s.readBoolean() must beFalse
+ s.readLong() mustEqual 123456789L
+ s.readDouble() mustEqual 3.1415927
+ s.readString() mustEqual "hello world"
+ }
+
+ "handle optional scalars" in {
+ "null" in {
+ Streamy("null").readNullOption() must beSome(null)
+ Streamy("3").readNullOption() must beNone
+ }
+ "boolean" in {
+ Streamy("null").readBooleanOption() must beNone
+ Streamy("true").readBooleanOption() must beSome(true)
+ }
+ "long" in {
+ Streamy("null").readLongOption() must beNone
+ Streamy("42").readLongOption() must beSome(42L)
+ }
+ "double" in {
+ Streamy("null").readDoubleOption() must beNone
+ Streamy("3.14").readDoubleOption() must beSome(3.14)
+ }
+ "string" in {
+ Streamy("null").readStringOption() must beNone
+ Streamy("\"hello world\"").readStringOption() must beSome("hello world")
+ }
+ }
- def buildTrackKeys() = {
- val s = new HashSet[String]()
- s.add("*=*")
- s.add(name + "=*")
- s.add("*=" + value)
- s.add(name + "=" + value)
- s
- }
-}
+ "handle empty arrays" in {
+ val s = Streamy("[] 42")
+ s.readArray(_ => fail())
+ s.readLong() mustEqual 42L
+ }
-case class Annotation(typeName: String, attributes: Seq[AnnotationAttribute]) {
- val trackKeys = buildTrackKeys()
+ "handle simples arrays with readArray" in {
+ val s = Streamy("[true, 123456789, 3.1415927, \"hello world\"] 42")
+ var a0: Option[Boolean] = None
+ var a1: Option[Long] = None
+ var a2: Option[Double] = None
+ var a3: Option[String] = None
- def buildTrackKeys() = {
- val s = new HashSet[String]()
- attributes.foreach {attr =>
- val i = attr.trackKeys.iterator
- while (i.hasNext()) {
- val attrKey = i.next()
- s.add("*:" + attrKey)
- s.add(typeName + ":" + attrKey)
+ def verifyResults() {
+ s.readLong() mustEqual 42L
+ a0 must beSome(true)
+ a1 must beSome(123456789L)
+ a2 must beSome(3.1415927)
+ a3 must beSome("hello world")
}
- }
- s
- }
-}
-
-object StreamySpec extends Specification {
- var kind = -1
- var userIdOpt:Option[Long] = None
- var retweetUserIdOpt:Option[Long] = None
- var statusIdOpt:Option[Long] = None
- var limitTrackOpt:Option[Long] = None
- var geoOpt:Option[Geo] = None
- var placeOpt:Option[Place] = None
- var sourceOpt:Option[String] = None
- var inReplyToUserIdOpt:Option[Long] = None
- var idOpt:Option[Long] = None
- var retweetIdOpt:Option[Long] = None
- var createdAtOpt:Option[String] = None
- var textOpt:Option[String] = None
- var annotationsOpt:Option[Seq[Annotation]] = None
+ val fn: Int => Unit = {
+ case 0 => a0 = Some(s.readBoolean())
+ case 1 => a1 = Some(s.readLong())
+ case 2 => a2 = Some(s.readDouble())
+ case 3 => a3 = Some(s.readString())
+ }
- val annotatedJSON = """{"geo":null,"in_reply_to_user_id":null,"favorited":false,"annotations":[{"amazon":{"price":"$49.99","id":"4"}},{"book":{"author":"Dickens","title":"Some Dickens Thing"}}],"text":"an annotated tweet","created_at":"Sat Apr 24 00:00:00 +0000 2010","truncated":false,"coordinates":null,"in_reply_to_screen_name":null,"contributors":null,"user":{"geo_enabled":false,"profile_background_tile":false,"profile_background_color":"9ae4e8","notifications":false,"lang":"en","following":false,"profile_text_color":"000000","utc_offset":-28800,"created_at":"Sat Apr 24 00:00:00 +0000 2010","followers_count":2,"url":null,"statuses_count":1,"profile_link_color":"0000ff","profile_image_url":"http://s3.amazonaws.com/twitter_development/profile_images/2/jack_normal.jpg","friends_count":2,"contributors_enabled":false,"time_zone":"Pacific Time (US & Canada)","profile_sidebar_fill_color":"e0ff92","protected":true,"description":"love, love","screen_name":"jack","favourites_count":0,"name":"Jack","profile_sidebar_border_color":"87bc44","id":3,"verified":false,"profile_background_image_url":"/images/themes/theme1/bg.png","location":"San Francisco"},"place":null,"in_reply_to_status_id":null,"source":"web","id":1234}"""
- val unAnnotatedJSON = """{"geo":null,"in_reply_to_user_id":null,"favorited":false,"text":"an annotated tweet","created_at":"Sat Apr 24 00:00:00 +0000 2010","truncated":false,"coordinates":null,"in_reply_to_screen_name":null,"contributors":null,"user":{"geo_enabled":false,"profile_background_tile":false,"profile_background_color":"9ae4e8","notifications":false,"lang":"en","following":false,"profile_text_color":"000000","utc_offset":-28800,"created_at":"Sat Apr 24 00:00:00 +0000 2010","followers_count":2,"url":null,"statuses_count":1,"profile_link_color":"0000ff","profile_image_url":"http://s3.amazonaws.com/twitter_development/profile_images/2/jack_normal.jpg","friends_count":2,"contributors_enabled":false,"time_zone":"Pacific Time (US & Canada)","profile_sidebar_fill_color":"e0ff92","protected":true,"description":"love, love","screen_name":"jack","favourites_count":0,"name":"Jack","profile_sidebar_border_color":"87bc44","id":3,"verified":false,"profile_background_image_url":"/images/themes/theme1/bg.png","location":"San Francisco"},"place":null,"in_reply_to_status_id":null,"source":"web","id":1234}"""
- val blankAnnotatedJSON = """{"geo":null,"in_reply_to_user_id":null,"favorited":false,"annotations":[],"text":"an annotated tweet","created_at":"Sat Apr 24 00:00:00 +0000 2010","truncated":false,"coordinates":null,"in_reply_to_screen_name":null,"contributors":null,"user":{"geo_enabled":false,"profile_background_tile":false,"profile_background_color":"9ae4e8","notifications":false,"lang":"en","following":false,"profile_text_color":"000000","utc_offset":-28800,"created_at":"Sat Apr 24 00:00:00 +0000 2010","Followers_count":2,"url":null,"statuses_count":1,"profile_link_color":"0000ff","profile_image_url":"http://s3.amazonaws.com/twitter_development/profile_images/2/jack_normal.jpg","friends_count":2,"contributors_enabled":false,"time_zone":"Pacific Time (US & Canada)","profile_sidebar_fill_color":"e0ff92","protected":true,"description":"love, love","screen_name":"jack","favourites_count":0,"name":"Jack","profile_sidebar_border_color":"87bc44","id":3,"verified":false,"profile_background_image_url":"/images/themes/theme1/bg.png","location":"San Francisco"},"place":null,"in_reply_to_status_id":null,"source":"web","id":1234}"""
+ "with readArray" in {
+ s readArray fn
+ verifyResults()
+ }
- "Streamy" should {
- "work" in {
- val s = new Streamy("""{"id":1, "bar":{"id":3, "bar":2}, "arr":[1,2,3], "text":"some text", "bool":true}""")
- s.next()
+ "with arr" in {
+ s arr fn
+ verifyResults()
+ }
- var id1 = -1L
- var id3 = -1L
- var arr = List[Long]()
- var text = ""
- var bool = false
+ "with readArrayOption" in {
+ s readArrayOption fn must beTrue
+ verifyResults()
+ }
- val submatch:Streamy.ParseFunc = {
- case FieldName("id") => id3 = s.readLongField()
+ "with startArray/readArrayBody" in {
+ s.startArray
+ s readArrayBody fn
+ verifyResults()
}
+ }
- s.obj {
- case FieldName("id") => id1 = s.readLongField()
- case FieldName("text") => text = s.readStringField()
- case StartArray => {
- s.arr {
- case ValueLong(l) => arr = l :: arr
- }
- }
- case StartObject => {
- s.obj(submatch)
- }
- case FieldName("bool") => {
- s.next() match {
- case ValueFalse => bool = false
- case ValueTrue => bool = true
- case _ => throw new IllegalArgumentException
- }
+ "read arrays piecemeal" in {
+ val s = Streamy("[true, 123456789, 3.1415927, \"hello world\"] 42")
+ s.startArray()
+ s.readBoolean() must beTrue
+ s.readLong() mustEqual 123456789L
+ s.readDouble() mustEqual 3.1415927
+ s.readString() mustEqual "hello world"
+ s.skipToArrayEnd()
+ s.readLong() mustEqual 42L
+ }
+
+ "handle nested arrays" in {
+ val s = Streamy("[[[0, 1], [2, 3]]]")
+ val seen = new ListBuffer[Long]
+ def fn(index: Int) {
+ s.next() match {
+ case ValueLong(x) => seen += x
+ case StartArray => s.readArrayBody(fn)
+ case _ => throw new Exception
}
}
- id1 must be_==(1)
- id3 must be_==(3)
- arr must be_==(List(3L,2L,1L))
- text must be_==("some text")
- bool must be_==(true)
+ s.readArray(fn)
+ seen.toList mustEqual Range(0, 4).toList
}
- "work with no parsing logic" in {
- val s = new Streamy("""{"id":1, "text":"text", "bar":{"id":3, "bar":2}, "foo":2}""")
- // just make sure this doesn't throw anything (e.g. stack overflow)
- s.obj(s.eat)
- true must be_==(true)
+ "foldArray" in {
+ val s = Streamy("[2, 4, 6, 8]")
+ val res = s.foldArray(new ListBuffer[Int]) {
+ case (buf, i) => buf += s.readInt(); buf
+ }.toList
+ res mustEqual List(2, 4, 6, 8)
}
- "handle null objects correctly" in {
- val s = new Streamy("""{"id":1, "text":"text", "bar":null, "foo":2}""")
- // just make sure this doesn't throw anything (e.g. stack overflow)
- var foo = 0L
- s \ {
- case FieldName("foo") => foo = s.readLongField()
+ "handle simple object, ignore unmatched fields" in {
+ val s = Streamy("""{"id":1, "text":"text", "flag":true, "foo":3} 42""")
+ var id: Option[Long] = None
+ var text: Option[String] = None
+ var flag: Option[Boolean] = None
+
+ def verifyResults() {
+ s.readLong() mustEqual 42L
+ id must beSome(1)
+ text must beSome("text")
+ flag must beSome(true)
}
- foo must be_==(2)
- }
- "handle embedded objects and arrays" in {
- val s = new Streamy("""{"id":1, "embed":{"foo":"bar", "baz":{"baz":1}, "arr":[[1],2,3,4]},"id2":2}""")
- var id = 0L
- var id2 = 0L
- s \ {
- case FieldName("id") => id = s.readLongField()
- case FieldName("id2") => id2 = s.readLongField()
+ val fn: PartialFunction[String,Unit] = {
+ case "id" => id = Some(s.readLong())
+ case "text" => text = Some(s.readString())
+ case "flag" => flag = Some(s.readBoolean())
}
- id must be_==(1)
- id2 must be_==(2)
- }
- "work on a tweet" in {
- val s = new Streamy(unAnnotatedJSON)
+ "with \\ " in {
+ s \ fn
+ verifyResults()
+ }
- s.obj {
- case FieldName("delete") => {
- kind = 1 // delete
- readDelete(s)
- }
- case FieldName("scrub_geo") => {
- kind = 2 // scrub geo
- readScrubGeo(s)
- }
- case FieldName("limit") => {
- kind = 3 // limit
- readLimit(s)
- }
- case FieldName("geo") => readGeo(s)
- case FieldName("place") => readPlace(s)
- case FieldName("retweeted_status") => readRetweet(s)
- case FieldName("user") => {
- readUser(s, false)
- }
- case FieldName("annotations") => readAnnotations(s)
- case FieldName("entities") => readEntities(s)
- case FieldName("id") => idOpt = Some(s.readLongField())
- case FieldName("source") => sourceOpt = Some(s.readStringField())
- case FieldName("created_at") => createdAtOpt = Some(s.readStringField())
- case FieldName("text") => textOpt = Some(s.readStringField())
- case FieldName("in_reply_to_user_id") => {
- val token = s.next()
- if (token != ValueNull) {
- inReplyToUserIdOpt = Some(s.readLongField())
- }
- }
+ "with readObject" in {
+ s readObject fn
+ verifyResults()
}
- userIdOpt must be_==(Some(3))
- }
- "work on a blank annotated tweet" in {
- val s = new Streamy(blankAnnotatedJSON)
- s.obj {
- case FieldName("delete") => {
- kind = 1 // delete
- readDelete(s)
- }
- case FieldName("scrub_geo") => {
- kind = 2 // scrub geo
- readScrubGeo(s)
- }
- case FieldName("limit") => {
- kind = 3 // limit
- readLimit(s)
- }
- case FieldName("geo") => readGeo(s)
- case FieldName("place") => readPlace(s)
- case FieldName("retweeted_status") => readRetweet(s)
- case FieldName("user") => {
- readUser(s, false)
- }
- case FieldName("annotations") => readAnnotations(s)
- case FieldName("entities") => readEntities(s)
- case FieldName("id") => idOpt = Some(s.readLongField())
- case FieldName("source") => sourceOpt = Some(s.readStringField())
- case FieldName("created_at") => createdAtOpt = Some(s.readStringField())
- case FieldName("text") => textOpt = Some(s.readStringField())
- case FieldName("in_reply_to_user_id") => {
- val token = s.next()
- if (token != ValueNull) {
- inReplyToUserIdOpt = Some(s.readLongField())
- }
- }
+ "with readObjectOption" in {
+ s readObjectOption fn must beTrue
+ verifyResults()
}
- userIdOpt must be_==(Some(3))
- }
- }
- def readDelete(s: Streamy) = {
- s \ {
- case FieldName("status") => {
- s \ {
- case FieldName("user_id") => userIdOpt = Some(s.readLongField())
- case FieldName("id") => idOpt = Some(s.readLongField())
- }
+ "with startObject/readObjectBody" in {
+ s.startObject()
+ s readObjectBody fn
+ verifyResults()
}
}
- }
-
- def readLimit(s: Streamy) = {
- s \ {
- case FieldName("track") => limitTrackOpt = Some(s.readLongField())
- }
- }
-
- def readScrubGeo(s: Streamy) = {
- s \ {
- case FieldName("user_id") => userIdOpt = Some(s.readLongField())
- case FieldName("up_to_status_id") => idOpt = Some(s.readLongField())
+
+ "foldObject" in {
+ val s = Streamy("""{"id":1,"text":"hello"}""")
+ val map = s.foldObject(Map[String,Any]()) {
+ case (map, key) => map + (key -> s.readScalar())
+ }
+ map mustEqual Map("id" -> 1L, "text" -> "hello")
}
- }
- def readRetweet(s: Streamy) = {
- s \ {
- case FieldName("user") => readUser(s, true)
- case FieldName("id") => retweetIdOpt = Some(s.readLongField())
+ "can skip entire objects" in {
+ val s = Streamy("""{"id":1, "text":"text", "bar":{"id":3, "bar":2}, "foo":2} 42""")
+ // just make sure this doesn't throw anything (e.g. stack overflow)
+ s.skipNext() must not(throwA[Exception])
+ s.readLong() mustEqual 42L
}
- }
- def readUser(s: Streamy, isRetweet: Boolean) = {
- s \ {
- case FieldName("id") => {
- if (isRetweet) {
- retweetUserIdOpt = Some(s.readLongField())
- } else {
- userIdOpt = Some(s.readLongField())
- }
- }
+ "can skip entire arrays" in {
+ val s = Streamy("""[42, true, {"id":1, "bar":{"id":3, "bar":2}}] 42""")
+ // just make sure this doesn't throw anything (e.g. stack overflow)
+ s.skipNext() must not(throwA[Exception])
+ s.readLong() mustEqual 42L
}
- }
- def readGeo(s: Streamy) = {
- s \ {
- case FieldName("coordinates") => {
- try {
- s arr {
- case ValueDouble(d) => {
- val latitude = s.readDoubleField()
- s.next()
- val longitude = s.readDoubleField()
- geoOpt = Some(Geo(latitude, longitude))
- }
- }
- } catch {
- case e: NumberFormatException => throw new IllegalArgumentException()
- }
+ "handle null objects correctly" in {
+ val s = Streamy("""{"id":1, "text":"text", "bar":null, "foo":2}""")
+ // just make sure this doesn't throw anything (e.g. stack overflow)
+ var foo = 0L
+ s \ {
+ case "foo" => foo = s.readLong()
}
+ foo must be_==(2)
}
- }
- def readPlace(s: Streamy) = {
- s \ {
- case FieldName("bounding_box") => {
- s \ {
- case FieldName("coordinates") => {
- var minX = Math.MAX_DOUBLE
- var minY = Math.MAX_DOUBLE
- var maxX = Math.MIN_DOUBLE
- var maxY = Math.MIN_DOUBLE
- // two nested arrays
- s arr {
- case StartArray => s arr {
- case ValueDouble(d) => {
- val x = s.readDoubleField()
- s.next()
- val y = s.readDoubleField()
- if (x < minX) minX = x
- if (y < minY) minY = y
- if (x > maxX) maxX = x
- if (y > maxY) maxY = y
- }
- }
- }
- placeOpt = Some(Place(minX, minY, maxX, maxY))
+ "handle embedded objects and arrays" in {
+ val s = Streamy("""{"id":1, "embed":{"foo":"bar", "baz":{"baz":1}, "arr":[[1],2,3,4]},"id2":2}""")
+ var id: Option[Long] = None
+ var foo: Option[String] = None
+ var baz: Option[Long] = None
+ var id2: Option[Long] = None
+ var arr: List[Any] = Nil
+ def readArray(): List[Any] = {
+ val buf = new ListBuffer[Any]
+ s.readArray { _ =>
+ s.peek() match {
+ case _: ValueScalar => buf += s.readScalar()
+ case StartArray => buf += readArray()
+ case _ => s.skipNext()
}
}
+ buf.toList
}
- }
- }
-
- def readAnnotations(s: Streamy) = {
- var parsedAnnotations = new ListBuffer[Annotation]()
- s arr {
- case StartObject => {
- s \ {
- case FieldName(typeName) => {
- var attributes = new ListBuffer[AnnotationAttribute]()
- s \ {
- case FieldName(attrName) => {
- attributes += AnnotationAttribute(attrName, s.readStringField())
- }
- }
- parsedAnnotations += Annotation(typeName, attributes)
+ s \ {
+ case "id" => id = Some(s.readLong())
+ case "id2" => id2 = Some(s.readLong())
+ case "embed" => s \ {
+ case "foo" => foo = Some(s.readString())
+ case "baz" => s \ {
+ case "baz" => baz = Some(s.readLong())
}
+ case "arr" => arr = readArray()
}
}
- annotationsOpt = Some(parsedAnnotations)
- }
- }
-
- def readEntities(s: Streamy) = {
- s \ {
- case FieldName("urls") => {
- }
- case FieldName("hashtags") => {
- }
+ id must beSome(1L)
+ id2 must beSome(2L)
+ foo must beSome("bar")
+ baz must beSome(1L)
+ arr mustEqual List(List(1L), 2L, 3L, 4L)
}
}
-
}
Please sign in to comment.
Something went wrong with that request. Please try again.