feat(analysis): scaffold chess analysis microservice (NCS-71) NCI-10 (#69)
Build & Test (NowChessSystems) TeamCity build finished

NCS-95 NCS-96 NCS-97 NCI-10

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #69
This commit was merged in pull request #69.
This commit is contained in:
2026-06-15 21:40:24 +02:00
parent 0a5a216032
commit 0bdf72bddc
23 changed files with 683 additions and 0 deletions
@@ -0,0 +1,29 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
#
# Before building the container image run:
#
# ./gradlew :modules:analysis:build -Dquarkus.native.enabled=true
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native -t quarkus/backcore .
#
# Then run the container using:
#
# docker run -i --rm -p 8087:8087 quarkus/backcore
#
# The `registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
###
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root --chmod=0755 modules/analysis/build/*-runner /work/application
EXPOSE 8087
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,40 @@
quarkus:
http:
port: 8087
application:
name: nowchess-analysis
config:
yaml:
enabled: true
nowchess:
analysis:
chess-api:
base-url: ${CHESS_API_URL:https://chess-api.com/v1}
timeout-ms: ${CHESS_API_TIMEOUT_MS:5000}
"%dev":
quarkus:
rest-client:
chess-api:
url: https://chess-api.com/v1
connect-timeout: 5000
read-timeout: 5000
"%deployed":
quarkus:
log:
console:
json: true
otel:
traces:
sampler: parentbased_traceidratio
sampler-arg: 0.1
exporter:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
rest-client:
chess-api:
url: ${CHESS_API_URL:https://chess-api.com/v1}
connect-timeout: ${CHESS_API_CONNECT_TIMEOUT_MS:5000}
read-timeout: ${CHESS_API_TIMEOUT_MS:5000}
@@ -0,0 +1,18 @@
package de.nowchess.analysis.client
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
/** MicroProfile REST client for chess-api.com v1.
*
* Base URL is resolved from `quarkus.rest-client.chess-api.url` in application.yml.
*/
@Path("/")
@RegisterRestClient(configKey = "chess-api")
trait ChessApiClient:
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def analyse(body: ChessApiRequestDto): ChessApiResponseDto
@@ -0,0 +1,4 @@
package de.nowchess.analysis.client
/** Request body sent to chess-api.com v1 `/` endpoint. */
case class ChessApiRequestDto(fen: String, depth: Int)
@@ -0,0 +1,23 @@
package de.nowchess.analysis.client
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/** Response from chess-api.com v1 analysis endpoint.
*
* The API returns a JSON object. Fields not listed here are ignored.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
case class ChessApiResponseDto(
/** Best move in UCI format (e.g. "e2e4"). */
move: Option[String] = None,
/** Centipawn evaluation (from white's perspective). */
centipawns: Option[Double] = None,
/** Mate-in-N (positive = white wins, negative = black wins). */
mate: Option[Int] = None,
/** Principal variation: space-separated UCI moves. */
pv: Option[String] = None,
/** Actual depth searched. */
depth: Option[Int] = None,
/** Text description of the position/move quality. */
text: Option[String] = None,
)
@@ -0,0 +1,17 @@
package de.nowchess.analysis.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(new DefaultScalaModule() {
override def version(): Version =
// scalafix:off DisableSyntax.null
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
@@ -0,0 +1,18 @@
package de.nowchess.analysis.config
import de.nowchess.analysis.client.{ChessApiRequestDto, ChessApiResponseDto}
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
import de.nowchess.analysis.error.AnalysisErrorDto
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[AnalysisRequestDto],
classOf[AnalysisResponseDto],
classOf[ChessApiRequestDto],
classOf[ChessApiResponseDto],
classOf[AnalysisErrorDto],
),
registerFullHierarchy = true,
)
class NativeReflectionConfig
@@ -0,0 +1,10 @@
package de.nowchess.analysis.dto
/** Request body for the analysis endpoint.
*
* @param fen
* FEN string representing the position to analyse.
* @param depth
* Engine search depth (1-99). Defaults to 12 when absent.
*/
case class AnalysisRequestDto(fen: String, depth: Option[Int] = None)
@@ -0,0 +1,25 @@
package de.nowchess.analysis.dto
/** Response from the analysis endpoint.
*
* @param fen
* The analysed FEN.
* @param depth
* The search depth used.
* @param bestMove
* Best move in UCI notation (e.g. "e2e4"), or None if not available.
* @param evaluation
* Centipawn evaluation from white's perspective, or None.
* @param mate
* Mate-in-N value (positive = white wins, negative = black wins), or None.
* @param continuationMoves
* Principal variation as list of UCI moves.
*/
case class AnalysisResponseDto(
fen: String,
depth: Int,
bestMove: Option[String],
evaluation: Option[Double],
mate: Option[Int],
continuationMoves: List[String],
)
@@ -0,0 +1,3 @@
package de.nowchess.analysis.error
case class AnalysisErrorDto(code: String, message: String)
@@ -0,0 +1,8 @@
package de.nowchess.analysis.error
sealed class AnalysisException(val status: Int, val code: String, message: String) extends RuntimeException(message)
class InvalidFenException(fen: String) extends AnalysisException(400, "INVALID_FEN", s"Invalid FEN string: $fen")
class AnalysisUpstreamException(cause: Throwable)
extends AnalysisException(502, "UPSTREAM_ERROR", s"Chess API unavailable: ${cause.getMessage}")
@@ -0,0 +1,13 @@
package de.nowchess.analysis.error
import jakarta.ws.rs.core.{MediaType, Response}
import jakarta.ws.rs.ext.{ExceptionMapper, Provider}
@Provider
class AnalysisExceptionMapper extends ExceptionMapper[AnalysisException]:
def toResponse(ex: AnalysisException): Response =
Response
.status(ex.status)
.entity(AnalysisErrorDto(ex.code, ex.getMessage))
.`type`(MediaType.APPLICATION_JSON)
.build()
@@ -0,0 +1,33 @@
package de.nowchess.analysis.resource
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
import de.nowchess.analysis.service.AnalysisService
import jakarta.annotation.security.PermitAll
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import scala.compiletime.uninitialized
@Path("/api/analysis")
@ApplicationScoped
class AnalysisResource:
// scalafix:off DisableSyntax.var
@Inject
var analysisService: AnalysisService = uninitialized
// scalafix:on DisableSyntax.var
/** Analyse a chess position.
*
* Accepts a FEN string and optional depth, proxies to chess-api.com, and returns structured analysis data.
*/
@POST
@Path("/position")
@PermitAll
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def analysePosition(body: AnalysisRequestDto): Response =
val result = analysisService.analyse(body)
Response.ok(result).build()
@@ -0,0 +1,68 @@
package de.nowchess.analysis.service
import de.nowchess.analysis.client.{ChessApiClient, ChessApiRequestDto}
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
import de.nowchess.analysis.error.{AnalysisUpstreamException, InvalidFenException}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
@ApplicationScoped
class AnalysisService:
private val log = Logger.getLogger(classOf[AnalysisService])
private val DefaultDepth = 12
private val MinDepth = 1
private val MaxDepth = 99
// scalafix:off DisableSyntax.var
@Inject
@RestClient
var chessApiClient: ChessApiClient = uninitialized
// scalafix:on DisableSyntax.var
// scalafix:off DisableSyntax.throw
def analyse(request: AnalysisRequestDto): AnalysisResponseDto =
val fen = request.fen.trim
if fen.isEmpty then throw InvalidFenException(fen)
validateFen(fen)
val depth = request.depth
.map(d => d.max(MinDepth).min(MaxDepth))
.getOrElse(DefaultDepth)
log.debugf("Analysing FEN '%s' at depth %d", fen, depth)
val apiResponse =
try chessApiClient.analyse(ChessApiRequestDto(fen, depth))
catch
case ex: Exception =>
log.warnf(ex, "Chess API call failed for FEN '%s'", fen)
throw AnalysisUpstreamException(ex)
val continuationMoves = apiResponse.pv
.map(_.split(" ").toList.filter(_.nonEmpty))
.getOrElse(List.empty)
AnalysisResponseDto(
fen = fen,
depth = apiResponse.depth.getOrElse(depth),
bestMove = apiResponse.move,
evaluation = apiResponse.centipawns,
mate = apiResponse.mate,
continuationMoves = continuationMoves,
)
// scalafix:on DisableSyntax.throw
/** Rudimentary FEN structure validation — checks the board part has 8 ranks. */
// scalafix:off DisableSyntax.throw
private def validateFen(fen: String): Unit =
val parts = fen.split(" ")
if parts.length < 1 then throw InvalidFenException(fen)
val ranks = parts(0).split("/")
if ranks.length != 8 then throw InvalidFenException(fen)
// scalafix:on DisableSyntax.throw