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:
Jim Martens 2024-01-06 14:06:43 +01:00 committed by GitHub
parent b5681181fb
commit 7fbd270019
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
166 changed files with 2039 additions and 482 deletions

View File

@ -7,9 +7,9 @@ tasks.withType<JavaCompile>().configureEach {
}
tasks.withType<Test>().configureEach {
jvmArgs?.plusAssign("--enable-preview")
jvmArgs.plusAssign("--enable-preview")
}
tasks.withType<JavaExec>().configureEach {
jvmArgs?.plusAssign("--enable-preview")
jvmArgs.plusAssign("--enable-preview")
}

View File

@ -53,8 +53,10 @@ normalization.runtimeClasspath.metaInf {
ignoreAttribute("Build-Timestamp")
}
tasks.register("cleanLibs") {
delete("${buildDir}/libs")
delete("${layout.buildDirectory.get().asFile}/libs")
}
tasks.build {

View File

@ -16,6 +16,6 @@ tasks.named("build") {
}
tasks.register("cleanCache") {
delete("${buildDir}/jib-cache")
delete("${buildDir}/libs")
delete("${layout.buildDirectory.get().asFile}/jib-cache")
delete("${layout.buildDirectory.get().asFile}/libs")
}

View File

@ -16,7 +16,7 @@ val projectSourceCompatibility: String = rootProject.properties["projectSourceCo
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xjvm-default=all")
freeCompilerArgs.addAll("-Xjvm-default=all", "-Xjsr305=strict")
jvmTarget.set(JvmTarget.fromTarget(projectSourceCompatibility))
}
}

View File

@ -9,10 +9,10 @@ apply(plugin="com.netflix.nebula.release")
tasks.register("writeVersionProperties") {
group = "version"
mustRunAfter("release")
outputs.file("$buildDir/version.properties")
val directory = buildDir
outputs.file("${layout.buildDirectory.get().asFile}/version.properties")
val directory = layout.buildDirectory.get().asFile
doLast {
Files.createDirectories(directory.toPath())
File("$buildDir/version.properties").writeText("VERSION=${project.version}\n")
File("${layout.buildDirectory.get().asFile}/version.properties").writeText("VERSION=${project.version}\n")
}
}

View File

@ -1,18 +1,16 @@
import org.gradle.accessors.dm.LibrariesForLibs
import org.springframework.boot.gradle.tasks.bundling.BootJar
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatter.ofPattern
plugins {
id("org.springframework.boot")
id("twomartens.java")
id("twomartens.spring-boot-base")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(platform(libs.spring.boot))
implementation(libs.bundles.spring.boot)
testImplementation(libs.spring.boot.test)
}
@ -37,13 +35,6 @@ val integrationTestImplementation: Configuration by configurations.getting {
extendsFrom(configurations.testImplementation.get())
}
configurations {
configureEach {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
}
}
tasks.register<Test>("integrationTest") {
systemProperty("junit.jupiter.execution.parallel.enabled", true)
systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent")
@ -76,5 +67,9 @@ tasks.jar {
springBoot {
buildInfo()
mainClass.set("de.twomartens.timetable.MainApplicationKt")
mainClass.set(project.properties["mainClass"].toString())
}
tasks.named<BootJar>("bootJar") {
enabled = true
}

View File

@ -0,0 +1,25 @@
import org.gradle.accessors.dm.LibrariesForLibs
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
id("twomartens.java")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(platform(libs.spring.boot))
implementation(libs.spring.boot.log4j)
}
configurations {
configureEach {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
}
}
tasks.named<BootJar>("bootJar") {
enabled = false
}

View File

@ -1,13 +1,13 @@
import org.gradle.accessors.dm.LibrariesForLibs
plugins {
id("twomartens.spring-boot")
id("twomartens.spring-boot-application")
id("twomartens.spring-boot-cloud-base")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(platform(libs.spring.cloud))
implementation(libs.bundles.spring.boot.server)
implementation(libs.spring.openapi)

View File

@ -0,0 +1,11 @@
import org.gradle.accessors.dm.LibrariesForLibs
plugins {
id("twomartens.spring-boot-base")
}
val libs = the<LibrariesForLibs>()
dependencies {
implementation(platform(libs.spring.cloud))
}

View File

@ -1,6 +1,7 @@
projectname=timetable
projectgroup=de.2martens
projectSourceCompatibility=21
mainClass=de.twomartens.timetable.MainApplicationKt
file.encoding=utf-8
org.gradle.parallel=true
org.gradle.daemon=true

View File

@ -0,0 +1,23 @@
plugins {
id("twomartens.spring-boot-cloud-base")
id("twomartens.kotlin")
kotlin("kapt")
}
dependencies {
implementation(project(":common"))
implementation(project(":model"))
implementation(project(":support"))
implementation(libs.mapstruct.base)
annotationProcessor(libs.mapstruct.processor)
kapt(libs.mapstruct.processor)
implementation(libs.jaxb.impl)
implementation(libs.jakarta.xml.binding)
implementation(libs.spring.openapi)
implementation(libs.spring.boot.mongo)
implementation(libs.spring.cloud.leader.election)
implementation(libs.spring.cloud.starter.bus.kafka)
}

View File

@ -13,7 +13,7 @@ open class ThreadPoolTaskSchedulerConfig {
open fun threadPoolTaskScheduler(): ThreadPoolTaskScheduler {
val scheduler = ThreadPoolTaskScheduler()
scheduler.poolSize = POOL_SIZE
scheduler.threadNamePrefix = THREAD_NAME_PREFIX
scheduler.setThreadNamePrefix(THREAD_NAME_PREFIX)
return scheduler
}
}

View File

@ -11,11 +11,7 @@ class ScheduledTasksCreatedEvent private constructor(source: Instant, originServ
destination: Destination)
: RemoteApplicationEvent(source, originService, destination) {
private val creationTime: Instant
init {
creationTime = source
}
private val creationTime: Instant = source
override fun getSource(): Instant {
return creationTime

View File

@ -1,6 +1,9 @@
package de.twomartens.timetable.bahnApi.mapper
import de.twomartens.timetable.bahnApi.model.db.BahnStation
import de.twomartens.timetable.model.common.CountryCode
import de.twomartens.timetable.model.common.StationId
import de.twomartens.timetable.model.db.Station
import de.twomartens.timetable.types.NonEmptyString
import org.mapstruct.*
@ -22,4 +25,16 @@ interface BahnStationMapper {
dto.db
)
}
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToCommonDB(db: BahnStation, countryCode: String): Station {
return Station(
StationId.of(NonEmptyString(countryCode + "-" + db.eva.value.toString())),
CountryCode(NonEmptyString(countryCode)),
db.name,
listOf()
)
}
}

View File

@ -1,7 +1,5 @@
package de.twomartens.timetable.bahnApi.model
import de.twomartens.timetable.model.common.StationId
@JvmInline
value class Eva(val value: Int) {
init {
@ -17,8 +15,8 @@ value class Eva(val value: Int) {
companion object {
val UNKNOWN = Eva(-1)
fun of(stationId: StationId): Eva {
return Eva(stationId.stationIdWithinCountry.toInt())
fun of(stationId: String): Eva {
return Eva(stationId.toInt())
}
}
}

View File

@ -12,7 +12,7 @@ import java.time.Instant
import java.time.LocalDateTime
@Document
@CompoundIndex(def = "{'eva': 1, 'fetchedDateTime': 1", unique = true)
@CompoundIndex(def = "{'eva': 1, 'fetchedDateTime': 1}", unique = true)
data class ScheduledFetchTask(
var eva: Eva,
var fetchedDateTime: HourAtDay,

View File

@ -8,7 +8,7 @@ import jakarta.xml.bind.annotation.XmlRootElement
@XmlRootElement(name = "stations")
@XmlAccessorType(XmlAccessType.FIELD)
data class BahnStations(
@field:XmlElement(name = "station") var stations: List<BahnStation>
@field:XmlElement(name = "station") var stations: MutableList<BahnStation>
) {
constructor() : this(listOf())
constructor() : this(mutableListOf())
}

View File

@ -0,0 +1,51 @@
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.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Service
class BahnApiService(
private val restClient: RestClient,
private val properties: BahnApiProperties
) {
fun fetchStations(pattern: String): List<BahnStation> {
val body = restClient.get()
.uri("https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/station/${pattern}")
.headers {
it.accept = mutableListOf(MediaType.APPLICATION_XML)
it.contentType = MediaType.APPLICATION_XML
it.set("DB-Client-Id", properties.clientId)
it.set("DB-Api-Key", properties.clientSecret)
}
.retrieve()
.body(BahnStations::class.java)
return body?.stations ?: listOf()
}
fun fetchTimetable(eva: Eva, hourAtDay: HourAtDay): BahnTimetable {
val dateFormatter = DateTimeFormatter.ofPattern("yyMMdd")
val timeFormatter = DateTimeFormatter.ofPattern("HH")
val time = LocalTime.of(hourAtDay.hour.value, 0)
val body = restClient.get()
.uri("https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/plan/" +
"${eva}/${hourAtDay.date.format(dateFormatter)}/${time.format(timeFormatter)}")
.headers {
it.accept = mutableListOf(MediaType.APPLICATION_XML)
it.contentType = MediaType.APPLICATION_XML
it.set("DB-Client-Id", properties.clientId)
it.set("DB-Api-Key", properties.clientSecret)
}
.retrieve()
.body(BahnTimetable::class.java)
return body!!
}
}

View File

@ -0,0 +1,97 @@
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.model.db.Station
import de.twomartens.timetable.model.repository.StationRepository
import de.twomartens.timetable.types.HourAtDay
import org.mapstruct.factory.Mappers
import org.springframework.data.mongodb.core.BulkOperations
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.stereotype.Service
@Service
open class BahnDatabaseService(
private val bahnStationRepository: BahnStationRepository,
private val stationRepository: StationRepository,
private val bahnTimetableRepository: BahnTimetableRepository,
private val mongoTemplate: MongoTemplate
) {
private val bahnStationMapper = Mappers.getMapper(BahnStationMapper::class.java)
private val bahnTimetableMapper = Mappers.getMapper(BahnTimetableMapper::class.java)
fun storeStations(stations: List<BahnStation>) {
val existingStations = stationRepository.findAllByCountryCode(COUNTRY_CODE)
val commonStationMap = existingStations
.associateBy { it.stationId.stationIdWithinCountry }
val bahnStationMap = stations.asSequence()
.map(bahnStationMapper::mapToDB)
.associateBy { it.eva.toString() }
updateBahnStations(bahnStationMap)
deleteRemovedStations(existingStations, bahnStationMap)
updateStations(existingStations, bahnStationMap)
addNewStations(bahnStationMap, commonStationMap)
}
private fun updateBahnStations(bahnStationMap: Map<String, de.twomartens.timetable.bahnApi.model.db.BahnStation>) {
bahnStationRepository.deleteAll()
val bahnStations: List<de.twomartens.timetable.bahnApi.model.db.BahnStation> = buildList {
this.addAll(bahnStationMap.values)
}
mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED,
de.twomartens.timetable.bahnApi.model.db.BahnStation::class.java)
.insert(bahnStations)
.execute()
}
private fun deleteRemovedStations(
existingStations: List<Station>,
bahnStationMap: Map<String, de.twomartens.timetable.bahnApi.model.db.BahnStation>
) {
val deletedStations = existingStations
.filterNot { bahnStationMap.containsKey(it.stationId.stationIdWithinCountry) }
stationRepository.deleteAll(deletedStations)
}
private fun updateStations(
existingStations: List<Station>,
bahnStationMap: Map<String, de.twomartens.timetable.bahnApi.model.db.BahnStation>
) {
val updatedStations = existingStations
.filter { bahnStationMap.containsKey(it.stationId.stationIdWithinCountry) }
updatedStations.map {
it.name = bahnStationMap[it.stationId.stationIdWithinCountry]!!.name
}
stationRepository.saveAll(updatedStations)
}
private fun addNewStations(
bahnStations: Map<String, de.twomartens.timetable.bahnApi.model.db.BahnStation>,
commonStationMap: Map<String, Station>
) {
val newStations = bahnStations.asSequence()
.filterNot { commonStationMap.containsKey(it.key) }
.map { bahnStationMapper.mapToCommonDB(it.value, COUNTRY_CODE) }
.toList()
mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED,
Station::class.java)
.insert(newStations)
.execute()
}
fun storeTimetable(timetable: BahnTimetable, hourAtDay: HourAtDay) {
bahnTimetableRepository.save(bahnTimetableMapper.mapToDB(timetable, hourAtDay))
}
companion object {
private const val COUNTRY_CODE = "de"
}
}

View File

@ -16,7 +16,7 @@ import java.time.ZonedDateTime
private const val PAST_TASK_EXECUTION_OFFSET = 1
@Service
class TaskScheduler(
class FetchTaskScheduler(
private val clock: Clock,
private val threadPoolTaskScheduler: ThreadPoolTaskScheduler,
private val threadPoolTaskExecutor: ThreadPoolTaskExecutor,

View File

@ -1,5 +1,7 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.support.model.LeadershipStatus
import de.twomartens.support.service.BusService
import de.twomartens.timetable.bahnApi.events.ScheduledTasksCreatedEvent
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.FetchDates
@ -7,8 +9,6 @@ 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
@ -27,7 +27,7 @@ class ScheduledTaskService(
private val leaderProperties: LeaderProperties,
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository,
private val taskFactory: TaskFactory,
private val taskScheduler: TaskScheduler
private val fetchTaskScheduler: FetchTaskScheduler
) {
private var createdTime: Instant = Instant.EPOCH
private var lastUpdate: Instant = Instant.EPOCH
@ -81,8 +81,8 @@ class ScheduledTaskService(
fetchDates: FetchDates
): List<ScheduledFetchTask> {
val newTasks = mutableListOf<ScheduledFetchTask>()
tswRoute.stationIds.forEach {
val stationId = it
tswRoute.stations.forEach {
val stationId = it.id
val eva = Eva.of(stationId)
var hourAtDay = HourAtDay.of(Hour.of(23), fetchDates.previousDay)
var newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
@ -103,7 +103,7 @@ class ScheduledTaskService(
private fun scheduleTasksIfLeader(tasksToSchedule: List<ScheduledFetchTask>) {
if (leadershipStatus.isLeader) {
taskScheduler.scheduleFetchTasks(tasksToSchedule)
fetchTaskScheduler.scheduleFetchTasks(tasksToSchedule)
}
}

View File

@ -2,7 +2,7 @@ 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.bahnApi.service.FetchTaskScheduler
import de.twomartens.timetable.types.HourAtDay
import mu.KotlinLogging
import org.springframework.scheduling.annotation.Async
@ -12,7 +12,7 @@ open class FetchTimetableTask(
private val eva: Eva,
private val hourAtDay: HourAtDay,
private val bahnApiService: BahnApiService,
private val scheduler: TaskScheduler) : Runnable {
private val scheduler: FetchTaskScheduler) : Runnable {
override fun run() {
log.info {
"Fetch timetable: [eva: $eva], [date: ${hourAtDay.date}], [hour: ${hourAtDay.hour}]"

View File

@ -0,0 +1,11 @@
package de.twomartens.timetable.types
@JvmInline
value class Email private constructor(val value: String) {
companion object {
fun of(email: NonEmptyString): Email {
require(email.value.contains("@")) { "Invalid email format" }
return Email(email.value)
}
}
}

View File

@ -0,0 +1,10 @@
package de.twomartens.timetable.types
@JvmInline
value class ZeroOrPositiveInteger(val value: Int) {
init {
require(value >= 0) {
"Value must be zero or positive integer"
}
}
}

View File

@ -0,0 +1,14 @@
plugins {
id("twomartens.spring-boot-base")
id("twomartens.kotlin")
kotlin("kapt")
}
dependencies {
implementation(project(":common"))
implementation(libs.spring.boot.mongo)
implementation(libs.mapstruct.base)
annotationProcessor(libs.mapstruct.processor)
kapt(libs.mapstruct.processor)
}

View File

@ -0,0 +1,6 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.ZeroOrPositiveInteger
@JvmInline
value class CoachCapacity(val capacity: ZeroOrPositiveInteger)

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class DepotId private constructor(val id: String) {
companion object {
fun of(id: NonEmptyString): DepotId {
return DepotId(id.value)
}
}
}

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class FormationId private constructor(val id: String) {
companion object {
fun of(id: NonEmptyString): FormationId {
return FormationId(id.value)
}
}
}

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class PortalId private constructor(val id: String) {
companion object {
fun of(id: NonEmptyString): PortalId {
return PortalId(id.value)
}
}
}

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class RouteId private constructor(val id: String) {
companion object {
fun of(id: NonEmptyString): RouteId {
return RouteId(id.value)
}
}
}

View File

@ -2,8 +2,7 @@ package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class StationId private constructor(val value: String) {
class StationId private constructor(val value: String) {
companion object {
private val idPattern = Regex("^\\w{2}-(?<countryStationId>\\w.*)")

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.model.common
import de.twomartens.timetable.types.NonEmptyString
@JvmInline
value class TimetableId private constructor(val value: String) {
companion object {
fun of(id: NonEmptyString): TimetableId {
return TimetableId(id.value)
}
}
}

View File

@ -0,0 +1,14 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.DepotId
import de.twomartens.timetable.model.dto.Station
import de.twomartens.timetable.model.dto.Track
import de.twomartens.timetable.types.NonEmptyString
data class Depot(
val id: DepotId,
val name: NonEmptyString,
val nearestStation: Station,
val tracks: List<Track>,
val travelDurations: List<TravelDuration>
)

View File

@ -3,6 +3,7 @@ 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 de.twomartens.timetable.types.ZeroOrPositiveInteger
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
@ -17,8 +18,9 @@ data class Formation(
var userId: UserId,
var formationId: FormationId,
var name: NonEmptyString,
var trainSimWorldFormationId: FormationId,
var coaches: List<NonEmptyString>
var trainSimWorldFormationId: FormationId?,
var formation: String,
var length: ZeroOrPositiveInteger
) {
@Id
var id: ObjectId = ObjectId()

View File

@ -0,0 +1,12 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.PortalId
import de.twomartens.timetable.model.dto.Station
import de.twomartens.timetable.types.NonEmptyString
data class Portal(
val id: PortalId,
val name: NonEmptyString,
val nearestStation: Station,
val travelDurations: List<TravelDuration>
)

View File

@ -1,6 +1,8 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.*
import de.twomartens.timetable.model.common.CountryCode
import de.twomartens.timetable.model.common.Platform
import de.twomartens.timetable.model.common.StationId
import de.twomartens.timetable.types.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
@ -11,14 +13,12 @@ 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,
@CompoundIndex(def = "{stationId: 1, countryCode: 1}", unique = true)
data class Station(
var stationId: StationId,
var countryCode: CountryCode,
var stationIds: List<StationId>,
var portals: List<Portal>,
var depots: List<Depot>
var name: NonEmptyString,
var platforms: List<Platform>
) {
@Id
var id: ObjectId = ObjectId()

View File

@ -0,0 +1,39 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.RouteId
import de.twomartens.timetable.model.common.TimetableId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.TimetableState
import de.twomartens.timetable.types.NonEmptyString
import de.twomartens.timetable.types.ZeroOrPositiveInteger
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.LocalDate
@Document
@CompoundIndex(def = "{userId: 1, timetableId: 1}", unique = true)
@CompoundIndex(def = "{userId: 1, routeId: 1}")
data class Timetable(
var userId: UserId,
var routeId: RouteId,
var routeName: String,
var timetableId: TimetableId,
var name: NonEmptyString,
var fetchDate: LocalDate,
var timetableState: TimetableState,
var numberOfServices: ZeroOrPositiveInteger
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}

View File

@ -0,0 +1,8 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.dto.Formation
data class TravelDuration(
val formation: Formation,
val time: Long
)

View File

@ -0,0 +1,35 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.RouteId
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.model.dto.Country
import de.twomartens.timetable.model.dto.Station
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 routeId: RouteId,
var name: NonEmptyString,
var country: Country,
var stations: List<Station>,
var depots: List<Depot>,
var portals: List<Portal>
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}

View File

@ -1,18 +1,21 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.common.UserId
import de.twomartens.timetable.types.Email
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.mapping.Document
import java.time.Instant
@Document
data class User(
var userId: UserId,
var name: NonEmptyString
@Indexed(unique = true) var userId: UserId,
var name: NonEmptyString,
var email: Email
) {
@Id
var id: ObjectId = ObjectId()

View File

@ -1,14 +1,11 @@
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 id: String,
val line: String,
val formationId: String,
val direction: Direction,
val start: Station,
val destination: Station,

View File

@ -1,12 +1,11 @@
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 platformAndSection: String,
val loading: Boolean
)

View File

@ -0,0 +1,6 @@
package de.twomartens.timetable.model.dto
data class Country(
val code: String,
val name: String
)

View File

@ -0,0 +1,9 @@
package de.twomartens.timetable.model.dto
data class Depot(
val id: String,
val name: String,
val nearestStation: Station,
val tracks: List<Track>,
val travelDurations: List<TravelDuration>
)

View File

@ -0,0 +1,9 @@
package de.twomartens.timetable.model.dto
data class Formation(
val id: String,
val name: String,
val trainSimWorldFormation: Formation?,
val formation: String,
val length: Int
)

View File

@ -0,0 +1,8 @@
package de.twomartens.timetable.model.dto
data class Portal(
val id: String,
val name: String,
val nearestStation: Station,
val travelDurations: List<TravelDuration>
)

View File

@ -0,0 +1,11 @@
package de.twomartens.timetable.model.dto
import java.time.LocalTime
data class Rotation(
val id: String,
val formationId: String,
val firstServiceStartTime: LocalTime,
val lastServiceEndTime: LocalTime,
val startsInVault: Boolean
)

View File

@ -0,0 +1,11 @@
package de.twomartens.timetable.model.dto
data class ServiceFormationStage(
val id: String,
val line: String,
val direction: Direction,
val formation: String,
val formationReversed: Boolean,
val startStop: ServiceStop,
val destinationStop: ServiceStop
)

View File

@ -1,13 +1,9 @@
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 id: String,
val formationId: String,
val line: String,
val direction: Direction,
val formationReversed: Boolean,
val startStop: ServiceStop,

View File

@ -0,0 +1,15 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.Line
import de.twomartens.timetable.model.common.ServiceId
data class ServiceRotationStage(
val id: ServiceId,
val line: Line,
val originStop: String,
val virtualDestinations: List<String>,
val startingFrom: String,
val endingIn: String,
val serviceStartTime: String,
val stops: List<ServiceStop>
)

View File

@ -1,11 +1,9 @@
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 platform: String,
val section: String
)

View File

@ -0,0 +1,9 @@
package de.twomartens.timetable.model.dto
import de.twomartens.timetable.model.common.Platform
data class Station(
val id: String,
val name: String,
val platforms: List<Platform>
)

View File

@ -0,0 +1,13 @@
package de.twomartens.timetable.model.dto
import java.time.LocalDate
data class Timetable(
val id: String,
val name: String,
val routeId: String,
val routeName: String,
val date: LocalDate,
val state: TimetableState,
val numberOfServices: Int
)

View File

@ -0,0 +1,7 @@
package de.twomartens.timetable.model.dto
data class Track(
val id: Int,
val name: String,
val capacity: Int
)

View File

@ -0,0 +1,6 @@
package de.twomartens.timetable.model.dto
data class TravelDuration(
val formation: Formation,
val time: Long
)

View File

@ -0,0 +1,13 @@
package de.twomartens.timetable.model.dto
data class TswRoute(
val id: String,
val name: String,
val country: Country,
val stations: List<Station>,
val firstStation: Station,
val lastStation: Station,
val numberOfStations: Int,
val depots: List<Depot>,
val portals: List<Portal>
)

View File

@ -0,0 +1,7 @@
package de.twomartens.timetable.model.dto
data class User(
val id: String,
val name: String,
val email: String
)

View File

@ -0,0 +1,36 @@
package de.twomartens.timetable.model.mapper
import de.twomartens.timetable.model.common.CountryCode
import de.twomartens.timetable.model.common.StationId
import de.twomartens.timetable.model.dto.Station
import de.twomartens.timetable.types.NonEmptyString
import org.mapstruct.*
@Mapper(
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
interface StationMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
fun mapToDB(countryCode: CountryCode, dto: Station): de.twomartens.timetable.model.db.Station {
return de.twomartens.timetable.model.db.Station(
StationId.of(NonEmptyString(countryCode.countryCode.value + "-" + dto.id)),
countryCode,
NonEmptyString(dto.name),
dto.platforms
)
}
fun mapStationsToDto(stations: List<de.twomartens.timetable.model.db.Station>): List<Station>
fun mapToDto(db: de.twomartens.timetable.model.db.Station): Station {
return Station(
db.stationId.stationIdWithinCountry,
db.name.value,
db.platforms
)
}
}

View File

@ -0,0 +1,30 @@
package de.twomartens.timetable.model.repository
import de.twomartens.timetable.model.common.StationId
import de.twomartens.timetable.model.db.Station
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.MongoRepository
interface StationRepository : MongoRepository<Station, ObjectId> {
fun findByCountryCodeAndStationId(countryCode: String, stationId: StationId): Station?
fun findAllByCountryCode(countryCode: String): List<Station>
@Aggregation(pipeline = [
"{\$search: { index: \"stations\", returnStoredSource: true, compound: {must: [{phrase: {query: ?0, path: \"countryCode\"}},{autocomplete: {query: ?1,path: \"name\",tokenOrder: \"sequential\"}}]}}}",
"{\$limit: 10}",
"{\$lookup: { from: \"station\", localField: \"_id\", foreignField: \"_id\", as: \"document\" }}",
"{\$unwind: \"\$document\"}",
"{\$replaceWith: \"\$document\"}"
])
fun findAllByCountryCodeAndNameContainingIgnoreCase(countryCode: String, name: String): List<Station>
@Aggregation(pipeline = [
"{\$search: { index: \"stations\", returnStoredSource: true, autocomplete: {query: ?0, path: \"name\",tokenOrder: \"sequential\"}}}",
"{\$limit: 10}",
"{\$lookup: { from: \"station\", localField: \"_id\", foreignField: \"_id\", as: \"document\" }}",
"{\$unwind: \"\$document\"}",
"{\$replaceWith: \"\$document\"}"
])
fun findAllByNameContainingIgnoreCase(name: String): List<Station>
}

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)

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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
)
}
}

View File

@ -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?
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

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