feat: Support CRUD functionality (#7)

The station search implementation only works via DB and is very slow. For a simple query, the answer takes about 1s or longer. That is 100 times slower than it should be. For now, however, this solution is adequate to achieve a first prototype that includes the core functionality.

feat: Add station search
This commit is contained in:
2024-01-06 14:06:43 +01:00
committed by GitHub
parent b5681181fb
commit 7fbd270019
166 changed files with 2039 additions and 482 deletions
+9 -3
View File
@@ -1,17 +1,23 @@
plugins {
id("twomartens.spring-boot-cloud")
id("twomartens.spring-boot-cloud-application")
id("twomartens.kotlin")
kotlin("kapt")
}
dependencies {
implementation(project(":bahnApi"))
implementation(project(":common"))
implementation(libs.mapstruct.base)
implementation(libs.bundles.spring.boot.security)
implementation(project(":model"))
implementation(project(":support"))
implementation(libs.jaxb.impl)
implementation(libs.jakarta.xml.binding)
implementation(libs.mapstruct.base)
annotationProcessor(libs.mapstruct.processor)
kapt(libs.mapstruct.processor)
implementation(libs.bundles.spring.boot.security)
implementation(libs.spring.cloud.starter.config)
implementation(libs.spring.cloud.leader.election)
implementation(libs.spring.cloud.starter.bus.kafka)
@@ -1,6 +1,6 @@
package de.twomartens.timetable
import de.twomartens.timetable.support.model.LeadershipStatus
import de.twomartens.support.model.LeadershipStatus
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.boot.runApplication
@@ -13,7 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling
@EnableMongoAuditing
@EnableScheduling
@SpringBootApplication
@SpringBootApplication(scanBasePackages = ["de.twomartens.support", "de.twomartens.timetable"])
open class MainApplication(
private val leadershipStatus: LeadershipStatus,
private val leaderProperties: LeaderProperties
@@ -0,0 +1,84 @@
package de.twomartens.timetable.auth
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.User
import de.twomartens.timetable.types.Email
import de.twomartens.timetable.types.NonEmptyString
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import org.mapstruct.factory.Mappers
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping(value = ["/v1/users"])
@Tag(name = "Users", description = "all requests relating to user resources")
class UserController(
private val userRepository: UserRepository
) {
private val mapper = Mappers.getMapper(UserMapper::class.java)
@Operation(
summary = "Store user",
responses = [ApiResponse(
responseCode = "200",
description = "User was updated",
content = [Content(
schema = Schema(implementation = User::class)
)]
), ApiResponse(
responseCode = "201",
description = "User was created",
content = [Content(
schema = Schema(implementation = User::class)
)]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@PutMapping("/{userId}")
fun putUser(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@RequestBody(required = true) @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
content = [
Content(
schema = Schema(implementation = User::class)
)
]) body: User
): ResponseEntity<User> {
var created = false
val userIdConverted = UserId.of(NonEmptyString(userId))
var user = userRepository.findByUserId(userIdConverted)
if (user == null) {
created = true
user = mapper.mapToDB(body)
} else {
user.name = NonEmptyString(body.name)
user.email = Email.of(NonEmptyString(body.email))
}
userRepository.save(user)
val updatedUser = mapper.mapToDto(user)
return if (created) {
ResponseEntity.status(HttpStatus.CREATED).body(updatedUser)
} else {
ResponseEntity.ok(updatedUser)
}
}
}
@@ -0,0 +1,34 @@
package de.twomartens.timetable.auth
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.User
import de.twomartens.timetable.types.Email
import de.twomartens.timetable.types.NonEmptyString
import org.mapstruct.*
@Mapper(
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToDB(dto: User): de.twomartens.timetable.model.db.User {
return de.twomartens.timetable.model.db.User(
UserId.of(NonEmptyString(dto.id)),
NonEmptyString(dto.name),
Email.of(NonEmptyString(dto.email))
)
}
fun mapToDto(db: de.twomartens.timetable.model.db.User): User {
return User(
db.userId.value,
db.name.value,
db.email.value
)
}
}
@@ -0,0 +1,11 @@
package de.twomartens.timetable.auth
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.db.User
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
interface UserRepository : MongoRepository<User, ObjectId> {
fun existsByUserId(userId: UserId): Boolean
fun findByUserId(userId: UserId): User?
}
@@ -1,19 +0,0 @@
package de.twomartens.timetable.bahnApi.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
private const val CORE_POOL_SIZE = 1
@Configuration
open class ThreadPoolTaskExecutorConfig {
@Bean
open fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = CORE_POOL_SIZE
executor.maxPoolSize = CORE_POOL_SIZE
return executor
}
}
@@ -1,19 +0,0 @@
package de.twomartens.timetable.bahnApi.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
private const val POOL_SIZE = 1
private const val THREAD_NAME_PREFIX = "ThreadPoolTaskScheduler"
@Configuration
open class ThreadPoolTaskSchedulerConfig {
@Bean
open fun threadPoolTaskScheduler(): ThreadPoolTaskScheduler {
val scheduler = ThreadPoolTaskScheduler()
scheduler.poolSize = POOL_SIZE
scheduler.threadNamePrefix = THREAD_NAME_PREFIX
return scheduler
}
}
@@ -1,33 +0,0 @@
package de.twomartens.timetable.bahnApi.events
import org.springframework.cloud.bus.event.Destination
import org.springframework.cloud.bus.event.PathDestinationFactory
import org.springframework.cloud.bus.event.RemoteApplicationEvent
import java.time.Instant
private const val TIMETABLE_SERVICE_NAME = "timetable"
class ScheduledTasksCreatedEvent private constructor(source: Instant, originService: String,
destination: Destination)
: RemoteApplicationEvent(source, originService, destination) {
private val creationTime: Instant
init {
creationTime = source
}
override fun getSource(): Instant {
return creationTime
}
companion object {
private val destinationFactory = PathDestinationFactory()
fun of(source: Instant): ScheduledTasksCreatedEvent {
return ScheduledTasksCreatedEvent(source,
TIMETABLE_SERVICE_NAME,
destinationFactory.getDestination(TIMETABLE_SERVICE_NAME))
}
}
}
@@ -1,30 +0,0 @@
package de.twomartens.timetable.bahnApi.jaxb
import jakarta.xml.bind.annotation.adapters.XmlAdapter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.*
class LocalDateTimeAdapter : XmlAdapter<String?, LocalDateTime>() {
@Throws(DateTimeParseException::class)
override fun unmarshal(xmlValue: String?): LocalDateTime {
return LocalDateTime.parse(xmlValue, dateFormatter.get())
}
@Throws(DateTimeParseException::class)
override fun marshal(kotlinValue: LocalDateTime): String? {
return dateFormatter.get().format(kotlinValue)
}
companion object {
private val dateFormatter: ThreadLocal<DateTimeFormatter> = object : ThreadLocal<DateTimeFormatter>() {
override fun initialValue(): DateTimeFormatter {
return DateTimeFormatter.ofPattern("yyMMddHHmm", Locale.GERMANY)
}
}
}
}
@@ -1,25 +0,0 @@
package de.twomartens.timetable.bahnApi.mapper
import de.twomartens.timetable.bahnApi.model.db.BahnStation
import de.twomartens.timetable.types.NonEmptyString
import org.mapstruct.*
@Mapper(
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
interface BahnStationMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToDB(dto: de.twomartens.timetable.bahnApi.model.dto.BahnStation): BahnStation {
return BahnStation(
dto.eva,
NonEmptyString(dto.name),
dto.ds100,
dto.db
)
}
}
@@ -1,26 +0,0 @@
package de.twomartens.timetable.bahnApi.mapper
import de.twomartens.timetable.bahnApi.model.db.BahnTimetable
import de.twomartens.timetable.types.HourAtDay
import de.twomartens.timetable.types.NonEmptyString
import org.mapstruct.*
@Mapper(
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
interface BahnTimetableMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToDB(dto: de.twomartens.timetable.bahnApi.model.dto.BahnTimetable,
hourAtDay: HourAtDay): BahnTimetable {
return BahnTimetable(
dto.eva,
hourAtDay,
NonEmptyString(dto.station),
dto.stops
)
}
}
@@ -1,24 +0,0 @@
package de.twomartens.timetable.bahnApi.model
import de.twomartens.timetable.model.common.StationId
@JvmInline
value class Eva(val value: Int) {
init {
if (isKnown()) {
require(value in 8000000..8999999) {
"Eva number must start with an 8 and have 7 digits"
}
}
}
private fun isKnown() = value != -1
companion object {
val UNKNOWN = Eva(-1)
fun of(stationId: StationId): Eva {
return Eva(stationId.stationIdWithinCountry.toInt())
}
}
}
@@ -1,5 +0,0 @@
package de.twomartens.timetable.bahnApi.model
import java.time.LocalDate
data class FetchDates(val previousDay: LocalDate, val fetchDate: LocalDate, val nextDate: LocalDate)
@@ -1,65 +0,0 @@
package de.twomartens.timetable.bahnApi.model
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.types.HourAtDay
import org.springframework.stereotype.Component
import java.time.LocalDateTime
private const val SECONDS_BETWEEN_TASKS = 2
private const val ZERO = 0
@Component
class TaskFactory {
private val _tasksPerHour: MutableMap<HourAtDay, Int> = mutableMapOf()
fun updateCounterWith(existingTasks: List<ScheduledFetchTask>) {
countTasks(existingTasks)
}
fun createTaskAndUpdateCounter(eva: Eva, hourAtDay: HourAtDay): ScheduledFetchTask {
val fetchDateTime = calculateNextAvailableFetchDateTime(hourAtDay)
incrementTaskCountForHourAtDay(hourAtDay)
return ScheduledFetchTask(eva, hourAtDay, fetchDateTime)
}
private fun calculateNextAvailableFetchDateTime(hourAtDay: HourAtDay): LocalDateTime {
val secondInHourToExecute = calculateSecondInHourToCalculate(hourAtDay)
return calculateFetchDateTime(hourAtDay, secondInHourToExecute)
}
private fun calculateSecondInHourToCalculate(hourAtDay: HourAtDay): Int {
val numberOfTasksInHour = getNumberOfTasksInHour(hourAtDay)
return numberOfTasksInHour * SECONDS_BETWEEN_TASKS
}
private fun calculateFetchDateTime(
hourAtDay: HourAtDay,
secondInHourToExecute: Int
): LocalDateTime {
return hourAtDay.dateTime.plusSeconds(secondInHourToExecute.toLong())
}
private fun countTasks(existingTasks: List<ScheduledFetchTask>) {
existingTasks.forEach { countTask(it) }
}
private fun countTask(scheduledFetchTask: ScheduledFetchTask) {
val scheduledTime = scheduledFetchTask.scheduledExecutionDateTime
val hourAtDay = HourAtDay.of(scheduledTime)
incrementTaskCountForHourAtDay(hourAtDay)
}
private fun incrementTaskCountForHourAtDay(hourAtDay: HourAtDay) {
val numberOfTasksInHour = getNumberOfTasksInHour(hourAtDay)
_tasksPerHour[hourAtDay] = numberOfTasksInHour + 1
}
private fun getNumberOfTasksInHour(hourAtDay: HourAtDay) =
_tasksPerHour.computeIfAbsent(hourAtDay) { ZERO }
private fun resetCounter() {
_tasksPerHour.clear()
}
}
@@ -1,29 +0,0 @@
package de.twomartens.timetable.bahnApi.model.db
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.types.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.index.TextIndexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
data class BahnStation(
@Indexed(unique = true) var eva: Eva,
@TextIndexed var name: NonEmptyString,
var ds100: String,
var db: Boolean
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,32 +0,0 @@
package de.twomartens.timetable.bahnApi.model.db
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.dto.BahnStationStop
import de.twomartens.timetable.types.HourAtDay
import de.twomartens.timetable.types.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.index.TextIndexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
@CompoundIndex(def = "{'eva': 1, 'dateTime': 1}", unique = true)
data class BahnTimetable(
var eva: Eva,
var hourAtDay: HourAtDay,
@TextIndexed var station: NonEmptyString,
var stops: List<BahnStationStop>
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,29 +0,0 @@
package de.twomartens.timetable.bahnApi.model.db
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.types.HourAtDay
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
import java.time.LocalDateTime
@Document
@CompoundIndex(def = "{'eva': 1, 'fetchedDateTime': 1", unique = true)
data class ScheduledFetchTask(
var eva: Eva,
var fetchedDateTime: HourAtDay,
var scheduledExecutionDateTime: LocalDateTime
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,16 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.bahnApi.model.Eva
import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlAttribute
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnStation(
@field:XmlAttribute var name: String,
@field:XmlAttribute var eva: Eva,
@field:XmlAttribute var ds100: String,
@field:XmlAttribute var db: Boolean
) {
constructor() : this("", Eva.UNKNOWN, "", true)
}
@@ -1,18 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.bahnApi.model.Eva
import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlAttribute
import jakarta.xml.bind.annotation.XmlElement
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnStationStop(
@field:XmlAttribute var eva: Eva,
@field:XmlAttribute var id: String,
@field:XmlElement(name = "tl") var tripLabel: BahnTripLabel,
@field:XmlElement(name = "ar") var arrival: BahnStopEvent?,
@field:XmlElement(name = "dp") var departure: BahnStopEvent?
) {
constructor() : this(Eva.UNKNOWN, "", BahnTripLabel(), null, null)
}
@@ -1,14 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlElement
import jakarta.xml.bind.annotation.XmlRootElement
@XmlRootElement(name = "stations")
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnStations(
@field:XmlElement(name = "station") var stations: List<BahnStation>
) {
constructor() : this(listOf())
}
@@ -1,32 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.bahnApi.jaxb.LocalDateTimeAdapter
import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlAttribute
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter
import java.time.LocalDateTime
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnStopEvent(
@field:XmlAttribute(name = "pde") var plannedDistantEndpoint: String,
@field:XmlAttribute(name = "cde") var changedDistantEndpoint: String,
@field:XmlAttribute(name = "pt")
@field:XmlJavaTypeAdapter(value = LocalDateTimeAdapter::class)
var plannedTime: LocalDateTime?,
@field:XmlAttribute(name = "ct")
@field:XmlJavaTypeAdapter(value = LocalDateTimeAdapter::class)
var changedTime: LocalDateTime?,
@field:XmlAttribute(name = "ppth") var plannedPath: String,
@field:XmlAttribute(name = "cpth") var changedPath: String,
@field:XmlAttribute(name = "pp") var plannedPlatform: String,
@field:XmlAttribute(name = "cp") var changedPlatform: String,
@field:XmlAttribute(name = "wings") var wings: String,
@field:XmlAttribute(name = "l") var line: String
) {
constructor() : this("", "",
null,
null,
"", "", "", "",
"", "")
}
@@ -1,14 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.bahnApi.model.Eva
import jakarta.xml.bind.annotation.*
@XmlRootElement(name = "timetable")
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnTimetable(
@field:XmlAttribute var eva: Eva,
@field:XmlAttribute var station: String,
@field:XmlElement(name = "s") var stops: List<BahnStationStop>
) {
constructor() : this(Eva.UNKNOWN, "", listOf())
}
@@ -1,17 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlAttribute
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnTripLabel(
@field:XmlAttribute(name = "c") var category: String,
// S: S-Bahn, N: Nahverkehr, F: Fernverkehr
@field:XmlAttribute(name = "f") var filterFlags: String,
@field:XmlAttribute(name = "n") var trainNumber: String,
@field:XmlAttribute(name = "o") var owner: String,
@field:XmlAttribute(name = "t") var tripType: BahnTripType
) {
constructor() : this("", "", "", "", BahnTripType.p)
}
@@ -1,10 +0,0 @@
package de.twomartens.timetable.bahnApi.model.dto
enum class BahnTripType {
p, // passenger?
e,
z,
s,
h,
n
}
@@ -1,14 +0,0 @@
package de.twomartens.timetable.bahnApi.property
import io.swagger.v3.oas.annotations.media.Schema
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding
import org.springframework.cloud.context.config.annotation.RefreshScope
@RefreshScope
@ConfigurationProperties(prefix = "de.twomartens.timetable.bahn-api")
@Schema(description = "Properties to configure the BahnAPI")
data class BahnApiProperties @ConstructorBinding constructor(
val clientId: String,
val clientSecret: String
)
@@ -1,10 +0,0 @@
package de.twomartens.timetable.bahnApi.repository
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.db.BahnStation
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
interface BahnStationRepository : MongoRepository<BahnStation, ObjectId> {
fun findByEva(eva: Eva): BahnStation?
}
@@ -1,12 +0,0 @@
package de.twomartens.timetable.bahnApi.repository
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.db.BahnTimetable
import de.twomartens.timetable.types.HourAtDay
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
// TODO: figure out what ID type should be
interface BahnTimetableRepository : MongoRepository<BahnTimetable, ObjectId> {
fun findByEvaAndHourAtDay(eva: Eva, hourAtDay: HourAtDay): BahnTimetable?
}
@@ -1,14 +0,0 @@
package de.twomartens.timetable.bahnApi.repository
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.types.HourAtDay
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
import java.time.Instant
interface ScheduledFetchTaskRepository : MongoRepository<ScheduledFetchTask, ObjectId> {
fun findByEvaAndFetchedDateTime(eva: Eva, fetchedDateTime: HourAtDay): ScheduledFetchTask?
fun findAllByCreatedAfter(createdAt: Instant): List<ScheduledFetchTask>
}
@@ -1,58 +0,0 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.dto.BahnStation
import de.twomartens.timetable.bahnApi.model.dto.BahnStations
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.property.BahnApiProperties
import de.twomartens.timetable.types.HourAtDay
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Service
class BahnApiService(
private val restTemplate: RestTemplate,
private val properties: BahnApiProperties
) {
fun fetchStations(pattern: String): List<BahnStation> {
val requestEntity = buildRequestEntity<BahnStations>()
val response = restTemplate.exchange(
"https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/station/${pattern}",
HttpMethod.GET,
requestEntity,
BahnStations::class.java
)
val body = response.body
return body?.stations ?: listOf()
}
fun fetchTimetable(eva: Eva, hourAtDay: HourAtDay): BahnTimetable {
val requestEntity = buildRequestEntity<BahnTimetable>()
val dateFormatter = DateTimeFormatter.ofPattern("yyMMdd")
val timeFormatter = DateTimeFormatter.ofPattern("HH")
val time = LocalTime.of(hourAtDay.hour.value, 0)
val response = restTemplate.exchange(
"https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/plan/" +
"${eva}/${hourAtDay.date.format(dateFormatter)}/${time.format(timeFormatter)}",
HttpMethod.GET,
requestEntity,
BahnTimetable::class.java
)
return response.body!!
}
private fun <T> buildRequestEntity(): HttpEntity<T> {
val headers = HttpHeaders()
headers.accept = mutableListOf(MediaType.APPLICATION_XML)
headers.contentType = MediaType.APPLICATION_XML
headers.set("DB-Client-Id", properties.clientId)
headers.set("DB-Api-Key", properties.clientSecret)
return HttpEntity<T>(headers)
}
}
@@ -1,29 +0,0 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.mapper.BahnStationMapper
import de.twomartens.timetable.bahnApi.mapper.BahnTimetableMapper
import de.twomartens.timetable.bahnApi.model.dto.BahnStation
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.repository.BahnStationRepository
import de.twomartens.timetable.bahnApi.repository.BahnTimetableRepository
import de.twomartens.timetable.types.HourAtDay
import org.mapstruct.factory.Mappers
import org.springframework.stereotype.Service
@Service
class BahnDatabaseService(
private val bahnStationRepository: BahnStationRepository,
private val bahnTimetableRepository: BahnTimetableRepository
) {
private val bahnStationMapper = Mappers.getMapper(BahnStationMapper::class.java)
private val bahnTimetableMapper = Mappers.getMapper(BahnTimetableMapper::class.java)
fun storeStations(stations: List<BahnStation>) {
bahnStationRepository.saveAll(stations
.map { bahnStationMapper.mapToDB(it) })
}
fun storeTimetable(timetable: BahnTimetable, hourAtDay: HourAtDay) {
bahnTimetableRepository.save(bahnTimetableMapper.mapToDB(timetable, hourAtDay))
}
}
@@ -1,142 +0,0 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.events.ScheduledTasksCreatedEvent
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.FetchDates
import de.twomartens.timetable.bahnApi.model.TaskFactory
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.bahnApi.repository.ScheduledFetchTaskRepository
import de.twomartens.timetable.model.db.TswRoute
import de.twomartens.timetable.support.model.LeadershipStatus
import de.twomartens.timetable.support.service.BusService
import de.twomartens.timetable.types.Hour
import de.twomartens.timetable.types.HourAtDay
import mu.KotlinLogging
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.cloud.kubernetes.commons.leader.LeaderProperties
import org.springframework.context.event.EventListener
import org.springframework.integration.leader.event.OnGrantedEvent
import org.springframework.stereotype.Service
import java.time.Instant
import java.time.LocalDate
@Service
class ScheduledTaskService(
private val busService: BusService,
private val leadershipStatus: LeadershipStatus,
private val leaderProperties: LeaderProperties,
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository,
private val taskFactory: TaskFactory,
private val taskScheduler: TaskScheduler
) {
private var createdTime: Instant = Instant.EPOCH
private var lastUpdate: Instant = Instant.EPOCH
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady(event: ApplicationReadyEvent) {
if (!leaderProperties.isEnabled) {
val updateTime = Instant.ofEpochMilli(event.timestamp)
updateTaskCounterAndScheduleTasksIfLeader(updateTime)
}
}
@EventListener(OnGrantedEvent::class)
fun onLeadershipGranted(event: OnGrantedEvent) {
val updateTime = Instant.ofEpochMilli(event.timestamp)
updateTaskCounterAndScheduleTasksIfLeader(updateTime)
}
@EventListener(ScheduledTasksCreatedEvent::class)
fun onScheduledTasksCreated(event: ScheduledTasksCreatedEvent) {
updateTaskCounterAndScheduleTasksIfLeader(event.source)
}
private fun updateTaskCounterAndScheduleTasksIfLeader(updateTime: Instant) {
log.info { "Update tasks from database and schedule if leader" }
val createdTasks = findTasksCreatedSince(lastUpdate)
updateCounterIfNotUpToDate(updateTime, createdTasks)
scheduleTasksIfLeader(createdTasks)
lastUpdate = updateTime
}
fun triggerTimetableFetch(tswRoute: TswRoute, fetchedDate: LocalDate) {
log.info {
"Trigger timetable fetch: [route ${tswRoute.name}]"
}
val fetchDates = calculateDatesToFetch(fetchedDate)
val newTasks = buildScheduledTasks(tswRoute, fetchDates)
storeTasksInDatabaseAndStoreCreationTime(newTasks)
publishTasksCreatedEvent()
}
private fun calculateDatesToFetch(fetchedDate: LocalDate): FetchDates {
val previousDay = fetchedDate.minusDays(1)
val nextDay = fetchedDate.plusDays(1)
return FetchDates(previousDay, fetchedDate, nextDay)
}
private fun buildScheduledTasks(
tswRoute: TswRoute,
fetchDates: FetchDates
): List<ScheduledFetchTask> {
val newTasks = mutableListOf<ScheduledFetchTask>()
tswRoute.stationIds.forEach {
val stationId = it
val eva = Eva.of(stationId)
var hourAtDay = HourAtDay.of(Hour.of(23), fetchDates.previousDay)
var newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
newTasks.add(newTask)
for (hour in 0..23) {
hourAtDay = HourAtDay.of(Hour.of(hour), fetchDates.fetchDate)
newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
newTasks.add(newTask)
}
for (hour in 0..3) {
hourAtDay = HourAtDay.of(Hour.of(hour), fetchDates.nextDate)
newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
newTasks.add(newTask)
}
}
return newTasks
}
private fun scheduleTasksIfLeader(tasksToSchedule: List<ScheduledFetchTask>) {
if (leadershipStatus.isLeader) {
taskScheduler.scheduleFetchTasks(tasksToSchedule)
}
}
private fun updateCounterIfNotUpToDate(updateTime: Instant,
createdTasks: List<ScheduledFetchTask>) {
if (notUpToDate(updateTime)) {
taskFactory.updateCounterWith(createdTasks)
}
}
private fun notUpToDate(updateTime: Instant) = createdTime != updateTime
private fun findTasksCreatedSince(lastScheduledTime: Instant): List<ScheduledFetchTask> {
return scheduledFetchTaskRepository.findAllByCreatedAfter(lastScheduledTime)
}
private fun storeTasksInDatabaseAndStoreCreationTime(newTasks: List<ScheduledFetchTask>) {
val savedEntities = scheduledFetchTaskRepository.saveAll(newTasks)
storeCreationTime(savedEntities)
}
private fun storeCreationTime(savedEntities: MutableList<ScheduledFetchTask>) {
if (savedEntities.isNotEmpty()) {
createdTime = savedEntities[savedEntities.lastIndex].created
}
}
private fun publishTasksCreatedEvent() {
val event = ScheduledTasksCreatedEvent.of(createdTime)
busService.publishEvent(event)
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -1,94 +0,0 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.repository.ScheduledFetchTaskRepository
import de.twomartens.timetable.bahnApi.tasks.DeleteScheduledTask
import de.twomartens.timetable.bahnApi.tasks.FetchTimetableTask
import de.twomartens.timetable.bahnApi.tasks.StoreTimetableTask
import de.twomartens.timetable.types.HourAtDay
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import org.springframework.stereotype.Service
import java.time.Clock
import java.time.ZonedDateTime
private const val PAST_TASK_EXECUTION_OFFSET = 1
@Service
class TaskScheduler(
private val clock: Clock,
private val threadPoolTaskScheduler: ThreadPoolTaskScheduler,
private val threadPoolTaskExecutor: ThreadPoolTaskExecutor,
private val bahnApiService: BahnApiService,
private val bahnDatabaseService: BahnDatabaseService,
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository
) {
fun scheduleStoreTask(timetable: BahnTimetable, hourAtDay: HourAtDay) {
val storeTask = StoreTimetableTask(timetable, hourAtDay, bahnDatabaseService)
threadPoolTaskExecutor.execute(storeTask)
}
fun scheduleFetchTasks(scheduledTasks: List<ScheduledFetchTask>) {
val nowPlus1Minute = ZonedDateTime.now(clock).plusMinutes(1)
scheduleTasksFromThePast(scheduledTasks, nowPlus1Minute)
scheduleTasksFromTheFuture(scheduledTasks, nowPlus1Minute)
}
private fun scheduleTasksFromThePast(
scheduledTasks: List<ScheduledFetchTask>,
nowPlus1Minute: ZonedDateTime
) {
val tasksScheduledForPast = scheduledTasks.filter {
val zonedExecutionTime = it.scheduledExecutionDateTime.atZone(clock.zone)
nowPlus1Minute.isAfter(zonedExecutionTime)
}
tasksScheduledForPast.forEach {
val zonedExecutionTime = it.scheduledExecutionDateTime.atZone(clock.zone)
val adjustedExecutionTime = nowPlus1Minute.withOffsetAndSecondsFrom(zonedExecutionTime)
scheduleDeleteTask(it, adjustedExecutionTime)
scheduleFetchTimetableTask(it, adjustedExecutionTime)
}
}
private fun scheduleTasksFromTheFuture(
scheduledTasks: List<ScheduledFetchTask>,
nowPlus1Minute: ZonedDateTime
) {
val tasksScheduledForFuture = scheduledTasks.filter {
val zonedExecutionTime = it.scheduledExecutionDateTime.atZone(clock.zone)
nowPlus1Minute.isOnOrBefore(zonedExecutionTime)
}
tasksScheduledForFuture.forEach {
val zonedExecutionTime = it.scheduledExecutionDateTime.atZone(clock.zone)
scheduleDeleteTask(it, zonedExecutionTime)
scheduleFetchTimetableTask(it, zonedExecutionTime)
}
}
private fun scheduleDeleteTask(
scheduledFetchTask: ScheduledFetchTask,
zonedExecutionTime: ZonedDateTime
) {
val deleteTask = DeleteScheduledTask(scheduledFetchTaskRepository, scheduledFetchTask)
threadPoolTaskScheduler.schedule(deleteTask, zonedExecutionTime.toInstant())
}
private fun scheduleFetchTimetableTask(
scheduledFetchTask: ScheduledFetchTask,
zonedExecutionTime: ZonedDateTime
) {
val timetableTask = FetchTimetableTask(scheduledFetchTask.eva,
scheduledFetchTask.fetchedDateTime,
bahnApiService, this)
threadPoolTaskScheduler.schedule(timetableTask, zonedExecutionTime.toInstant())
}
companion object {
fun ZonedDateTime.withOffsetAndSecondsFrom(other: ZonedDateTime): ZonedDateTime =
this.withSecond(other.second + PAST_TASK_EXECUTION_OFFSET)
fun ZonedDateTime.isOnOrBefore(other: ZonedDateTime): Boolean =
this.isBefore(other) || this.isEqual(other)
}
}
@@ -1,26 +0,0 @@
package de.twomartens.timetable.bahnApi.tasks
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.bahnApi.repository.ScheduledFetchTaskRepository
import mu.KotlinLogging
import org.springframework.scheduling.annotation.Async
@Async
open class DeleteScheduledTask(
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository,
private val scheduledFetchTask: ScheduledFetchTask
) : Runnable {
override fun run() {
log.info {
"Delete scheduled fetch task after the fetch occurred: " +
"[eva: ${scheduledFetchTask.eva}], " +
"[date: ${scheduledFetchTask.fetchedDateTime.date}], " +
"[time: ${scheduledFetchTask.fetchedDateTime.hour}]"
}
scheduledFetchTaskRepository.delete(scheduledFetchTask)
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -1,27 +0,0 @@
package de.twomartens.timetable.bahnApi.tasks
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.service.BahnApiService
import de.twomartens.timetable.bahnApi.service.TaskScheduler
import de.twomartens.timetable.types.HourAtDay
import mu.KotlinLogging
import org.springframework.scheduling.annotation.Async
@Async
open class FetchTimetableTask(
private val eva: Eva,
private val hourAtDay: HourAtDay,
private val bahnApiService: BahnApiService,
private val scheduler: TaskScheduler) : Runnable {
override fun run() {
log.info {
"Fetch timetable: [eva: $eva], [date: ${hourAtDay.date}], [hour: ${hourAtDay.hour}]"
}
val timetable = bahnApiService.fetchTimetable(eva, hourAtDay)
scheduler.scheduleStoreTask(timetable, hourAtDay)
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -1,23 +0,0 @@
package de.twomartens.timetable.bahnApi.tasks
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.service.BahnDatabaseService
import de.twomartens.timetable.types.HourAtDay
import mu.KotlinLogging
import org.springframework.scheduling.annotation.Async
@Async
open class StoreTimetableTask(
private val timetable: BahnTimetable,
private val hourAtDay: HourAtDay,
private val bahnDatabaseService: BahnDatabaseService
) : Runnable {
override fun run() {
log.info { "Store timetable: [eva: ${timetable.eva}], station: ${timetable.station}]" }
bahnDatabaseService.storeTimetable(timetable, hourAtDay)
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -1,8 +1,8 @@
package de.twomartens.timetable.support.configuration
package de.twomartens.timetable.configuration
import org.springframework.cloud.bus.jackson.RemoteApplicationEventScan
import org.springframework.context.annotation.Configuration
@Configuration
@RemoteApplicationEventScan(basePackages = ["de.twomartens.timetable"])
@RemoteApplicationEventScan(basePackages = ["de.twomartens.timetable", "de.twomartens.support"])
open class BusConfiguration
@@ -1,4 +1,4 @@
package de.twomartens.timetable.support.configuration
package de.twomartens.timetable.configuration
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.OAuthFlow
@@ -1,10 +1,11 @@
package de.twomartens.timetable.configuration
import de.twomartens.support.property.HealthCheckProperties
import de.twomartens.support.property.RestTemplateTimeoutProperties
import de.twomartens.support.property.StatusProbeProperties
import de.twomartens.support.property.TimeProperties
import de.twomartens.timetable.bahnApi.property.BahnApiProperties
import de.twomartens.timetable.property.RestTemplateTimeoutProperties
import de.twomartens.timetable.property.ServiceProperties
import de.twomartens.timetable.property.StatusProbeProperties
import de.twomartens.timetable.property.TimeProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.cloud.kubernetes.commons.leader.LeaderProperties
import org.springframework.context.annotation.Configuration
@@ -12,5 +13,6 @@ import org.springframework.context.annotation.Configuration
@Configuration
@EnableConfigurationProperties(RestTemplateTimeoutProperties::class, ServiceProperties::class,
StatusProbeProperties::class, TimeProperties::class, BahnApiProperties::class,
HealthCheckProperties::class,
LeaderProperties::class)
open class PropertyConfiguration
@@ -1,6 +1,6 @@
package de.twomartens.timetable.support.configuration
package de.twomartens.timetable.configuration
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import de.twomartens.support.interceptor.HeaderInterceptorRest
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.web.servlet.config.annotation.CorsRegistry
@@ -17,7 +17,8 @@ open class WebConfiguration(private val headerInterceptorRest: HeaderInterceptor
val registration = registry.addMapping("/**")
registration.allowedMethods(
HttpMethod.GET.name(), HttpMethod.POST.name(),
HttpMethod.PUT.name(), HttpMethod.HEAD.name()
HttpMethod.PUT.name(), HttpMethod.HEAD.name(),
HttpMethod.DELETE.name()
)
registration.allowCredentials(true)
registration.allowedOrigins(
@@ -1,6 +1,6 @@
package de.twomartens.timetable.support.configuration
package de.twomartens.timetable.configuration
import de.twomartens.timetable.support.security.SpringPolicyEnforcerFilter
import de.twomartens.support.security.SpringPolicyEnforcerFilter
import org.keycloak.adapters.authorization.spi.ConfigurationResolver
import org.keycloak.adapters.authorization.spi.HttpRequest
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig
@@ -72,10 +72,10 @@ open class WebSecurityConfiguration {
companion object {
private val PERMITTED_PATHS: Collection<String> = listOf(
"/timetable/v1/healthCheck",
"/timetable/healthCheck",
"/actuator/**",
"/timetable/v1/doc/**",
"/timetable/v1/api-docs/**",
"/doc/v1/timetable/**",
"/api-docs/v1/timetable/**",
"/error",
"/timetable/version",
)
@@ -1,59 +0,0 @@
package de.twomartens.timetable.controller
import de.twomartens.timetable.bahnApi.service.ScheduledTaskService
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.repository.TswRouteRepository
import de.twomartens.timetable.types.NonEmptyString
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.Clock
import java.time.LocalDate
@RestController
@RequestMapping(value = ["/timetable/v1"])
@Tag(name = "Timetables", description = "all requests relating to timetables")
class TimetableController(
private val clock: Clock,
private val routeRepository: TswRouteRepository,
private val scheduledTaskService: ScheduledTaskService
) {
@Operation(
summary = "Task the backend with fetching a timetable for specified route and date",
responses = [ApiResponse(
responseCode = "200",
description = "Timetable will be fetched as requested"
), ApiResponse(
responseCode = "400",
description = "Request does not follow specification"
)]
)
@PostMapping("/{userId}/{routeName}/fetchTimetable/{date}")
fun scheduleFetch(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: UserId,
@PathVariable @Parameter(description = "The name of the TSW route.",
example = "CologneAachen",
required = true) routeName: NonEmptyString,
@PathVariable @Parameter(
description = "The date to fetch timetable for. Must be in the future.",
example = "2023-09-12",
required = true) date: LocalDate
) {
if (!date.isAfter(LocalDate.now(clock))) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Date must be in the future")
}
val route = routeRepository.findByUserIdAndName(userId, routeName)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST,
"Route name must belong to existing route")
scheduledTaskService.triggerTimetableFetch(route, date)
}
}
@@ -0,0 +1,261 @@
package de.twomartens.timetable.formation
import de.twomartens.timetable.auth.UserRepository
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.Formation
import de.twomartens.timetable.types.NonEmptyString
import de.twomartens.timetable.types.ZeroOrPositiveInteger
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.ArraySchema
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import org.mapstruct.factory.Mappers
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping(value = ["/v1/formations"])
@Tag(name = "Formations", description = "all requests relating to formation resources")
class FormationController(
private val formationRepository: FormationRepository,
private val userRepository: UserRepository
) {
private val mapper = Mappers.getMapper(FormationMapper::class.java)
@Operation(
summary = "Access formations of user and filter by name",
responses = [ApiResponse(
responseCode = "200",
description = "List of found formations",
content = [Content(
array = ArraySchema(schema = Schema(implementation = Formation::class))
)]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@GetMapping("/{userId}/")
fun getFormations(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@RequestParam(name = "name", required = false) @Parameter(description = "Searched name",
example = "ICE",
required = false) name: String?
): ResponseEntity<List<Formation>> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val formations = if (!name.isNullOrBlank()) {
formationRepository.findAllByUserIdAndNameContainingIgnoreCase(userIdConverted, name)
} else {
formationRepository.findAllByUserId(userIdConverted)
}
val formationsDto = formations.map {
val trainSimWorldFormation = if (it.trainSimWorldFormationId != null) {
formationRepository.findByUserIdAndFormationId(userIdConverted, it.trainSimWorldFormationId!!)
} else {
null
}
mapper.mapToDto(it, trainSimWorldFormation)
}
return ResponseEntity.ok(formationsDto)
}
@Operation(
summary = "Access formation with id belonging to user",
responses = [ApiResponse(
responseCode = "200",
description = "Formation exists and was returned",
content = [Content(
schema = Schema(implementation = Formation::class)
)]
), ApiResponse(
responseCode = "404",
description = "Formation was not found",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@GetMapping("/{userId}/{id}")
fun getFormation(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@PathVariable @Parameter(description = "The id of the formation",
example = "1",
required = true) id: String
): ResponseEntity<Formation> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val formationIdConverted = FormationId.of(NonEmptyString(id))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val formation = formationRepository.findByUserIdAndFormationId(userIdConverted, formationIdConverted)
?: return ResponseEntity.notFound().build()
val trainSimWorldFormation = if (formation.trainSimWorldFormationId != null) {
formationRepository.findByUserIdAndFormationId(userIdConverted, formation.trainSimWorldFormationId!!)
} else {
null
}
return ResponseEntity.ok(mapper.mapToDto(formation, trainSimWorldFormation))
}
@Operation(
summary = "Store formation with id belonging to user",
responses = [ApiResponse(
responseCode = "200",
description = "Formation was updated",
content = [Content(
schema = Schema(implementation = Formation::class)
)]
), ApiResponse(
responseCode = "201",
description = "Formation was created",
content = [Content(
schema = Schema(implementation = Formation::class)
)]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@PutMapping("/{userId}/{id}")
fun putFormation(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@PathVariable @Parameter(description = "The id of the formation",
example = "1",
required = true) id: String,
@RequestBody(required = true) @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
content = [
Content(
schema = Schema(implementation = Formation::class)
)
]) body: Formation
): ResponseEntity<Formation> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val formationIdConverted = FormationId.of(NonEmptyString(id))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
var formation = formationRepository.findByUserIdAndFormationId(userIdConverted, formationIdConverted)
val trainSimWorldFormation = createOrUpdateTrainSimWorldFormation(userIdConverted, body.trainSimWorldFormation)
if (trainSimWorldFormation != null) {
formationRepository.save(trainSimWorldFormation)
}
var created = false
if (formation == null) {
created = true
formation = mapper.mapToDB(userIdConverted, body)
} else {
formation.name = NonEmptyString(body.name)
formation.trainSimWorldFormationId = if (body.trainSimWorldFormation != null)
FormationId.of(NonEmptyString(body.trainSimWorldFormation!!.id)) else null
formation.formation = body.formation
formation.length = ZeroOrPositiveInteger(body.length)
}
formationRepository.save(formation)
val updatedFormation = mapper.mapToDto(formation, trainSimWorldFormation)
return if (created) {
ResponseEntity.status(HttpStatus.CREATED).body(updatedFormation)
} else {
ResponseEntity.ok(updatedFormation)
}
}
private fun createOrUpdateTrainSimWorldFormation(
userId: UserId,
formation: Formation?
): de.twomartens.timetable.model.db.Formation? {
if (formation == null) {
return null
}
var trainSimWorldFormation = formationRepository.findByUserIdAndFormationId(
userId, FormationId.of(NonEmptyString(formation.id)))
if (trainSimWorldFormation == null) {
trainSimWorldFormation = mapper.mapToDB(userId, formation)
trainSimWorldFormation.trainSimWorldFormationId = null
} else {
trainSimWorldFormation.name = NonEmptyString(formation.name)
trainSimWorldFormation.trainSimWorldFormationId = null
trainSimWorldFormation.formation = formation.formation
trainSimWorldFormation.length = ZeroOrPositiveInteger(formation.length)
}
return trainSimWorldFormation
}
@Operation(
summary = "Delete formation with id belonging to user",
responses = [ApiResponse(
responseCode = "204",
description = "Formation was deleted",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "404",
description = "Formation was not found",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@DeleteMapping("/{userId}/{id}")
fun deleteFormation(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@PathVariable @Parameter(description = "The id of the formation",
example = "1",
required = true) id: String,
): ResponseEntity<Void> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val formationIdConverted = FormationId.of(NonEmptyString(id))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val formation = formationRepository.findByUserIdAndFormationId(userIdConverted, formationIdConverted)
?: return ResponseEntity.notFound().build()
formationRepository.delete(formation)
return ResponseEntity.status(HttpStatus.NO_CONTENT).build()
}
}
@@ -0,0 +1,49 @@
package de.twomartens.timetable.formation
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.Formation
import de.twomartens.timetable.types.NonEmptyString
import de.twomartens.timetable.types.ZeroOrPositiveInteger
import org.mapstruct.*
@Mapper(
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
interface FormationMapper {
fun mapToDto(db: de.twomartens.timetable.model.db.Formation,
trainSimWorldFormation: de.twomartens.timetable.model.db.Formation?): Formation {
val dtoTrainSimWorldFormation = if (trainSimWorldFormation != null) {
mapToDto(trainSimWorldFormation, null)
} else {
null
}
return Formation(
db.formationId.id,
db.name.value,
dtoTrainSimWorldFormation,
db.formation,
db.length.value
)
}
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToDB(userId: UserId, dto: Formation): de.twomartens.timetable.model.db.Formation {
return de.twomartens.timetable.model.db.Formation(
userId,
FormationId.of(NonEmptyString(dto.id)),
NonEmptyString(dto.name),
if (dto.trainSimWorldFormation != null)
FormationId.of(NonEmptyString(dto.trainSimWorldFormation!!.id)) else null,
dto.formation,
ZeroOrPositiveInteger(dto.length)
)
}
}
@@ -0,0 +1,13 @@
package de.twomartens.timetable.formation
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.db.Formation
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
interface FormationRepository : MongoRepository<Formation, ObjectId> {
fun findByUserIdAndFormationId(userId: UserId, formationId: FormationId): Formation?
fun findAllByUserId(userId: UserId): List<Formation>
fun findAllByUserIdAndNameContainingIgnoreCase(userId: UserId, name: String): List<Formation>
}
@@ -1,6 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.PositiveInteger
@JvmInline
value class CoachCapacity(val capacity: PositiveInteger)
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class CountryCode(val countryCode: NonEmptyString) {
init {
require(countryCode.value.length == 2) {
"CountryCode must be only two characters long to conform to ISO 3166-1 alpha-2"
}
}
}
@@ -1,11 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
import kotlin.time.Duration
data class Depot(
var name: NonEmptyString,
var nearestStationId: StationId,
var tracks: Map<TrackId, CoachCapacity>,
var formationTimes: Map<FormationId, Duration>
)
@@ -1,14 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.PositiveInteger
@JvmInline
value class FormationId private constructor(val id: Int) {
companion object {
val EMPTY = FormationId(-1)
fun of(id: PositiveInteger): FormationId {
return FormationId(id.value)
}
}
}
@@ -1,6 +0,0 @@
package de.twomartens.timetable.model.common
enum class LanguageCode {
de,
en
}
@@ -1,14 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class Line private constructor(val name: String) {
companion object {
val EMPTY = Line("EMPTY")
fun of(name: NonEmptyString): Line {
return Line(name.value)
}
}
}
@@ -1,6 +0,0 @@
package de.twomartens.timetable.model.common
data class Platform(
var name: String,
var sections: MutableList<Section>
)
@@ -1,10 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
import kotlin.time.Duration
data class Portal(
var name: NonEmptyString,
var nearestStationId: StationId,
var formationTimes: Map<FormationId, Duration>
)
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.PositiveInteger
@JvmInline
value class RotationId private constructor(val id: Int) {
companion object {
fun of(id: PositiveInteger): RotationId {
return RotationId(id.value)
}
}
}
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class Section private constructor(val value: String) {
companion object {
fun of(section: NonEmptyString): Section {
return Section(section.value)
}
}
}
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class ServiceId private constructor(val value: String) {
companion object {
fun of(id: NonEmptyString): ServiceId {
return ServiceId(id.value)
}
}
}
@@ -1,30 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class StationId private constructor(val value: String) {
companion object {
private val idPattern = Regex("^\\w{2}-(?<countryStationId>\\w.*)")
fun of(id: NonEmptyString): StationId {
require(idPattern.matches(id.value)) {
"StationId must start with two character country code, followed by a dash, " +
"and at least one word character"
}
return StationId(id.value)
}
}
val stationIdWithinCountry: String
get() {
val matchResult = idPattern.matchEntire(value)
val groupMatch = matchResult?.groups?.get("countryStationId")?.value
require(matchResult != null && groupMatch != null)
return groupMatch
}
}
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.PositiveInteger
@JvmInline
value class TrackId private constructor(val value: Int) {
companion object {
fun of(id: PositiveInteger): TrackId {
return TrackId(id.value)
}
}
}
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class UserId private constructor(val value: String) {
companion object {
fun of(id: NonEmptyString): UserId {
return UserId(id.value)
}
}
}
@@ -1,31 +0,0 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.types.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
@CompoundIndex(def = "{'userId': 1, 'formationId': 1}", unique = true)
data class Formation(
var userId: UserId,
var formationId: FormationId,
var name: NonEmptyString,
var trainSimWorldFormationId: FormationId,
var coaches: List<NonEmptyString>
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,27 +0,0 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.Platform
import de.twomartens.timetable.model.common.StationId
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
data class Station(
@Indexed(unique = true) var stationId: StationId,
var name: String,
var platforms: List<Platform>
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,31 +0,0 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.*
import de.twomartens.timetable.types.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
@CompoundIndex(def = "{'userId': 1, 'name': 1}", unique = true)
data class TswRoute(
var userId: UserId,
var name: NonEmptyString,
var countryCode: CountryCode,
var stationIds: List<StationId>,
var portals: List<Portal>,
var depots: List<Depot>
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,25 +0,0 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.types.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
data class User(
var userId: UserId,
var name: NonEmptyString
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -1,17 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.Line
import de.twomartens.timetable.model.common.ServiceId
import java.time.LocalTime
data class AdditionalService(
val id: ServiceId,
val line: Line,
val formationId: FormationId,
val direction: Direction,
val start: Station,
val destination: Station,
val serviceStartTime: LocalTime,
val stops: List<AdditionalServiceStop>
)
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.types.NonEmptyString
import java.time.LocalTime
data class AdditionalServiceStop(
val stop: Station,
val arrivalTime: LocalTime,
val departureTime: LocalTime,
val platformAndSection: NonEmptyString,
val loading: Boolean
)
@@ -1,9 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.CountryCode
import de.twomartens.timetable.types.NonEmptyString
data class Country(
val countryCode: CountryCode,
val name: NonEmptyString
)
@@ -1,6 +0,0 @@
package de.twomartens.timetable.model.dto
enum class Direction {
LEFT,
RIGHT
}
@@ -1,11 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.types.NonEmptyString
data class Formation(
val id: FormationId,
val name: NonEmptyString,
val trainSimWorldFormationId: FormationId?,
val coaches: List<NonEmptyString>
)
@@ -1,14 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.RotationId
import java.time.LocalTime
data class Rotation(
val id: RotationId,
val formationId: FormationId,
val firstServiceStartTime: LocalTime,
val lastServiceEndTime: LocalTime,
val startsInVault: Boolean,
)
@@ -1,15 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.Line
import de.twomartens.timetable.model.common.ServiceId
data class ServiceFormationStage(
val id: ServiceId,
val line: Line,
val direction: Direction,
val formation: FormationId,
val formationReversed: Boolean,
val startStop: ServiceStop,
val destinationStop: ServiceStop
)
@@ -1,18 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.FormationId
import de.twomartens.timetable.model.common.Line
import de.twomartens.timetable.model.common.ServiceId
data class ServiceLinkingStage(
val id: ServiceId,
val formationId: FormationId,
val line: Line,
val direction: Direction,
val formationReversed: Boolean,
val startStop: ServiceStop,
val fromDepotOrSidingAtStart: Boolean,
val destinationStop: ServiceStop,
val fromDepotOrSidingAtDestination: Boolean,
)
@@ -1,16 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.Line
import de.twomartens.timetable.model.common.ServiceId
import de.twomartens.timetable.types.NonEmptyString
data class ServiceRotationStage(
val id: ServiceId,
val line: Line,
val originStop: NonEmptyString,
val virtualDestinations: List<NonEmptyString>,
val startingFrom: NonEmptyString,
val endingIn: NonEmptyString,
val serviceStartTime: NonEmptyString,
val stops: List<ServiceStop>
)
@@ -1,11 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.types.NonEmptyString
data class ServiceStop(
val stop: Station,
val arrivalTime: String,
val departureTime: String,
val platform: NonEmptyString,
val section: String
)
@@ -1,9 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.StationId
import de.twomartens.timetable.types.NonEmptyString
data class Station(
val id: StationId,
val name: NonEmptyString
)
@@ -1,13 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.types.NonEmptyString
import java.time.LocalDate
data class Timetable(
val name: NonEmptyString,
val country: Country,
val routeName: NonEmptyString,
val fetchDate: LocalDate,
val state: TimetableState,
val numberOfServices: String
)
@@ -1,9 +0,0 @@
package de.twomartens.timetable.model.dto
enum class TimetableState {
NEW,
PROCESSING,
ENTER_FORMATIONS,
LINK_SERVICES,
READY_FOR_USAGE
}
@@ -1,13 +0,0 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.Depot
import de.twomartens.timetable.model.common.Portal
import de.twomartens.timetable.types.NonEmptyString
data class TswRoute(
val name: NonEmptyString,
val country: Country,
val stations: List<Station>,
val portals: List<Portal>,
val depots: List<Depot>
)
@@ -11,6 +11,5 @@ import org.springframework.cloud.context.config.annotation.RefreshScope
@ConfigurationProperties(prefix = "de.twomartens.timetable")
@Schema(description = "Properties, to configure this Application")
class ServiceProperties {
lateinit var greeting: String
lateinit var countryNames: Map<CountryCode, Map<LanguageCode, NonEmptyString>>
}
@@ -0,0 +1,220 @@
package de.twomartens.timetable.route
import de.twomartens.timetable.auth.UserRepository
import de.twomartens.timetable.model.common.RouteId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.TswRoute
import de.twomartens.timetable.types.NonEmptyString
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.ArraySchema
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import org.mapstruct.factory.Mappers
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping(value = ["/v1/routes"])
@Tag(name = "Routes", description = "all requests relating to routes")
class RouteController(
private val routeRepository: TswRouteRepository,
private val userRepository: UserRepository
) {
private val mapper = Mappers.getMapper(RouteMapper::class.java)
@Operation(
summary = "Access routes of user and filter by name",
responses = [ApiResponse(
responseCode = "200",
description = "List of found routes",
content = [Content(
array = ArraySchema(schema = Schema(implementation = TswRoute::class))
)]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@GetMapping("/{userId}/")
fun getRoutes(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@RequestParam(name = "name", required = false) @Parameter(description = "Searched name",
example = "1",
required = false) name: String?
): ResponseEntity<List<TswRoute>> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val routes = if (!name.isNullOrBlank()) {
routeRepository.findAllByUserIdAndNameContainingIgnoreCase(userIdConverted, name)
} else {
routeRepository.findAllByUserId(userIdConverted)
}
return ResponseEntity.ok(mapper.mapRoutesToDto(routes))
}
@Operation(
summary = "Access route with id belonging to user",
responses = [ApiResponse(
responseCode = "200",
description = "Route exists and was returned",
content = [Content(
schema = Schema(implementation = TswRoute::class)
)]
), ApiResponse(
responseCode = "404",
description = "Route was not found",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@GetMapping("/{userId}/{id}")
fun getRoute(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@PathVariable @Parameter(description = "The id of the route",
example = "1",
required = true) id: String
): ResponseEntity<TswRoute> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val routeIdConverted = RouteId.of(NonEmptyString(id))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val route = routeRepository.findByUserIdAndRouteId(userIdConverted, routeIdConverted)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(mapper.mapToDto(route))
}
@Operation(
summary = "Store route with id belonging to user",
responses = [ApiResponse(
responseCode = "200",
description = "Route was updated",
content = [Content(
schema = Schema(implementation = TswRoute::class)
)]
), ApiResponse(
responseCode = "201",
description = "Route was created",
content = [Content(
schema = Schema(implementation = TswRoute::class)
)]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@PutMapping("/{userId}/{id}")
fun putRoute(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@PathVariable @Parameter(description = "The id of the route",
example = "1",
required = true) id: String,
@RequestBody(required = true) @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
content = [
Content(
schema = Schema(implementation = TswRoute::class)
)
]) body: TswRoute
): ResponseEntity<TswRoute> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val routeIdConverted = RouteId.of(NonEmptyString(id))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
var route = routeRepository.findByUserIdAndRouteId(userIdConverted, routeIdConverted)
var created = false
if (route == null) {
created = true
route = mapper.mapToDB(userIdConverted, body)
} else {
route.name = NonEmptyString(body.name)
route.country = body.country
route.stations = body.stations
route.depots = mapper.mapDepotsToDB(body.depots)
route.portals = mapper.mapPortalsToDB(body.portals)
}
routeRepository.save(route)
val updatedRoute = mapper.mapToDto(route)
return if (created) {
ResponseEntity.status(HttpStatus.CREATED).body(updatedRoute)
} else {
ResponseEntity.ok(updatedRoute)
}
}
@Operation(
summary = "Delete route with id belonging to user",
responses = [ApiResponse(
responseCode = "204",
description = "Route was deleted",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "404",
description = "Route was not found",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@DeleteMapping("/{userId}/{id}")
fun deleteRoute(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: String,
@PathVariable @Parameter(description = "The id of the route",
example = "1",
required = true) id: String,
): ResponseEntity<Void> {
val userIdConverted = UserId.of(NonEmptyString(userId))
val routeIdConverted = RouteId.of(NonEmptyString(id))
val userExists = userRepository.existsByUserId(userIdConverted)
if (!userExists) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val route = routeRepository.findByUserIdAndRouteId(userIdConverted, routeIdConverted)
?: return ResponseEntity.notFound().build()
routeRepository.delete(route)
return ResponseEntity.status(HttpStatus.NO_CONTENT).build()
}
}
@@ -0,0 +1,114 @@
package de.twomartens.timetable.route
import de.twomartens.timetable.model.common.DepotId
import de.twomartens.timetable.model.common.PortalId
import de.twomartens.timetable.model.common.RouteId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.Depot
import de.twomartens.timetable.model.dto.Portal
import de.twomartens.timetable.model.dto.TravelDuration
import de.twomartens.timetable.model.dto.TswRoute
import de.twomartens.timetable.types.NonEmptyString
import org.mapstruct.*
@Mapper(
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
interface RouteMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToDB(userId: UserId, dto: TswRoute): de.twomartens.timetable.model.db.TswRoute {
return de.twomartens.timetable.model.db.TswRoute(
userId,
RouteId.of(NonEmptyString(dto.id)),
NonEmptyString(dto.name),
dto.country,
dto.stations,
mapDepotsToDB(dto.depots),
mapPortalsToDB(dto.portals)
)
}
fun mapDepotsToDB(depots: List<Depot>): List<de.twomartens.timetable.model.db.Depot> {
return depots.map {
de.twomartens.timetable.model.db.Depot(
DepotId.of(NonEmptyString(it.id)),
NonEmptyString(it.name),
it.nearestStation,
it.tracks,
mapTravelDurationsToDB(it.travelDurations)
)
}
}
fun mapPortalsToDB(portals: List<Portal>): List<de.twomartens.timetable.model.db.Portal> {
return portals.map {
de.twomartens.timetable.model.db.Portal(
PortalId.of(NonEmptyString(it.id)),
NonEmptyString(it.name),
it.nearestStation,
mapTravelDurationsToDB(it.travelDurations)
)
}
}
fun mapTravelDurationsToDB(travelDurations: List<TravelDuration>): List<de.twomartens.timetable.model.db.TravelDuration> {
return travelDurations.map {
de.twomartens.timetable.model.db.TravelDuration(
it.formation,
it.time
)
}
}
fun mapRoutesToDto(routes: List<de.twomartens.timetable.model.db.TswRoute>): List<TswRoute>
fun mapToDto(db: de.twomartens.timetable.model.db.TswRoute): TswRoute {
return TswRoute(
db.routeId.id,
db.name.value,
db.country,
db.stations,
db.stations.first(),
db.stations.last(),
db.stations.size,
mapDepotsToDto(db.depots),
mapPortalsToDto(db.portals)
)
}
fun mapDepotsToDto(depots: List<de.twomartens.timetable.model.db.Depot>): List<Depot> {
return depots.map { depot ->
Depot(
depot.id.id,
depot.name.value,
depot.nearestStation,
depot.tracks,
mapTravelDurationsToDto(depot.travelDurations)
)
}
}
fun mapPortalsToDto(portals: List<de.twomartens.timetable.model.db.Portal>): List<Portal> {
return portals.map { portal ->
Portal(
portal.id.id,
portal.name.value,
portal.nearestStation,
mapTravelDurationsToDto(portal.travelDurations)
)
}
}
fun mapTravelDurationsToDto(travelDurations: List<de.twomartens.timetable.model.db.TravelDuration>): List<TravelDuration> {
return travelDurations.map {
TravelDuration(
it.formation,
it.time
)
}
}
}
@@ -1,5 +1,6 @@
package de.twomartens.timetable.repository
package de.twomartens.timetable.route
import de.twomartens.timetable.model.common.RouteId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.db.TswRoute
import de.twomartens.timetable.types.NonEmptyString
@@ -7,5 +8,8 @@ import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
interface TswRouteRepository : MongoRepository<TswRoute, ObjectId> {
fun findByUserIdAndRouteId(userId: UserId, routeId: RouteId): TswRoute?
fun findByUserIdAndName(userId: UserId, name: NonEmptyString): TswRoute?
fun findAllByUserId(userId: UserId): List<TswRoute>
fun findAllByUserIdAndNameContainingIgnoreCase(userId: UserId, name: String): List<TswRoute>
}
@@ -0,0 +1,97 @@
package de.twomartens.timetable.station
import de.twomartens.timetable.bahnApi.service.BahnApiService
import de.twomartens.timetable.bahnApi.service.BahnDatabaseService
import de.twomartens.timetable.model.dto.Station
import de.twomartens.timetable.model.mapper.StationMapper
import de.twomartens.timetable.model.repository.StationRepository
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.ArraySchema
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import org.mapstruct.factory.Mappers
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping(value = ["/v1/stations"])
@Tag(name = "Stations", description = "all requests relating to stations")
class StationController(
private val stationRepository: StationRepository,
private val bahnApiService: BahnApiService,
private val bahnDatabaseService: BahnDatabaseService
) {
private val mapper = Mappers.getMapper(StationMapper::class.java)
@Operation(
summary = "Access stations and filter by name",
responses = [ApiResponse(
responseCode = "200",
description = "List of found stations",
content = [Content(
array = ArraySchema(schema = Schema(implementation = Station::class))
)]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@GetMapping("/")
fun getStations(
@RequestParam(name = "country", required = false) @Parameter(description = "Searched country",
example = "de",
required = false) countryCode: String?,
@RequestParam(name = "name", required = false) @Parameter(description = "Searched name",
example = "Aachen",
required = false) name: String?
): ResponseEntity<List<Station>> {
val stations = findStations(countryCode, name)
return ResponseEntity.ok(mapper.mapStationsToDto(stations))
}
private fun findStations(
countryCode: String?,
name: String?
): List<de.twomartens.timetable.model.db.Station> {
val stations = if (!name.isNullOrBlank()) {
if (!countryCode.isNullOrBlank()) {
stationRepository.findAllByCountryCodeAndNameContainingIgnoreCase(countryCode, name)
} else {
stationRepository.findAllByNameContainingIgnoreCase(name)
}
} else {
stationRepository.findAll()
}
return stations
}
@Operation(
summary = "Update stations",
responses = [ApiResponse(
responseCode = "200",
description = "Stations updated",
content = [Content(mediaType = "text/plain")]
), ApiResponse(
responseCode = "403",
description = "Access forbidden for user",
content = [Content(mediaType = "text/plain")]
)]
)
@SecurityRequirement(name = "bearer")
@SecurityRequirement(name = "oauth2")
@PostMapping("/update")
fun updateStations(): ResponseEntity<Void> {
val stations = bahnApiService.fetchStations("*")
bahnDatabaseService.storeStations(stations)
return ResponseEntity.ok().build()
}
}
@@ -1,15 +0,0 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.property.TimeProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
open class ClockConfiguration(private val properties: TimeProperties) {
@Bean
open fun clock(): Clock {
return Clock.system(properties.defaultTimeZone)
}
}
@@ -1,13 +0,0 @@
package de.twomartens.timetable.support.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.ForwardedHeaderFilter
@Configuration
open class FilterConfiguration {
@Bean
open fun forwardedFilter(): ForwardedHeaderFilter {
return ForwardedHeaderFilter()
}
}
@@ -1,20 +0,0 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import de.twomartens.timetable.support.interceptor.LoggingInterceptorRest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
open class InterceptorConfiguration {
@Bean
open fun loggingInterceptorRest(clock: Clock): LoggingInterceptorRest {
return LoggingInterceptorRest(clock)
}
@Bean
open fun headerInterceptorRest(): HeaderInterceptorRest {
return HeaderInterceptorRest()
}
}
@@ -1,37 +0,0 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.property.RestTemplateTimeoutProperties
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import de.twomartens.timetable.support.interceptor.LoggingInterceptorRest
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestTemplate
@Configuration
open class RestTemplateConfiguration {
@Bean("restTemplate")
open fun restTemplate(
headerInterceptorRest: HeaderInterceptorRest,
loggingInterceptor: LoggingInterceptorRest,
restTemplateTimeoutProperties: RestTemplateTimeoutProperties
): RestTemplate {
return RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest, loggingInterceptor)
.setConnectTimeout(restTemplateTimeoutProperties.connectionRestTemplateTimeoutInMillis)
.setReadTimeout(restTemplateTimeoutProperties.readTimeoutRestTemplateInMillis)
.build()
}
@Bean("restTemplateRestHealthIndicator")
open fun restTemplateRestHealthIndicator(
headerInterceptorRest: HeaderInterceptorRest,
restTemplateTimeoutProperties: RestTemplateTimeoutProperties
): RestTemplate {
return RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest)
.setConnectTimeout(restTemplateTimeoutProperties.connectionRestHealthIndicatorTimeoutInMillis)
.setReadTimeout(restTemplateTimeoutProperties.readTimeoutRestHealthIndicatorInMillis)
.build()
}
}
@@ -1,25 +0,0 @@
package de.twomartens.timetable.support.configuration
import de.twomartens.timetable.support.monitoring.statusprobe.CountBasedStatusProbe
import de.twomartens.timetable.support.monitoring.statusprobe.StatusProbe
import de.twomartens.timetable.support.monitoring.statusprobe.StatusProbeCriticality
import de.twomartens.timetable.support.monitoring.statusprobe.StatusProbeLogger
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
@Configuration
open class StatusProbeConfiguration(private val clock: Clock) {
@Bean
open fun statusProbeLogger(): StatusProbeLogger {
return StatusProbeLogger(clock)
}
@Bean
open fun testStatusProbe(statusProbeLogger: StatusProbeLogger): StatusProbe {
return CountBasedStatusProbe(
1,
clock, StatusProbeCriticality.K1, "testStatusProbe", statusProbeLogger
)
}
}
@@ -1,36 +0,0 @@
package de.twomartens.timetable.support.controller
import de.twomartens.timetable.support.exception.HttpStatusException
import de.twomartens.timetable.support.model.dto.ErrorMessage
import mu.KotlinLogging
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
@ControllerAdvice(annotations = [RestController::class])
class ExceptionController : ResponseEntityExceptionHandler() {
@ExceptionHandler(HttpStatusException::class)
fun handleException(e: HttpStatusException): ResponseEntity<ErrorMessage> {
if (e.cause != null) {
log.info(e.cause.toString(), e.cause)
} else {
log.info(e.toString())
}
return ResponseEntity.status(e.status)
.body(ErrorMessage(e.message!!))
}
@ExceptionHandler(RuntimeException::class)
fun handleRuntimeException(e: RuntimeException): ResponseEntity<ErrorMessage> {
log.error("unexpected exception occurred", e)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorMessage(message = e.message!!))
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -1,82 +0,0 @@
package de.twomartens.timetable.support.controller
import mu.KotlinLogging
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import java.io.FileNotFoundException
import java.net.InetAddress
import java.net.UnknownHostException
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.util.jar.JarFile
@Controller
@RequestMapping(value = ["/timetable"])
class VersionHtmlController {
@GetMapping(path = ["/version"])
fun version(): String {
return "version"
}
@ModelAttribute("version")
private fun getApplicationVersion(): String {
return "${getTitle()} ${getVersion()}"
}
@ModelAttribute("footerString")
private fun getApplicationVersion(@RequestHeader("host") hostName: String): String {
return "${getTitle()} ${getVersion()} - $hostName"
}
private fun getTitle(): String {
val title = VersionHtmlController::class.java.`package`.implementationTitle
?: return "application"
return title.ifBlank {
"application"
}
}
private fun getVersion(): String {
val version = VersionHtmlController::class.java.`package`.implementationVersion
?: return "DEVELOPER"
return version.ifBlank {
"DEVELOPER"
}
}
@ModelAttribute("hostname")
private fun getHostname(): String {
try {
return InetAddress.getLocalHost().hostName
} catch (e: UnknownHostException) {
log.warn(e.toString(), e)
}
return ""
}
@ModelAttribute("manifest")
private fun getManifest(): Collection<String> {
try {
val location = javaClass.getProtectionDomain().codeSource.location
val jarFileName = Paths.get(location.toURI()).toString()
JarFile(jarFileName).use { jarFile ->
val entry = jarFile.getEntry(JarFile.MANIFEST_NAME)
jarFile.getInputStream(entry).use { `in` ->
return String(`in`.readAllBytes(), StandardCharsets.UTF_8).lines().toList()
}
}
} catch (ignored: FileNotFoundException) {
// do nothing if manifest file is not available
} catch (e: Exception) {
log.info(e.toString(), e)
}
return listOf("${getTitle()} ${getVersion()}")
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -1,19 +0,0 @@
package de.twomartens.timetable.support.controller.v1
import de.twomartens.timetable.property.ServiceProperties
import io.swagger.v3.oas.annotations.Hidden
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping(value = ["/timetable/v1"])
class HealthCheckController(private val properties: ServiceProperties) {
@Hidden
@GetMapping("/healthCheck")
fun healthCheck(message: String): String {
return properties.greeting.format(message)
}
}
@@ -1,6 +0,0 @@
package de.twomartens.timetable.support.exception
import org.springframework.http.HttpStatus
class HttpStatusException(val status: HttpStatus, message: String, cause: Throwable?) :
RuntimeException(message, cause)
@@ -1,45 +0,0 @@
package de.twomartens.timetable.support.interceptor
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatusCode
import org.springframework.http.client.ClientHttpResponse
import org.springframework.util.StreamUtils
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
class BufferingClientHttpResponseWrapper(private val response: ClientHttpResponse) : ClientHttpResponse {
private var body: ByteArray? = null
@Throws(IOException::class)
override fun getStatusCode(): HttpStatusCode {
return response.statusCode
}
@Deprecated("Replace calls of getRawStatusCode with getStatusCode", ReplaceWith("getStatusCode()"))
@Throws(IOException::class)
override fun getRawStatusCode(): Int {
return statusCode.value()
}
@Throws(IOException::class)
override fun getStatusText(): String {
return response.statusText
}
override fun getHeaders(): HttpHeaders {
return response.headers
}
@Throws(IOException::class)
override fun getBody(): InputStream {
if (body == null) {
body = StreamUtils.copyToByteArray(response.body)
}
return ByteArrayInputStream(body)
}
override fun close() {
response.close()
}
}
@@ -1,85 +0,0 @@
package de.twomartens.timetable.support.interceptor
import org.slf4j.MDC
import java.io.Closeable
import java.util.*
abstract class HeaderInterceptor {
companion object {
const val LOGGER_TRACE_ID = "trace.id"
const val LOGGER_REQTYPE_ID = "REQTYPE"
const val HEADER_FIELD_TRACE_ID = "X-TraceId"
const val HEADER_FIELD_B3_TRACE_ID = "x-b3-traceid"
const val HEADER_FIELD_TYPE_ID = "x-type"
const val REQ_TYPE_HEALTHCHECK = "HEALTH_CHECK"
const val REQ_TYPE_INTEGRATION_TEST = "INTEGRATION_TEST"
const val REQ_TYPE_SERVER_TEST = "SERVER_TEST"
const val REQ_TYPE_WARMUP = "WARMUP"
fun createNewTraceId(): String {
return UUID.randomUUID().toString()
}
fun getTraceId(): String {
val traceId = MDC.get(LOGGER_TRACE_ID)
return if (traceId.isNullOrBlank()) createNewTraceId() else traceId
}
fun getRequestType(): String? {
val type = MDC.get(LOGGER_REQTYPE_ID)
return if (type.isNullOrBlank()) null else type
}
private fun setTraceId(traceId: String): InterceptorCloseables {
return InterceptorCloseables(MDC.putCloseable(LOGGER_TRACE_ID, traceId))
}
private fun mark(requestType: String?): InterceptorCloseables {
return InterceptorCloseables(MDC.putCloseable(LOGGER_REQTYPE_ID, requestType))
}
fun set(traceId: String, requestType: String?): InterceptorCloseables {
return if (requestType != null) {
InterceptorCloseables(setTraceId(traceId), mark(requestType))
} else setTraceId(traceId)
}
}
fun markAsHealthCheck(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_HEALTHCHECK), setTraceId(createNewTraceId()))
}
fun markAsIntegrationTest(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_INTEGRATION_TEST), setTraceId(createNewTraceId()))
}
fun markAsServerTest(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_SERVER_TEST), setTraceId(createNewTraceId()))
}
fun markAsWarmup(): InterceptorCloseables {
return InterceptorCloseables(mark(REQ_TYPE_WARMUP), setTraceId(createNewTraceId()))
}
class InterceptorCloseables(vararg closeables: Closeable) : Closeable {
private val closeables: Array<out Closeable>
init {
this.closeables = closeables
}
override fun close() {
closeables.forEach {
try {
it.close()
} catch (ignored: Exception) {
// do nothing
}
}
}
}
}
@@ -1,88 +0,0 @@
package de.twomartens.timetable.support.interceptor
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpRequest
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.web.servlet.HandlerInterceptor
import java.io.IOException
import java.util.*
class HeaderInterceptorRest : HeaderInterceptor(), HandlerInterceptor, ClientHttpRequestInterceptor {
companion object {
val CLASS_NAME: String = HeaderInterceptorRest::class.java.getName()
fun extractTraceId(request: HttpServletRequest): String {
var traceId = request.getHeader(HEADER_FIELD_TRACE_ID)
if (traceId.isNullOrBlank()) traceId = request.getHeader(HEADER_FIELD_B3_TRACE_ID)
if (traceId.isNullOrBlank()) return createNewTraceId()
return traceId
}
fun extractTraceId(request: HttpRequest): String {
var traceId = request.headers[HEADER_FIELD_TRACE_ID]?.first()
if (traceId.isNullOrBlank()) traceId = request.headers[HEADER_FIELD_B3_TRACE_ID]?.first()
if (traceId.isNullOrBlank()) return UUID.randomUUID().toString()
return traceId
}
fun extractRequestType(request: HttpServletRequest): String? {
val type = request.getHeader(HEADER_FIELD_TYPE_ID)
if (type.isNullOrBlank()) return null
return type
}
fun extractRequestType(request: HttpRequest): String? {
val type = request.headers[HEADER_FIELD_TYPE_ID]?.first()
if (type.isNullOrBlank()) return null
return type
}
}
// ClientHttpRequestInterceptor
@Throws(IOException::class)
override fun intercept(
request: HttpRequest, body: ByteArray,
execution: ClientHttpRequestExecution
): ClientHttpResponse {
request.headers.add(HEADER_FIELD_TRACE_ID, getTraceId())
val requestType = getRequestType()
if (requestType != null) {
request.headers.add(HEADER_FIELD_TYPE_ID, requestType)
}
return try {
execution.execute(request, body)
} finally {
request.headers.remove(HEADER_FIELD_TRACE_ID)
request.headers.remove(HEADER_FIELD_TYPE_ID)
}
}
// HandlerInterceptor
override fun preHandle(
request: HttpServletRequest, response: HttpServletResponse,
handler: Any
): Boolean {
val traceId = extractTraceId(request)
val requestType = extractRequestType(request)
val closeable = set(traceId, requestType)
request.setAttribute(CLASS_NAME, closeable)
return true
}
// HandlerInterceptor
// override fun postHandle(
// request: HttpServletRequest,
// response: HttpServletResponse,
// handler: Any,
// modelAndView: ModelAndView?
// ) {
// val obj = request.getAttribute(CLASS_NAME)
// if (obj != null && obj is InterceptorCloseables) {
// obj.close()
// }
// }
}
@@ -1,438 +0,0 @@
package de.twomartens.timetable.support.interceptor
import jakarta.servlet.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import mu.KotlinLogging
import org.apache.logging.log4j.message.StringMapMessage
import org.slf4j.Marker
import org.slf4j.MarkerFactory
import org.springframework.http.*
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.util.StreamUtils
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import java.io.IOException
import java.net.URL
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.time.Clock
import java.time.Duration
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.function.Function
class LoggingInterceptorRest(
private val requestLogBehaviour: FieldLogBehaviour,
private val responseLogBehaviour: FieldLogBehaviour,
val clock: Clock
) : Filter, ClientHttpRequestInterceptor {
constructor(clock: Clock) : this(FieldLogBehaviour.NEVER, FieldLogBehaviour.NEVER, clock)
@Throws(IOException::class, ServletException::class)
override fun doFilter(
request: ServletRequest, response: ServletResponse,
chain: FilterChain
) {
val requestTime = ZonedDateTime.now(clock)
val httpRequest = request as HttpServletRequest
val requestWrapper = ContentCachingRequestWrapper(httpRequest)
val responseWrapper = ContentCachingResponseWrapper(
(response as HttpServletResponse)
)
var fullResponseBytes: ByteArray? = null
var throwable: Throwable? = null
var responseBody: String? = null
var httpStatusCode = -1
try {
try {
chain.doFilter(
if (requestLogBehaviour != FieldLogBehaviour.NEVER) requestWrapper else httpRequest,
responseWrapper
)
if (responseLogBehaviour != FieldLogBehaviour.NEVER) {
fullResponseBytes = responseWrapper.contentAsByteArray
}
httpStatusCode = responseWrapper.status
} finally {
responseWrapper.copyBodyToResponse()
}
} catch (e: Exception) {
throwable = e
throw e
} finally {
try {
val responseSize = responseWrapper.contentSize
val responseHeaders = extractHeaders(
headerNames = responseWrapper.headerNames.iterator()
) { responseWrapper.getHeaders(it).iterator() }
if (
(responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS)
&& fullResponseBytes != null
) {
responseBody = String(
fullResponseBytes,
determineResponseEncoding()
)
}
val query = if (httpRequest.queryString != null) "?${httpRequest.queryString}" else ""
val requestUrl = URL("${httpRequest.requestURL}$query")
val requestHeaders = extractHeaders(
headerNames = httpRequest.headerNames.asIterator(),
ignoreList = listOf("authorization")) {
httpRequest.getHeaders(it).asIterator()
}
var requestBody: String? = null
var businessType: String? = null
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS
) {
val fullRequestBytes = requestWrapper.contentAsByteArray
requestBody = String(fullRequestBytes, determineRequestEncoding())
businessType = determineBusinessType()
}
log(
LogMessage(
requestHeaders = requestHeaders,
responseHeaders = responseHeaders,
url = requestUrl,
method = httpRequest.method,
requestMimeType = typeToString(request.getContentType()),
responseMimeType = typeToString(response.getContentType()),
requestBody = requestBody,
responseBody = responseBody,
requestSize = httpRequest.contentLength,
responseSize = responseSize,
httpStatus = httpStatusCode,
direction = DIRECTION_IN,
requestTime = requestTime,
responseTime = ZonedDateTime.now(clock),
traceId = HeaderInterceptor.getTraceId(),
requestType = HeaderInterceptor.getRequestType(),
businessType = businessType,
throwable = throwable
)
)
val interceptorCloseables =
request.getAttribute(HeaderInterceptorRest.CLASS_NAME) as HeaderInterceptor.InterceptorCloseables
interceptorCloseables.close()
} catch (e: java.lang.RuntimeException) {
log.error(e.toString(), e)
}
}
}
private fun isError(httpStatusCode: Int): Boolean {
return httpStatusCode in 400..599
}
@Throws(IOException::class)
override fun intercept(
request: HttpRequest,
requestBytes: ByteArray, execution: ClientHttpRequestExecution
): ClientHttpResponse {
val requestTime = ZonedDateTime.now(clock)
var responseSize = 0
var responseHeaders: Map<String, Collection<String>> = emptyMap()
var responseMediaType: MediaType? = null
var httpStatusCode = -1
var throwable: Throwable? = null
var requestBody: String? = null
var responseBody: String? = null
var businessType: String? = null
return try {
val result = BufferingClientHttpResponseWrapper(
execution.execute(request, requestBytes)
)
val responseBytes = StreamUtils.copyToByteArray(result.getBody())
responseSize = responseBytes.size
responseHeaders = extractHeaders(result.headers)
responseMediaType = result.headers.getContentType()
httpStatusCode = result.statusCode.value()
if (responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS
) {
responseBody = String(
responseBytes,
determineRequestEncoding()
)
}
result
} catch (e: Exception) {
throwable = e
throw e
} finally {
try {
val url = request.uri.toURL()
val requestHeaders: Map<String, Collection<String>> = extractHeaders(request.headers)
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS
) {
requestBody = String(
requestBytes,
determineRequestEncoding()
)
businessType = determineBusinessType()
}
log(
LogMessage(
requestHeaders = requestHeaders,
responseHeaders = responseHeaders,
url = url,
method = request.method.name(),
requestMimeType = typeToString(request.headers.getContentType()),
requestBody = requestBody,
responseBody = responseBody,
responseMimeType = typeToString(responseMediaType!!),
requestSize = requestBytes.size,
responseSize = responseSize,
httpStatus = httpStatusCode,
direction = DIRECTION_OUT,
requestTime = requestTime,
responseTime = ZonedDateTime.now(clock),
businessType = businessType,
throwable = throwable,
traceId = HeaderInterceptorRest.extractTraceId(request),
requestType = HeaderInterceptorRest.extractRequestType(request),
)
)
} catch (e: java.lang.RuntimeException) {
log.error(e.toString(), e)
}
}
}
private fun log(logMessage: LogMessage) {
val stringMapMessage = StringMapMessage()
addLogString(
stringMapMessage, PARAM_REQUEST_HEADERS,
toHeaderString(logMessage.requestHeaders)
)
addLogString(
stringMapMessage, PARAM_RESPONSE_HEADERS,
toHeaderString(logMessage.responseHeaders)
)
addLogString(stringMapMessage, PARAM_URL_FULL, logMessage.url.toString())
addLogString(stringMapMessage, PARAM_URL_DOMAIN, logMessage.url.host)
addLogString(
stringMapMessage, PARAM_URL_EXTENSION,
extractExtension(logMessage.url.path)
)
addLogString(stringMapMessage, PARAM_URL_PATH, logMessage.url.path)
addLogString(stringMapMessage, PARAM_URL_PORT, logMessage.url.port.toString())
addLogString(stringMapMessage, PARAM_URL_SCHEME, logMessage.url.protocol)
addLogString(stringMapMessage, PARAM_URL_QUERY, logMessage.url.query)
addLogString(stringMapMessage, PARAM_REQUEST_METHOD, logMessage.method)
addLogString(
stringMapMessage, PARAM_REQUEST_REFERER,
getHeader(logMessage.requestHeaders, HttpHeaders.REFERER)
)
addLogString(stringMapMessage, PARAM_REQUEST_MIMETYPE, logMessage.requestMimeType)
addLogString(stringMapMessage, PARAM_RESPONSE_MIMETYPE, logMessage.responseMimeType)
addLogString(stringMapMessage, PARAM_REQUEST_BYTES, logMessage.requestSize.toString())
addLogString(stringMapMessage, PARAM_RESPONSE_BYTES, logMessage.responseSize.toString())
addLogString(stringMapMessage, PARAM_RESPONSE_STATUS, logMessage.httpStatus.toString())
addLogString(stringMapMessage, PARAM_DIRECTION, logMessage.direction)
addLogString(stringMapMessage, PARAM_PROTOCOL, PROTOCOL_NAME)
addLogString(
stringMapMessage, PARAM_REQUEST_TIME,
logMessage.requestTime.format(DATE_TIME_FORMATTER)
)
addLogString(
stringMapMessage, PARAM_RESPONSE_TIME,
logMessage.responseTime.format(DATE_TIME_FORMATTER)
)
addLogString(
stringMapMessage,
PARAM_DURATION,
getDurationBetweenRequestAndResponseTime(logMessage).toNanos().toString()
)
addLogString(
stringMapMessage, PARAM_USER_AGENT,
getHeader(logMessage.requestHeaders, HttpHeaders.USER_AGENT)
)
addLogString(stringMapMessage, HeaderInterceptor.LOGGER_TRACE_ID, logMessage.traceId)
addLogString(stringMapMessage, HeaderInterceptor.LOGGER_REQTYPE_ID, logMessage.requestType)
addLogString(stringMapMessage, PARAM_BUSINESS_TYPE, logMessage.businessType)
addLogString(stringMapMessage, PARAM_REQUEST_BODY, cutToMaxLength(logMessage.requestBody))
addLogString(stringMapMessage, PARAM_RESPONSE_BODY, cutToMaxLength(logMessage.responseBody))
log.debug(MARKER, stringMapMessage.asString(), logMessage.throwable)
}
private fun getDurationBetweenRequestAndResponseTime(logMessage: LogMessage): Duration {
return Duration.between(logMessage.requestTime, logMessage.responseTime)
}
private fun getHeader(headers: Map<String, Collection<String?>>, headerKey: String): String? {
return headers.entries.asSequence()
.filter { it.key.equals(headerKey, ignoreCase = true) }
.flatMap { it.value.asSequence() }
.firstOrNull()
}
private fun addLogString(stringMapMessage: StringMapMessage, key: String, value: String?) {
if (!value.isNullOrBlank()) {
stringMapMessage.with(key, value.trim { it <= ' ' })
}
}
/**
* usually returns null, but can be overridden to implement more complex logic
*/
private fun determineBusinessType(): String? {
return null
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
private fun determineRequestEncoding(): Charset {
return StandardCharsets.UTF_8
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
private fun determineResponseEncoding(): Charset {
return StandardCharsets.UTF_8
}
companion object {
private val log = KotlinLogging.logger {}
private const val MAX_LOG_SIZE = 20480 // 20 KB - logging could fail with bigger logmessages
val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
val MARKER: Marker = MarkerFactory.getMarker("communication")
const val DIRECTION_IN = "inbound"
const val DIRECTION_OUT = "outbound"
const val PROTOCOL_NAME = "http"
const val PARAM_URL_FULL = "url.full"
const val PARAM_URL_DOMAIN = "url.domain"
const val PARAM_URL_EXTENSION = "url.extension"
const val PARAM_URL_PATH = "url.path"
const val PARAM_URL_PORT = "url.port"
const val PARAM_URL_SCHEME = "url.scheme"
const val PARAM_URL_QUERY = "url.query"
const val PARAM_BUSINESS_TYPE = "http.request.type"
const val PARAM_REQUEST_BODY = "http.request.body.content"
const val PARAM_RESPONSE_BODY = "http.response.body.content"
const val PARAM_RESPONSE_STATUS = "http.response.status_code"
const val PARAM_REQUEST_HEADERS = "http.request.headers"
const val PARAM_RESPONSE_HEADERS = "http.response.headers"
const val PARAM_REQUEST_BYTES = "http.request.body.bytes"
const val PARAM_RESPONSE_BYTES = "http.response.body.bytes"
const val PARAM_REQUEST_MIMETYPE = "http.request.mime_type"
const val PARAM_RESPONSE_MIMETYPE = "http.response.mime_type"
const val PARAM_REQUEST_METHOD = "http.request.method"
const val PARAM_REQUEST_REFERER = "http.request.referrer"
const val PARAM_REQUEST_TIME = "event.start"
const val PARAM_RESPONSE_TIME = "event.end"
const val PARAM_DURATION = "event.duration"
const val PARAM_USER_AGENT = "user_agent.original"
const val PARAM_DIRECTION = "network.direction"
const val PARAM_PROTOCOL = "network.protocol"
private fun extractHeaders(
headerNames: Iterator<String>,
ignoreList: List<String> = emptyList(),
headerValuesSupplier: Function<String, Iterator<String>>
): Map<String, MutableCollection<String>> {
val requestHeaders: MutableMap<String, MutableCollection<String>> = mutableMapOf()
while (headerNames.hasNext()) {
val name = headerNames.next()
if (name in ignoreList) continue
val values = requestHeaders.computeIfAbsent(name) { mutableSetOf() }
val headerValues = headerValuesSupplier.apply(name)
while (headerValues.hasNext()) {
values.add(headerValues.next())
}
}
return requestHeaders
}
private fun extractHeaders(headers: HttpHeaders): Map<String, Collection<String>> {
val result: MutableMap<String, Collection<String>> = mutableMapOf()
for ((key, value) in headers) {
result[key] = value.toList()
}
return result
}
private fun toHeaderString(headerMap: Map<String, Collection<String>>): String {
return headerMap.entries.asSequence()
.flatMap { entry ->
val key = entry.key
entry.value.asSequence().map { Pair(key, it) }
}
.map { "${it.first}=${it.second}" }
.joinToString(separator = ",")
}
private fun typeToString(contentType: String?): String? {
return try {
if (contentType == null) return null
val mediaType = MediaType.parseMediaType(contentType)
return typeToString(mediaType)
} catch (e: InvalidMediaTypeException) {
log.info(e.toString(), e)
e.toString()
}
}
private fun typeToString(mediaType: MediaType?): String? {
return try {
if (mediaType == null) return null
"${mediaType.type}/${mediaType.subtype}"
} catch (e: RuntimeException) {
log.info(e.toString(), e)
e.toString()
}
}
private fun extractExtension(fileName: String?): String? {
if (fileName == null || !fileName.contains(".")) return null
return fileName.substring(fileName.lastIndexOf('.') + 1)
}
private fun cutToMaxLength(string: String?): String? {
return if (string != null && string.length > MAX_LOG_SIZE) {
string.substring(0, MAX_LOG_SIZE)
} else string
}
private data class LogMessage(
val requestHeaders: Map<String, Collection<String>>,
val responseHeaders: Map<String, Collection<String>>,
val url: URL,
val method: String,
val requestMimeType: String?,
val responseMimeType: String?,
val requestBody: String?,
val responseBody: String?,
val requestSize: Int,
val responseSize: Int,
val httpStatus: Int,
val direction: String,
val requestTime: ZonedDateTime,
val responseTime: ZonedDateTime,
val traceId: String?,
val requestType: String?,
val businessType: String?,
val throwable: Throwable?
)
enum class FieldLogBehaviour {
NEVER,
ONLY_ON_ERROR,
ALWAYS
}
}
}
@@ -1,8 +0,0 @@
package de.twomartens.timetable.support.model
import org.springframework.stereotype.Component
@Component
class LeadershipStatus {
var isLeader = false
}
@@ -1,3 +0,0 @@
package de.twomartens.timetable.support.model.dto
data class ErrorMessage(val message: String)
@@ -1,83 +0,0 @@
package de.twomartens.timetable.support.monitoring.actuator
import mu.KotlinLogging
import org.slf4j.MDC
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.boot.actuate.health.Status
import java.io.IOException
import java.time.Clock
import java.time.Duration
import java.util.*
abstract class AbstractHealthIndicator(
private val clock: Clock,
private val preparable: Preparable
) : HealthIndicator {
private val logStatusDownMessage = "health indicator '${indicatorName()}' invoked with status '${Status.DOWN.code}'"
private val logStatusUpMessage = "health indicator '${indicatorName()}' invoked with status '${Status.UP.code}'"
private var firstTime = true
/**
* main method that determines the health of the service
*/
protected abstract fun determineHealth(): Health
override fun health(): Health {
try {
preparable.prepare().use {
var result: Health? = null
var exception: Exception? = null
val start = clock.millis()
try {
result = determineHealth()
} catch (e: RuntimeException) {
exception = e
result = Health.down().withException(e).build()
} finally {
logInvocation(result, exception, start, clock.millis())
}
return result!!
}
} catch (e: IOException) {
log.error("unexpected exception occurred", e)
return Health.down(e).build()
}
}
private fun logInvocation(health: Health?, exception: Exception?, start: Long, end: Long) {
val duration = Duration.ofMillis(end - start)
MDC.putCloseable("event.duration", duration.toNanos().toString()).use {
if (exception != null || health == null) {
log.error(logStatusDownMessage, exception)
firstTime = true
} else if (health.status === Status.DOWN) {
log.warn(logStatusDownMessage)
firstTime = true
} else if (firstTime) {
log.info(logStatusUpMessage)
firstTime = false
} else {
log.trace(logStatusUpMessage)
}
}
}
private fun indicatorName(): String {
return this.javaClass.getSimpleName()
.replace("HealthIndicator", "")
.lowercase(Locale.getDefault())
}
companion object {
const val HOST = "localhost"
const val HTTP_PREFIX = "http://"
const val HOST_PORT_SEPARATOR = ":"
const val PATH_SEPARATOR = "/"
const val PARAMETER_SEPARATOR = "?"
const val DETAIL_ENDPOINT_KEY = "endpoint"
private val log = KotlinLogging.logger {}
}
}
@@ -1,30 +0,0 @@
package de.twomartens.timetable.support.monitoring.actuator
import de.twomartens.timetable.support.monitoring.statusprobe.StatusProbe
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import java.time.Clock
abstract class AbstractStatusProbeHealthIndicator(
timeProvider: Clock, headerInterceptor: Preparable,
private val statusProbe: StatusProbe
) : AbstractHealthIndicator(timeProvider, headerInterceptor), HealthIndicator {
override fun determineHealth(): Health {
val healthBuilder = Health.status(statusProbe.status)
if (statusProbe.lastStatusChange != null) {
healthBuilder.withDetail(LAST_STATUS_CHANGE_KEY, statusProbe.lastStatusChange)
}
if (statusProbe.throwable != null) {
healthBuilder.withException(statusProbe.throwable)
}
if (statusProbe.message != null) {
healthBuilder.withDetail(MESSAGE_KEY, statusProbe.message)
}
return healthBuilder.build()
}
companion object {
const val MESSAGE_KEY = "message"
const val LAST_STATUS_CHANGE_KEY = "lastStatusChange"
}
}
@@ -1,7 +0,0 @@
package de.twomartens.timetable.support.monitoring.actuator
import java.io.Closeable
fun interface Preparable {
fun prepare(): Closeable
}
@@ -1,59 +0,0 @@
package de.twomartens.timetable.support.monitoring.actuator
import de.twomartens.timetable.property.ServiceProperties
import de.twomartens.timetable.support.interceptor.HeaderInterceptorRest
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.boot.actuate.health.Status
import org.springframework.boot.autoconfigure.web.ServerProperties
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import java.security.SecureRandom
import java.time.Clock
/**
* A Health check which checks if the rest services are working.
*
*
* If you have a complex service, you should think about an easy greeting or echo service, which
* only tests the
* network/service stack and not the full application.
*
*
* The health check will be called by kubernetes to check if the container/pod should be in load
* balancing. It is possible
* to have as much health checks as you like.
*
*
* There should be a health check which is ok not before all data is loaded.
*/
@Component
class RestHealthIndicator(
clock: Clock, interceptor: HeaderInterceptorRest,
serverProperties: ServerProperties,
private val restTemplateRestHealthIndicator: RestTemplate,
private val serviceProperties: ServiceProperties
) : AbstractHealthIndicator(clock, Preparable { interceptor.markAsHealthCheck() }), HealthIndicator {
private val randomizer = SecureRandom()
private val urlPrefix: String = (HTTP_PREFIX + HOST + HOST_PORT_SEPARATOR
+ serverProperties.port
+ URL_PATH + PARAMETER_SEPARATOR + GET_PARAMETER)
/**
* main method that determines the health of the service
*/
override fun determineHealth(): Health {
val random = randomizer.nextInt(100000, 999999).toString()
val url = "$urlPrefix{random}"
val response = restTemplateRestHealthIndicator.getForEntity(url, String::class.java, random)
val status = if (response.body == serviceProperties.greeting.format(random)) Status.UP else Status.DOWN
return Health.status(status)
.withDetail(DETAIL_ENDPOINT_KEY, url)
.build()
}
companion object {
private const val URL_PATH = "/timetable/v1/healthCheck"
private const val GET_PARAMETER = "message="
}
}

Some files were not shown because too many files have changed in this diff Show More