Permalink
Browse files

! http: introduce `HttpData` model replacing the byte array in `HttpB…

…ody` and `MessageChunk`, closes #365

So far the HTTP model for message entities and chunks was based on "raw" byte arrays thereby violating the immutability "standard".
 With this change we introduce a dedicated model for HTTP binary data called `HttpData`, which also adds support for file-based (off-memory) data.

 We use the opportunity of this breaking change to refactor the `HttpEntity` model.
 `EmptyEntity` and `HttpBody` are gone and replaced with `HttpEntity.Empty` and `HttpEntity.NonEmpty`.
 Additionally `MessageChunk::bodyAsString` is gone. Use `chunk.data.asString` instead.
  • Loading branch information...
sirthias committed Aug 30, 2013
1 parent f625b5a commit c6f49cc31b70b152413f1030b55731b5b38132a1
View
@@ -71,6 +71,7 @@ object Build extends Build with DocSupport {
.settings(osgiSettings(exports = Seq("spray.http")): _*)
.settings(libraryDependencies ++=
compile(parboiled) ++
+ provided(akkaActor) ++
test(specs2)
)
@@ -0,0 +1,218 @@
+/*
+ * Copyright (C) 2011-2013 spray.io
+ * Based on code copyright (C) 2010-2011 by the BlueEyes Web Framework Team (http://github.com/jdegoes/blueeyes)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package spray.http
+
+import java.io.{ FileInputStream, File }
+import java.nio.charset.Charset
+import scala.collection.immutable.VectorBuilder
+import scala.annotation.tailrec
+import akka.util.ByteString
+import spray.util.UTF8
+
+sealed abstract class HttpData {
+ def isEmpty: Boolean
+ def nonEmpty: Boolean = !isEmpty
+ def length: Long
+ def copyToArray(xs: Array[Byte], sourceOffset: Long = 0, targetOffset: Int = 0, span: Int = length.toInt): Unit
+ def toByteArray: Array[Byte]
+ def toByteString: ByteString
+ def +:(other: HttpData): HttpData
+ def asString: String = asString(UTF8)
+ def asString(charset: HttpCharset): String = asString(charset.nioCharset)
+ def asString(charset: java.nio.charset.Charset): String = new String(toByteArray, charset)
+}
+
+object HttpData {
+ def apply(string: String): HttpData =
+ apply(string, HttpCharsets.`UTF-8`)
+ def apply(string: String, charset: HttpCharset): HttpData =
+ if (string.isEmpty) Empty else new Bytes(ByteString(string getBytes charset.nioCharset))
+ def apply(bytes: Array[Byte]): HttpData =
+ if (bytes.isEmpty) Empty else new Bytes(ByteString(bytes))
+ def apply(bytes: ByteString): HttpData =
+ if (bytes.isEmpty) Empty else new Bytes(bytes)
+
+ /**
+ * Creates an HttpData.FileBytes instance if the given file exists, is readable, non-empty
+ * and the given `length` parameter is non-zero. Otherwise the method returns HttpData.Empty.
+ * A negative `length` value signifies that the respective number of bytes at the end of the
+ * file is to be ommitted, i.e., a value of -10 will select all bytes starting at `offset`
+ * except for the last 10.
+ * If `length` is greater or equal to "file length - offset" all bytes in the file starting at
+ * `offset` are selected.
+ */
+ def apply(file: File, offset: Long = 0, length: Long = Long.MaxValue): HttpData = {
+ val fileLength = file.length
+ if (fileLength > 0) {
+ require(offset >= 0 && offset < fileLength, s"offset $offset out of range $fileLength")
+ if (file.canRead)
+ if (length > 0) new FileBytes(file.getAbsolutePath, offset, math.min(fileLength - offset, length))
+ else if (length < 0 && length > offset - fileLength) new FileBytes(file.getAbsolutePath, offset, fileLength - offset + length)
+ else Empty
+ else Empty
+ } else Empty
+ }
+
+ /**
+ * Creates an HttpData.FileBytes instance if the given file exists, is readable, non-empty
+ * and the given `length` parameter is non-zero. Otherwise the method returns HttpData.Empty.
+ * A negative `length` value signifies that the respective number of bytes at the end of the
+ * file is to be ommitted, i.e., a value of -10 will select all bytes starting at `offset`
+ * except for the last 10.
+ * If `length` is greater or equal to "file length - offset" all bytes in the file starting at
+ * `offset` are selected.
+ */
+ def fromFile(fileName: String, offset: Long = 0, length: Long = Long.MaxValue) =
+ apply(new File(fileName), offset, length)
+
+ case object Empty extends HttpData {
+ def isEmpty = true
+ def length = 0L
+ def copyToArray(xs: Array[Byte], sourceOffset: Long, targetOffset: Int, span: Int) = ()
+ val toByteArray = Array.empty[Byte]
+ def toByteString = ByteString.empty
+ def +:(other: HttpData) = other
+ override def asString(charset: Charset) = ""
+ }
+
+ sealed abstract class NonEmpty extends HttpData {
+ def isEmpty = false
+ def +:(other: HttpData): NonEmpty =
+ other match {
+ case Empty this
+ case x: CompactNonEmpty Compound(x, this)
+ case Compound(head, tail: CompactNonEmpty) Compound(head, Compound(tail, this))
+ case x: Compound newBuilder.+=(x).+=(this).result().asInstanceOf[Compound]
+ }
+ def toByteArray = {
+ require(length <= Int.MaxValue, "Cannot create a byte array greater than 2GB")
+ val array = Array.ofDim[Byte](length.toInt)
+ copyToArray(array)
+ array
+ }
+ }
+
+ sealed abstract class CompactNonEmpty extends NonEmpty { _: Product
+ override def toString = s"$productPrefix(<$length bytes>)"
+ }
+
+ case class Bytes private[HttpData] (bytes: ByteString) extends CompactNonEmpty {
+ def length = bytes.length
+ def copyToArray(xs: Array[Byte], sourceOffset: Long = 0, targetOffset: Int = 0, span: Int = length.toInt) = {
+ require(sourceOffset >= 0, "sourceOffset must be >= 0 but is " + sourceOffset)
+ if (sourceOffset < length)
+ bytes.iterator.drop(sourceOffset.toInt).copyToArray(xs, targetOffset, span)
+ }
+ def toByteString = bytes
+ }
+
+ case class FileBytes private[HttpData] (fileName: String, offset: Long = 0, length: Long) extends CompactNonEmpty {
+ def copyToArray(xs: Array[Byte], sourceOffset: Long = 0, targetOffset: Int = 0, span: Int = length.toInt) = {
+ require(sourceOffset >= 0, "sourceOffset must be >= 0 but is " + sourceOffset)
+ if (span > 0 && xs.length > 0 && sourceOffset < length) {
+ require(0 <= targetOffset && targetOffset < xs.length, s"start must be >= 0 and <= ${xs.length} but is $targetOffset")
+ val stream = new FileInputStream(fileName)
+ try {
+ stream.skip(offset + sourceOffset)
+ val targetEnd = math.min(xs.length, targetOffset + math.min(span, (length - sourceOffset).toInt))
+ @tailrec def load(ix: Int = targetOffset): Unit =
+ if (ix < targetEnd)
+ stream.read(xs, ix, targetEnd - ix) match {
+ case -1 // file length changed since this FileBytes instance was created
+ case count load(ix + count)
+ }
+ load()
+ } finally stream.close()
+ }
+ }
+ def toByteString = ByteString(toByteArray)
+ }
+
+ case class Compound private[HttpData] (head: CompactNonEmpty, tail: NonEmpty) extends NonEmpty {
+ val length = head.length + tail.length
+ def foreach(f: CompactNonEmpty Unit): Unit = {
+ @tailrec def rec(compound: Compound = this): Unit = {
+ f(compound.head)
+ compound.tail match {
+ case x: CompactNonEmpty f(x)
+ case x: Compound rec(x)
+ }
+ }
+ rec()
+ }
+ def copyToArray(xs: Array[Byte], sourceOffset: Long = 0, targetOffset: Int = 0, span: Int = length.toInt) = {
+ require(sourceOffset >= 0, "sourceOffset must be >= 0 but is " + sourceOffset)
+ if (span > 0 && xs.length > 0 && sourceOffset < length) {
+ require(0 <= targetOffset && targetOffset < xs.length, s"start must be >= 0 and <= ${xs.length} but is $targetOffset")
+ val targetEnd: Int = math.min(xs.length, targetOffset + math.min(span, (length - sourceOffset).toInt))
+ var sCursor: Long = 0
+ var tCursor: Int = targetOffset
+ foreach { current
+ val nextSCursor: Long = sCursor + current.length
+ if (tCursor < targetEnd && nextSCursor > sourceOffset) {
+ val sOffset = -math.min(sCursor - sourceOffset, 0)
+ current.copyToArray(xs,
+ sourceOffset = sOffset,
+ targetOffset = tCursor,
+ span = targetEnd - tCursor)
+ tCursor = math.min(tCursor + current.length - sOffset, Int.MaxValue).toInt
+ }
+ sCursor = nextSCursor
+ }
+ }
+ }
+ override def toString = head.toString + " +: " + tail
+ def toByteString = ByteString(toByteArray)
+ }
+
+ def newBuilder: Builder = new Builder
+
+ class Builder extends scala.collection.mutable.Builder[HttpData, HttpData] {
+ private val b = new VectorBuilder[CompactNonEmpty]
+ private var _byteCount = 0L
+
+ def byteCount: Long = _byteCount
+
+ def +=(x: CompactNonEmpty): this.type = {
+ b += x
+ _byteCount += x.length
+ this
+ }
+
+ def +=(elem: HttpData): this.type =
+ elem match {
+ case Empty this
+ case x: CompactNonEmpty this += x
+ case x: Compound
+ @tailrec def append(current: NonEmpty): this.type =
+ current match {
+ case x: CompactNonEmpty this += x
+ case Compound(head, tail) this += head; append(tail)
+ }
+ append(x)
+ }
+
+ def clear(): Unit = b.clear()
+
+ def result(): HttpData =
+ b.result().foldRight(Empty: HttpData) {
+ case (x, Empty) x
+ case (x, tail: NonEmpty) Compound(x, tail)
+ }
+ }
+}
@@ -16,80 +16,66 @@
package spray.http
-import java.util
-
/**
* Models the entity (aka "body" or "content) of an HTTP message.
*/
sealed trait HttpEntity {
def isEmpty: Boolean
- def buffer: Array[Byte]
- def flatMap(f: HttpBody HttpEntity): HttpEntity
+ def nonEmpty: Boolean = !isEmpty
+ def data: HttpData
+ def flatMap(f: HttpEntity.NonEmpty HttpEntity): HttpEntity
def orElse(other: HttpEntity): HttpEntity
def asString: String
def asString(defaultCharset: HttpCharset): String
- def toOption: Option[HttpBody]
-}
-
-/**
- * Models an empty entity.
- */
-case object EmptyEntity extends HttpEntity {
- def isEmpty: Boolean = true
- val buffer = Array.empty[Byte]
- def flatMap(f: HttpBody HttpEntity): HttpEntity = this
- def orElse(other: HttpEntity): HttpEntity = other
- def asString = ""
- def asString(defaultCharset: HttpCharset) = ""
- def toOption = None
-}
-
-/**
- * Models a non-empty entity. The buffer array is guaranteed to have a size greater than zero.
- * CAUTION: Even though the byte array is directly exposed for performance reasons all instances of this class are
- * assumed to be immutable! spray never modifies the buffer contents after an HttpBody instance has been created.
- * If you modify the buffer contents by writing to the array things WILL BREAK!
- */
-case class HttpBody private (contentType: ContentType, buffer: Array[Byte]) extends HttpEntity {
- def isEmpty: Boolean = false
- def flatMap(f: HttpBody HttpEntity): HttpEntity = f(this)
- def orElse(other: HttpEntity): HttpEntity = this
- def asString = new String(buffer, contentType.charset.nioCharset)
- def asString(defaultCharset: HttpCharset) =
- new String(buffer, contentType.definedCharset.getOrElse(defaultCharset).nioCharset)
- def toOption = Some(this)
-
- override def toString =
- "HttpEntity(" + contentType + ',' + (if (buffer.length > 500) asString.take(500) + "..." else asString) + ')'
-
- override def hashCode = contentType.## * 31 + util.Arrays.hashCode(buffer)
- override def equals(obj: Any) = obj match {
- case x: HttpBody (this eq x) || contentType == x.contentType && util.Arrays.equals(buffer, x.buffer)
- case _ false
- }
-}
-
-object HttpBody {
- private[http] def from(contentType: ContentType, buffer: Array[Byte]): HttpEntity =
- if (buffer.length == 0) EmptyEntity else new HttpBody(contentType, buffer)
+ def toOption: Option[HttpEntity.NonEmpty]
}
object HttpEntity {
- implicit def apply(string: String): HttpEntity =
- apply(ContentTypes.`text/plain(UTF-8)`, string)
-
- implicit def apply(buffer: Array[Byte]): HttpEntity =
- apply(ContentTypes.`application/octet-stream`, buffer)
+ implicit def apply(string: String): HttpEntity = apply(ContentTypes.`text/plain(UTF-8)`, string)
+ implicit def apply(bytes: Array[Byte]): HttpEntity = apply(HttpData(bytes))
+ implicit def apply(data: HttpData): HttpEntity = apply(ContentTypes.`application/octet-stream`, data)
+ def apply(contentType: ContentType, string: String): HttpEntity =
+ if (string.isEmpty) Empty else apply(contentType, HttpData(string, contentType.charset))
+ def apply(contentType: ContentType, bytes: Array[Byte]): HttpEntity = apply(contentType, HttpData(bytes))
+ def apply(contentType: ContentType, data: HttpData): HttpEntity =
+ data match {
+ case x: HttpData.NonEmpty new NonEmpty(contentType, x)
+ case _ Empty
+ }
implicit def flatten(optionalEntity: Option[HttpEntity]): HttpEntity =
optionalEntity match {
case Some(body) body
- case None EmptyEntity
+ case None Empty
}
- def apply(contentType: ContentType, string: String): HttpEntity =
- if (string.isEmpty) EmptyEntity
- else apply(contentType, string.getBytes(contentType.charset.nioCharset))
+ /**
+ * Models an empty entity.
+ */
+ case object Empty extends HttpEntity {
+ def isEmpty = true
+ def data = HttpData.Empty
+ def flatMap(f: HttpEntity.NonEmpty HttpEntity): HttpEntity = this
+ def orElse(other: HttpEntity): HttpEntity = other
+ def asString = ""
+ def asString(defaultCharset: HttpCharset) = ""
+ def toOption = None
+ }
- def apply(contentType: ContentType, buffer: Array[Byte]): HttpEntity = HttpBody.from(contentType, buffer)
+ /**
+ * Models a non-empty entity. The buffer array is guaranteed to have a size greater than zero.
+ * CAUTION: Even though the byte array is directly exposed for performance reasons all instances of this class are
+ * assumed to be immutable! spray never modifies the buffer contents after an HttpEntity.NonEmpty instance has been created.
+ * If you modify the buffer contents by writing to the array things WILL BREAK!
+ */
+ case class NonEmpty private[HttpEntity] (contentType: ContentType, data: HttpData.NonEmpty) extends HttpEntity {
+ def isEmpty = false
+ def flatMap(f: HttpEntity.NonEmpty HttpEntity): HttpEntity = f(this)
+ def orElse(other: HttpEntity): HttpEntity = this
+ def asString = data.asString(contentType.charset)
+ def asString(defaultCharset: HttpCharset) = data.asString(contentType.definedCharset getOrElse defaultCharset)
+ def toOption = Some(this)
+ override def toString =
+ "HttpEntity(" + contentType + ',' + (if (data.length > 500) asString.take(500) + "..." else asString) + ')'
+ }
}
Oops, something went wrong.

0 comments on commit c6f49cc

Please sign in to comment.