Improve REST layer user experience

This commit is contained in:
2020-05-16 17:17:55 +02:00
parent 6a208428c9
commit bb4ef60007
5 changed files with 73 additions and 77 deletions

View File

@@ -1,15 +1,8 @@
package io.bartek.ttsserver.core.web.dto
import org.json.JSONObject
import java.util.*
data class BaseDTO(val text: String, val language: Locale) : DTO() {
companion object {
fun fromJSON(json: String) = JSONObject(json).let {root ->
val language = root.nullableString("language") ?.let { Locale(it) } ?: Locale.US
val text = root.requiredString("text")
BaseDTO(text, language)
}
}
class BaseDTO(json: String) : DTO(json) {
val language = nullableObject("language", Locale.US, { Locale(it) }, { it.toString() })
val text = requiredString("text")
}

View File

@@ -1,16 +1,27 @@
package io.bartek.ttsserver.core.web.dto
import fi.iki.elonen.NanoHTTPD.Response
import fi.iki.elonen.NanoHTTPD.ResponseException
import io.bartek.ttsserver.core.web.exception.WebException
import org.json.JSONObject
abstract class DTO {
companion object {
fun JSONObject.requiredString(key: String) = this.nullableString(key)
?: throw ResponseException(Response.Status.BAD_REQUEST, "")
abstract class DTO(json: String) : JSONObject(json) {
val json: String
get() = toString()
protected fun requiredString(key: String) = this.optString(key)
.takeIf { it.isNotBlank() }
?: throw WebException(Response.Status.BAD_REQUEST, "The '$key' field is required")
fun JSONObject.nullableString(key: String) = this.optString(key)
.takeIf { it.isNotBlank() }
}
protected fun nullableInt(key: String, default: Int) = this.optInt(key, default)
.also { put(key, it) }
protected fun <T> nullableObject(
key: String,
default: T,
deserializer: (String) -> T,
serializer: (T) -> String
): T = this.optString(key)
.takeIf { it.isNotBlank() }
?.let { deserializer(it) }
?: default.also { put(key, serializer(it)) }
}

View File

@@ -1,18 +1,10 @@
package io.bartek.ttsserver.core.web.dto
import org.json.JSONObject
import java.util.*
data class SonosDTO(val text: String, val language: Locale, val zone: String, val volume: Int) :
DTO() {
companion object {
fun fromJSON(json: String) = JSONObject(json).let { root ->
val language = root.nullableString("language") ?.let { Locale(it) } ?: Locale.US
val text = root.requiredString("text")
val zone = root.requiredString("zone")
val volume = root.optInt("volume", 50)
SonosDTO(text, language, zone, volume)
}
}
class SonosDTO(json: String) : DTO(json) {
val language = nullableObject("language", Locale.US, { Locale(it) }, { it.toString() })
val text = requiredString("text")
val zone = requiredString("zone")
val volume = nullableInt("volume", 50)
}

View File

@@ -0,0 +1,12 @@
package io.bartek.ttsserver.core.web.exception
import fi.iki.elonen.NanoHTTPD.Response
import org.json.JSONObject
class WebException(val status: Response.Status, message: String? = null) : Exception(message) {
val json: String
get() = message?.takeIf { it.isNotBlank() }
?.let { JSONObject().put("message", it).toString() }
?: ""
}

View File

@@ -7,9 +7,6 @@ import android.net.Uri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.Response.Status.*
import io.bartek.ttsserver.ui.preference.PreferenceKey
import io.bartek.ttsserver.service.foreground.ForegroundService
import io.bartek.ttsserver.service.state.ServiceState
import io.bartek.ttsserver.core.sonos.queue.SonosQueue
import io.bartek.ttsserver.core.tts.engine.TTSEngine
import io.bartek.ttsserver.core.tts.status.TTSStatus
@@ -17,6 +14,10 @@ import io.bartek.ttsserver.core.web.dto.BaseDTO
import io.bartek.ttsserver.core.web.dto.SonosDTO
import io.bartek.ttsserver.core.web.endpoint.Endpoint
import io.bartek.ttsserver.core.web.endpoint.EndpointMatcher
import io.bartek.ttsserver.core.web.exception.WebException
import io.bartek.ttsserver.service.foreground.ForegroundService
import io.bartek.ttsserver.service.state.ServiceState
import io.bartek.ttsserver.ui.preference.PreferenceKey
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
@@ -37,10 +38,12 @@ class WebServer(
return dispatch(it)
}
throw ResponseException(BAD_REQUEST, "")
throw WebException(BAD_REQUEST, "Unknown error")
} catch (e: WebException) {
return newFixedLengthResponse(e.status, MIME_JSON, e.json)
} catch (e: Exception) {
return newFixedLengthResponse(INTERNAL_ERROR, MIME_PLAINTEXT, e.toString())
}
catch (e: ResponseException) { throw e }
catch (e: Exception) { throw ResponseException(INTERNAL_ERROR, e.toString(), e) }
}
private fun dispatch(it: IHTTPSession): Response {
@@ -49,37 +52,33 @@ class WebServer(
Endpoint.WAVE -> wave(it)
Endpoint.SONOS -> sonos(it)
Endpoint.SONOS_CACHE -> sonosCache(it)
Endpoint.UNKNOWN -> throw ResponseException(NOT_FOUND, "")
Endpoint.UNKNOWN -> throw WebException(NOT_FOUND, "")
}
}
private fun assertThatTTSIsReady() {
if (tts.status != TTSStatus.READY) {
throw ResponseException(NOT_ACCEPTABLE, "Server is not ready yet")
throw WebException(NOT_ACCEPTABLE, "Server is not ready yet")
}
}
private fun say(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_SAY_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
throw WebException(NOT_FOUND, "")
}
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
throw WebException(METHOD_NOT_ALLOWED, "Only POST methods are allowed")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
throw WebException(BAD_REQUEST, "The only accepted format is JSON")
}
val (text, language) = extractBody(session) {
BaseDTO.fromJSON(
it
)
}
val dto = extractBody(session) { BaseDTO(it) }
tts.performTTS(text, language)
return newFixedLengthResponse(OK, MIME_PLAINTEXT, "")
tts.performTTS(dto.text, dto.language)
return newFixedLengthResponse(OK, MIME_JSON, dto.json)
}
private fun <T> extractBody(session: IHTTPSession, provider: (String) -> T): T {
@@ -91,73 +90,62 @@ class WebServer(
private fun wave(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_WAVE_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
throw WebException(NOT_FOUND, "")
}
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
throw WebException(METHOD_NOT_ALLOWED, "Only POST methods are allowed")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
throw WebException(BAD_REQUEST, "The only accepted format is JSON")
}
val (text, language) = extractBody(session) {
BaseDTO.fromJSON(
it
)
}
val dto = extractBody(session) {BaseDTO(it) }
val (stream, size) = tts.fetchTTSStream(text, language)
return newFixedLengthResponse(OK,
MIME_WAVE, stream, size)
val (stream, size) = tts.fetchTTSStream(dto.text, dto.language)
return newFixedLengthResponse(OK, MIME_WAVE, stream, size)
}
private fun sonos(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_SONOS_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
throw WebException(NOT_FOUND, "")
}
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
throw WebException(METHOD_NOT_ALLOWED, "Only POST methods are allowed")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
throw WebException(BAD_REQUEST, "The only accepted format is JSON")
}
val data = extractBody(session) {
SonosDTO.fromJSON(
it
)
}
val dto = extractBody(session) { SonosDTO(it) }
sonos.push(data)
sonos.push(dto)
return newFixedLengthResponse(ACCEPTED, MIME_PLAINTEXT, "")
return newFixedLengthResponse(ACCEPTED, MIME_JSON, dto.json)
}
private fun sonosCache(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_SONOS_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
throw WebException(NOT_FOUND, "")
}
if (session.method != Method.GET) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
throw WebException(METHOD_NOT_ALLOWED, "Only GET methods are allowed")
}
val filename =
Uri.parse(session.uri).lastPathSegment ?: throw ResponseException(BAD_REQUEST, "")
val filename = Uri.parse(session.uri).lastPathSegment ?: throw WebException(BAD_REQUEST, "")
val file = File(context.cacheDir, filename)
if (!file.exists()) {
throw ResponseException(NOT_FOUND, "")
throw WebException(NOT_FOUND, "")
}
val stream = BufferedInputStream(FileInputStream(file))
val size = file.length()
return newFixedLengthResponse(OK,
MIME_WAVE, stream, size)
return newFixedLengthResponse(OK, MIME_WAVE, stream, size)
}
override fun start() {