Skip to content

Commit 9cd9974

Browse files
authored
Merge pull request #1228 from modelix/bugfix/no-500-on-modelql-error
feat(model-server)!: use a consistent status code for query failures
2 parents 5a891a7 + ff653fd commit 9cd9974

File tree

5 files changed

+63
-23
lines changed

5 files changed

+63
-23
lines changed

model-server-openapi/specifications/model-server-v2.yaml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,8 @@ paths:
324324
responses:
325325
"200":
326326
$ref: '#/components/responses/200json'
327-
"404":
328-
$ref: '#/components/responses/modelql-404'
327+
"422":
328+
$ref: '#/components/responses/ModelQlQueryExecutionFailed'
329329
default:
330330
$ref: '#/components/responses/GeneralError'
331331
/repositories/{repository}/init:
@@ -433,8 +433,8 @@ paths:
433433
responses:
434434
"200":
435435
$ref: '#/components/responses/200json'
436-
"404":
437-
$ref: '#/components/responses/modelql-404'
436+
"422":
437+
$ref: '#/components/responses/ModelQlQueryExecutionFailed'
438438
default:
439439
$ref: '#/components/responses/GeneralError'
440440

@@ -507,8 +507,16 @@ components:
507507
application/problem+json:
508508
schema:
509509
$ref: 'problem.yaml#/Problem'
510-
"modelql-404":
511-
description: "A IProducingStep<INodeReference>.resolve() call failed to find the node."
510+
"ModelQlQueryExecutionFailed":
511+
description: "A syntactically valid query failed to provide a result due to a mismatch of the query and the underlying data."
512+
content:
513+
text/plain:
514+
schema:
515+
type: string
516+
description: "Contains a technical error description why query execution failed."
517+
example: |-
518+
server version: 0.0.0
519+
Exception in thread "main" java.lang.IllegalArgumentException: At least one element was expected. it<1004>.typed().flatMap { ...
512520
GeneralError:
513521
description: Unexpected error
514522
content:

modelql-client/src/jvmTest/kotlin/org/modelix/modelql/client/ModelQLClientTest.kt

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.ktor.server.testing.testApplication
77
import kotlinx.coroutines.withTimeout
88
import org.junit.jupiter.api.assertThrows
99
import org.modelix.model.api.ConceptReference
10+
import org.modelix.model.api.IChildLinkReference
1011
import org.modelix.model.api.IConceptReference
1112
import org.modelix.model.api.INode
1213
import org.modelix.model.api.NodeReference
@@ -17,6 +18,7 @@ import org.modelix.model.lazy.CLTree
1718
import org.modelix.model.lazy.ObjectStoreCache
1819
import org.modelix.model.persistent.MapBaseStore
1920
import org.modelix.modelql.core.IFluxUnboundQuery
21+
import org.modelix.modelql.core.assertNotEmpty
2022
import org.modelix.modelql.core.buildFluxQuery
2123
import org.modelix.modelql.core.contains
2224
import org.modelix.modelql.core.count
@@ -262,8 +264,8 @@ class ModelQLClientTest {
262264
it.memoize { n ->
263265
n.count()
264266
.sum(
265-
n.allChildren().fold(0) { acc, it ->
266-
acc.sum(it.mapRecursive())
267+
n.allChildren().fold(0) { acc, child ->
268+
acc.sum(child.mapRecursive())
267269
},
268270
)
269271
}
@@ -272,13 +274,35 @@ class ModelQLClientTest {
272274
}
273275

274276
@Test
275-
fun `resolving a non-existing node reference returns 404`() = runTest { httpClient ->
277+
fun `resolving a non-existing node reference returns 422`() = runTest { httpClient ->
276278
val client = ModelQLClient("http://localhost/query", httpClient)
277279
val ex = assertThrows<ModelQueryRequestException> {
278280
client.query<INode> {
279281
NodeReference("doesnotexist").asMono().resolve()
280282
}
281283
}
282-
assertEquals(HttpStatusCode.NotFound, ex.httpResponse.status)
284+
assertEquals(HttpStatusCode.UnprocessableEntity, ex.httpResponse.status)
285+
}
286+
287+
@Test
288+
fun `failed assertions in queries return 422`() = runTest { httpClient ->
289+
val client = ModelQLClient("http://localhost/query", httpClient)
290+
val ex = assertThrows<ModelQueryRequestException> {
291+
client.query<INode> {
292+
it.children(IChildLinkReference.fromName("test")).first().assertNotEmpty()
293+
}
294+
}
295+
assertEquals(HttpStatusCode.UnprocessableEntity, ex.httpResponse.status)
296+
}
297+
298+
@Test
299+
fun `empty query results return 422`() = runTest { httpClient ->
300+
val client = ModelQLClient("http://localhost/query", httpClient)
301+
val ex = assertThrows<ModelQueryRequestException> {
302+
client.query<INode> {
303+
it.children(IChildLinkReference.fromName("test")).first()
304+
}
305+
}
306+
assertEquals(HttpStatusCode.UnprocessableEntity, ex.httpResponse.status)
283307
}
284308
}

modelql-core/src/commonMain/kotlin/org/modelix/modelql/core/Query.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ private abstract class BoundQuery<In, out AggregationOut, out ElementOut>(val ex
6262
}
6363
}
6464

65+
class EmptyQueryResultException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
66+
6567
private class MonoBoundQuery<In, Out>(executor: IQueryExecutor<In>, override val query: MonoUnboundQuery<In, Out>) : BoundQuery<In, Out, Out>(executor), IMonoQuery<Out> {
6668

6769
override suspend fun execute(): IStepOutput<Out> {
6870
try {
6971
return executor.createStream(query).exactlyOne().getSuspending()
7072
} catch (ex: NoSuchElementException) {
71-
throw RuntimeException("Empty query result: " + this, ex)
73+
throw EmptyQueryResultException("Empty query result: $this", ex)
7274
}
7375
}
7476

@@ -170,7 +172,7 @@ class MonoUnboundQuery<In, ElementOut>(
170172
try {
171173
return asStream(evaluationContext, input.asObservable()).exactlyOne().getSynchronous()
172174
} catch (ex: NoSuchElementException) {
173-
throw RuntimeException("Empty query result: " + this, ex)
175+
throw EmptyQueryResultException("Empty query result: $this", ex)
174176
}
175177
}
176178

modelql-server/src/main/kotlin/org/modelix/modelql/server/ModelQLServer.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kotlinx.serialization.KSerializer
1313
import org.modelix.model.api.INode
1414
import org.modelix.model.api.UnresolvableNodeReferenceException
1515
import org.modelix.model.area.IArea
16+
import org.modelix.modelql.core.EmptyQueryResultException
1617
import org.modelix.modelql.core.IMonoUnboundQuery
1718
import org.modelix.modelql.core.IStepOutput
1819
import org.modelix.modelql.core.MODELIX_VERSION
@@ -22,6 +23,7 @@ import org.modelix.modelql.core.VersionAndData
2223
import org.modelix.modelql.core.upcast
2324
import org.modelix.modelql.untyped.UntypedModelQL
2425
import org.modelix.modelql.untyped.createQueryExecutor
26+
import org.modelix.streams.StreamAssertionError
2527
import kotlin.system.measureTimeMillis
2628

2729
class ModelQLServer private constructor(val rootNodeProvider: () -> INode?, val area: IArea? = null) {
@@ -101,17 +103,19 @@ class ModelQLServer private constructor(val rootNodeProvider: () -> INode?, val
101103
val serializedResult = json.encodeToString(VersionAndData.serializer(serializer), versionAndResult)
102104
afterQueryExecution()
103105
call.respondText(text = serializedResult, contentType = ContentType.Application.Json)
104-
} catch (ex: UnresolvableNodeReferenceException) {
105-
afterQueryExecution()
106-
call.respondText(
107-
text = "server version: $MODELIX_VERSION\n" + ex.stackTraceToString(),
108-
status = HttpStatusCode.NotFound,
109-
)
110-
} catch (ex: Throwable) {
106+
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
111107
afterQueryExecution()
108+
val statusCode = when (ex) {
109+
is UnresolvableNodeReferenceException,
110+
is StreamAssertionError,
111+
is EmptyQueryResultException,
112+
-> HttpStatusCode.UnprocessableEntity
113+
114+
else -> HttpStatusCode.InternalServerError
115+
}
112116
call.respondText(
113117
text = "server version: $MODELIX_VERSION\n" + ex.stackTraceToString(),
114-
status = HttpStatusCode.InternalServerError,
118+
status = statusCode,
115119
)
116120
}
117121
}

streams/src/commonMain/kotlin/org/modelix/streams/StreamExtensions.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import com.badoo.reaktive.single.map
3434
import com.badoo.reaktive.single.subscribe
3535
import kotlinx.coroutines.flow.single
3636

37+
class StreamAssertionError(message: String) : IllegalArgumentException(message)
38+
3739
fun Observable<*>.count(): Single<Int> = collect({ arrayOf(0) }) { acc, it -> acc[0]++ }.map { it[0] }
3840
fun <T, R> Observable<T>.fold(initial: R, operation: (R, T) -> R): Single<R> {
3941
return collect({ mutableListOf(initial) }) { acc, it -> acc[0] = operation(acc[0], it) }.map { it[0] }
@@ -51,13 +53,13 @@ fun <T> Observable<T>.withIndex(): Observable<IndexedValue<T>> {
5153
return map { IndexedValue(index++, it) }
5254
}
5355
fun <T> Observable<T>.assertNotEmpty(message: () -> String): Observable<T> {
54-
return this.switchIfEmpty { throw IllegalArgumentException("At least one element was expected. " + message()) }
56+
return this.switchIfEmpty { throw StreamAssertionError("At least one element was expected. xxx " + message()) }
5557
}
5658
fun <T> Maybe<T>.assertEmpty(message: (T) -> String): Completable {
57-
return map { throw IllegalArgumentException(message(it)) }.asCompletable()
59+
return map { throw StreamAssertionError(message(it)) }.asCompletable()
5860
}
5961
fun <T> Observable<T>.assertEmpty(message: (T) -> String): Completable {
60-
return map { throw IllegalArgumentException(message(it)) }.asCompletable()
62+
return map { throw StreamAssertionError(message(it)) }.asCompletable()
6163
}
6264
fun <T> Single<T>.cached(): Single<T> {
6365
return this.asObservable().replay(1).autoConnect()

0 commit comments

Comments
 (0)