Skip to content
Draft
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 TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@

## Nice to have
* applicative validator
* kotlin logging
* audit logging, check https://github.com/logstash/logstash-logback-encoder
11 changes: 6 additions & 5 deletions realworld-app/web/src/main/kotlin/io/realworld/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package io.realworld

import arrow.core.getOrElse
import io.realworld.domain.common.Auth
import io.realworld.domain.common.AuthError
import io.realworld.domain.common.Settings
import io.realworld.domain.common.Token
import io.realworld.domain.users.User
import io.realworld.errors.RestException
import io.realworld.persistence.ArticleRepository
import io.realworld.persistence.UserRepository
import org.springframework.beans.factory.annotation.Autowired
Expand Down Expand Up @@ -39,7 +41,9 @@ class Application : WebMvcConfigurer {

// TODO check token match
override fun invoke(token: Token): User {
return repo.findById(token.id).unsafeRunSync().map { it.user }.getOrElse { throw UnauthorizedException() }
return repo.findById(token.id).unsafeRunSync().map { it.user }.getOrElse {
throw RestException.Unauthorized(AuthError.BadCredentials)
}
}
}

Expand Down Expand Up @@ -76,10 +80,7 @@ inline fun <reified User, reified Token> userArgumentResolver(
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
) = resolveToken(webRequest.authHeader()).fold(
{ throw UnauthorizedException() },
{ throw RestException.Unauthorized(it) },
{ createUser(it) }
)
}

class ForbiddenException : Throwable()
class UnauthorizedException : Throwable()
189 changes: 121 additions & 68 deletions realworld-app/web/src/main/kotlin/io/realworld/ErrorHandler.kt
Original file line number Diff line number Diff line change
@@ -1,93 +1,146 @@
package io.realworld

import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.core.NestedExceptionUtils
import io.realworld.domain.common.DomainError
import io.realworld.errors.ErrorResponseDto
import io.realworld.errors.RestException
import io.realworld.errors.SingleErrorDto
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.validation.FieldError
import org.springframework.validation.ObjectError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.context.request.ServletWebRequest
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
import org.springframework.web.util.WebUtils
import javax.servlet.ServletException

@ControllerAdvice
class ErrorHandler {
val log: Logger = LoggerFactory.getLogger(ErrorHandler::class.java)
class RestExceptionHandler : ResponseEntityExceptionHandler() {
// thrown when an object fails the @Valid validation
override fun handleMethodArgumentNotValid(
ex: MethodArgumentNotValidException,
headers: HttpHeaders,
status: HttpStatus,
request: WebRequest
): ResponseEntity<Any> {
val fieldErrors = ex.bindingResult.fieldErrors.map<FieldError, SingleErrorDto>(FieldError::toErrorDto)
val globalErrors = ex.bindingResult.globalErrors.map(ObjectError::toErrorDto)

@ExceptionHandler(HttpMessageNotReadableException::class)
fun httpMessageNotReadable(ex: HttpMessageNotReadableException): ResponseEntity<ValidationErrorResponse> {
val t = NestedExceptionUtils.getMostSpecificCause(ex)
return when (t) {
is JsonMappingException -> {
handleError(HttpStatus.UNPROCESSABLE_ENTITY, listOf(t.toValidationError()))
}
else -> handleError(HttpStatus.UNPROCESSABLE_ENTITY, listOf(ValidationError(
type = "ValidationError",
path = "body",
message = t.message ?: ""
)))
val httpStatus = HttpStatus.UNPROCESSABLE_ENTITY
val servletWebRequest = request as ServletWebRequest

val responseBody = ErrorResponseDto(
status = httpStatus.value(),
errorCode = "InvalidData",
message = "There are validation errors in the data",
path = servletWebRequest.request.servletPath,
errors = listOf(fieldErrors, globalErrors).flatten()
)
return handleExceptionInternal(ex, responseBody, headers, httpStatus, request)
}

// use ErrorResponseDto also for errors detected by Spring
override fun handleExceptionInternal(
ex: Exception,
body: Any?,
headers: HttpHeaders,
status: HttpStatus,
request: WebRequest
): ResponseEntity<Any> {
if (HttpStatus.INTERNAL_SERVER_ERROR.equals(status)) {
request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST)
}

val errorCode = ex::class.simpleName ?: "Undefined"
val servletWebRequest = request as ServletWebRequest
val responseBody = body ?: ErrorResponseDto(
status = status.value(),
errorCode = errorCode,
message = ex.localizedMessage,
path = servletWebRequest.request.servletPath,
errors = listOf()
)
// TODO log error
return ResponseEntity(responseBody, headers, status)
}

@ExceptionHandler(ForbiddenException::class)
fun forbidden() = handleError(HttpStatus.FORBIDDEN)
@ExceptionHandler(RestException::class)
fun handleRestException(e: RestException, req: WebRequest) = e.error.toErrorResponse(e.status, req)

@ExceptionHandler(UnauthorizedException::class)
fun unauthorized() = handleError(HttpStatus.UNAUTHORIZED)
// TODO find out why this overrides more specific errors
// @ExceptionHandler(Throwable::class)
fun handleAny(t: Throwable, request: WebRequest): ResponseEntity<ErrorResponseDto> {
val cause = resolveCause(t)
val msg = "Unexpected error"

@ExceptionHandler(FieldError::class)
fun fieldError(ex: FieldError) = handleError(HttpStatus.UNPROCESSABLE_ENTITY, listOf(ex.toValidationError()))
return ResponseEntity(
ErrorResponseDto(
status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
errorCode = "Undefined",
message = msg,
path = (request as ServletWebRequest).request.servletPath,
errors = listOf()
),
HttpStatus.INTERNAL_SERVER_ERROR
)
// TODO log error
}

@ExceptionHandler(MethodArgumentNotValidException::class)
fun methodArgumentNotValid(ex: MethodArgumentNotValidException) =
handleError(HttpStatus.UNPROCESSABLE_ENTITY, ex.bindingResult.fieldErrors.map {
ValidationError(type = it.code ?: "", path = it.field, message = it.defaultMessage ?: "")
})
private fun resolveCause(t: Throwable): Throwable {
var resolved = t
while (resolved is ServletException && resolved.cause != null) {
resolved = resolved.cause!!
}
return resolved
}
}

fun handleError(
httpStatus: HttpStatus,
validationErrors: List<ValidationError> = emptyList()
) = when (validationErrors.isEmpty()) {
true -> ResponseEntity(httpStatus)
else -> ResponseEntity(
ValidationErrorResponse(validationErrors.associateBy { it.path }),
httpStatus)
}
fun FieldError.toErrorDto() = SingleErrorDto(
message = defaultMessage ?: "Undefined error",
metadata = mapOf("path" to field)
)

data class ValidationError(
val type: String,
val path: String,
val message: String,
val arguments: Map<String, Any>? = emptyMap()
fun ObjectError.toErrorDto() = SingleErrorDto(
message = defaultMessage ?: "Undefined error",
metadata = mapOf("path" to objectName)
)

fun JsonMappingException.toValidationErrorPath(): String =
path.joinToString(separator = ".", transform = { it ->
val i = if (it.index >= 0) "${it.index}" else ""
"${it.fieldName ?: ""}$i"
})
fun DomainError.toErrorResponse(
status: HttpStatus,
request: WebRequest
): ResponseEntity<ErrorResponseDto> = when (this) {
is DomainError.Single -> toErrorResponse(status, request)
is DomainError.Multi -> toErrorResponse(status, request)
}

fun JsonMappingException.toValidationError() = ValidationError(
type = "TypeMismatch",
path = toValidationErrorPath(),
message = message ?: ""
fun DomainError.Single.toErrorResponse(
status: HttpStatus,
request: WebRequest
): ResponseEntity<ErrorResponseDto> = ResponseEntity(
ErrorResponseDto(
status = status.value(),
errorCode = this::class.simpleName ?: "Undefined",
message = msg,
path = (request as ServletWebRequest).request.servletPath,
errors = listOf()
),
status
)

fun MissingKotlinParameterException.toValidationError() = ValidationError(
type = "TypeMismatch",
path = toValidationErrorPath(),
message = message ?: ""
fun DomainError.Multi.toErrorResponse(
status: HttpStatus,
request: WebRequest
): ResponseEntity<ErrorResponseDto> = ResponseEntity(
ErrorResponseDto(
status = status.value(),
errorCode = errorCode,
message = msg,
path = (request as ServletWebRequest).request.servletPath,
errors = errors.all.map { SingleErrorDto(it.msg) }
),
status
)

class FieldError(val path: String, message: String) : Throwable(message) {
fun toValidationError() = ValidationError(
type = "FieldError",
path = path,
message = this.message ?: ""
)
}

class ValidationErrorResponse(val errors: Map<String, ValidationError>)
33 changes: 10 additions & 23 deletions realworld-app/web/src/main/kotlin/io/realworld/Jwt.kt
Original file line number Diff line number Diff line change
@@ -1,34 +1,21 @@
package io.realworld

import arrow.core.Either
import arrow.core.left
import arrow.core.right
import io.realworld.JwtError.NoToken
import io.realworld.JwtError.ParseFail
import arrow.core.flatMap
import arrow.core.toOption
import io.realworld.domain.common.AuthError
import org.springframework.http.HttpHeaders
import org.springframework.web.context.request.NativeWebRequest

typealias ResolveToken<T> = (authHeader: String?) -> Either<JwtError, T>
typealias ParseToken<T> = (token: String) -> T

sealed class JwtError {
object ParseFail : JwtError()
object NoToken : JwtError()
}
typealias ResolveToken<T> = (authHeader: String?) -> Either<AuthError, T>
typealias ParseToken<T> = (token: String) -> Either<AuthError, T>

class JwtTokenResolver<T>(val parseToken: ParseToken<T>) : ResolveToken<T> {
override fun invoke(authHeader: String?): Either<JwtError, T> {
authHeader?.apply {
if (startsWith(TOKEN_PREFIX)) {
return try {
parseToken(substring(TOKEN_PREFIX.length)).right()
} catch (t: Throwable) {
ParseFail.left()
}
}
}
return NoToken.left()
}
override fun invoke(authHeader: String?): Either<AuthError, T> =
authHeader.toOption()
.filter { it.startsWith(TOKEN_PREFIX) }
.toEither { AuthError.InvalidAuthorizationHeader }
.flatMap { parseToken(it.substring(TOKEN_PREFIX.length)) }

companion object {
const val TOKEN_PREFIX = "Token "
Expand Down
26 changes: 4 additions & 22 deletions realworld-app/web/src/main/kotlin/io/realworld/articles/Routes.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
package io.realworld.articles

import io.realworld.ForbiddenException
import io.realworld.JwtTokenResolver
import io.realworld.authHeader
import io.realworld.domain.articles.Article
import io.realworld.domain.articles.ArticleCommentDeleteError
import io.realworld.domain.articles.ArticleCommentError
import io.realworld.domain.articles.ArticleDeleteError
import io.realworld.domain.articles.ArticleFavoriteError
import io.realworld.domain.articles.ArticleFilter
import io.realworld.domain.articles.ArticleUnfavoriteError
import io.realworld.domain.articles.ArticleUpdateError
import io.realworld.domain.articles.Comment
import io.realworld.domain.articles.CommentArticleCommand
import io.realworld.domain.articles.CommentUseCase
Expand Down Expand Up @@ -43,6 +39,7 @@ import io.realworld.domain.articles.ValidateArticleUpdateService
import io.realworld.domain.articles.articleScopedCommentId
import io.realworld.domain.common.Auth
import io.realworld.domain.users.User
import io.realworld.errors.toRestException
import io.realworld.persistence.ArticleRepository
import io.realworld.persistence.UserRepository
import io.realworld.runReadTx
Expand Down Expand Up @@ -190,12 +187,7 @@ class ArticleController(
}.run {
DeleteArticleCommand(slug, user).runUseCase()
}.runWriteTx(txManager).fold(
{
when (it) {
is ArticleDeleteError.NotAuthor -> throw ForbiddenException()
is ArticleDeleteError.NotFound -> ResponseEntity.notFound().build()
}
},
{ it.toRestException() },
{ ResponseEntity.noContent().build() }
)
}
Expand All @@ -221,12 +213,7 @@ class ArticleController(
}.run {
UpdateArticleCommand(update.toDomain(), slug, user).runUseCase()
}.runWriteTx(txManager).fold(
{
when (it) {
is ArticleUpdateError.NotAuthor -> throw ForbiddenException()
is ArticleUpdateError.NotFound -> ResponseEntity.notFound().build()
}
},
{ it.toRestException() },
{ ResponseEntity.ok(ArticleResponse.fromDomain(it)) }
)
}
Expand All @@ -243,12 +230,7 @@ class ArticleController(
}.run {
FavoriteArticleCommand(slug, user).runUseCase()
}.runWriteTx(txManager).fold(
{
when (it) {
is ArticleFavoriteError.Author -> throw ForbiddenException()
is ArticleFavoriteError.NotFound -> ResponseEntity.notFound().build()
}
},
{ it.toRestException() },
{ ResponseEntity.ok(ArticleResponse.fromDomain(it)) }
)
}
Expand Down
14 changes: 14 additions & 0 deletions realworld-app/web/src/main/kotlin/io/realworld/errors/Dtos.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.realworld.errors

import java.time.Instant

data class SingleErrorDto(val message: String, val metadata: Any? = null)

data class ErrorResponseDto(
val status: Int,
val errorCode: String,
val message: String,
val path: String,
val timestamp: Instant = Instant.now(),
val errors: List<SingleErrorDto>
)
Loading