Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ksidelta.libruch.modules.copy

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.MediaType
import org.springframework.web.reactive.function.client.WebClient

@Configuration
class BookApiConfig {

@Bean("book-client")
fun createBookApiClient(
webBuilder: WebClient.Builder,
@Value("\${api.search-book.base-url}") baseUrl: String,
): WebClient = webBuilder
.baseUrl(baseUrl)
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.ksidelta.libruch.modules.copy

import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.coroutines.reactive.awaitSingle
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import java.util.*

@Component
class BookApiService(
@Qualifier("book-client") private val webClient: WebClient,
@Value("\${api.search-book.key}") private val apiKey: String,
) {

suspend fun search(isbn: String): BookInfo? = webClient.get()
.uri {
it.path("/volumes")
.queryParam("q", "isbn:$isbn")
.queryParam("key", apiKey)
.build()
}
.retrieve()
.bodyToMono(BookVolumes::class.java)
.awaitSingle()
.items
.firstOrNull()
?.bookInfo

private data class BookVolumes(
@JsonProperty("items") val items: List<VolumeItem>
)

private data class VolumeItem(
@JsonProperty("volumeInfo") val bookInfo: BookInfo,
)

data class BookInfo(
@JsonProperty("title") val title: String,
@JsonProperty("subtitle") val subtitle: String = "",
@JsonProperty("authors") val authors: List<String> = emptyList(),
@JsonProperty("publishedDate") val publishedDate: String = "",
@JsonProperty("language") val language: String = "",
@JsonProperty("industryIdentifiers") val industryIdentifiers: List<IndustryIdentifier>,
)

data class IndustryIdentifier(
@JsonProperty("type") val type: String,
@JsonProperty("identifier") val identifier: String
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.ksidelta.libruch.modules.copy

import org.springframework.stereotype.Service


@Service
class BookSearchService(
private val bookApiService: BookApiService,
) {

private val cache: MutableMap<String, BookDetails> = mutableMapOf()

suspend fun get(isbn: String): BookDetails? =
cache.getOrElse(isbn) { search(isbn) }

private suspend fun search(isbn: String): BookDetails? =
bookApiService.search(isbn = isbn)?.let {
BookDetails(
isbn = it.industryIdentifiers.first { i -> i.type == "ISBN_13" }.identifier,
title = it.title,
subtitle = it.subtitle,
authors = it.authors.toString(),
publishedDate = it.publishedDate,
language = it.language,
)
}
}

data class BookDetails(
val isbn: String,
val title: String,
val subtitle: String,
val authors: String,
val publishedDate: String,
val language: String,
)

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ package com.ksidelta.libruch.modules.copy
import com.ksidelta.libruch.modules.kernel.Party
import org.axonframework.commandhandling.CommandHandler
import org.axonframework.eventsourcing.EventSourcingHandler
import org.axonframework.extensions.kotlin.applyEvent
import org.axonframework.modelling.command.AggregateIdentifier
import org.axonframework.modelling.command.AggregateLifecycle.apply
import org.axonframework.modelling.command.TargetAggregateIdentifier
import org.axonframework.spring.stereotype.Aggregate
import java.util.*

Expand All @@ -18,13 +17,14 @@ class CopyAggregate() {

@CommandHandler
constructor(command: RegisterNewCopy) : this() {
apply(command.run {
applyEvent(
NewCopyRegistered(
copyId = UUID.randomUUID(),
isbn = isbn,
owner = owner,
copy = command.copy,
owner = command.owner,
organisation = command.organisation,
)
})
)
}

@EventSourcingHandler
Expand All @@ -34,7 +34,12 @@ class CopyAggregate() {
}


data class RegisterNewCopy(val isbn: String, val owner: Party)
data class RegisterNewCopy(val copy: BookDetails, val owner: Party.User, val organisation: Party.Organisation)


data class NewCopyRegistered(val copyId: UUID, val isbn: String, val owner: Party)
data class NewCopyRegistered(
val copyId: UUID,
val copy: BookDetails,
val owner: Party,
val organisation: Party.Organisation
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ class CopyEventProcessor(val copyReadModelRepository: CopyReadModelRepository) {
copyReadModelRepository.save(
CopyAvailabilityModel(
id = event.copyId,
isbn = event.isbn,
isbn = event.copy.isbn,
title = event.copy.title,
owner = event.owner.partyId,
organisation = event.organisation.partyId,
)
)
}
Expand All @@ -29,14 +31,18 @@ class CopyEventProcessor(val copyReadModelRepository: CopyReadModelRepository) {
interface CopyReadModelRepository : CrudRepository<CopyAvailabilityModel, UUID> {
fun findAllByOwner(owner: Party): Iterable<CopyAvailabilityModel>
fun findAllByOwnerIn(owner: Collection<Party>): Iterable<CopyAvailabilityModel>
fun findByIsbn(isbn: String): Optional<CopyAvailabilityModel>
fun findAllByOrganisationAndTitleContains(organisation: UUID, titleFragment: String): Iterable<CopyAvailabilityModel>
}

@Entity(name = "copy_availability_model")
data class CopyAvailabilityModel(
@Id
val id: UUID,
val isbn: String,
val title: String,
val owner: UUID,
val organisation: UUID,
)


Original file line number Diff line number Diff line change
@@ -1,46 +1,62 @@
package com.ksidelta.libruch.modules.copy

import com.ksidelta.libruch.modules.kernel.Party
import com.ksidelta.libruch.modules.user.UserService
import com.ksidelta.libruch.modules.user.withUser
import kotlinx.coroutines.future.await
import org.axonframework.commandhandling.gateway.CommandGateway
import org.axonframework.messaging.responsetypes.ResponseTypes
import org.axonframework.queryhandling.QueryGateway
import org.springframework.web.bind.annotation.*
import java.security.Principal
import java.util.*

@RestController
@RequestMapping(path = ["/api/copy"])
class CopyController(
val userService: UserService,
val bookSearchService: BookSearchService,
val commandGateway: CommandGateway,
val queryGateway: QueryGateway
) {

@PostMapping
suspend fun create(@RequestBody body: CreateCopyDTO, principal: Principal) =
suspend fun create(@RequestBody body: CreateCopyDTO, user: Party.User) =
body.run {
val user = userService.findUser(principal)
val aggregateId = commandGateway.send<UUID>(RegisterNewCopy(isbn, Party.User(user.id))).await()
val copy = bookSearchService.get(isbn = body.isbn) ?: throw TODO()

val aggregateId = commandGateway.send<UUID>(
RegisterNewCopy(
copy = copy,
owner = Party.User(user.id),
organisation = Party.Organisation(organisationId)
)
).await()
CreatedCopyDTO(aggregateId)
}

@GetMapping
suspend fun listAllByOrganisations(principal: Principal): CopyAvailabilityListDTO =
userService.withUser(principal) { it.organisations }.let { organisations ->
//TODO: change QueryByOwners to QueryByOrganisations
@GetMapping("/organisation")
suspend fun listAllByOrganisations(user: Party.User): CopyAvailabilityListDTO =
user.organisations.let { organisations ->
queryGateway.query(
QueryByOwners(owners = organisations),
ResponseTypes.multipleInstancesOf(CopyAvailabilityModel::class.java)
).await()
.map { it.run { CopyAvailabilityDTO(id = id, isbn = isbn) } }
.map { it.run { CopyAvailabilityDTO(id = id, isbn = isbn, title = title) } }
.let { CopyAvailabilityListDTO(it) }
}

@PostMapping("/by-organisation")
suspend fun listAllByOrganisationMatching(@RequestBody body: GetCopyDTO, user: Party.User): CopyAvailabilityListDTO =
body.run {
queryGateway.query(
QueryByOrganisationAndTitleFragment(organisationId, titleFragment),
ResponseTypes.multipleInstancesOf(CopyAvailabilityModel::class.java)
).await()
.map { it.run { CopyAvailabilityDTO(id = id, isbn = isbn, title = title) } }
.let { CopyAvailabilityListDTO(it) }
}
}

data class CreateCopyDTO(val isbn: String);
data class CreateCopyDTO(val isbn: String, val organisationId: UUID);
data class GetCopyDTO(val organisationId: UUID, val titleFragment: String);
data class CreatedCopyDTO(val id: UUID);

data class CopyAvailabilityListDTO(
Expand All @@ -49,5 +65,6 @@ data class CopyAvailabilityListDTO(

data class CopyAvailabilityDTO(
val id: UUID,
val title: String,
var isbn: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.ksidelta.libruch.modules.copy
import com.ksidelta.libruch.modules.kernel.Party
import org.axonframework.queryhandling.QueryHandler
import org.springframework.stereotype.Service
import java.util.UUID
import kotlin.jvm.optionals.getOrNull

@Service
class CopyQueryHandler(val copyReadModelRepository: CopyReadModelRepository) {
Expand All @@ -18,8 +20,21 @@ class CopyQueryHandler(val copyReadModelRepository: CopyReadModelRepository) {
fun query(queryByOwners: QueryByOwners) =
copyReadModelRepository.findAllByOwnerIn(owner = queryByOwners.owners).toList()

@QueryHandler
fun query(queryByOwners: QueryByOrganisationAndTitleFragment) =
copyReadModelRepository.findAllByOrganisationAndTitleContains(
organisation = queryByOwners.organisationId,
titleFragment = queryByOwners.titleFragment
).toList()

@OptIn(ExperimentalStdlibApi::class)
@QueryHandler
fun query(queryByIsbn: QueryByIsbn) =
copyReadModelRepository.findByIsbn(isbn = queryByIsbn.isbn).getOrNull()
}

class QueryAllCopies() {}
class QueryByOwner(val owner: Party) {}
class QueryByOwners(val owners: Collection<Party>) {}
class QueryByIsbn(val isbn: String) {}
class QueryByOrganisationAndTitleFragment(val organisationId: UUID, val titleFragment: String) {}
3 changes: 3 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ server.port=8080

# spring.security.oauth2.client.registration.google.client-id
# {baseUrl}/login/oauth2/code/{registrationId}

api.search-book.base-url=https://www.googleapis.com/books/v1
api.search-book.key=
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.ksidelta.libruch.modules.copy

import com.ksidelta.libruch.BaseTest
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import java.util.*

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CopyControllerTest : BaseTest() {

@Autowired
private lateinit var testRestTemplate: TestRestTemplate

companion object {
private const val URI = "/api/copy"
}

@Test
fun `should return UUID after create copy`() {
val isbn = "9788383223445"
val organisationId = UUID.randomUUID()


val result = testRestTemplate.postForEntity(URI, CreateCopyDTO(isbn, organisationId), CreatedCopyDTO::class.java)
println(result)


assertNotNull(result.body)
}

@Test
fun `should return list of books after given title fragment`() {
val organisationId = UUID.randomUUID()
val isbn1 = "9788383223445" // Czysty kod
val isbn2 = "9788328364622" // Czysty kod w Pythonie
val isbn3 = "9781617297571" // Spring in Action, Sixth Edition

testRestTemplate.postForEntity(URI, CreateCopyDTO(isbn1, organisationId), String::class.java)
testRestTemplate.postForEntity(URI, CreateCopyDTO(isbn2, organisationId), String::class.java)
testRestTemplate.postForEntity(URI, CreateCopyDTO(isbn3, organisationId), String::class.java)

val fragment = "Czysty"
val request = GetCopyDTO(organisationId =organisationId, titleFragment = fragment)


val result = testRestTemplate.postForEntity(URI.plus("/by-organisation"), request, CopyAvailabilityListDTO::class.java)


assertNotNull(result.body)
val copies = result.body!!.copies
assertEquals(2, copies.size)
assertTrue(copies.all { it.title.contains(fragment) })
}
}