Commit 04250fef authored by Ricki Hirner's avatar Ricki Hirner

Refactor exceptions

parent ac0cdc7b
Pipeline #22331059 passed with stage
in 2 minutes 20 seconds
......@@ -59,7 +59,6 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "commons-io:commons-io:2.6"
androidTestImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
androidTestImplementation 'com.android.support.test:runner:1.0.2'
......
......@@ -24,13 +24,13 @@ class DavCollectionTest {
private val mockServer = MockWebServer()
private fun sampleUrl() = mockServer.url("/dav/")
@Before
fun startServer() = mockServer.start()
@After
fun stopServer() = mockServer.shutdown()
/**
* Test sample response for an initial sync-collection report from RFC 6578 3.8.
*/
......@@ -197,7 +197,7 @@ class DavCollectionTest {
collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME).close()
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(507, e.status)
assertEquals(507, e.code)
}
}
......
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4android
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.property.ResourceType
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class DavExceptionTest {
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
private fun sampleUrl() = mockServer.url("/dav/")
@Before
fun startServer() = mockServer.start()
@After
fun stopServer() = mockServer.shutdown()
/**
* Test a large HTML response which has a multi-octet UTF-8 character
* exactly at the cut-off position.
*/
@Test
fun testLargeTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val builder = StringBuilder()
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1, { '*' }))
builder.append("\u03C0") // Pi
val body = builder.toString()
mockServer.enqueue(MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "text/html")
.setBody(body))
try {
dav.propfind(0, ResourceType.NAME).close()
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 404)
assertTrue(e.errors.isEmpty())
assertEquals(
body.substring(0, DavException.MAX_EXCERPT_SIZE-1),
e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1)
)
}
}
@Test
fun testNonTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(403)
.setHeader("Content-Type", "application/octet-stream")
.setBody("12345"))
try {
dav.propfind(0, ResourceType.NAME).close()
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 403)
assertTrue(e.errors.isEmpty())
assertNull(e.responseBody)
}
}
/**
* Test precondition XML element (sample from RFC 4918 16)
*/
@Test
fun testXmlError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val body = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
"<D:error xmlns:D=\"DAV:\">\n" +
" <D:lock-token-submitted>\n" +
" <D:href>/workspace/webdav/</D:href>\n" +
" </D:lock-token-submitted>\n" +
"</D:error>\n"
mockServer.enqueue(MockResponse()
.setResponseCode(423)
.setHeader("Content-Type", "application/xml; charset=\"utf-8\"")
.setBody(body))
try {
dav.propfind(0, ResourceType.NAME).close()
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 423)
assertTrue(e.errors.contains(Property.Name(XmlUtils.NS_WEBDAV, "lock-token-submitted")))
assertEquals(body, e.responseBody)
}
}
}
\ No newline at end of file
......@@ -161,7 +161,7 @@ open class DavResource @JvmOverloads constructor(
properties += GetETag(eTag)
}
val body = response.body() ?: throw HttpException("Received GET response without body", response)
val body = response.body() ?: throw DavException("Received GET response without body", httpResponse = response)
body.contentType()?.let { mimeType ->
properties += GetContentType(mimeType)
}
......@@ -393,7 +393,7 @@ open class DavResource @JvmOverloads constructor(
log.fine("Redirected, new location = $target")
location = target
} else
throw HttpException("Redirected without new Location")
throw DavException("Redirected without new Location")
}
} finally {
response.body()?.close()
......
......@@ -6,7 +6,151 @@
package at.bitfire.dav4android.exception
import at.bitfire.dav4android.Constants
import at.bitfire.dav4android.Property
import at.bitfire.dav4android.XmlUtils
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.Serializable
import java.util.logging.Level
/**
* Signals that an error occurred during a WebDAV-related operation.
*
* This could be a logical error like when a required ETag has not been
* received, but also an explicit HTTP error.
*/
open class DavException @JvmOverloads constructor(
message: String,
ex: Throwable? = null
): Exception(message, ex)
\ No newline at end of file
ex: Throwable? = null,
/**
* An associated HTTP [Response]. Will be closed after evaluation.
*/
httpResponse: Response? = null
): Exception(message, ex), Serializable {
companion object {
const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB
fun isPlainText(type: MediaType) =
type.type() == "text" ||
(type.type() == "application" && type.subtype() in arrayOf("html", "xml"))
}
var request: Request? = null
private set
var requestBody: String? = null
private set
/**
* Associated HTTP [Response]. Do not access [Response.body] because it will be closed.
* Use [responseBody] instead.
*/
val response: Response?
/**
* Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP response body was textual content.
*/
var responseBody: String? = null
private set
/**
* Precondition/postcondition XML elements which have been found in the XML response.
*/
var errors: Set<Property.Name> = setOf()
private set
init {
if (httpResponse != null) {
response = httpResponse
try {
request = httpResponse.request()
request?.body()?.let { body ->
body.contentType()?.let {
if (isPlainText(it)) {
val buffer = Buffer()
body.writeTo(buffer)
val baos = ByteArrayOutputStream()
buffer.writeTo(baos)
requestBody = baos.toString(it.charset(Charsets.UTF_8)!!.name())
}
}
}
} catch (e: Exception) {
Constants.log.log(Level.WARNING, "Couldn't read HTTP request", e)
requestBody = "Couldn't read HTTP request: ${e.message}"
}
try {
// save response body excerpt
if (httpResponse.body()?.source() != null) {
// response body has a source
httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong())?.use { body ->
body.contentType()?.let {
if (isPlainText(it))
responseBody = body.string()
}
}
httpResponse.body()?.use { body ->
body.contentType()?.let {
if (it.type() in arrayOf("application", "text") && it.subtype() == "xml") {
// look for precondition/postcondition XML elements
try {
val parser = XmlUtils.newPullParser()
parser.setInput(body.charStream())
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
if (parser.namespace == XmlUtils.NS_WEBDAV && parser.name == "error")
errors = parseXmlErrors(parser)
eventType = parser.next()
}
} catch (e: XmlPullParserException) {
Constants.log.log(Level.WARNING, "Couldn't parse XML response", e)
}
}
}
}
}
} catch (e: IOException) {
Constants.log.log(Level.WARNING, "Couldn't read HTTP response", e)
responseBody = "Couldn't read HTTP response: ${e.message}"
} finally {
httpResponse.body()?.close()
}
} else
response = null
}
private fun parseXmlErrors(parser: XmlPullParser): Set<Property.Name> {
val names = mutableSetOf<Property.Name>()
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
names += Property.Name(parser.namespace, parser.name)
eventType = parser.next()
}
return names
}
}
\ No newline at end of file
......@@ -6,112 +6,24 @@
package at.bitfire.dav4android.exception
import at.bitfire.dav4android.Constants
import okhttp3.Response
import okio.Buffer
import org.apache.commons.io.IOUtils
import java.io.*
open class HttpException: Exception, Serializable {
companion object {
// don't dump more than 20 kB
private const val MAX_DUMP_SIZE = 20*1024
}
val status: Int
val request: String?
val response: String?
constructor(message: String?): super(message) {
status = -1
request = null
response = null
}
constructor(status: Int, message: String?): super("$status $message") {
this.status = status
request = null
response = null
}
constructor(message: String, response: Response): this(message) {
// TODO
}
/**
* Brings [response] into an readable format. Reads and closes the [response] body.
*/
constructor(response: Response): super("${response.code()} ${response.message()}") {
status = response.code()
/* As we don't know the media type and character set of request and response body,
only printable ASCII characters will be shown in clear text. Other octets will
be shown as "[xx]" where xx is the hex value of the octet.
*/
// format request
val request = response.request()
var formatted = StringBuilder()
formatted.append(request.method()).append(" ").append(request.url().encodedPath()).append("\n")
for ((name,values) in request.headers().toMultimap())
for (value in values)
formatted.append(name).append(": ").append(value).append("\n")
request.body()?.let {
formatted.append("Content-Type: ").append(it.contentType()).append("\n")
formatted.append("Content-Length: ").append(it.contentLength()).append("\n")
try {
val buffer = Buffer()
it.writeTo(buffer)
val baos = ByteArrayOutputStream()
formatByteStream(buffer.inputStream(), baos)
formatted.append("\n").append(baos.toString())
} catch (e: IOException) {
Constants.log.warning("Couldn't read request body")
}
}
this.request = formatted.toString()
/**
* Signals that a HTTP error was sent by the server.
*/
open class HttpException: DavException {
// format response
formatted = StringBuilder()
formatted.append(response.protocol()).append(" ").append(response.code()).append(" ").append(response.message()).append("\n")
for ((name,values) in response.headers().toMultimap())
for (value in values)
formatted.append(name).append(": ").append(value).append("\n")
var code: Int
response.body()?.use {
formatted.append("[body length: ").append(it.contentLength()).append(" bytes]").append("\n")
try {
val baos = ByteArrayOutputStream()
formatByteStream(it.byteStream(), baos)
formatted.append("\n").append(baos.toString())
} catch(e: IOException) {
Constants.log.warning("Couldn't read response body")
}
}
this.response = formatted.toString()
constructor(response: Response): super(
"HTTP ${response.code()} ${response.message()}",
httpResponse = response
) {
code = response.code()
}
private fun formatByteStream(input: InputStream, output: ByteArrayOutputStream) {
OutputStreamWriter(output).use { writer ->
var b = input.read()
var written = 0
while (b != -1) {
if (written++ >= MAX_DUMP_SIZE) {
writer.append("[…]")
break
}
when (b) {
'\t'.toInt() -> writer.append('↦')
'\r'.toInt() -> writer.append('↵')
'\n'.toInt() -> writer.append('\n')
in 0x20..0x7E -> writer.write(b) // printable ASCII
else -> writer.append("[${String.format("%02x", b)}]")
}
b = input.read()
}
}
constructor(code: Int, message: String?): super("HTTP $code $message") {
this.code = code
}
}
......@@ -17,22 +17,22 @@ class HttpExceptionTest {
@Test
fun testHttpFormatting() {
val request = Request.Builder()
.post(RequestBody.create(null, "REQUEST\nBODY" + 5.toChar()))
.post(RequestBody.create(MediaType.parse("text/something"), "REQUEST\nBODY"))
.url("http://example.com")
.build()
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_2)
.protocol(Protocol.HTTP_1_1)
.code(500)
.message(responseMessage)
.body(ResponseBody.create(null, 0x99.toChar() + "SERVER\r\nRESPONSE"))
.body(ResponseBody.create(MediaType.parse("text/something-other"), "SERVER\r\nRESPONSE"))
.build()
val e = HttpException(response)
assertTrue(e.message!!.contains("500"))
assertTrue(e.message!!.contains(responseMessage))
assertTrue(e.request!!.contains("REQUEST\nBODY[05]"))
assertTrue(e.response!!.contains("[99]SERVER↵\nRESPONSE"))
assertTrue(e.requestBody!!.contains("REQUEST\nBODY"))
assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE"))
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment