Skip to content

Instantly share code, notes, and snippets.

@dacr
Last active April 5, 2025 07:36
Show Gist options
  • Save dacr/75e31e80fc6371c37ebb43ab9ece7855 to your computer and use it in GitHub Desktop.
Save dacr/75e31e80fc6371c37ebb43ab9ece7855 to your computer and use it in GitHub Desktop.
Chronos API using zio, tapir and zhttp / published by https://github.com/dacr/code-examples-manager #84978260-6962-410a-8fca-dfe6e187262c/acf308276fe17d1dfdac65c222fe8c4f9d66ff7b
{
"openapi" : "3.1.0",
"info" : {
"title" : "Chronos API",
"version" : "1.0",
"description" : "Timekeeping backend"
},
"paths" : {
"/chronos" : {
"get" : {
"tags" : [
"chronos"
],
"summary" : "List all defined chronos",
"operationId" : "chronos list",
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/Chronos"
}
}
}
}
}
}
},
"post" : {
"tags" : [
"chronos"
],
"summary" : "Create a new chronos",
"operationId" : "chronos create",
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosSpec"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Chronos"
}
}
}
},
"400" : {
"description" : "Invalid value for: body",
"content" : {
"text/plain" : {
"schema" : {
"type" : "string"
}
}
}
}
}
}
},
"/chronos/{chronosId}" : {
"get" : {
"tags" : [
"chronos"
],
"summary" : "get detailed information for given chronos",
"operationId" : "chronos get",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
}
],
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Chronos"
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
},
"put" : {
"tags" : [
"chronos"
],
"summary" : "update the given chronos",
"operationId" : "chronos update",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
}
],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosSpec"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Chronos"
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
},
"delete" : {
"tags" : [
"chronos"
],
"summary" : "delete the given chronos",
"operationId" : "chronos delete",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
}
],
"responses" : {
"200" : {
"description" : ""
},
"400" : {
"description" : "Invalid value for: path parameter chronosId",
"content" : {
"text/plain" : {
"schema" : {
"type" : "string"
}
}
}
}
}
}
},
"/chronos/{chronosId}/competitor" : {
"get" : {
"tags" : [
"competitors"
],
"summary" : "List defined competitors for given chronos",
"operationId" : "competitors list",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
}
],
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/Competitor"
}
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
},
"post" : {
"tags" : [
"competitors"
],
"summary" : "create a new competitor for the given chronos",
"operationId" : "competitor create",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
}
],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/CompetitorSpec"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Competitor"
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
}
},
"/chronos/{chronosId}/competitor/{competitorId}" : {
"get" : {
"tags" : [
"competitors"
],
"summary" : "List defined competitors for given chronos",
"operationId" : "competitor get",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c40dfc42-3b2d-4294-b059-8dcd48d8f380"
},
{
"name" : "competitorId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "ad016ce3-e245-442b-b970-f3fb9b87d2f4"
}
],
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Competitor"
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
},
"put" : {
"tags" : [
"competitors"
],
"summary" : "update the given competitor for the given chronos",
"operationId" : "competitor update",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
},
{
"name" : "competitorId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "ad016ce3-e245-442b-b970-f3fb9b87d2f4"
}
],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/CompetitorSpec"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Competitor"
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
},
"delete" : {
"tags" : [
"competitors"
],
"summary" : "delete the given competitor for the given chronos",
"operationId" : "competitor delete",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
},
{
"name" : "competitorId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "ad016ce3-e245-442b-b970-f3fb9b87d2f4"
}
],
"responses" : {
"200" : {
"description" : ""
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
}
},
"/chronos/{chronosId}/competitor/{competitorId}/timings" : {
"get" : {
"tags" : [
"measure"
],
"summary" : "get timings for the given chronos and competitor",
"operationId" : "timings get",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
},
{
"name" : "competitorId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
}
],
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/Timing"
}
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
},
"put" : {
"tags" : [
"measure"
],
"summary" : "add a new timing for the given competitor and chronos, the internal current time is used as timestamp for this timing",
"operationId" : "timing put",
"parameters" : [
{
"name" : "chronosId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
},
{
"name" : "competitorId",
"in" : "path",
"required" : true,
"schema" : {
"type" : "string",
"format" : "uuid"
},
"example" : "c2968f8e-bcbb-46f1-9dba-cb1969b8d391"
},
{
"name" : "Category",
"in" : "query",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/TimingCategory"
}
}
],
"responses" : {
"200" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/Timing"
}
}
}
},
"400" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosBadRequestFailure"
}
}
}
},
"404" : {
"description" : "",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChronosNotFoundFailure"
}
}
}
}
}
}
}
},
"components" : {
"schemas" : {
"Chronos" : {
"title" : "Chronos",
"type" : "object",
"required" : [
"id",
"name",
"description",
"created"
],
"properties" : {
"id" : {
"type" : "string",
"format" : "uuid"
},
"name" : {
"type" : "string"
},
"description" : {
"type" : "string"
},
"created" : {
"type" : "string",
"format" : "date-time"
}
}
},
"ChronosBadRequestFailure" : {
"title" : "ChronosBadRequestFailure",
"type" : "object",
"required" : [
"message"
],
"properties" : {
"message" : {
"type" : "string"
}
}
},
"ChronosNotFoundFailure" : {
"title" : "ChronosNotFoundFailure",
"type" : "object",
"required" : [
"message",
"entityId"
],
"properties" : {
"message" : {
"type" : "string"
},
"entityId" : {
"type" : "string",
"format" : "uuid"
}
}
},
"ChronosSpec" : {
"title" : "ChronosSpec",
"type" : "object",
"required" : [
"name",
"description"
],
"properties" : {
"name" : {
"type" : "string"
},
"description" : {
"type" : "string"
}
}
},
"Competitor" : {
"title" : "Competitor",
"type" : "object",
"required" : [
"id",
"firstName",
"lastName",
"birthDate"
],
"properties" : {
"id" : {
"type" : "string",
"format" : "uuid"
},
"firstName" : {
"type" : "string"
},
"lastName" : {
"type" : "string"
},
"birthDate" : {
"type" : "string",
"format" : "date-time"
}
}
},
"CompetitorSpec" : {
"title" : "CompetitorSpec",
"type" : "object",
"required" : [
"firstName",
"lastName",
"birthDate"
],
"properties" : {
"firstName" : {
"type" : "string"
},
"lastName" : {
"type" : "string"
},
"birthDate" : {
"type" : "string",
"format" : "date-time"
}
}
},
"Timing" : {
"title" : "Timing",
"type" : "object",
"required" : [
"category",
"timestamp"
],
"properties" : {
"category" : {
"$ref" : "#/components/schemas/TimingCategory"
},
"timestamp" : {
"type" : "string",
"format" : "date-time"
}
}
},
"TimingCategory" : {
"title" : "TimingCategory",
"type" : "string",
"enum" : [
"End",
"Start",
"Step"
]
}
}
}
}
OPENAPI_DEP="org.openapitools:openapi-generator-cli:7.11.0"
OPENAPI_GEN="scala-cli --dep $OPENAPI_DEP -e org.openapitools.codegen.OpenAPIGenerator.main(args) --"
API_SPECS_FILE=chronos-api.json
GENERATED_CODE_DESTINATION=chronos-target/
gen_python_flask() {
${OPENAPI_GEN} generate \
-g python-flask \
-i ${API_SPECS_FILE} \
--additional-properties=generateSourceCodeOnly=true \
-o ${GENERATED_CODE_DESTINATION}/python-flask
}
gen_rust_server() {
${OPENAPI_GEN} generate \
-g rust-server \
-i ${API_SPECS_FILE} \
-o ${GENERATED_CODE_DESTINATION}/rust-server
}
gen_scala_http4s() {
${OPENAPI_GEN} generate \
-g scala-http4s-server \
-i ${API_SPECS_FILE} \
-o ${GENERATED_CODE_DESTINATION}/scala-http4s-server
}
gen_java_undertow() {
${OPENAPI_GEN} generate \
-g java-undertow-server \
-i ${API_SPECS_FILE} \
-o ${GENERATED_CODE_DESTINATION}/java-undertow-server
}
gen_python_flask
gen_rust_server
gen_scala_http4s
gen_java_undertow
#!/usr/bin/env -S scala-cli test -S 3
// summary : Chronos API tests
// keywords : scala, cats, sttp, chronos, test
// publish : gist
// authors : David Crosson
// license : Apache NON-AI License Version 2.0 (https://raw.githubusercontent.com/non-ai-licenses/non-ai-licenses/main/NON-AI-APACHE2)
// id : 91025af9-38bd-4b7a-8b6c-7997162400fc
// created-on : 2025-01-11T00:22:40+01:00
// managed-by : https://github.com/dacr/code-examples-manager
// run-with : scala-cli test $file
// test-with : curl http://127.0.0.1:8080/chronos
// ---------------------
//> using scala 3.6.4
//> using objectWrapper
////> using platform native
////> using nativeVersion 0.5.7
//> using dep com.softwaremill.sttp.client4::circe:4.0.0-RC3
//> using dep com.softwaremill.sttp.client4::cats:4.0.0-RC3
//> using dep org.typelevel::cats-effect:3.6.0
//> using dep io.circe::circe-generic:0.14.12
//> using dep org.typelevel::munit-cats-effect-3:1.0.7
// ---------------------
import io.circe.generic.auto.*
import io.circe.*
import io.circe.parser.*
import io.circe.syntax.*
import sttp.client4.*
import sttp.client4.circe.*
import sttp.client4.httpclient.cats.HttpClientCatsBackend
import HttpClientCatsBackend.resource
import cats.effect.{IO, SyncIO}
import munit.CatsEffectSuite
import java.time.{Instant, OffsetDateTime}
import java.util.UUID
object Model {
type CompetitorId = UUID
type ChronosId = UUID
// -------------------------------------------------------------------------------------------------------------------
case class ChronosSpec(
name: String,
description: String
)
// -------------------------------------------------------------------------------------------------------------------
case class CompetitorSpec(
firstName: String,
lastName: String,
birthDate: OffsetDateTime
)
// -------------------------------------------------------------------------------------------------------------------
case class TimerSpec(
name: String
)
// -------------------------------------------------------------------------------------------------------------------
case class Chronos(
id: ChronosId,
name: String,
description: String,
created: Instant
)
// -------------------------------------------------------------------------------------------------------------------
case class Competitor(
id: CompetitorId,
firstName: String,
lastName: String,
birthDate: OffsetDateTime
)
// -------------------------------------------------------------------------------------------------------------------
enum TimingCategory {
case Start
case Step
case End
}
case class Timing(
category: TimingCategory,
timestamp: Instant
)
// -------------------------------------------------------------------------------------------------------------------
}
class ClassicTests extends CatsEffectSuite {
import Model.*
given Encoder[TimingCategory] = Encoder[String].contramap(_.toString)
given Decoder[TimingCategory] = Decoder[String].emap(s => Right(TimingCategory.valueOf(s))) // TODO manage left case
val baseAPI = "http://127.0.0.1:8080"
// -----------------------------------------------------------------------------------------------
test("List chronos") {
resource[IO]().use { backend =>
for {
chronos <-
quickRequest
.get(uri"$baseAPI/chronos")
.response(asJson[List[Chronos]])
.send(backend)
} yield assertEquals(chronos.code.code, 200)
}
}
// -----------------------------------------------------------------------------------------------
test("Create, read, update, delete a chronos") {
resource[IO]().use { backend =>
for {
randName <- IO.randomUUID.map(uuid => s"Chronos $uuid")
chronos <-
quickRequest
.post(uri"$baseAPI/chronos")
.body(ChronosSpec(randName, s"$randName description").asJson.noSpaces)
.response(asJson[Chronos])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
gottenChronos <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}")
.response(asJson[Chronos])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
_ <-
quickRequest
.put(uri"$baseAPI/chronos/${chronos.id}")
.body(ChronosSpec(randName, s"$randName updated description").asJson.noSpaces)
.send(backend)
updatedChronos <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}")
.response(asJson[Chronos])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
responseDelete <-
quickRequest
.delete(uri"$baseAPI/chronos/${chronos.id}")
.send(backend)
resultAfterDelete <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}")
.response(asJson[Chronos])
.send(backend)
} yield {
assertEquals(chronos, gottenChronos)
assertEquals(updatedChronos.description, s"$randName updated description")
assert(resultAfterDelete.code.code == 404)
}
}
}
// -----------------------------------------------------------------------------------------------
test("List Competitors") {
resource[IO]().use { backend =>
for {
randName <- IO.randomUUID.map(uuid => s"Chronos $uuid")
chronos <-
quickRequest
.post(uri"$baseAPI/chronos")
.body(ChronosSpec(randName, s"$randName description").asJson.noSpaces)
.response(asJson[Chronos])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
competitors <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}/competitor")
.response(asJson[List[Competitor]])
.send(backend)
} yield assertEquals(competitors.code.code, 200)
}
}
// -----------------------------------------------------------------------------------------------
test("Create, read, update, delete a competitor") {
resource[IO]().use { backend =>
for {
randName <- IO.randomUUID.map(uuid => s"Chronos $uuid")
randFirstName <- IO.randomUUID.map(uuid => s"Joe-$uuid")
chronos <-
quickRequest
.post(uri"$baseAPI/chronos")
.body(ChronosSpec(randName, s"$randName description").asJson.noSpaces)
.response(asJson[Chronos])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
competitor <-
quickRequest
.post(uri"$baseAPI/chronos/${chronos.id}/competitor")
.body(CompetitorSpec(randFirstName, "doe", OffsetDateTime.parse("1942-01-01T00:00:00Z")).asJson.noSpaces)
.response(asJson[Competitor])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
gottenCompetitor <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}")
.response(asJson[Competitor])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
_ <-
quickRequest
.put(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}")
.body(CompetitorSpec(s"$randFirstName-updated", competitor.lastName, competitor.birthDate).asJson.noSpaces)
.send(backend)
updatedCompetitor <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}")
.response(asJson[Competitor])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
responseDelete <-
quickRequest
.delete(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}")
.send(backend)
resultAfterDelete <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}")
.response(asJson[Competitor])
.send(backend)
} yield {
assertEquals(competitor, gottenCompetitor)
assert(updatedCompetitor.firstName.contains("updated"))
assert(resultAfterDelete.code.code == 404)
}
}
}
// -----------------------------------------------------------------------------------------------
test("Take timings of a competitor ") {
resource[IO]().use { backend =>
for {
randName <- IO.randomUUID.map(uuid => s"Chronos $uuid")
chronos <-
quickRequest
.post(uri"$baseAPI/chronos")
.body(ChronosSpec(randName, s"$randName description").asJson.noSpaces)
.response(asJson[Chronos])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
competitor <-
quickRequest
.post(uri"$baseAPI/chronos/${chronos.id}/competitor")
.body(CompetitorSpec("john", "doe", OffsetDateTime.parse("1942-01-01T00:00:00Z")).asJson.noSpaces)
.response(asJson[Competitor])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
timingStart <-
quickRequest
.put(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}/timings?Category=${TimingCategory.Start}")
.response(asJson[Timing])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
timingEnd <-
quickRequest
.put(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}/timings?Category=${TimingCategory.End}")
.response(asJson[Timing])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
timings <-
quickRequest
.get(uri"$baseAPI/chronos/${chronos.id}/competitor/${competitor.id}/timings")
.response(asJson[List[Timing]])
.send(backend)
.flatMap(response => IO.fromEither(response.body))
} yield {
assert(timings.size == 2)
}
}
}
// -----------------------------------------------------------------------------------------------
}
#!/usr/bin/env -S scala-cli shebang -S 3
// summary : Chronos API using zio, tapir and zhttp
// keywords : scala, chronos, zio, tapir, http, http-server, zhttp, stateful, state, dto, json, chronos, @testable, @exclusive
// publish : gist
// authors : David Crosson
// license : Apache NON-AI License Version 2.0 (https://raw.githubusercontent.com/non-ai-licenses/non-ai-licenses/main/NON-AI-APACHE2)
// id : 84978260-6962-410a-8fca-dfe6e187262c
// created-on : 2022-03-21T07:42:42+01:00
// managed-by : https://github.com/dacr/code-examples-manager
// run-with : scala-cli $file
// test-with : curl http://127.0.0.1:8080/chronos
// attachments : chronos-api.json, chronos-gen.sh, chronos-tests.sc
// ---------------------
//> using scala 3.6.4
////> using platform native
////> using nativeVersion 0.5.7
//> using dep com.softwaremill.sttp.tapir::tapir-zio:1.11.20
//> using dep com.softwaremill.sttp.tapir::tapir-zio-http-server:1.11.20
//> using dep com.softwaremill.sttp.tapir::tapir-json-zio:1.11.20
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.11.20
//> using dep dev.zio::zio-nio:2.0.2
//> using dep dev.zio::zio-json:0.7.39
// ---------------------
import java.time.{Instant, OffsetDateTime}
import java.util.UUID
import zio.*
import zio.json.*
import sttp.tapir.ztapir.*
import sttp.tapir.server.ziohttp.ZioHttpInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import sttp.tapir.json.zio.*
import sttp.tapir.generic.auto.*
import sttp.apispec.openapi.Info
import sttp.model.StatusCode.{BadRequest, NotFound}
import sttp.tapir.Codec.PlainCodec
import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
import sttp.tapir.{Codec, Schema}
import zio.http.Server
import zio.nio.file.*
object Model {
type CompetitorId = UUID
type ChronosId = UUID
// -------------------------------------------------------------------------------------------------------------------
case class ChronosSpec(
name: String,
description: String
) derives JsonCodec
// -------------------------------------------------------------------------------------------------------------------
case class CompetitorSpec(
firstName: String,
lastName: String,
birthDate: OffsetDateTime
) derives JsonCodec
// -------------------------------------------------------------------------------------------------------------------
case class Chronos(
id: ChronosId,
name: String,
description: String,
created: Instant
) derives JsonCodec
// -------------------------------------------------------------------------------------------------------------------
case class Competitor(
id: CompetitorId,
firstName: String,
lastName: String,
birthDate: OffsetDateTime
) derives JsonCodec
// -------------------------------------------------------------------------------------------------------------------
enum TimingCategory {
case Start
case Step
case End
}
object TimingCategory {
given JsonEncoder[TimingCategory] = JsonEncoder[String].contramap(p => p.toString)
given JsonDecoder[TimingCategory] = JsonDecoder[String].map(p => TimingCategory.valueOf(p))
given Schema[TimingCategory] = Schema.derivedEnumeration.defaultStringBased
given PlainCodec[TimingCategory] = Codec.derivedEnumeration[String, TimingCategory].defaultStringBased
}
// -------------------------------------------------------------------------------------------------------------------
case class Timing(
category: TimingCategory,
timestamp: Instant
) derives JsonCodec
}
// ===================================================================================================
object WebApp extends ZIOAppDefault {
import Model.*
sealed trait ChronosFailure derives JsonCodec {
val message: String
}
case class ChronosInvalidOperationFailure(message: String, entityId: UUID) extends ChronosFailure derives JsonCodec
case class ChronosNotFoundFailure(message: String, entityId: UUID) extends ChronosFailure derives JsonCodec
case class ChronosBadRequestFailure(message: String) extends ChronosFailure derives JsonCodec
// -------------------------------------------------------------------------------------------------------------------
case class AppState(
activeChronos: Map[ChronosId, Chronos] = Map.empty,
activeCompetitors: Map[ChronosId, List[Competitor]] = Map.empty,
activeTimings: Map[CompetitorId, List[Timing]] = Map.empty
) {
def upsertChronos(chronos: Chronos): AppState = {
copy(activeChronos = activeChronos + (chronos.id -> chronos))
}
def deleteChronos(chronosId: ChronosId): AppState = {
copy(
activeChronos = activeChronos - chronosId,
activeCompetitors = activeCompetitors - chronosId,
activeTimings = activeTimings -- activeCompetitors.getOrElse(chronosId, Nil).map(_.id)
)
}
def upsertCompetitor(chronosId: ChronosId, competitor: Competitor): AppState = {
copy(activeCompetitors = activeCompetitors + (chronosId -> (competitor :: activeCompetitors.getOrElse(chronosId, Nil))))
}
def deleteCompetitor(chronosId: ChronosId, competitorId: CompetitorId): AppState = {
copy(
activeCompetitors = activeCompetitors + (chronosId -> activeCompetitors.getOrElse(chronosId, Nil).filterNot(_.id == competitorId)),
activeTimings = activeTimings -- activeCompetitors.getOrElse(chronosId, Nil).map(_.id)
)
}
def addTiming(competitorId: CompetitorId, timing: Timing): AppState = {
copy(activeTimings = activeTimings + (competitorId -> (timing :: activeTimings.getOrElse(competitorId, Nil))))
}
def deleteTiming(competitorId: CompetitorId, timingId: UUID): AppState = {
//TODO généré par Gemini, à tester tout de même
copy(
activeTimings = activeTimings.get(competitorId) match {
case Some(timings) =>
val updatedTimings = timings.filterNot(_.timestamp == timingId) // Filter out the matching timing
if (updatedTimings.isEmpty) activeTimings - competitorId // Remove competitor if no timings remain
else activeTimings + (competitorId -> updatedTimings) // Update with new timing list
case None => activeTimings // No changes if competitor not found
}
)
}
}
// ===================================================================================================================
type ChronosEnv = Ref[AppState]
// -------------------------------------------------------------------------------------------------------------------
val chronosList: ZIO[ChronosEnv, Nothing, List[Chronos]] =
for {
state <- ZIO.serviceWithZIO[Ref[AppState]](_.get)
} yield state.activeChronos.values.toList
val chronosListEndPoint =
endpoint
.tag("chronos")
.name("chronos list")
.summary("List all defined chronos")
.get
.in("chronos")
.out(jsonBody[List[Chronos]])
val chronosListRoute = chronosListEndPoint.zServerLogic(_ => chronosList)
// -------------------------------------------------------------------------------------------------------------------
def chronosGet(id: ChronosId): ZIO[ChronosEnv, ChronosFailure, Chronos] =
for {
state <- ZIO.serviceWithZIO[Ref[AppState]](_.get)
chronos <- ZIO.from(state.activeChronos.get(id)).mapError(err => ChronosNotFoundFailure(s"Chronos not found", id))
} yield chronos
val chronosGetEndPoint =
endpoint
.tag("chronos")
.name("chronos get")
.summary("get detailed information for given chronos")
.get
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.out(jsonBody[Chronos])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val chronosGetRoute = chronosGetEndPoint.zServerLogic(chronosGet)
// -------------------------------------------------------------------------------------------------------------------
def chronosCreate(spec: ChronosSpec): ZIO[ChronosEnv, Nothing, Chronos] =
for {
ref <- ZIO.service[Ref[AppState]]
uuid <- Random.nextUUID
now <- Clock.instant
chronos = Chronos(id = uuid, name = spec.name, description = spec.description, created = now)
_ <- ref.update(_.upsertChronos(chronos))
} yield chronos
val chronosCreateEndPoint =
endpoint
.tag("chronos")
.name("chronos create")
.summary("Create a new chronos")
.post
.in("chronos")
.in(jsonBody[ChronosSpec])
.out(jsonBody[Chronos])
val chronosCreateRoute = chronosCreateEndPoint.zServerLogic(chronosCreate)
// -------------------------------------------------------------------------------------------------------------------
def chronosUpdate(chronosId: ChronosId, spec: ChronosSpec): ZIO[ChronosEnv, ChronosFailure, Chronos] =
for {
ref <- ZIO.service[Ref[AppState]]
previous <- chronosGet(chronosId)
chronos = previous.copy(name = spec.name, description = spec.description)
_ <- ref.update(_.upsertChronos(chronos))
} yield chronos
val chronosUpdateEndPoint =
endpoint
.tag("chronos")
.name("chronos update")
.summary("update the given chronos")
.put
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in(jsonBody[ChronosSpec])
.out(jsonBody[Chronos])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val chronosUpdateRoute = chronosUpdateEndPoint.zServerLogic(chronosUpdate)
// -------------------------------------------------------------------------------------------------------------------
def chronosDelete(id: ChronosId): ZIO[ChronosEnv, Nothing, Unit] =
for {
ref <- ZIO.service[Ref[AppState]]
_ <- ref.update(_.deleteChronos(id))
} yield ()
val chronosDeleteEndPoint =
endpoint
.tag("chronos")
.name("chronos delete")
.summary("delete the given chronos")
.delete
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
val chronosDeleteRoute = chronosDeleteEndPoint.zServerLogic(chronosDelete)
// -------------------------------------------------------------------------------------------------------------------
def competitorList(chronosId: ChronosId): ZIO[ChronosEnv, ChronosFailure, List[Competitor]] =
for {
state <- ZIO.serviceWithZIO[Ref[AppState]](_.get)
competitors = state.activeCompetitors.getOrElse(chronosId, Nil)
} yield competitors
val competitorListEndPoint =
endpoint
.tag("competitors")
.name("competitors list")
.summary("List defined competitors for given chronos")
.get
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("competitor")
.out(jsonBody[List[Competitor]])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val competitorListRoute = competitorListEndPoint.zServerLogic(chronosId => competitorList(chronosId))
// -------------------------------------------------------------------------------------------------------------------
def competitorGet(chronosId: ChronosId, competitorId: CompetitorId): ZIO[ChronosEnv, ChronosFailure, Competitor] =
for {
state <- ZIO.serviceWithZIO[Ref[AppState]](_.get)
competitors <- ZIO
.from(state.activeCompetitors.get(chronosId))
.mapError(err => ChronosNotFoundFailure(s"Chronos not found", chronosId))
competitor <- ZIO
.from(competitors.find(_.id == competitorId))
.mapError(err => ChronosNotFoundFailure(s"Competitor not found", competitorId))
} yield competitor
val competitorGetEndPoint =
endpoint
.tag("competitors")
.name("competitor get")
.summary("List defined competitors for given chronos")
.get
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c40dfc42-3b2d-4294-b059-8dcd48d8f380")))
.in("competitor")
.in(path[CompetitorId]("competitorId").example(UUID.fromString("ad016ce3-e245-442b-b970-f3fb9b87d2f4")))
.out(jsonBody[Competitor])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val competitorGetRoute = competitorGetEndPoint.zServerLogic((chronosId, competitorId) => competitorGet(chronosId, competitorId))
// -------------------------------------------------------------------------------------------------------------------
def competitorCreate(chronosId: ChronosId, spec: CompetitorSpec): ZIO[ChronosEnv, ChronosFailure, Competitor] =
for {
chronos <- chronosGet(chronosId)
ref <- ZIO.service[Ref[AppState]]
uuid <- Random.nextUUID
competitor = Competitor(
id = uuid,
firstName = spec.firstName,
lastName = spec.lastName,
birthDate = spec.birthDate
)
_ <- ref.update(_.upsertCompetitor(chronos.id, competitor))
} yield competitor
val competitorCreateEndPoint =
endpoint
.tag("competitors")
.name("competitor create")
.summary("create a new competitor for the given chronos")
.post
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("competitor")
.in(jsonBody[CompetitorSpec])
.out(jsonBody[Competitor])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val competitorCreateRoute = competitorCreateEndPoint.zServerLogic(competitorCreate)
// -------------------------------------------------------------------------------------------------------------------
def competitorUpdate(chronosId: ChronosId, competitorId: CompetitorId, spec: CompetitorSpec): ZIO[ChronosEnv, ChronosFailure, Competitor] =
for {
chronos <- chronosGet(chronosId)
ref <- ZIO.service[Ref[AppState]]
previous <- competitorGet(chronosId, competitorId)
competitor = previous.copy(
firstName = spec.firstName,
lastName = spec.lastName,
birthDate = spec.birthDate
)
_ <- ref.update(_.upsertCompetitor(chronos.id, competitor))
} yield competitor
val competitorUpdateEndPoint =
endpoint
.tag("competitors")
.name("competitor update")
.summary("update the given competitor for the given chronos")
.put
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("competitor")
.in(path[CompetitorId]("competitorId").example(UUID.fromString("ad016ce3-e245-442b-b970-f3fb9b87d2f4")))
.in(jsonBody[CompetitorSpec])
.out(jsonBody[Competitor])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val competitorUpdateRoute = competitorUpdateEndPoint.zServerLogic(competitorUpdate)
// -------------------------------------------------------------------------------------------------------------------
def competitorDelete(chronosId: ChronosId, competitorId: CompetitorId): ZIO[ChronosEnv, ChronosFailure, Unit] =
for {
chronos <- chronosGet(chronosId)
ref <- ZIO.service[Ref[AppState]]
competitor <- competitorGet(chronosId, competitorId)
_ <- ref.update(_.deleteCompetitor(chronos.id, competitor.id))
} yield ()
val competitorDeleteEndPoint =
endpoint
.tag("competitors")
.name("competitor delete")
.summary("delete the given competitor for the given chronos")
.delete
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("competitor")
.in(path[CompetitorId]("competitorId").example(UUID.fromString("ad016ce3-e245-442b-b970-f3fb9b87d2f4")))
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val competitorDeleteRoute = competitorDeleteEndPoint.zServerLogic(competitorDelete)
// -------------------------------------------------------------------------------------------------------------------
def timingList(chronosId: UUID, competitorId: UUID): ZIO[ChronosEnv, ChronosFailure, List[Timing]] =
for {
state <- ZIO.serviceWithZIO[Ref[AppState]](_.get)
chronos <- chronosGet(chronosId)
competitor <- competitorGet(chronosId, competitorId)
timings = state.activeTimings.getOrElse(competitorId, Nil)
} yield timings
val timingListEndPoint =
endpoint
.tag("measure")
.name("timings get")
.summary("get timings for the given chronos and competitor")
.get
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("competitor")
.in(path[CompetitorId]("competitorId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("timings")
.out(jsonBody[List[Timing]])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val timingListRoute = timingListEndPoint.zServerLogic(timingList)
// -------------------------------------------------------------------------------------------------------------------
def timingPut(chronosId: UUID, competitorId: UUID, category: TimingCategory): ZIO[ChronosEnv, ChronosFailure, Timing] =
for {
ref <- ZIO.service[Ref[AppState]]
chronos <- chronosGet(chronosId)
competitor <- competitorGet(chronosId, competitorId)
timestamp <- Clock.currentDateTime
timings <- timingList(chronosId, competitorId)
_ <- ZIO.cond(category != TimingCategory.Start || timings.isEmpty, (), ChronosInvalidOperationFailure(s"Timings already started", competitor.id))
_ <- ZIO.cond(category != TimingCategory.End || timings.nonEmpty, (), ChronosInvalidOperationFailure(s"Can not end a not started timings", competitor.id))
_ <- ZIO.cond(
category != TimingCategory.End || !timings.lastOption.contains(TimingCategory.End),
(),
ChronosInvalidOperationFailure(s"Already ended timings", competitor.id)
)
_ <- ZIO.cond(category != TimingCategory.Step || timings.nonEmpty, (), ChronosInvalidOperationFailure(s"Not started timings", competitor.id))
timing = Timing(category = category, timestamp = timestamp.toInstant)
_ <- ref.update(_.addTiming(competitor.id, timing))
} yield timing
val timingPutEndPoint =
endpoint
.tag("measure")
.name("timing put")
.summary("add a new timing for the given competitor and chronos, the internal current time is used as timestamp for this timing")
.put
.in("chronos")
.in(path[ChronosId]("chronosId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("competitor")
.in(path[CompetitorId]("competitorId").example(UUID.fromString("c2968f8e-bcbb-46f1-9dba-cb1969b8d391")))
.in("timings")
.in(query[TimingCategory]("Category"))
.out(jsonBody[Timing])
.errorOut(
oneOf(
oneOfVariant(BadRequest, jsonBody[ChronosBadRequestFailure]),
oneOfVariant(NotFound, jsonBody[ChronosNotFoundFailure])
)
)
val timingPutRoute = timingPutEndPoint.zServerLogic(timingPut)
// -------------------------------------------------------------------------------------------------------------------
val chronosRoutes = List(
chronosCreateRoute,
chronosGetRoute,
chronosUpdateRoute,
chronosDeleteRoute,
chronosListRoute,
competitorCreateRoute,
competitorGetRoute,
competitorUpdateRoute,
competitorDeleteRoute,
competitorListRoute,
timingListRoute,
timingPutRoute
)
// -------------------------------------------------------------------------------------------------------------------
val apiInfo = Info(title = "Chronos API", version = "1.0", description = Some("Timekeeping backend"))
val apiDocRoutes =
SwaggerInterpreter()
.fromServerEndpoints(
chronosRoutes,
apiInfo
)
// ===================================================================================================================
val generateAPIDocFile = {
import io.circe.syntax.*
import sttp.apispec.openapi.circe.*
for {
apiDocs <- ZIO.attempt(OpenAPIDocsInterpreter().toOpenAPI(chronosRoutes.map(_.endpoint), apiInfo))
apiDocsJson <- ZIO.attempt(apiDocs.asJson)
apiDocsJsonFilepath = Path(".") / "chronos-api.json"
_ <- Files.writeBytes(apiDocsJsonFilepath, Chunk.fromArray(apiDocsJson.toString.getBytes("UTF-8")))
infoMessage = s"OPENAPI specification file written to $apiDocsJsonFilepath"
_ <- ZIO.logInfo(infoMessage)
_ <- Console.printLine(infoMessage)
} yield ()
}
// ===================================================================================================================
val routes = ZioHttpInterpreter().toHttp(chronosRoutes ++ apiDocRoutes)
val appStateLayer = ZLayer.fromZIO(Ref.make(AppState()))
val server = for {
config <- ZIO.config(Server.Config.config)
port = config.address.getPort
_ <- generateAPIDocFile
_ <- Console.printLine(s"Server listening on http://127.0.0.1:$port/")
_ <- Console.printLine(s"API documentation ON http://127.0.0.1:$port/docs")
_ <- Server.serve(routes)
} yield ()
override def run = server.provide(appStateLayer, Server.default)
}
WebApp.main(Array.empty)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment