diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/HomeController.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/HomeController.kt index def839e..ba573d7 100644 --- a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/HomeController.kt +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/HomeController.kt @@ -3,22 +3,14 @@ package pt.isel.daw.tictactow.http import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController import pt.isel.daw.tictactow.http.model.HomeOutputModel -import pt.isel.daw.tictactow.http.model.LinkOutputModel +import pt.isel.daw.tictactow.infra.siren @RestController class HomeController { @GetMapping(Uris.HOME) - fun getHome() = HomeOutputModel( - links = listOf( - LinkOutputModel( - Uris.home(), - LinkRelation.SELF - ), - LinkOutputModel( - Uris.home(), - LinkRelation.HOME - ), - ) - ) + fun getHome() = siren(HomeOutputModel("Made for teaching purposes by P. FĂ©lix")) { + link(Uris.home(), Rels.SELF) + link(Uris.home(), Rels.HOME) + } } \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/LinkRelation.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/LinkRelation.kt deleted file mode 100644 index 687aeec..0000000 --- a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/LinkRelation.kt +++ /dev/null @@ -1,15 +0,0 @@ -package pt.isel.daw.tictactow.http - -class LinkRelation( - val value: String -) { - companion object { - - val SELF = LinkRelation("self") - - val HOME = LinkRelation( - "https://github.com/isel-leic-daw/s2223i-51d-51n-public/tree/main/code/tic-tac-tow-service/docs/" + - "rels/home" - ) - } -} \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Rels.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Rels.kt new file mode 100644 index 0000000..34b5f08 --- /dev/null +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Rels.kt @@ -0,0 +1,13 @@ +package pt.isel.daw.tictactow.http + +import pt.isel.daw.tictactow.infra.LinkRelation + +object Rels { + + val SELF = LinkRelation("self") + + val HOME = LinkRelation( + "https://github.com/isel-leic-daw/s2223i-51d-51n-public/tree/main/code/tic-tac-tow-service/docs/" + + "rels/home" + ) +} \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Uris.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Uris.kt index 6037e2a..5724f38 100644 --- a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Uris.kt +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/Uris.kt @@ -7,16 +7,18 @@ import java.net.URI object Uris { const val HOME = "/" - const val USER_HOME = "/me" const val GAME_BY_ID = "/games/{gid}" - const val USERS_CREATE = "/users" - const val USERS_TOKEN = "/users/token" - const val USERS_GET_BY_ID = "/users/{id}" - fun home(): URI = URI(HOME) - fun userHome(): URI = URI(USER_HOME) fun gameById(game: Game) = UriTemplate(GAME_BY_ID).expand(game.id) - fun userById(id: String) = UriTemplate(USERS_GET_BY_ID).expand(id) + object Users { + const val CREATE = "/users" + const val TOKEN = "/users/token" + const val GET_BY_ID = "/users/{id}" + const val HOME = "/me" + + fun byId(id: String) = UriTemplate(GET_BY_ID).expand(id) + fun home(): URI = URI(HOME) + } } \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/UsersController.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/UsersController.kt index f662107..b7d5c33 100644 --- a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/UsersController.kt +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/UsersController.kt @@ -22,14 +22,14 @@ class UsersController( private val userService: UsersService ) { - @PostMapping(Uris.USERS_CREATE) + @PostMapping(Uris.Users.CREATE) fun create(@RequestBody input: UserCreateInputModel): ResponseEntity<*> { val res = userService.createUser(input.username, input.password) return when (res) { is Either.Right -> ResponseEntity.status(201) .header( "Location", - Uris.userById(res.value).toASCIIString() + Uris.Users.byId(res.value).toASCIIString() ).build() is Either.Left -> when (res.value) { UserCreationError.InsecurePassword -> Problem.response(400, Problem.insecurePassword) @@ -38,7 +38,7 @@ class UsersController( } } - @PostMapping(Uris.USERS_TOKEN) + @PostMapping(Uris.Users.TOKEN) fun token(@RequestBody input: UserCreateTokenInputModel): ResponseEntity<*> { val res = userService.createToken(input.username, input.password) return when (res) { @@ -50,12 +50,12 @@ class UsersController( } } - @GetMapping(Uris.USERS_GET_BY_ID) + @GetMapping(Uris.Users.GET_BY_ID) fun getById(@PathVariable id: String) { TODO("TODO") } - @GetMapping(Uris.USER_HOME) + @GetMapping(Uris.Users.HOME) fun getUserHome(user: User): UserHomeOutputModel { return UserHomeOutputModel( id = user.id.toString(), diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/HomeOutputModel.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/HomeOutputModel.kt index 0b12b88..b164ac1 100644 --- a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/HomeOutputModel.kt +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/HomeOutputModel.kt @@ -1,5 +1,5 @@ package pt.isel.daw.tictactow.http.model data class HomeOutputModel( - val links: List, + val credits: String, ) \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/LinkOutputModel.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/LinkOutputModel.kt deleted file mode 100644 index d976325..0000000 --- a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/http/model/LinkOutputModel.kt +++ /dev/null @@ -1,12 +0,0 @@ -package pt.isel.daw.tictactow.http.model - -import pt.isel.daw.tictactow.http.LinkRelation -import java.net.URI - -data class LinkOutputModel( - private val targetUri: URI, - private val relation: LinkRelation -) { - val href = targetUri.toASCIIString() - val rel = relation.value -} \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/infra/LinkRelation.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/infra/LinkRelation.kt new file mode 100644 index 0000000..f2d9887 --- /dev/null +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/infra/LinkRelation.kt @@ -0,0 +1,6 @@ +package pt.isel.daw.tictactow.infra + +@JvmInline +value class LinkRelation( + val value: String +) \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/infra/Siren.kt b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/infra/Siren.kt new file mode 100644 index 0000000..8ea5648 --- /dev/null +++ b/code/tic-tac-tow-service/src/main/kotlin/pt/isel/daw/tictactow/infra/Siren.kt @@ -0,0 +1,122 @@ +package pt.isel.daw.tictactow.infra + +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.http.HttpMethod +import java.net.URI + +data class SirenModel( + @get:JsonProperty("class") + val clazz: List, + val properties: T, + val links: List, + val entities: List>, + val actions: List +) + +data class LinkModel( + val rel: List, + val href: String, +) + +data class EntityModel( + val properties: T, + val links: List, + val rel: List, +) + +data class ActionModel( + val name: String, + val href: String, + val method: String, + val type: String, + val fields: List, +) + +data class FieldModel( + val name: String, + val type: String, + val value: String? = null, +) + +class SirenBuilderScope( + val properties: T, +) { + private val links = mutableListOf() + private val entities = mutableListOf>() + private val classes = mutableListOf() + private val actions = mutableListOf() + + fun clazz(value: String) { + classes.add(value) + } + + fun link(href: URI, rel: LinkRelation) { + links.add(LinkModel(listOf(rel.value), href.toASCIIString())) + } + + fun entity(value: U, rel: LinkRelation, block: EntityBuilderScope.() -> Unit) { + val scope = EntityBuilderScope(value, listOf(rel.value)) + scope.block() + entities.add(scope.build()) + } + + fun action(name: String, href: URI, method: HttpMethod, type: String, block: ActionBuilderScope.() -> Unit) { + val scope = ActionBuilderScope(name, href, method, type) + scope.block() + actions.add(scope.build()) + } + + fun build(): SirenModel = SirenModel( + clazz = classes, + properties = properties, + links = links, + entities = entities, + actions = actions + ) +} + +class EntityBuilderScope( + val properties: T, + val rel: List, +) { + private val links = mutableListOf() + + fun link(href: URI, rel: LinkRelation) { + links.add(LinkModel(listOf(rel.value), href.toASCIIString())) + } + + fun build(): EntityModel = EntityModel( + properties = properties, + links = links, + rel = rel, + ) +} + +class ActionBuilderScope( + private val name: String, + private val href: URI, + private val method: HttpMethod, + private val type: String, +) { + private val fields = mutableListOf() + + fun textField(name: String) { + fields.add(FieldModel(name, "text")) + } + + fun numberField(name: String) { + fields.add(FieldModel(name, "number")) + } + + fun hiddenField(name: String, value: String) { + fields.add(FieldModel(name, "hidden", value)) + } + + fun build() = ActionModel(name, href.toASCIIString(), method.name, type, fields) +} + +fun siren(value: T, block: SirenBuilderScope.() -> Unit): SirenModel { + val scope = SirenBuilderScope(value) + scope.block() + return scope.build() +} \ No newline at end of file diff --git a/code/tic-tac-tow-service/src/test/kotlin/pt/isel/daw/tictactow/infra/SirenTests.kt b/code/tic-tac-tow-service/src/test/kotlin/pt/isel/daw/tictactow/infra/SirenTests.kt new file mode 100644 index 0000000..c22f8ad --- /dev/null +++ b/code/tic-tac-tow-service/src/test/kotlin/pt/isel/daw/tictactow/infra/SirenTests.kt @@ -0,0 +1,103 @@ +package pt.isel.daw.tictactow.infra + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert +import org.springframework.http.HttpMethod +import java.net.URI + +class SirenTests { + + @Test + fun `can produce siren representation`() { + // given: model classes + class GameModel( + val name: String, + val description: String + ) + + class PlayerModel( + val name: String, + ) + + // and: link relations + val self = LinkRelation("self") + val player = LinkRelation("https://example.com/rels/player") + + // and: a Jackson mapper + val mapper = ObjectMapper().apply { + setSerializationInclusion(JsonInclude.Include.NON_NULL) + } + + // when: producing a Siren model + val sirenModel = siren( + GameModel( + name = "the name", + description = "the description", + ) + ) { + clazz("game") + link(URI("https://example.com/games/1"), self) + entity(PlayerModel("Alice"), player) { + link(URI("https://example.com/users/1"), self) + } + entity(PlayerModel("Bob"), player) { + link(URI("https://example.com/users/2"), self) + } + action( + "cancel", + URI("https://example.com/games/1/cancel"), + HttpMethod.POST, + "application/json" + ) { + textField("reason") + } + } + + // and: serializing it to JSON + val jsonString = mapper.writeValueAsString(sirenModel) + + // then: the serialization is the expected one + val expected = """ + { + "class":["game"], + "properties": { + "name": "the name", + "description": "the description" + }, + "entities":[ + { + "rel": ["https://example.com/rels/player"], + "properties": { + "name": "Alice" + }, + "links": [ + {"rel": ["self"], "href": "https://example.com/users/1"} + ] + }, + { + "rel": ["https://example.com/rels/player"], + "properties": { + "name": "Bob" + }, + "links": [ + {"rel": ["self"], "href": "https://example.com/users/2"} + ] + } + ], + "links": [ + {"rel": ["self"], "href": "https://example.com/games/1"} + ], + "actions": [ + {"name": "cancel", "href":"https://example.com/games/1/cancel", "method":"POST", + "type": "application/json", + "fields": [ + {"name":"reason", "type": "text"} + ]} + ] + } + """.trimIndent() + JSONAssert.assertEquals(expected, jsonString, true) + } +} \ No newline at end of file