From c6f49cc31b70b152413f1030b55731b5b38132a1 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 30 Aug 2013 16:36:14 +0200 Subject: [PATCH] ! http: introduce `HttpData` model replacing the byte array in `HttpBody` 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. --- project/Build.scala | 1 + .../src/main/scala/spray/http/HttpData.scala | 218 ++++++++++++++++++ .../main/scala/spray/http/HttpEntity.scala | 102 ++++---- .../main/scala/spray/http/HttpMessage.scala | 34 ++- .../scala/spray/http/MessagePredicate.scala | 6 +- .../src/main/scala/spray/http/Rendering.scala | 106 +++++++-- .../test/scala/spray/http/HttpDataSpec.scala | 60 +++++ .../http/HttpModelSerializabilitySpec.scala | 2 +- 8 files changed, 431 insertions(+), 98 deletions(-) create mode 100644 spray-http/src/main/scala/spray/http/HttpData.scala create mode 100644 spray-http/src/test/scala/spray/http/HttpDataSpec.scala diff --git a/project/Build.scala b/project/Build.scala index 121fc2517b..257f876744 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -71,6 +71,7 @@ object Build extends Build with DocSupport { .settings(osgiSettings(exports = Seq("spray.http")): _*) .settings(libraryDependencies ++= compile(parboiled) ++ + provided(akkaActor) ++ test(specs2) ) diff --git a/spray-http/src/main/scala/spray/http/HttpData.scala b/spray-http/src/main/scala/spray/http/HttpData.scala new file mode 100644 index 0000000000..4714eb1792 --- /dev/null +++ b/spray-http/src/main/scala/spray/http/HttpData.scala @@ -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) + } + } +} \ No newline at end of file diff --git a/spray-http/src/main/scala/spray/http/HttpEntity.scala b/spray-http/src/main/scala/spray/http/HttpEntity.scala index 360d9f0c90..5e610b5a36 100644 --- a/spray-http/src/main/scala/spray/http/HttpEntity.scala +++ b/spray-http/src/main/scala/spray/http/HttpEntity.scala @@ -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) + ')' + } } diff --git a/spray-http/src/main/scala/spray/http/HttpMessage.scala b/spray-http/src/main/scala/spray/http/HttpMessage.scala index a9cebe163e..c25c9ccae8 100644 --- a/spray-http/src/main/scala/spray/http/HttpMessage.scala +++ b/spray-http/src/main/scala/spray/http/HttpMessage.scala @@ -16,8 +16,6 @@ package spray.http -import java.util -import java.nio.charset.Charset import scala.annotation.tailrec import scala.reflect.{ classTag, ClassTag } import HttpHeaders._ @@ -117,7 +115,7 @@ sealed abstract class HttpMessage extends HttpMessageStart with HttpMessageEnd { case class HttpRequest(method: HttpMethod = HttpMethods.GET, uri: Uri = Uri./, headers: List[HttpHeader] = Nil, - entity: HttpEntity = EmptyEntity, + entity: HttpEntity = HttpEntity.Empty, protocol: HttpProtocol = HttpProtocols.`HTTP/1.1`) extends HttpMessage with HttpRequestPart { require(!uri.isEmpty, "An HttpRequest must not have an empty Uri") @@ -246,7 +244,7 @@ case class HttpRequest(method: HttpMethod = HttpMethods.GET, * Immutable HTTP response model. */ case class HttpResponse(status: StatusCode = StatusCodes.OK, - entity: HttpEntity = EmptyEntity, + entity: HttpEntity = HttpEntity.Empty, headers: List[HttpHeader] = Nil, protocol: HttpProtocol = HttpProtocols.`HTTP/1.1`) extends HttpMessage with HttpResponsePart { type Self = HttpResponse @@ -268,18 +266,7 @@ case class HttpResponse(status: StatusCode = StatusCodes.OK, /** * Instance of this class represent the individual chunks of a chunked HTTP message (request or response). */ -case class MessageChunk(body: Array[Byte], extension: String) extends HttpRequestPart with HttpResponsePart { - require(body.length > 0, "MessageChunk must not have empty body") - def bodyAsString: String = bodyAsString(HttpCharsets.`ISO-8859-1`.nioCharset) - def bodyAsString(charset: HttpCharset): String = bodyAsString(charset.nioCharset) - def bodyAsString(charset: Charset): String = if (body.isEmpty) "" else new String(body, charset) - def bodyAsString(charset: String): String = if (body.isEmpty) "" else new String(body, charset) - override def hashCode = extension.## * 31 + util.Arrays.hashCode(body) - override def equals(obj: Any) = obj match { - case x: MessageChunk ⇒ (this eq x) || extension == x.extension && util.Arrays.equals(body, x.body) - case _ ⇒ false - } -} +case class MessageChunk(data: HttpData.NonEmpty, extension: String) extends HttpRequestPart with HttpResponsePart object MessageChunk { import HttpCharsets._ @@ -288,11 +275,18 @@ object MessageChunk { def apply(body: String, charset: HttpCharset): MessageChunk = apply(body, charset, "") def apply(body: String, extension: String): MessageChunk = - apply(body, `ISO-8859-1`, extension) + apply(body, `UTF-8`, extension) def apply(body: String, charset: HttpCharset, extension: String): MessageChunk = - apply(body.getBytes(charset.nioCharset), extension) - def apply(body: Array[Byte]): MessageChunk = - apply(body, "") + apply(HttpData(body, charset), extension) + def apply(bytes: Array[Byte]): MessageChunk = + apply(HttpData(bytes)) + def apply(data: HttpData): MessageChunk = + apply(data, "") + def apply(data: HttpData, extension: String): MessageChunk = + data match { + case x: HttpData.NonEmpty ⇒ new MessageChunk(x, extension) + case _ ⇒ throw new IllegalArgumentException("Cannot create MessageChunk with empty data") + } } case class ChunkedRequestStart(request: HttpRequest) extends HttpMessageStart with HttpRequestPart { diff --git a/spray-http/src/main/scala/spray/http/MessagePredicate.scala b/spray-http/src/main/scala/spray/http/MessagePredicate.scala index 6116e0d91b..2171b215b7 100644 --- a/spray-http/src/main/scala/spray/http/MessagePredicate.scala +++ b/spray-http/src/main/scala/spray/http/MessagePredicate.scala @@ -37,15 +37,15 @@ object MessagePredicate { def isRequest = apply(_.isRequest) def isResponse = apply(_.isResponse) - def minEntitySize(minSize: Int) = apply(_.entity.buffer.length >= minSize) + def minEntitySize(minSize: Int) = apply(_.entity.data.length >= minSize) def responseStatus(f: StatusCode ⇒ Boolean) = apply { case x: HttpResponse ⇒ f(x.status) case _: HttpRequest ⇒ false } def isCompressible: MessagePredicate = apply { _.entity match { - case HttpBody(contentType, _) ⇒ contentType.mediaType.compressible - case EmptyEntity ⇒ false + case HttpEntity.NonEmpty(contentType, _) ⇒ contentType.mediaType.compressible + case _ ⇒ false } } } \ No newline at end of file diff --git a/spray-http/src/main/scala/spray/http/Rendering.scala b/spray-http/src/main/scala/spray/http/Rendering.scala index f63aa7fc22..be8a9bbdd8 100644 --- a/spray-http/src/main/scala/spray/http/Rendering.scala +++ b/spray-http/src/main/scala/spray/http/Rendering.scala @@ -19,6 +19,7 @@ package spray.http import scala.annotation.tailrec import scala.collection.LinearSeq +import akka.util.{ ByteString, ByteStringBuilder } import spray.http.parser.{ CharUtils, CharPredicate } import spray.util._ @@ -64,8 +65,8 @@ object Renderer { implicit object StringRenderer extends Renderer[String] { def render[R <: Rendering](r: R, value: String): r.type = r ~~ value } - implicit object BytesRenderer extends Renderer[Array[Byte]] { - def render[R <: Rendering](r: R, value: Array[Byte]): r.type = r ~~ value + implicit object HttpDataRenderer extends Renderer[HttpData] { + def render[R <: Rendering](r: R, value: HttpData): r.type = r ~~ value } implicit object CharsRenderer extends Renderer[Array[Char]] { def render[R <: Rendering](r: R, value: Array[Char]): r.type = r ~~ value @@ -111,6 +112,7 @@ object Renderer { trait Rendering { def ~~(char: Char): this.type def ~~(bytes: Array[Byte]): this.type + def ~~(data: HttpData): this.type def ~~(f: Float): this.type = this ~~ f.toString def ~~(d: Double): this.type = this ~~ d.toString @@ -199,26 +201,98 @@ class StringRendering extends Rendering { if (ix < bytes.length) { this ~~ bytes(ix).asInstanceOf[Char]; rec(ix + 1) } else this rec() } - + def ~~(data: HttpData): this.type = this ~~ data.toByteArray def get: String = sb.toString } -class ByteArrayRendering(sizeHint: Int = 32) extends Rendering { - import java.util.Arrays._ - private[this] var array = new Array[Byte](sizeHint) - private[this] var cursor = 0 - def ~~(char: Char): this.type = put(char.toByte) +abstract class ByteArrayBasedRendering(sizeHint: Int) extends Rendering { + protected var array = new Array[Byte](sizeHint) + protected var size = 0 + + def ~~(char: Char): this.type = { + val oldSize = growBy(1) + array(oldSize) = char.toByte + this + } + + def ~~(bytes: Array[Byte]): this.type = { + if (bytes.length > 0) { + val oldSize = growBy(bytes.length) + System.arraycopy(bytes, 0, array, oldSize, bytes.length) + } + this + } + + def ~~(data: HttpData): this.type = { + if (data.nonEmpty) { + if (data.length <= Int.MaxValue) { + val oldSize = growBy(data.length.toInt) + data.copyToArray(array, targetOffset = oldSize) + } else sys.error("Cannot create byte array greater than 2GB in size") + } + this + } + + private def growBy(delta: Int): Int = { + val oldSize = size + val neededSize = oldSize.toLong + delta + if (array.length < neededSize) + if (neededSize < Int.MaxValue) { + val newLen = math.min(math.max(array.length.toLong * 2, neededSize), Int.MaxValue).toInt + val newArray = new Array[Byte](newLen) + System.arraycopy(array, 0, newArray, 0, array.length) + array = newArray + } else sys.error("Cannot create byte array greater than 2GB in size") + size = neededSize.toInt + oldSize + } +} + +class ByteArrayRendering(sizeHint: Int) extends ByteArrayBasedRendering(sizeHint) { + def get: Array[Byte] = + if (size == array.length) array + else java.util.Arrays.copyOfRange(array, 0, size) +} + +class ByteStringRendering(sizeHint: Int) extends ByteArrayBasedRendering(sizeHint) { + def get: ByteString = akka.spray.createByteStringUnsafe(array, 0, size) +} + +class HttpDataRendering(rawBytesSizeHint: Int) extends Rendering { + private[this] val bsb = new ByteStringBuilder + private[this] val hdb = HttpData.newBuilder + + bsb.sizeHint(rawBytesSizeHint) + + def ~~(char: Char): this.type = { + bsb.putByte(char.toByte) + this + } + def ~~(bytes: Array[Byte]): this.type = { - if (cursor + bytes.length > array.length) array = copyOf(array, math.max(cursor + bytes.length, array.length * 2)) - System.arraycopy(bytes, 0, array, cursor, bytes.length) - cursor += bytes.length + bsb.putBytes(bytes) this } - def put(byte: Byte): this.type = { - if (cursor == array.length) array = copyOf(array, cursor * 2) - array(cursor) = byte - cursor += 1 + + def ~~(data: HttpData): this.type = { + data match { + case HttpData.Empty ⇒ + case HttpData.Bytes(bytes) ⇒ bsb ++= bytes + case x ⇒ + closeBsb() + hdb += x + } this } - def get: Array[Byte] = if (cursor == array.length) array else copyOfRange(array, 0, cursor) + + def get: HttpData = { + closeBsb() + hdb.result() + } + + private def closeBsb(): Unit = + if (bsb.length > 0) { + hdb += HttpData(bsb.result()) + bsb.clear() + } } diff --git a/spray-http/src/test/scala/spray/http/HttpDataSpec.scala b/spray-http/src/test/scala/spray/http/HttpDataSpec.scala new file mode 100644 index 0000000000..2a3ec13eb9 --- /dev/null +++ b/spray-http/src/test/scala/spray/http/HttpDataSpec.scala @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2011-2013 spray.io + * + * 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.File +import org.parboiled.common.FileUtils +import org.specs2.mutable.Specification + +class HttpDataSpec extends Specification { + + "HttpData" should { + "properly support `copyToArray`" in { + "HttpData.Bytes" in { + test(HttpData("Ken sent me!")) + } + "HttpData.FileBytes" in { + val file = File.createTempFile("spray-http_HttpDataSpec", ".txt") + try { + FileUtils.writeAllText("Ken sent me!", file) + test(HttpData(file)) + + } finally file.delete + } + "HttpData.Compound" in { + test(HttpData("Ken") +: HttpData(" sent") +: HttpData(" me") +: HttpData("!")) + } + } + } + + def test(data: HttpData) = { + testCopyToArray(data, sourceOffset = 0, targetOffset = 0, span = 12) === "Ken sent me!xxxx" + testCopyToArray(data, sourceOffset = 0, targetOffset = 2, span = 12) === "xxKen sent me!xx" + testCopyToArray(data, sourceOffset = 0, targetOffset = 4, span = 12) === "xxxxKen sent me!" + testCopyToArray(data, sourceOffset = 0, targetOffset = 6, span = 12) === "xxxxxxKen sent m" + testCopyToArray(data, sourceOffset = 2, targetOffset = 0, span = 12) === "n sent me!xxxxxx" + testCopyToArray(data, sourceOffset = 8, targetOffset = 0, span = 12) === " me!xxxxxxxxxxxx" + testCopyToArray(data, sourceOffset = 8, targetOffset = 10, span = 2) === "xxxxxxxxxx mxxxx" + testCopyToArray(data, sourceOffset = 8, targetOffset = 10, span = 8) === "xxxxxxxxxx me!xx" + } + + def testCopyToArray(data: HttpData, sourceOffset: Long, targetOffset: Int, span: Int): String = { + val array = "xxxxxxxxxxxxxxxx".getBytes + data.copyToArray(array, sourceOffset, targetOffset, span) + new String(array) + } +} \ No newline at end of file diff --git a/spray-http/src/test/scala/spray/http/HttpModelSerializabilitySpec.scala b/spray-http/src/test/scala/spray/http/HttpModelSerializabilitySpec.scala index 9edc2c754c..8b48719856 100644 --- a/spray-http/src/test/scala/spray/http/HttpModelSerializabilitySpec.scala +++ b/spray-http/src/test/scala/spray/http/HttpModelSerializabilitySpec.scala @@ -29,7 +29,7 @@ class HttpModelSerializabilitySpec extends Specification { HttpRequest(uri = Uri("/test?blub=28&x=5+3")) must beSerializable } "with content type" in { - HttpRequest().withEntity(HttpEntity(ContentTypes.`application/json`, Array.empty[Byte])) must beSerializable + HttpRequest().withEntity(HttpEntity(ContentTypes.`application/json`, HttpData.Empty)) must beSerializable } "with accepted media types" in { HttpRequest().withHeaders(Accept(MediaTypes.`application/json`)) must beSerializable