diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookApiConfig.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookApiConfig.kt new file mode 100644 index 0000000..1c014e8 --- /dev/null +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookApiConfig.kt @@ -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() +} \ No newline at end of file diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookApiService.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookApiService.kt new file mode 100644 index 0000000..fed6ee7 --- /dev/null +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookApiService.kt @@ -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 + ) + + 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 = emptyList(), + @JsonProperty("publishedDate") val publishedDate: String = "", + @JsonProperty("language") val language: String = "", + @JsonProperty("industryIdentifiers") val industryIdentifiers: List, + ) + + data class IndustryIdentifier( + @JsonProperty("type") val type: String, + @JsonProperty("identifier") val identifier: String + ) +} diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookSearchService.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookSearchService.kt new file mode 100644 index 0000000..5f775ab --- /dev/null +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/BookSearchService.kt @@ -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 = 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, +) + diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAggregate.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAggregate.kt index 2a82d0f..021e3ca 100644 --- a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAggregate.kt +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAggregate.kt @@ -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.* @@ -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 @@ -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) \ No newline at end of file +data class NewCopyRegistered( + val copyId: UUID, + val copy: BookDetails, + val owner: Party, + val organisation: Party.Organisation +) \ No newline at end of file diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAvailabilityReadModel.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAvailabilityReadModel.kt index 348df39..efbf68f 100644 --- a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAvailabilityReadModel.kt +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyAvailabilityReadModel.kt @@ -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, ) ) } @@ -29,6 +31,8 @@ class CopyEventProcessor(val copyReadModelRepository: CopyReadModelRepository) { interface CopyReadModelRepository : CrudRepository { fun findAllByOwner(owner: Party): Iterable fun findAllByOwnerIn(owner: Collection): Iterable + fun findByIsbn(isbn: String): Optional + fun findAllByOrganisationAndTitleContains(organisation: UUID, titleFragment: String): Iterable } @Entity(name = "copy_availability_model") @@ -36,7 +40,9 @@ data class CopyAvailabilityModel( @Id val id: UUID, val isbn: String, + val title: String, val owner: UUID, + val organisation: UUID, ) diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyController.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyController.kt index b53f289..2b5471b 100644 --- a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyController.kt +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyController.kt @@ -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(RegisterNewCopy(isbn, Party.User(user.id))).await() + val copy = bookSearchService.get(isbn = body.isbn) ?: throw TODO() + + val aggregateId = commandGateway.send( + 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( @@ -49,5 +65,6 @@ data class CopyAvailabilityListDTO( data class CopyAvailabilityDTO( val id: UUID, + val title: String, var isbn: String, ) diff --git a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyQueryHandler.kt b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyQueryHandler.kt index 6d0d06a..efde588 100644 --- a/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyQueryHandler.kt +++ b/backend/src/main/kotlin/com/ksidelta/libruch/modules/copy/CopyQueryHandler.kt @@ -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) { @@ -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) {} +class QueryByIsbn(val isbn: String) {} +class QueryByOrganisationAndTitleFragment(val organisationId: UUID, val titleFragment: String) {} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b7a9b75..7ba2d95 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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= diff --git a/backend/src/test/kotlin/com/ksidelta/libruch/modules/copy/CopyControllerTest.kt b/backend/src/test/kotlin/com/ksidelta/libruch/modules/copy/CopyControllerTest.kt new file mode 100644 index 0000000..03dba1a --- /dev/null +++ b/backend/src/test/kotlin/com/ksidelta/libruch/modules/copy/CopyControllerTest.kt @@ -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) }) + } +} \ No newline at end of file