Commit 4fd3efb0 authored by Julien Topçu's avatar Julien Topçu
Browse files

Creating recommendations endpoint and handling recommendations when no profile is stored.

Including documentation
parent a5710f47
# TalkAdvisor
[![pipeline status](https://gitlab.com/crafts-records/talkadvisor/talkadvisor-back/badges/master/pipeline.svg)](https://gitlab.com/crafts-records/talkadvisor/talkadvisor-back/commits/master)
[![pipeline status](https://gitlab.com/crafts-records/talkadvisor/talkadvisor-back/badges/master/pipeline.svg)](https://gitlab.com/crafts-records/talkadvisor/talkadvisor-back/commits/master)
\ No newline at end of file
TalkAdvisor is a [hexagonal architecture](https://beyondxscratch.com/2017/08/19/decoupling-your-technical-code-from-your-business-logic-with-the-hexagonal-architecture-hexarch) demo application developed with Kotlin and SpringBoot.
This application recommends IT Talks recorded on YouTube given some criteria
## Testing Strategy
![Testing Strategy](testing-strategy.png)
### Unit Test and Test Composition:
For example in the resources, when testing the mapping of a Profile Domain to a Profile Resource,
we don't add a unit test inside resources.PreferencesTest to verify the mapping of a Preferences Resource
since the Profile, which contains it, will test it by composition
#### Custom Assertions
[TALK] talk about custom assert and factories
Custom asserts in the adapters: Mapping a domain object to an adapter one can be done in several places
Storing the mapping validation inside a custom assert will ensure no mapping tests will miss a new acceptance criteria.
TODO: GIVE AN EXAMPLE IN THE CODE
##Improvement
use Topics, Talks etc. classes instead of Iterable<Set>, Iterable<Talk>...
\ No newline at end of file
......@@ -231,6 +231,18 @@
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-library</artifactId>
<version>2.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
<version>2.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
......
......@@ -6,9 +6,20 @@ import org.craftsrecords.talkadvisor.recommendation.talk.Talk
import java.util.*
@Aggregate
class Recommendation(val id: UUID = UUID.randomUUID(), talks: Set<Talk>, val criteria: Criteria) {
class Recommendation(val id: UUID = UUID.randomUUID(), val criteria: Criteria, talks: Set<Talk>) {
val talks = talks.toSet()
val talks: Set<Talk>
init {
checkCriteriaTalkFormatsCorrespondToTheTalksOne(talks)
this.talks = talks.toSet()
}
private fun checkCriteriaTalkFormatsCorrespondToTheTalksOne(talks: Set<Talk>) {
if (talks.any { !criteria.hasTalkFormat(it.format) }) {
throw IllegalArgumentException("Criteria talk formats doesn't correspond to the format of the recommended talks")
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
......
......@@ -5,7 +5,7 @@ import org.craftsrecords.talkadvisor.recommendation.preferences.Preferences
class Profile(id: String, val preferences: Preferences) {
val id = notBlank(id, "Cannot create a Profile is a blank id")
val id: String = notBlank(id, "Cannot create a Profile is a blank id")
override fun equals(other: Any?): Boolean {
if (this === other) return true
......
......@@ -5,5 +5,6 @@ import org.craftsrecords.talkadvisor.recommendation.talk.Talk
@FunctionalInterface
interface SearchTalks {
val maxNumberOfTalks: Int
fun forTopics(topics: Set<Topic>): Set<Talk>
}
......@@ -4,31 +4,28 @@ import org.craftsrecords.hexarch.Stub
import org.craftsrecords.talkadvisor.recommendation.preferences.Topic
import org.craftsrecords.talkadvisor.recommendation.spi.SearchTalks
import org.craftsrecords.talkadvisor.recommendation.talk.Talk
import java.time.Duration
import kotlin.random.Random
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
import java.util.*
@Stub
class HardCodedTalksSearcher : SearchTalks {
override val maxNumberOfTalks: Int = 5//ignored value
override fun forTopics(topics: Set<Topic>): Set<Talk> {
return createTalks(topics)
}
private fun createTalks(topics: Set<Topic>): Set<Talk> {
return topics.flatMap { createTalksForTopic(it.name) }.shuffled().toSet()
return topics.flatMap { createTalksForTopic(it.name) }.toSet()
}
private fun createTalksForTopic(topicName: String): Set<Talk> {
return (1..30)
.map {
Talk.with {
id = it.toString()
title = generateTalkName(topicName)
duration = Duration.ofMinutes(Random.nextLong(2, 100))
}.build()
}.toSet()
return TalkFormat.values().map {
Talk.with {
id = UUID.randomUUID().toString()
title = "${it.name} $topicName"
duration = it.durationRange.start.plusSeconds(30)
}.build()
}.toSet()
}
private fun generateTalkName(topicName: String) = "${randomText()} $topicName ${randomText()}"
private fun randomText() = Random.nextBits(4).toString()
}
\ No newline at end of file
......@@ -6,7 +6,7 @@ import java.time.Duration
import java.time.Duration.ofHours
import java.time.Duration.ofMinutes
enum class TalkFormat(val format: String, private val durationRange: ClosedRange<Duration>) {
enum class TalkFormat(val format: String, val durationRange: ClosedRange<Duration>) {
IGNITE("IGNITE", ofMinutes(1).rangeTo(ofMinutes(10).minusNanos(1))),
QUICKIE("QUICKIE", ofMinutes(10).rangeTo(ofMinutes(20).minusNanos(1))),
TOOL_IN_ACTION("TOOL_IN_ACTION", ofMinutes(20).rangeTo(ofMinutes(40).minusNanos(1))),
......
package org.craftsrecords.talkadvisor.recommendation
import org.craftsrecords.talkadvisor.recommendation.criteria.createCriteria
import org.craftsrecords.talkadvisor.recommendation.talk.createTalks
fun createRecommendation(): Recommendation {
val criteria = createCriteria()
return Recommendation(criteria = criteria, talks = createTalks(criteria))
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.craftsrecords.talkadvisor.recommendation.criteria.Criteria
import org.craftsrecords.talkadvisor.recommendation.criteria.GuestCriteria
import org.craftsrecords.talkadvisor.recommendation.criteria.createCriteria
import org.craftsrecords.talkadvisor.recommendation.preferences.Topic
import org.craftsrecords.talkadvisor.recommendation.talk.Talk
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
import org.craftsrecords.talkadvisor.recommendation.talk.createTalk
import org.craftsrecords.talkadvisor.recommendation.talk.createTalks
import org.junit.jupiter.api.Test
import java.time.Duration
import java.util.*
internal class RecommendationTest {
@Test
fun `should create a recommendation`() {
val talks = createTalks()
val criteria = createCriteria()
val (criteria, talks) = bootstrap()
val recommendation = Recommendation(criteria = criteria, talks = talks)
......@@ -20,11 +26,26 @@ internal class RecommendationTest {
assertThat(recommendation.criteria).isEqualTo(criteria)
}
@Test
fun `should not create recommendation with criteria that doesn't corresponds to the talks`() {
val criteria = GuestCriteria(setOf(Topic("topic")), setOf(TalkFormat.UNIVERSITY))
val talk = Talk.with {
id = "id"
title = "something related to the topic"
duration = Duration.ofMinutes(15)
}.build()
assertThatThrownBy { Recommendation(criteria = criteria, talks = setOf(talk)) }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessage("Criteria talk formats doesn't correspond to the format of the recommended talks")
}
@Test
fun `should store a copy of the talks`() {
val talks = createTalks().toMutableSet()
val recommendation = Recommendation(criteria = createCriteria(), talks = talks)
var (criteria, talks) = bootstrap()
talks = talks.toMutableSet()
val recommendation = Recommendation(criteria = criteria, talks = talks)
val newTalk = createTalk()
talks.add(newTalk)
......@@ -35,9 +56,10 @@ internal class RecommendationTest {
@Test
fun `should satisfy entity equality`() {
val id = UUID.randomUUID()
val criteria = createCriteria()
val recommendation1 = Recommendation(id, criteria = createCriteria(), talks = createTalks())
val recommendation2 = Recommendation(id, criteria = createCriteria(), talks = createTalks())
val recommendation1 = Recommendation(id, criteria = criteria, talks = createTalks(criteria))
val recommendation2 = Recommendation(id, criteria = criteria, talks = createTalks(criteria))
assertThat(recommendation1).isEqualTo(recommendation2)
assertThat(recommendation1.hashCode()).isEqualTo(recommendation2.hashCode())
......@@ -46,8 +68,7 @@ internal class RecommendationTest {
@Test
fun `should satisfy entity inequality`() {
val talks = createTalks()
val criteria = createCriteria()
val (criteria, talks) = bootstrap()
val recommendation1 = Recommendation(criteria = criteria, talks = talks)
val recommendation2 = Recommendation(criteria = criteria, talks = talks)
......@@ -56,4 +77,10 @@ internal class RecommendationTest {
assertThat(recommendation1.hashCode()).isNotEqualTo(recommendation2.hashCode())
}
private fun bootstrap(): Pair<Criteria, Set<Talk>> {
val criteria = createCriteria()
val talks = createTalks(criteria)
return Pair(criteria, talks)
}
}
package org.craftsrecords.talkadvisor.recommendation.criteria
import org.craftsrecords.talkadvisor.nextString
import org.craftsrecords.talkadvisor.recommendation.preferences.Preferences
import org.craftsrecords.talkadvisor.recommendation.preferences.Topic
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
import kotlin.random.Random
fun createPreferences() =
Preferences(
setOf(Topic(Random.nextString()), Topic(Random.nextString())),
setOf(Topic("DDD"), Topic("Hexagonal Architecture")),
setOf(TalkFormat.CONFERENCE, TalkFormat.QUICKIE))
fun createCriteria(): Criteria = createPreferences()
......@@ -4,4 +4,6 @@ import org.craftsrecords.talkadvisor.nextString
import org.craftsrecords.talkadvisor.recommendation.criteria.createPreferences
import kotlin.random.Random
fun createProfile() = Profile(Random.nextString(), createPreferences())
\ No newline at end of file
fun createProfile() = createProfileFor(Random.nextString())
fun createProfileFor(userId: String) = Profile(userId, createPreferences())
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.talk
import org.craftsrecords.talkadvisor.recommendation.criteria.Criteria
import java.time.Duration.ofMinutes
import java.util.*
import kotlin.random.Random
fun createTalks() = setOf(createTalk(), createTalk())
fun createTalks(criteria: Criteria) = setOf(createTalk(criteria), createTalk(criteria))
fun createTalk(criteria: Criteria): Talk {
return prepareBuilder()
.apply { duration = durationFrom(criteria) }
.build()
}
fun createTalk(): Talk {
return prepareBuilder().apply { duration = ofMinutes(Random.nextLong(2, 120)) }.build()
}
private fun durationFrom(criteria: Criteria) = criteria.talksFormats.random().durationRange.start
private fun prepareBuilder(): Talk.Builder {
return Talk.with {
id = UUID.randomUUID().toString()
title = "title ${Random.nextInt()}"
duration = ofMinutes(Random.nextLong(2, 120))
}.build()
}
}
== Recommendations
Recommending talks to an user according to the preferences he has stored in his profile
[[recommendations_http_request]]
=== Request
include::{snippets}/recommendations/http-request.adoc[]
==== Headers
include::{snippets}/recommendations/request-headers.adoc[]
[[recommendations_http_response]]
=== Response
include::{snippets}/recommendations/http-response.adoc[]
==== Fields
include::{snippets}/recommendations/response-fields.adoc[]
[[recommendations-without-profile_http_response]]
=== Trying to get recommendation without any profile
If the user has no profile, the following error will be returned to him:
include::{snippets}/recommendations-without-profile/http-response.adoc[]
......@@ -5,4 +5,7 @@
:sectlinks:
//Create Profile
include::create-profile.adoc[leveloffset=+1]
\ No newline at end of file
include::create-profile.adoc[leveloffset=+1]
//Recommendations
include::recommendations.adoc[leveloffset=+1]
\ No newline at end of file
......@@ -29,7 +29,6 @@ class ProfileController(private val createProfile: CreateProfile) {
@ExceptionHandler(ProfileAlreadyExistsException::class)
fun handleProfileAlreadyExistsException(response: HttpServletResponse, exception: ProfileAlreadyExistsException) {
response.sendError(SC_CONFLICT, exception.message)
}
}
package org.craftsrecords.talkadvisor.infra.controller
import org.craftsrecords.talkadvisor.infra.resources.Recommendation
import org.craftsrecords.talkadvisor.infra.resources.toResource
import org.craftsrecords.talkadvisor.recommendation.api.RecommendTalks
import org.craftsrecords.talkadvisor.recommendation.profile.ProfileNotFoundException
import org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import javax.servlet.http.HttpServletResponse
@RestController
@RequestMapping(path = ["/recommendations"])
class RecommendationController(private val recommendTalks: RecommendTalks) {
@PostMapping
fun createRecommendation(@RequestHeader("User-Id") user: String): ResponseEntity<Recommendation> {
val domainRecommendation = recommendTalks to user
val recommendation = domainRecommendation.toResource()
val location = linkTo(this::class.java).slash(recommendation).toUri()
return ResponseEntity.created(location).body(recommendation)
}
@ExceptionHandler(ProfileNotFoundException::class)
fun handleProfileNotFoundException(response: HttpServletResponse, exception: ProfileNotFoundException) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, exception.message)
}
}
package org.craftsrecords.talkadvisor.infra.resources
import org.craftsrecords.talkadvisor.recommendation.preferences.Topic
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
import org.craftsrecords.talkadvisor.recommendation.preferences.Preferences as DomainPreferences
import org.craftsrecords.talkadvisor.recommendation.preferences.Topic as DomainTopic
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat as DomainTalkFormat
data class Preferences(val topics: Set<Topic>, val talksFormats: Set<TalkFormat>) {
data class Preferences(val topics: List<Topic>, val talksFormats: List<String>) {
fun toDomainObject(): DomainPreferences {
return DomainPreferences(topics, talksFormats)
return DomainPreferences(topics.toDomainObjects(), talksFormats.toDomainTalkFormats())
}
}
fun DomainPreferences.toResource() = Preferences(this.topics, this.talksFormats)
\ No newline at end of file
fun DomainPreferences.toResource() = Preferences(this.topics.toResources(), this.talksFormats.toResources())
private fun Iterable<String>.toDomainTalkFormats(): Set<DomainTalkFormat> {
return this.map(DomainTalkFormat::valueOf).toSet()
}
private fun Iterable<DomainTalkFormat>.toResources(): List<String> {
return this.map(DomainTalkFormat::name).toList()
}
\ No newline at end of file
......@@ -4,9 +4,7 @@ import org.springframework.hateoas.Identifiable
import org.craftsrecords.talkadvisor.recommendation.profile.Profile as DomainProfile
data class Profile(private val id: String, val preferences: Preferences) : Identifiable<String> {
override fun getId(): String {
return id
}
override fun getId() = id
}
fun DomainProfile.toResource(): Profile = Profile(this.id, this.preferences.toResource())
package org.craftsrecords.talkadvisor.infra.resources
import org.springframework.hateoas.Identifiable
import java.util.*
import org.craftsrecords.talkadvisor.recommendation.Recommendation as DomainRecommendation
import org.craftsrecords.talkadvisor.recommendation.talk.Talk as DomainTalk
class Recommendation(private val id: UUID, val talks: List<Talk>) : Identifiable<UUID> {
override fun getId() = id
}
fun DomainRecommendation.toResource() = Recommendation(id = id, talks = talks.map(DomainTalk::toResource).toList())
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment