Skip to content

Commit 127d2b0

Browse files
committed
More specific error handling
1 parent 55fe3ee commit 127d2b0

18 files changed

+389
-107
lines changed

.editorconfig

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[*.{kt,kts}]
2+
max_line_length = 120

backend/build.gradle.kts

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ version = "0.0.1"
1414

1515
kotlin {
1616
jvmToolchain(22)
17+
18+
compilerOptions {
19+
freeCompilerArgs = listOf("-Xconsistent-data-class-copy-visibility")
20+
}
1721
}
1822

1923
application {
@@ -31,6 +35,7 @@ dependencies {
3135
implementation(libs.bundles.ktor.server)
3236
implementation(libs.bundles.ktor.client)
3337

38+
implementation(libs.arrow.core)
3439
implementation(libs.cache4k)
3540
implementation(libs.kotlinx.coroutines.core)
3641

backend/src/main/kotlin/no/java/cupcake/Application.kt

+1-8
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package no.java.cupcake
22

33
import io.ktor.server.application.Application
44
import io.ktor.server.application.ApplicationEnvironment
5-
import io.ktor.server.auth.OAuthServerSettings
65
import io.ktor.server.cio.EngineMain
76
import no.java.cupcake.bring.BringService
87
import no.java.cupcake.clients.bringClient
@@ -52,13 +51,7 @@ private fun Application.slackService(): SlackService =
5251
membersUrl = environment.str("slack.members_url"),
5352
)
5453

55-
private fun Application.slackProvider(): OAuthServerSettings.OAuth2ServerSettings =
56-
slackProvider(
57-
clientId = environment.str("slack.client"),
58-
clientSecret = environment.str("slack.secret"),
59-
authUrl = environment.str("slack.authorize_url"),
60-
accessTokenUrl = environment.str("slack.accesstoken_url"),
61-
)
54+
private fun Application.slackProvider() = slackProvider(environment.slackConfig())
6255

6356
private fun Application.sleepingPillService(bringService: BringService): SleepingPillService =
6457
SleepingPillService(

backend/src/main/kotlin/no/java/cupcake/EnvironmentExtensions.kt

+9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package no.java.cupcake
33
import io.ktor.server.application.ApplicationEnvironment
44
import no.java.cupcake.config.BringConfig
55
import no.java.cupcake.config.JwtConfig
6+
import no.java.cupcake.config.SlackConfig
67
import no.java.cupcake.config.SleepingPillConfig
78

89
fun ApplicationEnvironment.bringConfig() =
@@ -26,3 +27,11 @@ fun ApplicationEnvironment.jwtConfig() =
2627
issuer = str("jwt.issuer"),
2728
redirect = str("jwt.redirect"),
2829
)
30+
31+
fun ApplicationEnvironment.slackConfig() =
32+
SlackConfig(
33+
clientId = str("slack.client"),
34+
clientSecret = str("slack.secret"),
35+
authUrl = str("slack.authorize_url"),
36+
accessTokenUrl = str("slack.accesstoken_url"),
37+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package no.java.cupcake.api
2+
3+
import io.ktor.http.HttpStatusCode
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class ErrorResponse(
8+
@Serializable(with = HttpStatusCodeSerializer::class)
9+
val status: HttpStatusCode,
10+
val message: String,
11+
val fieldValue: String? = null,
12+
)
13+
14+
sealed class ApiError(
15+
protected open val errorResponse: ErrorResponse,
16+
) {
17+
open fun messageMap(): Map<String, ErrorResponse> = mapOf("error" to errorResponse)
18+
19+
fun status() = this.errorResponse.status
20+
}
21+
22+
abstract class UpstreamError(
23+
open val upstream: ErrorResponse,
24+
val systemName: String,
25+
) : ApiError(
26+
ErrorResponse(
27+
status = HttpStatusCode.InternalServerError,
28+
message = "call to $systemName failed",
29+
),
30+
) {
31+
override fun messageMap() =
32+
mapOf(
33+
"upstream" to upstream,
34+
"error" to errorResponse,
35+
)
36+
}
37+
38+
abstract class RequiredField(
39+
val fieldName: String,
40+
) : ApiError(
41+
ErrorResponse(
42+
status = HttpStatusCode.BadRequest,
43+
message = "$fieldName required",
44+
),
45+
)
46+
47+
data object ConferenceIdRequired : RequiredField(fieldName = "id")
48+
49+
data class SleepingPillCallFailed(
50+
override val upstream: ErrorResponse,
51+
) : UpstreamError(
52+
upstream = upstream,
53+
systemName = "SleepingPill",
54+
)
55+
56+
data class SlackCallFailed(
57+
override val upstream: ErrorResponse,
58+
) : UpstreamError(
59+
upstream = upstream,
60+
systemName = "Slack",
61+
)
62+
63+
data object CallPrincipalMissing : ApiError(
64+
ErrorResponse(
65+
status = HttpStatusCode.Unauthorized,
66+
message = "Principal missing",
67+
),
68+
)
69+
70+
data object TokenMissing : ApiError(
71+
ErrorResponse(
72+
status = HttpStatusCode.Unauthorized,
73+
message = "Principal missing token",
74+
),
75+
)
76+
77+
data object TokenMissingUser : ApiError(
78+
ErrorResponse(
79+
status = HttpStatusCode.Unauthorized,
80+
message = "User missing in token",
81+
),
82+
)
83+
84+
data class MissingChannelMembership(
85+
val channelName: String,
86+
) : ApiError(
87+
ErrorResponse(
88+
status = HttpStatusCode.Unauthorized,
89+
message = "User not in correct slack channel - please ask in #kodesmia for access to $channelName",
90+
),
91+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package no.java.cupcake.api
2+
3+
import io.ktor.http.HttpStatusCode
4+
import kotlinx.serialization.KSerializer
5+
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
6+
import kotlinx.serialization.descriptors.element
7+
import kotlinx.serialization.encoding.Decoder
8+
import kotlinx.serialization.encoding.Encoder
9+
import kotlinx.serialization.encoding.decodeStructure
10+
import kotlinx.serialization.encoding.encodeStructure
11+
12+
object HttpStatusCodeSerializer : KSerializer<HttpStatusCode> {
13+
override val descriptor =
14+
buildClassSerialDescriptor("HttpStatusCode") {
15+
element<Int>("value")
16+
element<String>("description")
17+
}
18+
19+
override fun deserialize(decoder: Decoder): HttpStatusCode =
20+
decoder.decodeStructure(descriptor) {
21+
val code = decodeIntElement(descriptor, 0)
22+
HttpStatusCode.fromValue(code)
23+
}
24+
25+
override fun serialize(
26+
encoder: Encoder,
27+
value: HttpStatusCode,
28+
) = encoder.encodeStructure(descriptor) {
29+
encodeIntElement(descriptor, 0, value.value)
30+
encodeStringElement(descriptor, 1, value.description)
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package no.java.cupcake.api
2+
3+
import arrow.core.Either
4+
import io.ktor.http.HttpStatusCode
5+
import io.ktor.server.response.respond
6+
import io.ktor.server.response.respondRedirect
7+
import io.ktor.server.routing.RoutingContext
8+
9+
suspend inline fun <reified A : Any> Either<ApiError, A>.performResponse(
10+
context: RoutingContext,
11+
status: HttpStatusCode = HttpStatusCode.OK,
12+
redirect: Boolean = false,
13+
) = when (this) {
14+
is Either.Left -> context.respond(value)
15+
is Either.Right ->
16+
when (redirect) {
17+
false -> context.call.respond(status, value)
18+
true -> context.call.respondRedirect(value.toString())
19+
}
20+
}
21+
22+
suspend inline fun <reified A : Any> Either<ApiError, A>.respond(
23+
context: RoutingContext,
24+
status: HttpStatusCode = HttpStatusCode.OK,
25+
) = performResponse(context, status, redirect = false)
26+
27+
suspend inline fun <reified A : Any> Either<ApiError, A>.redirect(context: RoutingContext) =
28+
performResponse(context, redirect = true)
29+
30+
suspend fun RoutingContext.respond(error: ApiError) = call.respond(error.status(), error.messageMap())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package no.java.cupcake.config
2+
3+
data class SlackConfig(
4+
val clientId: String,
5+
val clientSecret: String,
6+
val authUrl: String,
7+
val accessTokenUrl: String,
8+
)

backend/src/main/kotlin/no/java/cupcake/plugins/Routing.kt

+12-14
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
package no.java.cupcake.plugins
22

3-
import io.github.oshai.kotlinlogging.KotlinLogging
3+
import arrow.core.raise.either
44
import io.ktor.http.HttpStatusCode
55
import io.ktor.server.application.Application
66
import io.ktor.server.application.install
77
import io.ktor.server.auth.authenticate
8-
import io.ktor.server.plugins.BadRequestException
98
import io.ktor.server.plugins.statuspages.StatusPages
10-
import io.ktor.server.response.respond
119
import io.ktor.server.response.respondText
1210
import io.ktor.server.routing.get
1311
import io.ktor.server.routing.route
1412
import io.ktor.server.routing.routing
13+
import no.java.cupcake.api.respond
14+
import no.java.cupcake.sleepingpill.ConferenceId
1515
import no.java.cupcake.sleepingpill.SleepingPillService
1616

17-
private val logger = KotlinLogging.logger {}
18-
1917
fun Application.configureRouting(
2018
sleepingPillService: SleepingPillService,
2119
securityOptional: Boolean,
@@ -31,19 +29,19 @@ fun Application.configureRouting(
3129
route("/api") {
3230
route("/conferences") {
3331
get {
34-
val conferences = sleepingPillService.conferences().sortedByDescending { it.name }
35-
36-
call.respond(conferences)
32+
either {
33+
sleepingPillService.conferences(raise = this).sortedByDescending { it.name }
34+
}.respond(this)
3735
}
3836

3937
route("/{id}") {
4038
get("/sessions") {
41-
val conferenceId =
42-
call.parameters["id"] ?: throw BadRequestException("Conference ID required")
43-
44-
val sessions = sleepingPillService.sessions(conferenceId)
45-
46-
call.respond(sessions)
39+
either {
40+
sleepingPillService.sessions(
41+
id = ConferenceId(call.parameters["id"]).bind(),
42+
raise = this,
43+
)
44+
}.respond(this)
4745
}
4846
}
4947
}

0 commit comments

Comments
 (0)