Skip to content

Improve DavException construction #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ tasks.withType<DokkaTask>().configureEach {

dependencies {
api(libs.okhttp)
api(libs.spotbugs.annotations)
api(libs.xpp3)

testImplementation(libs.junit4)
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ dokka = "2.0.0"
junit4 = "4.13.2"
kotlin = "2.2.0"
okhttpVersion = "5.1.0"
spotbugs = "4.9.4"
xpp3Version = "1.1.6"

[libraries]
junit4 = { module = "junit:junit", version.ref = "junit4" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttpVersion" }
spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs" }
xpp3 = { module = "org.ogce:xpp3", version.ref = "xpp3Version" }

[plugins]
Expand Down
42 changes: 14 additions & 28 deletions src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.exception.ConflictException
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.ForbiddenException
import at.bitfire.dav4jvm.exception.GoneException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.exception.PreconditionFailedException
Expand All @@ -39,7 +40,6 @@ import java.io.EOFException
import java.io.IOException
import java.io.Reader
import java.io.StringWriter
import java.net.HttpURLConnection
import java.util.logging.Level
import java.util.logging.Logger
import at.bitfire.dav4jvm.Response as DavResponse
Expand Down Expand Up @@ -666,34 +666,20 @@ open class DavResource @JvmOverloads constructor(
*
* @throws HttpException in case of an HTTP error
*/
protected fun checkStatus(response: Response) =
checkStatus(response.code, response.message, response)

/**
* Checks the status from an HTTP response and throws an exception in case of an error.
*
* @throws HttpException (with XML error names, if available) in case of an HTTP error
*/
private fun checkStatus(code: Int, message: String?, response: Response?) {
if (code / 100 == 2)
protected fun checkStatus(response: Response) {
if (response.code / 100 == 2)
// everything OK
return

throw when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED ->
if (response != null) UnauthorizedException(response) else UnauthorizedException(message)
HttpURLConnection.HTTP_FORBIDDEN ->
if (response != null) ForbiddenException(response) else ForbiddenException(message)
HttpURLConnection.HTTP_NOT_FOUND ->
if (response != null) NotFoundException(response) else NotFoundException(message)
HttpURLConnection.HTTP_CONFLICT ->
if (response != null) ConflictException(response) else ConflictException(message)
HttpURLConnection.HTTP_PRECON_FAILED ->
if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message)
HttpURLConnection.HTTP_UNAVAILABLE ->
if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message)
else ->
if (response != null) HttpException(response) else HttpException(code, message)
throw when (response.code) {
401 -> UnauthorizedException(response)
403 -> ForbiddenException(response)
404 -> NotFoundException(response)
409 -> ConflictException(response)
410 -> GoneException(response)
412 -> PreconditionFailedException(response)
503 -> ServiceUnavailableException(response)
else -> HttpException(response)
}
}

Expand Down Expand Up @@ -739,7 +725,7 @@ open class DavResource @JvmOverloads constructor(
*/
fun assertMultiStatus(response: Response) {
if (response.code != HTTP_MULTISTATUS)
throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response)
throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", response = response)

response.peekBody(XML_SIGNATURE.size.toLong()).use { body ->
body.contentType()?.let { mimeType ->
Expand All @@ -760,7 +746,7 @@ open class DavResource @JvmOverloads constructor(
logger.log(Level.WARNING, "Couldn't scan for XML signature", e)
}

throw DavException("Received non-XML 207 Multi-Status", httpResponse = response)
throw DavException("Received non-XML 207 Multi-Status", response = response)
}
} ?: logger.warning("Received 207 Multi-Status without Content-Type, assuming XML")
}
Expand Down
6 changes: 4 additions & 2 deletions src/main/kotlin/at/bitfire/dav4jvm/Error.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import java.io.Serializable
* name. Subclassed errors may have more specific information available.
*
* At the moment, there is no logic for subclassing errors.
*
* @param name property name for the XML error element
*/
class Error(
val name: Property.Name
data class Error(
val name: Property.Name
): Serializable {

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
package at.bitfire.dav4jvm.exception

import okhttp3.Response
import java.net.HttpURLConnection

class ConflictException: HttpException {

constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message)
constructor(response: Response) : super(response) {
if (response.code != 409)
throw IllegalArgumentException("Status code must be 409")
}

}
}
222 changes: 115 additions & 107 deletions src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,140 +13,148 @@ package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.Error
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.exception.DavException.Companion.MAX_EXCERPT_SIZE
import okhttp3.MediaType
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.lang.Long.min
import java.util.logging.Level
import java.util.logging.Logger
import java.io.StringReader
import javax.annotation.WillNotClose
import kotlin.math.min

/**
* 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.
* This could be a logical error like when a required ETag has not been received, but also an explicit HTTP error
* (usually with a subclass of [HttpException], which in turn extends this class).
*
* Often, HTTP response bodies contain valuable information about the error in text format (for instance, a HTML page
* that contains details about the error) and/or as `<DAV:error>` XML elements. However, such response bodies
* are sometimes very large.
*
* So, if possible and useful, a size-limited excerpt of the associated HTTP request and response can be
* attached and subsequently included in application-level debug info or shown to the user.
*
* Note: [Exception] is serializable, so objects of this class must contain only serializable objects.
*
* @param statusCode status code of associated HTTP response
* @param requestExcerpt cached excerpt of associated HTTP request body
* @param responseExcerpt cached excerpt of associated HTTP response body
* @param errors precondition/postcondition XML elements which have been found in the XML response
*/
open class DavException @JvmOverloads constructor(
message: String,
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"))

}

private val logger
get() = Logger.getLogger(javaClass.name)

var request: String? = null
message: String? = null,
cause: Throwable? = null,
statusCode: Int? = null,
requestExcerpt: String? = null,
responseExcerpt: String? = null,
errors: List<Error> = emptyList()
): Exception(message, cause) {

var statusCode: Int? = statusCode
private set

/**
* Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP request body was textual content and could be read again.
*/
var requestBody: String? = null
var requestExcerpt: String? = requestExcerpt
private set

val response: String?
var responseExcerpt: String? = responseExcerpt
private set

/**
* Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP response body was textual content.
*/
var responseBody: String? = null
var errors: List<Error> = errors
private set

/**
* Precondition/postcondition XML elements which have been found in the XML response.
* Takes the request, response and errors from a given HTTP response.
*
* @param response response to extract status code and request/response excerpt from (if possible)
* @param message optional exception message
* @param cause optional exception cause
*/
var errors: List<Error> = listOf()
private set
constructor(
message: String?,
@WillNotClose response: Response,
cause: Throwable? = null
) : this(message, cause) {
// extract status code
statusCode = response.code

// extract request body if it's text
val request = response.request
val requestExcerptBuilder = StringBuilder(
"${request.method} ${request.url}"
)
request.body?.let { requestBody ->
if (requestBody.contentType()?.isText() == true) {
// Unfortunately Buffer doesn't have a size limit.
// However large bodies are usually streaming/one-shot away.
val buffer = Buffer()
requestBody.writeTo(buffer)

ByteArrayOutputStream().use { baos ->
buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong()))
requestExcerptBuilder
.append("\n\n")
.append(baos.toString())
}
} else
requestExcerptBuilder.append("\n\n<request body>")
}
requestExcerpt = requestExcerptBuilder.toString()

// extract response body if it's text
val mimeType = response.body.contentType()
val responseBody =
if (mimeType?.isText() == true)
try {
response.peekBody(MAX_EXCERPT_SIZE.toLong()).string()
} catch (_: Exception) {
// response body not available anymore, probably already consumed / closed
null
}
else
null
responseExcerpt = responseBody

// get XML errors from request body excerpt
if (mimeType?.isXml() == true && responseBody != null)
errors = extractErrors(responseBody)
}

private fun extractErrors(xml: String): List<Error> {
try {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader(xml))

var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
if (parser.propertyName() == Error.NAME)
return Error.parseError(parser)
eventType = parser.next()
}
} catch (_: XmlPullParserException) {
// Couldn't parse XML, either invalid or maybe it wasn't even XML
}

return emptyList()
}

init {
if (httpResponse != null) {
response = httpResponse.toString()

try {
request = httpResponse.request.toString()
companion object {

httpResponse.request.body?.let { body ->
body.contentType()?.let { type ->
if (isPlainText(type)) {
val buffer = Buffer()
body.writeTo(buffer)
/**
* maximum size of extracted response body
*/
const val MAX_EXCERPT_SIZE = 20*1024

val baos = ByteArrayOutputStream()
buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong()))
private fun MediaType.isText() =
type == "text" ||
(type == "application" && subtype in arrayOf("html", "xml"))

requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name())
}
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't read HTTP request", e)
requestBody = "Couldn't read HTTP request: ${e.message}"
}
private fun MediaType.isXml() =
type in arrayOf("application", "text") && subtype == "xml"

try {
// save response body excerpt
if (httpResponse.body?.source() != null) {
// response body has a source

httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body ->
body.contentType()?.let { mimeType ->
if (isPlainText(mimeType))
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.propertyName() == Error.NAME)
errors = Error.parseError(parser)
eventType = parser.next()
}
} catch (e: XmlPullParserException) {
logger.log(Level.WARNING, "Couldn't parse XML response", e)
}
}
}
}
}
} catch (e: IOException) {
logger.log(Level.WARNING, "Couldn't read HTTP response", e)
responseBody = "Couldn't read HTTP response: ${e.message}"
} finally {
httpResponse.body?.close()
}
} else
response = null
}

}
Loading
Loading