Skip to content

Commit 29ddddf

Browse files
committed
More info in case of service failure on status
1 parent 3bdfb10 commit 29ddddf

2 files changed

Lines changed: 189 additions & 28 deletions

File tree

src/main/scala/com/tesobe/oidc/endpoints/StatusEndpoint.scala

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,46 @@ class StatusEndpoint(statusService: StatusService) {
3434
}
3535
}
3636

37+
private def renderDetailBlock(d: com.tesobe.oidc.status.CheckDetail): String = {
38+
def line(label: String, value: String): String =
39+
s"""<div class="detail-line"><span class="detail-label">${htmlEncode(label)}</span> <span class="detail-value">${htmlEncode(value)}</span></div>"""
40+
def block(label: String, value: String): String =
41+
s"""<div class="detail-line"><span class="detail-label">${htmlEncode(label)}</span></div><pre class="detail-pre">${htmlEncode(value)}</pre>"""
42+
43+
val urlLine = (d.method, d.url) match {
44+
case (Some(m), Some(u)) => Some(line(m, u))
45+
case (None, Some(u)) => Some(line("URL", u))
46+
case _ => None
47+
}
48+
49+
val parts = List(
50+
urlLine,
51+
d.requestBody.map(b => block("Request body", b)),
52+
d.responseStatus.map(s => line("Response status", s.toString)),
53+
d.responseBody.map(b => block("Response body", b)),
54+
d.error.map(e => block("Error", e))
55+
).flatten
56+
57+
parts.mkString("\n")
58+
}
59+
3760
private def renderRow(c: StatusCheck): String = {
3861
val label = if (c.ok) "OK" else "FAIL"
3962
val cls = if (c.ok) "ok" else "fail"
63+
val detailRow = c.detail match {
64+
case Some(d) if !c.ok =>
65+
val body = renderDetailBlock(d)
66+
if (body.isEmpty) ""
67+
else s"""
68+
|<tr class="$cls detail-row">
69+
| <td colspan="2"><div class="detail-box">$body</div></td>
70+
|</tr>""".stripMargin
71+
case _ => ""
72+
}
4073
s"""<tr class="$cls">
4174
| <td class="name">${htmlEncode(c.name)}</td>
4275
| <td class="badge"><span class="pill pill-$cls">$label</span></td>
43-
|</tr>""".stripMargin
76+
|</tr>$detailRow""".stripMargin
4477
}
4578

4679
private def renderHtml(report: StatusReport): String = {
@@ -88,6 +121,30 @@ class StatusEndpoint(statusService: StatusService) {
88121
| .pill-ok { background: #d1fae5; color: #065f46; }
89122
| .pill-fail { background: #fee2e2; color: #991b1b; }
90123
| .meta { color: #6b7280; font-size: 0.9rem; margin-top: 20px; }
124+
| tr.detail-row td { padding: 0 16px 12px 16px; border-bottom: 1px solid #e9ecef; }
125+
| .detail-box {
126+
| background: #fff7f7;
127+
| border-left: 3px solid #ef4444;
128+
| padding: 10px 14px;
129+
| border-radius: 4px;
130+
| font-size: 0.85rem;
131+
| color: #3f3f46;
132+
| }
133+
| .detail-line { margin: 4px 0; word-break: break-all; }
134+
| .detail-label { font-weight: 600; color: #991b1b; margin-right: 6px; }
135+
| .detail-value { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
136+
| .detail-pre {
137+
| margin: 4px 0 8px 0;
138+
| padding: 8px 10px;
139+
| background: #fff;
140+
| border: 1px solid #fecaca;
141+
| border-radius: 4px;
142+
| overflow-x: auto;
143+
| white-space: pre-wrap;
144+
| word-break: break-word;
145+
| font-size: 0.8rem;
146+
| color: #1f2937;
147+
| }
91148
| </style>
92149
|</head>
93150
|<body>

src/main/scala/com/tesobe/oidc/status/StatusService.scala

Lines changed: 131 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,23 @@ import org.slf4j.LoggerFactory
2828
import java.time.Instant
2929
import scala.concurrent.duration._
3030

31-
case class StatusCheck(name: String, ok: Boolean)
31+
/** Diagnostic detail for a status check. Populated when the check exercised a
32+
* remote endpoint or otherwise has actionable failure information to show.
33+
*/
34+
case class CheckDetail(
35+
url: Option[String] = None,
36+
method: Option[String] = None,
37+
requestBody: Option[String] = None,
38+
responseStatus: Option[Int] = None,
39+
responseBody: Option[String] = None,
40+
error: Option[String] = None
41+
)
42+
43+
case class StatusCheck(
44+
name: String,
45+
ok: Boolean,
46+
detail: Option[CheckDetail] = None
47+
)
3248

3349
case class StatusReport(
3450
overallOk: Boolean,
@@ -37,15 +53,31 @@ case class StatusReport(
3753
)
3854

3955
object StatusReport {
56+
private def detailToJson(d: CheckDetail): Json = Json.obj(
57+
List(
58+
d.url.map("url" -> Json.fromString(_)),
59+
d.method.map("method" -> Json.fromString(_)),
60+
d.requestBody.map("request_body" -> Json.fromString(_)),
61+
d.responseStatus.map(s => "response_status" -> Json.fromInt(s)),
62+
d.responseBody.map("response_body" -> Json.fromString(_)),
63+
d.error.map("error" -> Json.fromString(_))
64+
).flatten: _*
65+
)
66+
4067
def toJson(report: StatusReport): Json = Json.obj(
4168
"status" -> Json.fromString(if (report.overallOk) "ok" else "fail"),
4269
"generated_at" -> Json.fromString(report.generatedAt.toString),
4370
"checks" -> Json.arr(
4471
report.checks.map { c =>
45-
Json.obj(
72+
val base = List(
4673
"name" -> Json.fromString(c.name),
4774
"ok" -> Json.fromBoolean(c.ok)
4875
)
76+
val withDetail = c.detail match {
77+
case Some(d) => base :+ ("detail" -> detailToJson(d))
78+
case None => base
79+
}
80+
Json.obj(withDetail: _*)
4981
}: _*
5082
)
5183
)
@@ -94,9 +126,9 @@ class StatusService(
94126

95127
// Authentication + probes require a DirectLogin token
96128
val tokenAndProbesIO: IO[List[StatusCheck]] = obtainToken().flatMap {
97-
case Left(_) =>
129+
case Left(err) =>
98130
// OBP API authentication failed. Mark all dependent checks as FAIL.
99-
IO.pure(dependentFailChecks())
131+
IO.pure(dependentFailChecks(err))
100132
case Right(token) =>
101133
val authOk = StatusCheck("OBP API authentication", ok = true)
102134
val rolesIO = checkRoles()
@@ -125,26 +157,29 @@ class StatusService(
125157
} yield StatusReport(overall, all, now)
126158
}
127159

128-
private def dependentFailChecks(): List[StatusCheck] = {
160+
private def dependentFailChecks(authError: String): List[StatusCheck] = {
161+
val skipped =
162+
Some(CheckDetail(error = Some(s"Skipped — OBP API authentication failed: $authError")))
163+
val authDetail = Some(CheckDetail(error = Some(authError)))
129164
val base = List(
130-
StatusCheck("OBP API authentication", ok = false),
131-
StatusCheck("OBP API roles", ok = false),
132-
StatusCheck("Providers endpoint", ok = false)
165+
StatusCheck("OBP API authentication", ok = false, detail = authDetail),
166+
StatusCheck("OBP API roles", ok = false, detail = skipped),
167+
StatusCheck("Providers endpoint", ok = false, detail = skipped)
133168
)
134169
val credential =
135170
if (config.verifyCredentialsMethod == VerifyCredentialsMethod.ViaApiEndpoint)
136171
List(
137-
StatusCheck("Credential verification endpoint", ok = false),
138-
StatusCheck("User lookup endpoint", ok = false)
172+
StatusCheck("Credential verification endpoint", ok = false, detail = skipped),
173+
StatusCheck("User lookup endpoint", ok = false, detail = skipped)
139174
)
140175
else Nil
141176
val clientVerify =
142177
if (config.verifyClientMethod == VerifyClientMethod.ViaApiEndpoint)
143-
List(StatusCheck("Client verification endpoint", ok = false))
178+
List(StatusCheck("Client verification endpoint", ok = false, detail = skipped))
144179
else Nil
145180
val dcr =
146181
if (config.enableDynamicClientRegistration)
147-
List(StatusCheck("Consumer management endpoint", ok = false))
182+
List(StatusCheck("Consumer management endpoint", ok = false, detail = skipped))
148183
else Nil
149184
base ++ credential ++ clientVerify ++ dcr
150185
}
@@ -173,18 +208,59 @@ class StatusService(
173208
private def checkObpReachable(): IO[StatusCheck] = {
174209
val name = "OBP API reachable"
175210
config.obpApiUrl match {
176-
case None => IO.pure(StatusCheck(name, ok = false))
211+
case None =>
212+
IO.pure(
213+
StatusCheck(
214+
name,
215+
ok = false,
216+
detail = Some(CheckDetail(error = Some("OBP_API_URL not configured")))
217+
)
218+
)
177219
case Some(baseUrl) =>
178220
val endpoint = s"${baseUrl.stripSuffix("/")}/obp/v4.0.0/root"
179221
val req = Request[IO](Method.GET, Uri.unsafeFromString(endpoint))
180222
client
181223
.run(req)
182-
.use { resp => IO.pure(resp.status.isSuccess) }
183-
.handleError(_ => false)
184-
.map(ok => StatusCheck(name, ok))
224+
.use { resp =>
225+
val code = resp.status.code
226+
resp.as[String].attempt.map(_.toOption.map(truncateBody)).map { body =>
227+
val ok = resp.status.isSuccess
228+
val detail =
229+
if (ok) None
230+
else
231+
Some(
232+
CheckDetail(
233+
url = Some(endpoint),
234+
method = Some("GET"),
235+
responseStatus = Some(code),
236+
responseBody = body
237+
)
238+
)
239+
StatusCheck(name, ok, detail = detail)
240+
}
241+
}
242+
.handleError { e =>
243+
StatusCheck(
244+
name,
245+
ok = false,
246+
detail = Some(
247+
CheckDetail(
248+
url = Some(endpoint),
249+
method = Some("GET"),
250+
error = Some(Option(e.getMessage).getOrElse(e.toString))
251+
)
252+
)
253+
)
254+
}
185255
}
186256
}
187257

258+
private val MaxBodyChars = 2000
259+
260+
private def truncateBody(s: String): String =
261+
if (s.length <= MaxBodyChars) s
262+
else s.take(MaxBodyChars) + s"... [truncated, ${s.length - MaxBodyChars} more chars]"
263+
188264
private def checkRoles(): IO[List[StatusCheck]] = {
189265
val requiredRoles =
190266
(config.verifyCredentialsMethod match {
@@ -233,7 +309,8 @@ class StatusService(
233309
token: String,
234310
body: Option[Json]
235311
): IO[StatusCheck] = {
236-
val uri = Uri.unsafeFromString(s"${baseUrl.stripSuffix("/")}$path")
312+
val fullUrl = s"${baseUrl.stripSuffix("/")}$path"
313+
val uri = Uri.unsafeFromString(fullUrl)
237314
val base = Request[IO](method, uri).putHeaders(
238315
Header.Raw(ci"DirectLogin", s"token=$token")
239316
)
@@ -242,21 +319,48 @@ class StatusService(
242319
base.withEntity(json).putHeaders(`Content-Type`(MediaType.application.json))
243320
case None => base
244321
}
322+
val requestBodyStr = body.map(_.noSpaces)
245323
client
246324
.run(req)
247325
.use { resp =>
248326
val code = resp.status.code
249-
if (code >= 500) IO.pure(StatusCheck(name, ok = false))
250-
else if (code == 404) {
251-
resp.as[String].map { responseBody =>
252-
val routeMissing =
253-
responseBody.contains("OBP-10404") ||
254-
!responseBody.contains("OBP-")
255-
StatusCheck(name, ok = !routeMissing)
256-
}
257-
} else IO.pure(StatusCheck(name, ok = true))
327+
resp.as[String].attempt.map(_.toOption.map(truncateBody)).map { respBody =>
328+
val ok =
329+
if (code >= 500) false
330+
else if (code == 404) {
331+
val raw = respBody.getOrElse("")
332+
val routeMissing = raw.contains("OBP-10404") || !raw.contains("OBP-")
333+
!routeMissing
334+
} else true
335+
val detail =
336+
if (ok) None
337+
else
338+
Some(
339+
CheckDetail(
340+
url = Some(fullUrl),
341+
method = Some(method.name),
342+
requestBody = requestBodyStr,
343+
responseStatus = Some(code),
344+
responseBody = respBody
345+
)
346+
)
347+
StatusCheck(name, ok, detail = detail)
348+
}
349+
}
350+
.handleError { e =>
351+
StatusCheck(
352+
name,
353+
ok = false,
354+
detail = Some(
355+
CheckDetail(
356+
url = Some(fullUrl),
357+
method = Some(method.name),
358+
requestBody = requestBodyStr,
359+
error = Some(Option(e.getMessage).getOrElse(e.toString))
360+
)
361+
)
362+
)
258363
}
259-
.handleError(_ => StatusCheck(name, ok = false))
260364
}
261365

262366
private def endpointProbes(token: String): IO[List[StatusCheck]] = {

0 commit comments

Comments
 (0)